Desafio 02: SPI — Pagamentos Instantâneos (PIX) no Coração do Brasil
🇧🇷 Simulador do Sistema de Pagamentos Instantâneos
🇬🇧 Instant Payment System Simulator
O Sistema de Pagamentos Instantâneos (SPI) é a infraestrutura do Banco Central do Brasil que viabiliza o PIX. Ele permite transferências em tempo real, 24/7, entre qualquer instituição participante, em menos de 10 segundos.
Switch: TypeScript vs Go
O que é SPI?
| Componente | Descrição |
|---|---|
| SPI/BCB | Central que orquestra as transferências |
| ISPB | Identificador único de cada instituição (8 dígitos) |
| DICT | Diretório de chaves PIX (CPF, e-mail, telefone, aleatória) |
| ISO 20022 | Padrão de mensagens financeiras utilizado |
| Conta PI | Conta de Pagamentos Instantâneos no BCB |
| Característica | Valor |
|---|---|
| Latência | Menos de 10 segundos end-to-end |
| Disponibilidade | 24/7, 365 dias por ano |
| SLA | 99,99% de disponibilidade |
| Liquidação | Bruta em tempo real (RTGS) |
Fluxo Completo do PIX
Arquitetura de um PSP Integrado ao SPI
Mensagens ISO 20022 no SPI
| Mensagem | Direção | Propósito |
|---|---|---|
| pacs.008 | PSP → SPI | Instrução de pagamento |
| pacs.002 | SPI → PSP | Confirmação de status |
| pacs.008 | PSP → SPI | Devolução de pagamento |
| camt.053 | SPI → PSP | Extrato da Conta PI |
| Código | Significado |
|---|---|
| ACSP | Aceito para compensação |
| ACSC | Aceito e compensado (confirmado!) |
| RJCR | Rejeitado por falta de fundos |
| RJVA | Rejeitado por valor inválido |
| RJCT | Rejeitado (genérico) |
Domain Layer
typescript
export enum PixPaymentStatus {
PENDING = 'PENDING',
SENT_TO_SPI = 'SENT_TO_SPI',
CONFIRMED = 'CONFIRMED',
REJECTED = 'REJECTED',
REFUNDED = 'REFUNDED',
FAILED = 'FAILED'
}
export interface PixPaymentProps {
idempotencyKey: string;
debtorAccount: {
ispb: string;
branch: string;
accountNumber: string;
accountType: 'CACC' | 'SVGS' | 'TRAN';
};
creditorAccount: {
ispb: string;
branch: string;
accountNumber: string;
accountType: 'CACC' | 'SVGS' | 'TRAN';
};
amount: Money;
endToEndId: string;
description?: string;
status: PixPaymentStatus;
spiTransactionId?: string;
createdAt: Date;
confirmedAt?: Date;
}
export class PixPayment extends Entity<string> {
public static create(props: PixPaymentProps): PixPayment {
return new PixPayment(props);
}
public confirm(spiTransactionId: string): void {
this.props.status = PixPaymentStatus.CONFIRMED;
this.props.spiTransactionId = spiTransactionId;
this.props.confirmedAt = new Date();
}
public reject(reasonCode: string): void {
this.props.status = PixPaymentStatus.REJECTED;
}
}SPI Client
typescript
import { XMLBuilder, XMLParser } from 'fast-xml-parser';
export class SPIClient {
private readonly xmlBuilder: XMLBuilder;
private readonly xmlParser: XMLParser;
constructor(config: SPIConfig) {
this.xmlBuilder = new XMLBuilder({
attributeNamePrefix: '@_',
ignoreAttributes: false,
format: true
});
this.xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_'
});
}
public async sendPacs008(payment: PixPayment): Promise<Pacs002Response> {
const pacs008Xml = this.buildPacs008(payment);
const signedXml = this.signXml(pacs008Xml);
const response = await fetch(`${this.baseUrl}/pix/spi/pacs.008`, {
method: 'POST',
headers: {
'Content-Type': 'application/xml',
'X-Ispb': this.ispb,
'X-Signature': this.getSignatureHeader(signedXml)
},
body: signedXml
});
const responseBody = await response.text();
return this.mapPacs002Response(this.xmlParser.parse(responseBody));
}
private buildPacs008(payment: PixPayment): string {
return this.xmlBuilder.build({
'Document': {
'@_xmlns': 'urn:iso:std:iso:20022:tech:xsd:pacs.008.001.08',
'FIToFICustomerCreditTransfer': {
'GrpHdr': {
'MsgId': `MSG${Date.now()}`,
'CreDtTm': new Date().toISOString(),
'NbOfTxs': '1',
'SttlmMtd': 'CLRG'
},
'CdtTrfTxInf': {
'PmtId': {
'EndToEndId': payment.props.endToEndId,
'InstrId': payment.props.idempotencyKey
},
'IntrBkSttlmAmt': {
'@_Ccy': 'BRL',
'#text': payment.props.amount.toDecimal().toString()
},
'DbtrAgt': { 'FinInstnId': { 'ClrSysMmbId': { 'MmbId': payment.props.debtorAccount.ispb } } },
'CdtrAgt': { 'FinInstnId': { 'ClrSysMmbId': { 'MmbId': payment.props.creditorAccount.ispb } } },
'CdtrAcct': { 'Id': { 'Othr': { 'Id': payment.props.creditorAccount.accountNumber } } }
}
}
}
});
}
}Use Case — Iniciar Pagamento PIX
typescript
export class InitiatePixPaymentUseCase {
constructor(
private readonly pixPaymentRepo: PixPaymentRepository,
private readonly dictClient: DICTClient,
private readonly spiClient: SPIClient,
private readonly ledgerService: LedgerService,
private readonly fraudService: FraudDetectionService,
private readonly eventPublisher: EventPublisher
) {}
public async execute(input: InitiatePixPaymentInput): Promise<Either<DomainError, PixPayment>> {
// 1. Verifica idempotência
const existing = await this.pixPaymentRepo.findByIdempotencyKey(input.idempotencyKey);
if (existing) return right(existing);
// 2. Resolve dados do recebedor via DICT
let creditorAccount;
if (input.pixKey) {
const dictResult = await this.dictClient.resolveKey(input.pixKey);
if (dictResult.isLeft()) return left(new PixKeyNotFoundError(input.pixKey));
creditorAccount = dictResult.value;
}
// 3. Valida saldo
const debtorAccount = await this.ledgerService.getAccount(input.debtorAccountId);
if (debtorAccount.balance < input.amount) return left(new InsufficientFundsError());
// 4. Detecção de fraude
const fraudCheck = await this.fraudService.check({
debtorAccountId: input.debtorAccountId,
amount: input.amount,
creditorIspb: creditorAccount.ispb
});
if (fraudCheck.isHighRisk) return left(new FraudDetectedError());
// 5. Reserva fundos no ledger
await this.ledgerService.reserveFunds({
accountId: input.debtorAccountId,
amount: input.amount
});
// 6. Envia para SPI
const payment = PixPayment.create({ ... });
const spiResponse = await this.spiClient.sendPacs008(payment);
if (spiResponse.status === 'ACSC') {
payment.confirm(spiResponse.transactionId);
} else {
payment.reject(spiResponse.reasonCode);
await this.ledgerService.releaseReservation(payment.id);
}
return right(payment);
}
}Webhook Receiver
typescript
export class ReceiveSPIConfirmationUseCase {
public async execute(pacs002: Pacs002Message): Promise<Either<Error, void>> {
const payment = await this.pixPaymentRepo.findByEndToEndId(pacs002.endToEndId);
if (!payment) return left(new PaymentNotFoundError(pacs002.endToEndId));
switch (pacs002.status) {
case 'ACSC':
await this.ledgerService.confirmDebit(payment.props.debtorAccount.accountNumber, payment.id);
payment.confirm(pacs002.transactionId);
await this.eventPublisher.publish('pix.payment.confirmed', { paymentId: payment.id });
return right(undefined);
case 'RJCR':
case 'RJVA':
case 'RJCT':
await this.ledgerService.releaseReservation(payment.id);
payment.reject(pacs002.reasonCode);
await this.eventPublisher.publish('pix.payment.rejected', { paymentId: payment.id, reason: pacs002.reasonCode });
return right(undefined);
}
}
}Conciliação Diária
typescript
export class DailyConciliationJob {
public async execute(date: Date): Promise<ConciliationResult> {
// 1. Baixa extrato da conta PI no BCB (camt.053)
const statement = await this.spiStatementClient.getStatement(date);
// 2. Busca pagamentos locais do dia
const localPayments = await this.pixPaymentRepo.findByDate(date);
const localByEndToEnd = new Map(localPayments.map(p => [p.endToEndId, p]));
const discrepancies: Discrepancy[] = [];
// 3. Valida cada entrada do extrato
for (const entry of statement.entries) {
const local = localByEndToEnd.get(entry.endToEndId);
if (!local) {
discrepancies.push({ type: 'MISSING_LOCALLY', endToEndId: entry.endToEndId, severity: 'HIGH' });
} else if (entry.status === 'ACSC' && !local.isConfirmed()) {
discrepancies.push({ type: 'STATUS_MISMATCH', endToEndId: entry.endToEndId, severity: 'MEDIUM' });
}
}
return { date, discrepancies, status: discrepancies.length === 0 ? 'OK' : 'WITH_ISSUES' };
}
}Comparação: TypeScript vs Go para SPI
| Aspecto | TypeScript | Go |
|---|---|---|
| Velocidade de desenvolvimento | Rápido, XML/JSON libs prontas | Mais verboso, mas compila rápido |
| Performance | ~10-50ms latência | ~1-5ms latência |
| XML Processing | fast-xml-parser (rápido) | encoding/xml nativo (eficiente) |
| Criptografia | node-forge, xml-crypto | stdlib crypto (assembly otimizado) |
| Concorrência | Event loop (single-thread) | Goroutines (M:N scheduler) |
| Deploy | Precisa Node runtime | Binário único |
| Ecossistema ISO 20022 | Bibliotecas prontas | Precisa implementar |
Quando escolher TypeScript?
- MVP e validação — Fintech em fase inicial
- Baixo volume — Até 1.000 PIX/s
- Equipe TypeScript — Curva de aprendizado menor
- Integrações múltiplas — Múltiplos provedores
Caso Real: Nubank
O Nubank processa bilhões de PIX por mês com abordagem híbrida:
- Clojure + Go — Processamento de transações, assinatura, conciliação
- TypeScript + Node.js — APIs mobile (BFF), webhooks, notificações
Como testar
bash
# TypeScript
make infra-up
pnpm --filter @banking/spi dev
# Enviar pacs.008
curl -X POST http://localhost:3002/spi/pacs.008 \
-H "Content-Type: application/xml" \
-d @testdata/pacs008-example.xml
# Ver transações
curl http://localhost:3002/spi/transactions
# Go
cd packages/backend/spi-simulator-go
go run .
# Mesmos endpoints na porta 3002Troubleshooting
1. Namespace XML errado
O erro mais comum. O encoding/xml do Go retorna struct zerada sem erro:
go
// ERRADO: namespace antigo
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.07">
// CORRETO
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.08">2. Race condition em teste de carga
bash
go run -race . # ESSENCIAL — detecta acessos concorrentes sem sync3. Float impreciso
typescript
// ERRADO: 0.1 + 0.2 = 0.30000000000000004
const amount = 15_738_294.12
// CORRETO: tudo em centavos (int64)
type MonetaryAmount struct {
Value int64 // centavos
Currency string // ISO 4217
}Lições aprendidas
- XML é chato, mas necessário — O mundo financeiro roda em XML desde os anos 90
- ISO 20022 é o futuro — Pix já usa, Europa migrou com SEPA
- Go não é bala de prata — Use onde faz sentido, e pra SPI faz muito
- Performance importa — Mas não a qualquer custo. Conheça seu SLA
- Garbage collector é risco financeiro — Pausas de 100ms = transação perdida
- Prefira int64 para dinheiro — Float point = R$ 100 mil de divergência em 10M transações
- Idempotência é a base — EndToEndId único global, sem exceção
- Observabilidade não é opcional — Monitore latência P99, taxa de rejeição, conciliação
- Deploy simplificado — Go:
scp binário+ executar. Sem npm install. - Context é onipresente — Cada request carrega timeout, tracing, valores
O que vem depois
- S2 Simulator — Sistema de Transferência de Reservas
- LBTR — Liquidação bruta em tempo real
- Mensagens negativas — pacs.004 (devolução), camt.056 (cancelamento)
- Zona de espera — Fila quando banco destino offline
- AML — Transações > R$ 10.000 notificam COAF
- Limites Pix — Período noturno (R$ 1.000), diurno (banco define)
- Tarifação — R$ 0,01 por Pix liquidado