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 tabelaidempotency_keysé o núcleo do mecanismo anti-duplicação. - A tabela
outboxajuda 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:
- Chave não existe: gravação bem-sucedida, processe.
- Chave está
CONCLUÍDO: retorne a resposta armazenada, não chame o PSP novamente. - Chave está
EM_PROGRESSO: outro request com a mesma chave está processando. Retorne409 Conflictpara 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
SETNXcomo 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:
- Baixe o arquivo de settlement do PSP e carregue na tabela temporária
psp_settlements(psp_reference, amount, type). - Faça match com dados internos usando
FULL OUTER JOIN. - Grave as discrepâncias na tabela
recon_breakspara 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 registroCAPTURADO. Geralmente é transaçãoDESCONHECIDOou 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+EXPIREpor 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
paymentIdpara preservar ordem de eventos por pagamento. - Circuit breaker e timeout curto ao chamar PSP (ex. Resilience4j). Se PSP atrasar, falhe rápido, retorne
503comRetry-After, não esgote thread pool. Cliente retry com mesmaIdempotency-Keyé seguro pela seção 5. - Limpe tabela
idempotency_keysperiodicamente (mantenha 24–72 horas), particione por dia, evite que a tabela cresça e desacelereINSERT.
10. Erros comuns
- Usar
double/floatpara dinheiro. Uselongem menor unidade, ouBigDecimalse precisar de câmbio. - Tratar timeout como falha. Timeout significa desconhecido, defina
UNKNOWNe 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
@Transactionalesperando 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.