← todos os artigos

Payments

Como eu projetei um payment gateway orchestrator depois de um curso de iGaming

PayMaestro: uma API na frente de vários payment gateways, com routing, cascading retries, idempotency garantida pelo database, e fraud screening baseado numa certificação anti-fraude da iGaming Academy.

Como eu projetei um payment gateway orchestrator depois de um curso de iGaming

Depois de terminar o curso Anti-Fraud and Payments Handling da iGaming Academy, eu quis realmente construir algo com o que ele ensinou em vez de deixar isso só como um certificado. Isso virou o PayMaestro: uma payment orchestration API, construída spec-first num fim de semana.

O que orchestration realmente significa

Payment orchestration é a camada acima dos payment gateways. Um merchant integra uma vez, várias rotas de acquiring ficam atrás dessa única integração, e a orchestration platform adiciona a inteligência: escolher a melhor rota para cada transação, fazer retry com segurança quando uma rota falha, e manter evidência de tudo o que aconteceu.

Essa última parte importa mais nesse domínio do que na maioria. Registros de pagamento são evidência regulatória, então o design tem que tratá-los assim desde o início.

Hard declines e soft declines não são a mesma falha

Essa é a distinção que o curso tornou concreta para mim, e é o núcleo da cascade policy. Um soft decline, saldo insuficiente ou um timeout, é recuperável, então faz sentido tentar o próximo gateway na rota. Um hard decline significa um cartão roubado ou bloqueado, e tentar isso em outro acquirer não é resiliência, é só tentar empurrar uma transação fraudulenta para passar em outro lugar.

Então a cascade para imediatamente num hard decline e continua num soft decline:

if (result.ResultType is GatewayResultType.Approved)
{
    payment.Authorize();
    payment.Capture();
    return;
}

if (result.ResultType is GatewayResultType.HardDecline)
    break;                       // fraud signal: never retry elsewhere

// SoftDecline / Error -> next gateway in the route

Fraud screening antes de qualquer gateway ser contatado

Toda fraud rule implementa um único Domain contract, então adicionar uma não toca no orchestrator:

public interface IFraudRule
{
    string RuleName { get; }
    Task<FraudVerdict> EvaluateAsync(Payment payment, CancellationToken ct = default);
}

A primeira regra em produção é decline velocity: um cartão com três ou mais tentativas recusadas em 24 horas é o padrão clássico de card testing, então a quarta tentativa é rejeitada sem nenhuma chamada a gateway. O screening roda antes de qualquer gateway ser contatado, o que significa que um cartão já conhecido como ruim não custa nada para rejeitar.

Idempotency pertence ao database, não só ao código

Se um merchant repete um request, o mesmo pagamento nunca pode ser cobrado duas vezes. A abordagem óbvia é checar se a idempotency key já existe antes de inserir, mas isso tem uma janela de corrida entre a leitura e a escrita, e dois retries concorrentes podem passar pela checagem ao mesmo tempo.

Então a idempotency key tem um índice único nela. O database serializa as duas inserções, a que perde lança um erro, e essa exception é capturada e respondida repetindo o resultado armazenado. A garantia mora onde a concorrência é de fato resolvida, em vez de em código de aplicação torcendo para ganhar uma corrida.

Routing como configuração

Elegibilidade de gateway, quais moedas cada um suporta e quais limites de valor se aplicam, mais prioridade de rota, tudo vive no appsettings.json e é vinculado através do options pattern. Adicionar um acquirer é uma classe implementando o gateway contract e uma entrada de config, sem mudanças em nenhuma lógica de negócio.

Onde clean architecture realmente valeu a pena

Esse é o projeto onde o layering que eu vinha aprendendo parou de ser teórico. API → Application → Domain ← Infrastructure, com o Domain segurando as entities, a state machine de pagamento, e todos os contracts, e sem nenhuma referência externa. Infrastructure implementa esses contracts com EF Core e SQLite.

A state machine de pagamento é protegida dentro do próprio aggregate, então uma transição inválida lança uma domain exception em vez de silenciosamente corromper um registro. Não existe caminho de volta a partir de Captured. Meus projetos anteriores usavam um modelo anêmico onde a entity era só um saco de propriedades e as regras viviam em outro lugar. Mover os invariants para dentro do aggregate foi uma mudança deliberada em relação a isso.

Audit-first, e com consciência de PCI

Toda tentativa de gateway é persistida com o nome do gateway, a ordem em que foi tentada, o result code, e quanto tempo levou. Toda fraud flag também é armazenada, e deletes são restritos no nível do database. Se um acquirer levanta um pedido de informação, um único GET retorna o pacote de evidências completo.

Só o BIN e os últimos quatro dígitos de um cartão são armazenados, nunca o número completo, o que é a base mínima para lidar com dados de cartão de qualquer forma.

O que eu adicionaria a seguir

Mais regras no mesmo IFraudRule contract: geo mismatch entre o país do IP e o país do cartão, o que precisa de dados reais de BIN e GeoIP para fazer sentido, e detecção de anomalia de valor. Depois refunds, e CI/CD para Azure Container Apps.