Sistema de pagamento: idempotência, reconciliação e reembolso

1. Visão geral

O problema de pagamento difere dos típicos CRUD em um ponto: um erro uma vez e você perde dinheiro real. Tentativas do cliente podem fazer os clientes serem debitados duas vezes. O gateway de pagamento (PSP) pode ter timeout mesmo que a transação tenha sucesso. Duas solicitações de reembolso em paralelo podem reembolsar mais do que foi coletado.

Este artigo percorre um design específico compreendendo requisitos, modelo de dados, API e fluxos de processamento. Então aborda três desafios: idempotência, reconciliação e reembolso. Depois de ler, você pode construir o núcleo com Spring Boot 3 e PostgreSQL, incluindo testes provando que o sistema nunca reembolsa além do limite mesmo com concorrência.

2. Requisitos

2.1. Requisitos funcionais

  • O merchant cria um pagamento para um Order. O sistema chama o PSP para débito de cartão ou carteira.
  • O merchant reembolsa totalmente ou parcialmente. O total reembolsado nunca deve exceder o valor coletado.
  • Diariamente, o sistema reconcilia dados internos com o arquivo de settlement do PSP.
  • O sistema publica eventos (PaymentCaptured, RefundSucceeded, etc.) para outros serviços como Order e Accounting.

2.2. Escala e restrições

  • Média de 200 transações/segundo. Pico de flash sale cerca de 5.000 transações/segundo em alguns minutos.
  • Nunca débite duas vezes para a mesma intenção de pagamento. Este é um requisito crítico.
  • Prioriza consistência sobre disponibilidade: prefira retornar erro ao cliente do que gravar um valor incorreto.
  • O PSP é um sistema externo. Pode ser lento, ter timeout ou enviar webhooks atrasados ou duplicados.

3. Modelo de dados

O dinheiro é sempre armazenado em menor unidade (centavos para BRL, cents para USD) como BIGINT. Nunca use double.

CREATE TABLE payments (
    id              UUID PRIMARY KEY,
    merchant_id     TEXT        NOT NULL,
    order_id        TEXT        NOT NULL,
    amount          BIGINT      NOT NULL CHECK (amount > 0),
    currency        CHAR(3)     NOT NULL,
    status          TEXT        NOT NULL, -- CRIADO, PROCESSANDO, CAPTURADO, FALHOU, DESCONHECIDO
    refunded_amount BIGINT      NOT NULL DEFAULT 0,
    psp_reference   TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    CHECK (refunded_amount <= amount)
);

