# Conciliação de pagamentos no Laravel: como bater o extrato do provedor com o seu banco todo dia

> Webhook não garante que seu banco reflete o que o provedor processou. Veja como montar uma conciliação diária no Laravel com upsert, FULL OUTER JOIN e fila de divergências.

Por Lucas Roberto · Pagamentos · publicado em 2026-10-09 · 8 min de leitura
Versão web: https://lucasroberto.com/blog/conciliacao-de-pagamentos-no-laravel-como-bater-o-extrato-do-provedor-com-o-seu-banco-todo-dia

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:

1. O provedor nunca entregou o evento, ou desistiu depois de esgotar as tentativas.
2. Seu endpoint respondeu 200, mas o job que processava o evento falhou e foi parar na `failed_jobs`.
3. Alguém fez um estorno manual direto no painel do provedor.
4. 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.
5. 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.

```php
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:

```php
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:

```sql
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:

```php
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:

```php
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:

1. **Fila dedicada.** A fila `reconciliation` roda em supervisor próprio no Horizon, com poucos processos. Conciliação é pesada e não pode disputar worker com o processamento de webhooks.
2. **Execução reexecutável.** `ReconcileProviderDay` faz `updateOrCreate` no `reconciliation_runs` por 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.
3. **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.
4. **Rate limit do provedor.** O import respeita o limite da API com `RateLimited` middleware 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.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
