Conciliação de pagamentos é comparar, transação por transação, o que o provedor (Pagar.me, Stripe, Asaas) diz que processou com o que o seu banco de dados diz que aconteceu, e tratar cada diferença. No Laravel, o desenho que funciona para mim tem três partes: importar o extrato do provedor para uma tabela própria com upsert, cruzar com o ledger interno usando um FULL OUTER JOIN no PostgreSQL e transformar cada divergência em um item rastreável, com correção automática só onde for seguro. Webhook bem feito reduz o problema, mas não substitui esse processo.
Por que webhook idempotente não basta para garantir que os valores batem?
Mesmo com webhooks processados uma única vez, há caminhos em que seu banco e o provedor divergem sem ninguém perceber:
- O provedor nunca entregou o evento, ou desistiu depois de esgotar as tentativas.
- Seu endpoint respondeu 200, mas o job que processava o evento falhou e foi parar na
failed_jobs. - Alguém fez um estorno manual direto no painel do provedor.
- Um chargeback (contestação aberta pelo portador do cartão junto ao banco emissor) chegou com status ou nome de evento que seu código não mapeava.
- A taxa cobrada foi diferente da que você calcula localmente.
Webhook é um canal de notificação, não uma fonte de verdade. A fonte de verdade financeira é o extrato do provedor e, um nível acima, o dinheiro que efetivamente caiu na conta bancária. A conciliação é o processo que fecha esse ciclo.
Quais camadas de conciliação um sistema de pagamento precisa?
Eu separo em duas camadas, porque elas falham por motivos diferentes:
| Camada | Pergunta que responde | Fonte externa |
|---|---|---|
| Transacional | Cada cobrança, estorno e chargeback existe dos dois lados, com o mesmo valor e status? | API ou arquivo de transações do provedor |
| Liquidação | O valor que o provedor disse que ia repassar na data X caiu de fato na conta? | Extrato bancário (via API do banco ou agregador como Pluggy) |
A liquidação depende de entender o prazo de repasse, normalmente chamado de D+N (o provedor paga N dias após a transação), e de descontos como MDR (a taxa percentual que o adquirente retém sobre cada venda no cartão) e antecipação (receber antes do prazo pagando um deságio). Este post foca na camada transacional, que é onde a maior parte dos bugs de código aparece. A de liquidação reaproveita a mesma estrutura, trocando a fonte.
Como modelar a conciliação no Laravel?
Primeiro, nunca concilie direto contra a resposta da API em memória. Importe para uma tabela de staging. Isso te dá reprocessamento barato, histórico e a possibilidade de investigar depois com SQL.
Schema::create('provider_transactions', function (Blueprint $table) { $table->id(); $table->string('provider'); // pagarme, stripe, asaas $table->string('external_id'); $table->string('type'); // charge, refund, chargeback $table->bigInteger('gross_amount'); // sempre em centavos $table->bigInteger('fee_amount'); $table->bigInteger('net_amount'); $table->string('status'); // já normalizado $table->date('reference_date'); $table->jsonb('raw'); $table->timestamps(); $table->unique(['provider', 'external_id', 'type']); });
Dois detalhes importam aqui. O status é gravado já normalizado: cada provedor chama as coisas de um jeito (paid, succeeded, RECEIVED), e a comparação precisa de um vocabulário único. O raw guarda o payload original, porque em toda investigação de divergência alguém vai perguntar "mas o que o provedor mandou exatamente?".
Depois vêm duas tabelas: reconciliation_runs (uma execução por provedor e data, com status e contadores) e reconciliation_items (uma linha por divergência, com tipo, valores dos dois lados e situação: aberta, corrigida automaticamente, resolvida manualmente).
Como importar o extrato do provedor sem duplicar registros?
O import precisa ser idempotente, porque você vai rodá-lo várias vezes para o mesmo dia. Provedores atualizam transações retroativamente: um estorno de ontem pode aparecer hoje, um status pode mudar dias depois. Por isso uso upsert com a chave única da tabela:
class ImportProviderTransactions implements ShouldQueue { use Queueable; public function __construct( public string $provider, public string $date, ) {} public function handle(ProviderClientFactory $factory): void { $client = $factory->make($this->provider); foreach ($client->transactionsFor($this->date) as $page) { ProviderTransaction::upsert( array_map(fn ($t) => $client->toRow($t), $page), uniqueBy: ['provider', 'external_id', 'type'], update: ['status', 'gross_amount', 'fee_amount', 'net_amount', 'raw', 'updated_at'], ); } } }
O transactionsFor é um generator que pagina a API, então a memória do worker fica estável independentemente do volume. Cada provedor tem seu client implementando a mesma interface, e o toRow faz a normalização de status e a conversão para centavos. Se o provedor devolve valores em reais com casas decimais, a conversão acontece ali, uma vez, com aritmética inteira ou bcmath, nunca com float.
Como cruzar as transações do provedor com o banco interno?
Aqui o PostgreSQL faz o trabalho pesado. Um FULL OUTER JOIN devolve, numa consulta só, o que bate, o que só existe no provedor e o que só existe no seu lado:
SELECT p.external_id AS provider_external_id, l.external_id AS ledger_external_id, p.type AS provider_type, l.type AS ledger_type, p.net_amount AS provider_net, l.net_amount AS ledger_net, p.fee_amount AS provider_fee, l.fee_amount AS ledger_fee, p.status AS provider_status, l.status AS ledger_status FROM ( SELECT * FROM provider_transactions WHERE provider = :provider AND reference_date = :date ) p FULL OUTER JOIN ( SELECT * FROM ledger_transactions WHERE provider = :provider AND reference_date BETWEEN :date::date - 1 AND :date::date + 1 ) l ON l.external_id = p.external_id AND l.type = p.type
Repare na janela de D-1 a D+1 no lado interno. Transações perto da meia-noite caem em dias diferentes conforme o fuso que cada sistema usa para definir a data de referência. Sem essa folga, você gera divergências falsas todo dia, e divergência falsa recorrente é o jeito mais rápido de fazer o time parar de olhar o relatório. A consequência da janela é que um registro do lado interno pode aparecer como "só no ledger" na execução de um dia e casar na do dia seguinte. Por isso a classificação de "faltando no provedor" só é definitiva depois de um período de carência.
A chave de match é o external_id que o provedor devolveu na criação da cobrança. Se o seu sistema não guarda esse ID de forma confiável em toda transação, esse é o primeiro problema a resolver, antes de qualquer conciliação.
Que tipos de divergência aparecem e o que fazer com cada um?
Cada linha do resultado vira uma classificação. Eu modelo como enum:
enum Divergence: string { case Matched = 'matched'; case MissingInternal = 'missing_internal'; case MissingAtProvider = 'missing_at_provider'; case StatusMismatch = 'status_mismatch'; case AmountMismatch = 'amount_mismatch'; case FeeMismatch = 'fee_mismatch'; public static function classify(object $row): self { return match (true) { $row->ledger_external_id === null => self::MissingInternal, $row->provider_external_id === null => self::MissingAtProvider, $row->provider_status !== $row->ledger_status => self::StatusMismatch, $row->provider_net !== $row->ledger_net => self::AmountMismatch, $row->provider_fee !== $row->ledger_fee => self::FeeMismatch, default => self::Matched, }; } }
A ordem do match é proposital: status vem antes de valor, porque uma cobrança estornada no provedor e paga no seu banco vai ter valores líquidos diferentes como sintoma, e o problema real é o status.
| Divergência | Causa típica | Ação |
|---|---|---|
| Faltando no interno | Webhook perdido, estorno manual no painel | Buscar a transação na API e reprocessar pelo mesmo handler do webhook |
| Faltando no provedor | Cobrança criada localmente que falhou ou deu timeout na criação | Aguardar carência, depois item manual |
| Status diferente | Evento não processado, status não mapeado | Re-sync automático se o provedor estiver "à frente" |
| Valor diferente | Bug de cálculo, captura parcial, arredondamento | Sempre manual |
| Taxa diferente | Tabela de MDR desatualizada, regra de parcelamento | Manual, com comparação contra a taxa contratada |
Quando é seguro corrigir divergências automaticamente?
Minha regra: correção automática só quando ela é exatamente o que o webhook teria feito. Nos casos de "faltando no interno" e "status diferente" com o provedor mais avançado no ciclo de vida (por exemplo, pago no provedor e pendente aqui), o job de conciliação busca a transação individual na API e despacha o mesmo handler que processa o webhook. Como esse handler já é idempotente, não há caminho novo de código mexendo em saldo.
O que eu nunca corrijo automaticamente é valor. Se o líquido diverge, há um bug ou uma regra de negócio que ninguém entendeu, e sobrescrever o número do banco com o do provedor esconde o problema e pode desbalancear saldos derivados, como o que foi repassado num split. Esses casos viram itens abertos com os dois valores, o payload bruto e um link para a transação, e entram numa fila de análise com responsável.
Outro ponto: status que regride (pago no seu banco, pendente no provedor) também não deve ser corrigido sozinho. Normalmente indica que o provedor ainda não consolidou, ou que algo grave aconteceu no seu lado. Nos dois casos, quem decide é uma pessoa.
Como agendar e reprocessar a conciliação com filas?
No routes/console.php, agendo uma cadeia por provedor e por dia, reprocessando uma janela de dias anteriores, porque o extrato muda depois:
Schedule::call(function () { foreach (['pagarme', 'asaas'] as $provider) { foreach (range(1, 3) as $daysAgo) { $date = now('America/Sao_Paulo')->subDays($daysAgo)->toDateString(); Bus::chain([ new ImportProviderTransactions($provider, $date), new ReconcileProviderDay($provider, $date), ])->onQueue('reconciliation')->dispatch(); } } })->dailyAt('04:00');
Algumas decisões por trás disso:
- Fila dedicada. A fila
reconciliationroda em supervisor próprio no Horizon, com poucos processos. Conciliação é pesada e não pode disputar worker com o processamento de webhooks. - Execução reexecutável.
ReconcileProviderDayfazupdateOrCreatenoreconciliation_runspor provedor e data, e os itens são atualizados pela chave da transação. Rodar de novo não cria duplicata; itens que passaram a casar são marcados como resolvidos, com histórico. - Fuso explícito. A data de referência é calculada sempre no fuso do negócio. Deixar o servidor em UTC decidir o que é "ontem" é a origem clássica de divergência fantasma.
- Rate limit do provedor. O import respeita o limite da API com
RateLimitedmiddleware no job, para a conciliação não derrubar as chamadas de cobrança que acontecem em paralelo.
Como saber se a conciliação está funcionando?
O indicador que eu acompanho não é a quantidade de divergências encontradas, mas a quantidade de itens abertos há mais de alguns dias. Divergência que aparece e é tratada é sinal de sistema saudável. Divergência que envelhece é dinheiro sem dono. Um alerta simples, disparado quando existe item manual aberto além do prazo combinado ou quando a run de um dia não terminou, já cobre a maior parte dos riscos.
Conclusão
Se hoje você não concilia nada, comece pequeno: garanta que toda transação guarda o external_id do provedor, crie a tabela de staging com chave única e rode o FULL OUTER JOIN para um único provedor, só gerando relatório. Na primeira semana você vai descobrir os status não mapeados e os problemas de fuso. Depois disso, ligue o re-sync automático apenas para os casos em que ele reutiliza o handler do webhook, mantenha qualquer divergência de valor como item manual e só então avance para a camada de liquidação contra o extrato bancário. É a ordem que entrega mais segurança com menos código novo mexendo em saldo.