CREATE TABLE refunds (
    id          UUID PRIMARY KEY,
    payment_id  UUID   NOT NULL REFERENCES payments(id),
    amount      BIGINT NOT NULL CHECK (amount > 0),
    status      TEXT   NOT NULL, -- PENDENTE, SUCESSO, FALHOU
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE idempotency_keys (
    merchant_id   TEXT NOT NULL,
    idem_key      TEXT NOT NULL,
    request_hash  TEXT NOT NULL,
    status        TEXT NOT NULL, -- EM_PROGRESSO, CONCLUÍDO
    response_code INT,
    response_body TEXT,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (merchant_id, idem_key)
);

CREATE TABLE outbox (
    id         BIGSERIAL PRIMARY KEY,
    topic      TEXT  NOT NULL,
    payload    JSONB NOT NULL,
    sent_at    TIMESTAMPTZ
);

Alguns pontos a notar:

  • CHECK (refunded_amount <= amount) é a rede de segurança final no banco de dados. Se o código tiver bug, o banco ainda bloqueia.
  • A chave primária (merchant_id, idem_key) da tabela idempotency_keys é o núcleo do mecanismo anti-duplicação.
  • A tabela outbox ajuda a publicar eventos para Kafka sem precisar de transações distribuídas (seção 6).

4. Design da API

POST /v1/payments
Headers: Idempotency-Key: 7f3c1e2a-...
Body:    { "orderId": "ORD-1001", "amount": 250000, "currency": "VND", "source": "tok_visa" }
→ 201  { "id": "...", "status": "CAPTURADO" }

POST /v1/payments/{id}/refunds
Headers: Idempotency-Key: 9a8b...
Body:    { "amount": 100000 }
→ 202  { "refundId": "...", "status": "PENDENTE" }

GET  /v1/payments/{id}

Toda API que muda estado requer Idempotency-Key gerada pelo cliente. O cliente reenvia a mesma chave ao fazer retry. Reembolso retorna 202 Accepted porque o PSP processa reembolsos de forma assíncrona.

5. Idempotência: evitando débitos duplos

5.1. Ideia

Ao receber uma requisição, gravamos a chave no banco antes de fazer nada. Existem três possibilidades:

  1. Chave não existe: gravação bem-sucedida, processe.
  2. Chave está CONCLUÍDO: retorne a resposta armazenada, não chame o PSP novamente.
  3. Chave está EM_PROGRESSO: outro request com a mesma chave está processando. Retorne 409 Conflict para o cliente tentar depois.

Além disso, armazenamos um hash do corpo da requisição. Se o cliente reutilizar a chave com um corpo diferente (ex: amount diferente), é erro do cliente e retornamos 422.

5.2. Implementação

Dependências necessárias:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>
@Service
public class IdempotencyService {

    private final JdbcTemplate jdbc;

    public IdempotencyService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public record StoredResponse(int code, String body) {}

    public Optional<StoredResponse> begin(String merchantId, String key, String requestHash) {
        int inserted = jdbc.update("""
            INSERT INTO idempotency_keys (merchant_id, idem_key, request_hash, status)
            VALUES (?, ?, ?, 'EM_PROGRESSO')
            ON CONFLICT (merchant_id, idem_key) DO NOTHING
            """, merchantId, key, requestHash);
        if (inserted == 1) {
            return Optional.empty(); // requisição nova, permitida processar
        }
        Map<String, Object> row = jdbc.queryForMap("""
            SELECT request_hash, status, response_code, response_body
            FROM idempotency_keys WHERE merchant_id = ? AND idem_key = ?
            """, merchantId, key);
        if (!requestHash.equals(row.get("request_hash"))) {
            throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Chave reutilizada com corpo diferente");
        }
        if ("EM_PROGRESSO".equals(row.get("status"))) {
            throw new ResponseStatusException(HttpStatus.CONFLICT, "Requisição está sendo processada");
        }
        return Optional.of(new StoredResponse((Integer) row.get("response_code"), (String) row.get("response_body")));
    }

    public void complete(String merchantId, String key, int code, String body) {
        jdbc.update("""
            UPDATE idempotency_keys SET status = 'CONCLUÍDO', response_code = ?, response_body = ?
            WHERE merchant_id = ? AND idem_key = ?
            """, code, body, merchantId, key);
    }
}

ON CONFLICT DO NOTHING transforma a operação “verificar depois gravar” em uma única instrução atômica. Se dois requests com chave idêntica chegarem ao mesmo tempo, apenas um obterá inserted == 1. O outro request cai no ramo de releitura e recebe 409. Não há race condition tipo ‘ambos veem a chave inexistente’.

Nota: Não use apenas Redis SETNX como fonte da verdade para idempotência de pagamentos. Failover do Redis ou eviction da chave e você perde o registro e o dinheiro é debitado duas vezes. Redis deve ser apenas um cache frontal, enquanto o banco de dados é o árbitro final.

5.3. Idempotência do lado do PSP

Seus requests estão protegidos, mas suas chamadas para o PSP também podem sofrer retry. A maioria dos PSPs suporta chave de idempotência própria. Passe o próprio payments.id como chave:

PspResult result = pspClient.charge(new ChargeRequest(
        payment.id().toString(), // chave de idempotência enviada para o PSP
        payment.amount(),
        payment.currency(),
        sourceToken));

Quando chamar o PSP sofre timeout, você não sabe se o dinheiro foi debitado ou não. Não marque como FAILED. Defina o status como UNKNOWN e depois deixe um job consultar novamente o PSP usando payments.id. Marcar como FAILED e permitir que o cliente tente novamente com uma chave nova é o caminho clássico para débitos duplos.

6. Consistência entre banco e Kafka: Transactional Outbox

Após coletar o pagamento, devemos atualizar payments e publicar o evento PaymentCaptured. Sucesso ao gravar no banco mas falha ao enviar para Kafka (ou vice-versa) causará desincronização no serviço de Order. A solução é gravar o evento na tabela outbox na mesma transação:

@Transactional
public void markCaptured(UUID paymentId, String pspReference) {
    jdbc.update("UPDATE payments SET status = 'CAPTURADO', psp_reference = ? WHERE id = ? AND status IN ('PROCESSANDO','DESCONHECIDO')",
            pspReference, paymentId);
    jdbc.update("INSERT INTO outbox (topic, payload) VALUES ('payment-events', ?::jsonb)",
            """
            {"type":"PaymentCaptured","paymentId":"%s"}""".formatted(paymentId));
}

Um relay (job periódico ou Debezium CDC) lê linhas com sent_at IS NULL, envia para Kafka, depois atualiza sent_at. O relay pode enviar duplicatas, então o consumidor deve ser idempotente, por exemplo armazenando paymentId já processado. A condição status IN (...) no UPDATE impede que webhooks duplicados do PSP sobrescrevam um estado final diferente.

7. Reembolsos e concorrência

7.1. Problema

Pagamento de 100.000đ. Um agente de CS e um job automático enviam requisições de reembolso de 60.000đ ao mesmo tempo. Se o código seguir o padrão ‘ler refunded_amount, verificar, depois gravar’, ambos veem 0 + 60.000 <= 100.000 e ambos são aprovados. O resultado é que você reembolsa 120.000đ.

7.2. Solução: update condicional

Combine verificação e atualização em uma única instrução UPDATE condicional:

@Service
public class RefundService {

    private final JdbcTemplate jdbc;

    public RefundService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional
    public UUID requestRefund(UUID paymentId, long amount) {
        int reserved = jdbc.update("""
            UPDATE payments SET refunded_amount = refunded_amount + ?
            WHERE id = ? AND status = 'CAPTURADO' AND refunded_amount + ? <= amount
            """, amount, paymentId, amount);
        if (reserved == 0) {
            throw new IllegalStateException("Reembolso excede o valor reembolsável ou pagamento não foi capturado");
        }
        UUID refundId = UUID.randomUUID();
        jdbc.update("INSERT INTO refunds (id, payment_id, amount, status) VALUES (?, ?, ?, 'PENDENTE')",
                refundId, paymentId, amount);
        jdbc.update("INSERT INTO outbox (topic, payload) VALUES ('refund-commands', ?::jsonb)",
                """
                {"refundId":"%s","paymentId":"%s","amount":%d}""".formatted(refundId, paymentId, amount));
        return refundId;
    }
}

PostgreSQL bloqueia a linha payments quando faz UPDATE. A segunda transação aguarda o commit da primeira transação. Então ela reavalia a condição WHERE com os novos dados, vê 60.000 + 60.000 > 100.000 e recebe reserved == 0. Este é o comportamento padrão em nível READ COMMITTED, sem necessidade de SERIALIZABLE.

Nós ‘reservamos’ o valor antes, depois chamamos o PSP de forma assíncrona via outbox. Se o PSP recusar o reembolso, o consumidor devolverá o valor reservado:

UPDATE payments SET refunded_amount = refunded_amount - :amount WHERE id = :paymentId;
UPDATE refunds  SET status = 'FALHOU' WHERE id = :refundId AND status = 'PENDENTE';

Essas duas instruções devem estar na mesma transação. A condição status = 'PENDENTE' garante que um webhook falho reenviado não devolva o valor reservado duas vezes. Para ser mais rigoroso, execute o primeiro UPDATE apenas quando o segundo atualizar exatamente uma linha.

7.3. Teste de concorrência

Dependências de teste:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class RefundServiceTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired RefundService refundService;
    @Autowired JdbcTemplate jdbc;

    @Test
    void concurrentRefunds_neverExceedCapturedAmount() throws Exception {
        UUID paymentId = UUID.randomUUID();
        jdbc.update("""
            INSERT INTO payments (id, merchant_id, order_id, amount, currency, status)
            VALUES (?, 'shop-1', 'ORD-1001', 100000, 'VND', 'CAPTURADO')
            """, paymentId);

        Callable<Boolean> refund60k = () -> {
            try {
                refundService.requestRefund(paymentId, 60_000);
                return true;
            } catch (IllegalStateException e) {
                return false;
            }
        };

        List<Boolean> results;
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            results = executor.invokeAll(List.of(refund60k, refund60k))
                    .stream().map(Future::resultNow).toList();
        }

        assertThat(results).containsExactlyInAnyOrder(true, false);
        Long refunded = jdbc.queryForObject(
                "SELECT refunded_amount FROM payments WHERE id = ?", Long.class, paymentId);
        assertThat(refunded).isEqualTo(60_000L);
    }
}

