Skip to content

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?

ComponenteDescrição
SPI/BCBCentral que orquestra as transferências
ISPBIdentificador único de cada instituição (8 dígitos)
DICTDiretório de chaves PIX (CPF, e-mail, telefone, aleatória)
ISO 20022Padrão de mensagens financeiras utilizado
Conta PIConta de Pagamentos Instantâneos no BCB
CaracterísticaValor
LatênciaMenos de 10 segundos end-to-end
Disponibilidade24/7, 365 dias por ano
SLA99,99% de disponibilidade
LiquidaçãoBruta em tempo real (RTGS)

Fluxo Completo do PIX

Arquitetura de um PSP Integrado ao SPI

Mensagens ISO 20022 no SPI

MensagemDireçãoPropósito
pacs.008PSP → SPIInstrução de pagamento
pacs.002SPI → PSPConfirmação de status
pacs.008PSP → SPIDevolução de pagamento
camt.053SPI → PSPExtrato da Conta PI
CódigoSignificado
ACSPAceito para compensação
ACSCAceito e compensado (confirmado!)
RJCRRejeitado por falta de fundos
RJVARejeitado por valor inválido
RJCTRejeitado (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

AspectoTypeScriptGo
Velocidade de desenvolvimentoRápido, XML/JSON libs prontasMais verboso, mas compila rápido
Performance~10-50ms latência~1-5ms latência
XML Processingfast-xml-parser (rápido)encoding/xml nativo (eficiente)
Criptografianode-forge, xml-cryptostdlib crypto (assembly otimizado)
ConcorrênciaEvent loop (single-thread)Goroutines (M:N scheduler)
DeployPrecisa Node runtimeBinário único
Ecossistema ISO 20022Bibliotecas prontasPrecisa 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 3002

Troubleshooting

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 sync

3. 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

  1. XML é chato, mas necessário — O mundo financeiro roda em XML desde os anos 90
  2. ISO 20022 é o futuro — Pix já usa, Europa migrou com SEPA
  3. Go não é bala de prata — Use onde faz sentido, e pra SPI faz muito
  4. Performance importa — Mas não a qualquer custo. Conheça seu SLA
  5. Garbage collector é risco financeiro — Pausas de 100ms = transação perdida
  6. Prefira int64 para dinheiro — Float point = R$ 100 mil de divergência em 10M transações
  7. Idempotência é a base — EndToEndId único global, sem exceção
  8. Observabilidade não é opcional — Monitore latência P99, taxa de rejeição, conciliação
  9. Deploy simplificado — Go: scp binário + executar. Sem npm install.
  10. 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