O teste assume que o schema da seção 3 é carregado via src/test/resources/schema.sql (junto com spring.sql.init.mode=always) ou via Flyway. Dois virtual threads enviam reembolsos simultaneamente. O resultado esperado é sempre estável:

results          = [true, false]   (qualquer ordem)
refunded_amount  = 60000

Se você tentar substituir o UPDATE por leitura e comparação em Java, verá o teste falhar intermitentemente. Essa é exatamente a race condition que este design elimina.

8. Reconciliação

Por mais cuidadoso que você seja, dados internos e PSP ainda podem divergir. Webhooks podem ser perdidos, pode haver transações DESCONHECIDO não resolvidas, ou o PSP pode fazer reembolsos automáticos por chargeback. Reconciliação é a última linha de defesa.

Fluxo diário:

  1. Baixe o arquivo de settlement do PSP e carregue na tabela temporária psp_settlements(psp_reference, amount, type).
  2. Faça match com dados internos usando FULL OUTER JOIN.
  3. Grave as discrepâncias na tabela recon_breaks para processamento automático ou manual.
INSERT INTO recon_breaks (psp_reference, internal_amount, psp_amount, reason)
SELECT COALESCE(p.psp_reference, s.psp_reference),
       p.amount,
       s.amount,
       CASE
           WHEN p.psp_reference IS NULL THEN 'FALTANDO_INTERNO'
           WHEN s.psp_reference IS NULL THEN 'FALTANDO_NO_PSP'
           ELSE 'VALOR_MISMATCH'
       END
FROM (SELECT * FROM payments
      WHERE status = 'CAPTURADO' AND created_at >= :day AND created_at < :day + INTERVAL '1 day') p
FULL OUTER JOIN (SELECT * FROM psp_settlements WHERE type = 'CHARGE') s
       ON p.psp_reference = s.psp_reference
WHERE p.psp_reference IS NULL
   OR s.psp_reference IS NULL
   OR p.amount <> s.amount;

Significado de cada tipo de discrepância:

  • FALTANDO_INTERNO: PSP coletou dinheiro mas você não tem um registro CAPTURADO. Geralmente é transação DESCONHECIDO ou webhook perdido. Corrija atualizando status ou reembolsando se o pedido foi cancelado.
  • FALTANDO_NO_PSP: você registrou cobrança mas o PSP não tem. Caso crítico, pois você pode ter entregue sem receber.
  • VALOR_MISMATCH: geralmente devido a taxas, taxa de câmbio ou bug de arredondamento.

Nota: Tenha cuidado com janelas de tempo. Transações às 23:59 podem estar no arquivo de settlement do dia seguinte. Reconcile com lag de T+1 ou T+2 e só escale para humanos após a discrepância existir por duas corridas consecutivas.

9. Picos de carga

Em flash sales, a carga aumenta 25x. Algumas técnicas ajudam o sistema a aguentar:

  • Rate limit por merchant com Redis (INCR + EXPIRE por janela de um segundo). Protege o sistema de um merchant ‘barulhento’. Aqui Redis só limita, não decide correção de transação.
  • Separe síncrono de assíncrono. Caminho síncrono: gravar chave de idempotência, criar pagamento, chamar PSP authorize. Reembolso, email e atualização contábil via outbox → Kafka. Particione Kafka por paymentId para preservar ordem de eventos por pagamento.
  • Circuit breaker e timeout curto ao chamar PSP (ex. Resilience4j). Se PSP atrasar, falhe rápido, retorne 503 com Retry-After, não esgote thread pool. Cliente retry com mesma Idempotency-Key é seguro pela seção 5.
  • Limpe tabela idempotency_keys periodicamente (mantenha 24–72 horas), particione por dia, evite que a tabela cresça e desacelere INSERT.

10. Erros comuns

  • Usar double/float para dinheiro. Use long em menor unidade, ou BigDecimal se precisar de câmbio.
  • Tratar timeout como falha. Timeout significa desconhecido, defina UNKNOWN e consulte PSP novamente.
  • Gerar chave de idempotência no servidor. Chave deve ser gerada pelo cliente e mantida através dos retries.
  • Verificar saldo de reembolso em Java antes de UPDATE. Deixe o banco fazer isso em uma única instrução.
  • Enviar para Kafka dentro de @Transactional esperando que faça rollback com banco. Não fará. Use outbox.
  • Pule reconciliação porque ‘código está correto’. Sistemas externos estão fora do seu controle.

11. Conclusão

O artigo projetou o núcleo de um sistema de pagamento baseado em três princípios. Primeiro, idempotência é garantida por constraint unique no banco, tanto em sua API quanto ao chamar PSP. Segundo, concorrência em reembolsos é resolvida com update condicional mais constraint CHECK, provado por testes. Terceiro, consistência com mundo externo via outbox transacional e reconciliação diária.

Ponto comum: empurre invariantes críticos para banco, onde são executados atomicamente. Ao mesmo tempo, sempre assuma que rede, PSP e seu próprio código podem falhar, e prepare mecanismos de detecção e correção.

system-designpaymentidempotencyspring-bootpostgresql