Chargeback precisa de modelagem própria. Isso quer dizer uma tabela de disputas separada do pagamento, estados que só avançam, retenção do valor no saldo do lojista assim que a disputa abre e um agendador que trate o prazo de defesa como dado de primeira classe. Quem trata chargeback como um estorno qualquer acaba com saldo errado, defesa perdida por prazo e lojista sacando um dinheiro que já não é dele.
Este é o desenho que eu uso em plataformas com subcontas e conta digital. O fluxo é sempre o mesmo: o dinheiro da venda já foi repassado ao lojista quando o portador do cartão contesta a compra semanas depois.
O que é chargeback e por que ele quebra o fluxo normal de pagamento?
Chargeback é a reversão de uma transação de cartão iniciada pelo banco emissor a pedido do portador, e não pelo lojista. No fluxo normal, o pagamento nasce, é capturado, liquidado e repassado. O chargeback chega depois de tudo isso, às vezes meses depois da venda, com regras e prazos definidos pela bandeira, não por você.
Alguns termos que aparecem no resto do texto:
- Disputa: o processo inteiro, da abertura da contestação até a decisão final.
- Inquiry (ou pré-chargeback): um pedido de informação do emissor que pode ou não virar chargeback. Alguns provedores, como o Stripe, expõem isso como uma disputa em estado de alerta.
- Representment (defesa): o envio de evidências pelo lojista para tentar reverter a contestação.
- Evidence due date: a data limite para enviar a defesa. Passou dela, a disputa é perdida por omissão.
O ponto que mais pesa para o back-end é este: em plataformas com split, o provedor debita o valor da sua conta de plataforma, não da conta do lojista. Se o seu ledger não refletir isso na hora, o lojista continua com saldo disponível para sacar e você descobre o buraco na conciliação.
Qual a diferença entre estorno e chargeback?
O erro mais comum que vejo é reaproveitar o fluxo de estorno (refund) para chargeback. Eles parecem iguais porque nos dois o dinheiro volta para o portador, mas o comportamento é bem diferente:
| Aspecto | Estorno | Chargeback |
|---|---|---|
| Quem inicia | Lojista ou plataforma | Emissor, a pedido do portador |
| Momento | Quando você decide | Quando o emissor decide, até meses depois |
| Resultado | Definitivo na hora | Provisório até a decisão final |
| Pode reverter | Não | Sim, se a defesa for aceita |
| Prazo para agir | Nenhum | Data limite para enviar evidências |
| Custo extra | Geralmente nenhum | Muitos provedores cobram taxa por disputa |
O resultado provisório é o que muda a modelagem. Um estorno é um lançamento e acabou. Um chargeback pede um lançamento de retenção na abertura e outro na decisão, que pode devolver o dinheiro ao lojista ou confirmar a perda. Se você colocar isso numa coluna refunded_amount no pagamento, não tem onde guardar o estado intermediário.
Como modelar a disputa no banco de dados?
A disputa vira uma entidade própria, ligada ao pagamento mas com ciclo de vida independente. Um pagamento pode ter mais de uma disputa (uma inquiry que fechou e depois um chargeback de verdade), e o valor contestado pode ser parcial.
Schema::create('disputes', function (Blueprint $table) { $table->id(); $table->foreignId('payment_id')->constrained(); $table->foreignId('merchant_id')->constrained(); $table->string('provider'); $table->string('provider_dispute_id'); $table->string('status'); $table->string('reason')->nullable(); $table->bigInteger('amount'); // centavos $table->timestampTz('evidence_due_at')->nullable(); $table->timestampTz('evidence_submitted_at')->nullable(); $table->unsignedTinyInteger('reminders_sent')->default(0); $table->timestampTz('closed_at')->nullable(); $table->timestamps(); $table->unique(['provider', 'provider_dispute_id']); $table->index(['status', 'evidence_due_at']); });
Três decisões importam aqui:
amountpróprio, em centavos. Não derive do pagamento, porque a contestação pode ser parcial.merchant_iddenormalizado. A disputa é consultada o tempo todo por lojista (painel, alertas, bloqueio de saque) e eu não quero um join com pagamento em toda tela.- Índice em
(status, evidence_due_at). É exatamente a consulta do agendador de prazos, que roda de hora em hora.
Quais estados uma disputa pode ter e como lidar com eventos fora de ordem?
Cada provedor tem sua lista de status. O Stripe, por exemplo, usa warning_needs_response, needs_response, under_review, won, lost, entre outros. Eu não deixo esses nomes vazarem para o domínio. Mapeio tudo para um enum interno pequeno, no mesmo espírito da máquina de estados para status de pagamento.
A diferença em relação a um pagamento é que, em disputa, eu prefiro uma regra mais simples que um grafo de transições: o estado só anda para frente. Cada estado tem um nível, e um evento que tenta levar a disputa para um nível igual ou menor é ignorado. Isso resolve de uma vez o evento repetido e o evento atrasado, como um updated que chega depois do closed.
enum DisputeStatus: string { case Opened = 'opened'; case AwaitingEvidence = 'awaiting_evidence'; case UnderReview = 'under_review'; case Won = 'won'; case Lost = 'lost'; public function rank(): int { return match ($this) { self::Opened => 0, self::AwaitingEvidence => 1, self::UnderReview => 2, self::Won, self::Lost => 3, }; } public function isFinal(): bool { return $this->rank() === 3; } }
E o mapeamento do provedor fica isolado no adapter:
final class StripeDisputeStatus { public static function toDomain(string $status): DisputeStatus { return match ($status) { 'warning_needs_response', 'needs_response' => DisputeStatus::AwaitingEvidence, 'warning_under_review', 'under_review' => DisputeStatus::UnderReview, 'won', 'warning_closed' => DisputeStatus::Won, 'lost' => DisputeStatus::Lost, default => throw new UnexpectedValueException("Status de disputa desconhecido: {$status}"), }; } }
warning_closed é uma inquiry que fechou sem virar chargeback. Para o saldo do lojista ela equivale a ganhar, então mapeio para Won. O default que lança exceção é proposital: se o provedor criar um status novo, prefiro o job falhar e aparecer no Horizon a adivinhar o significado e mexer em dinheiro.
Um detalhe: "aceitar" a disputa, quando o lojista decide não se defender, não é um estado à parte. Você chama a API do provedor para fechar, ele responde com lost e o fluxo normal cuida do resto.
Como processar os webhooks de disputa sem duplicar o débito?
O handler precisa aguentar o mesmo evento chegando duas vezes e eventos chegando fora de ordem. A deduplicação genérica de eventos eu já cobri no post sobre webhooks de pagamento duplicados e fora de ordem. Aqui a proteção é em duas camadas: lock na linha da disputa e chave idempotente em cada lançamento do ledger.
final class HandleStripeDispute { public function __construct(private DisputeLedger $ledger) {} public function handle(array $object): void { DB::transaction(function () use ($object) { $payment = Payment::where('provider_charge_id', $object['charge'])->firstOrFail(); Dispute::query()->insertOrIgnore([ 'payment_id' => $payment->id, 'merchant_id' => $payment->merchant_id, 'provider' => 'stripe', 'provider_dispute_id' => $object['id'], 'status' => DisputeStatus::Opened->value, 'reason' => $object['reason'], 'amount' => $object['amount'], 'created_at' => now(), 'updated_at' => now(), ]); $dispute = Dispute::where('provider', 'stripe') ->where('provider_dispute_id', $object['id']) ->lockForUpdate() ->firstOrFail(); $this->ledger->hold($dispute); $dueBy = $object['evidence_details']['due_by'] ?? null; if ($dueBy) { $dispute->evidence_due_at = Carbon::createFromTimestamp($dueBy); } $next = StripeDisputeStatus::toDomain($object['status']); if ($next->rank() > $dispute->status->rank()) { $dispute->status = $next; $dispute->closed_at = $next->isFinal() ? now() : null; match ($next) { DisputeStatus::Won => $this->ledger->release($dispute), DisputeStatus::Lost => $this->ledger->settleLoss($dispute), default => null, }; } $dispute->save(); }); } }
Repare que hold() é chamado em todo evento, não só no created. Se o created se perder e o primeiro evento que chega for um updated, a retenção acontece do mesmo jeito. Quem garante que ela não roda duas vezes é a chave idempotente dentro do ledger. A unique em (provider, provider_dispute_id) com insertOrIgnore resolve a corrida entre dois workers recebendo eventos da mesma disputa ao mesmo tempo.
Antes de tudo isso, claro, vem a validação da assinatura do webhook. Disputa é um evento que gera débito no saldo de alguém, e aceitar payload forjado ali é grave. Se estiver depurando por que a assinatura não bate, o testador de webhook confere o HMAC direto no navegador.
Como lançar o chargeback no ledger da conta digital?
Se a conta digital usa partidas dobradas (cada movimentação é um débito numa conta e um crédito em outra, sempre somando zero), a disputa vira três lançamentos possíveis. A base dessa modelagem está no post sobre ledger de partidas dobradas no Laravel e PostgreSQL.
| Momento | Débito | Crédito |
|---|---|---|
| Disputa aberta | merchant:{id}:available |
merchant:{id}:disputed |
| Disputa ganha | merchant:{id}:disputed |
merchant:{id}:available |
| Disputa perdida | merchant:{id}:disputed |
platform:provider_balance:stripe |
final class DisputeLedger { public function __construct(private Ledger $ledger) {} public function hold(Dispute $d): void { $this->ledger->post("dispute:{$d->id}:hold", [ Entry::debit("merchant:{$d->merchant_id}:available", $d->amount), Entry::credit("merchant:{$d->merchant_id}:disputed", $d->amount), ]); } public function release(Dispute $d): void { $this->ledger->post("dispute:{$d->id}:release", [ Entry::debit("merchant:{$d->merchant_id}:disputed", $d->amount), Entry::credit("merchant:{$d->merchant_id}:available", $d->amount), ]); } public function settleLoss(Dispute $d): void { $this->ledger->post("dispute:{$d->id}:loss", [ Entry::debit("merchant:{$d->merchant_id}:disputed", $d->amount), Entry::credit('platform:provider_balance:stripe', $d->amount), ]); } }
Por que uma subconta disputed em vez de simplesmente debitar o disponível na abertura? Por dois motivos. Primeiro, o lojista vê no extrato que existe um valor em disputa, e não um sumiço de dinheiro sem explicação. Segundo, a soma de disputed de todos os lojistas tem que bater com o que o provedor reteve de você. Isso vira uma linha a mais na conciliação diária.
No nível da plataforma, siga os eventos de movimentação de fundos do provedor, quando eles existirem. No Stripe são charge.dispute.funds_withdrawn e charge.dispute.funds_reinstated. Eles dizem quando o dinheiro saiu e voltou da sua conta no provedor, o que nem sempre coincide com a abertura e o fechamento da disputa. Taxa de disputa cobrada pelo provedor é um lançamento separado, e a política de repassar ou não ao lojista é decisão de negócio. Não misture com o valor contestado.
O que fazer quando o lojista não tem saldo para cobrir o chargeback?
Esse é o trade-off que mais gera discussão. A disputa chega, o lojista já sacou tudo e o available vai ficar negativo. As opções que conheço:
- Permitir saldo negativo e compensar nos recebíveis futuros. É o mais comum em adquirência. O próximo repasse abate a dívida primeiro. Funciona bem para lojista ativo, e o risco fica com a plataforma se ele parar de vender.
- Reserva rolante (rolling reserve). Um percentual de cada venda fica retido por um período e serve de colchão para disputas. Protege a plataforma, mas irrita o lojista e precisa estar no contrato. Faz sentido para segmentos com histórico de contestação alto.
- Bloquear saques enquanto houver disputa aberta acima de um limite. É simples de implementar, mas trava o caixa de um negócio pequeno por algo que talvez nem se confirme.
- Cobrar ativamente (boleto, Pix de cobrança). Último recurso, porque a taxa de recuperação costuma ser baixa.
Na prática eu combino a opção 1 como padrão com a opção 2 para lojistas classificados como risco maior no onboarding. O que não pode é a regra de saldo negativo ficar implícita: o ledger precisa permitir explicitamente que available fique abaixo de zero para aquele tipo de lançamento, e o fluxo de saque precisa barrar qualquer saída enquanto o saldo estiver negativo.
Como não perder o prazo de defesa do chargeback?
Disputa perdida por prazo é o pior tipo de perda, porque às vezes a evidência existia e ninguém enviou. Trato evidence_due_at como um SLA e tenho um comando agendado que avisa o lojista em marcos fixos:
// routes/console.php Schedule::command('disputes:remind-deadlines')->hourly()->withoutOverlapping();
public function handle(): void { $thresholds = [5, 2, 1]; // dias antes do prazo Dispute::query() ->where('status', DisputeStatus::AwaitingEvidence) ->whereNull('evidence_submitted_at') ->where('evidence_due_at', '<=', now()->addDays($thresholds[0])) ->with('merchant') ->lazyById() ->each(function (Dispute $d) use ($thresholds) { $daysLeft = now()->diffInDays($d->evidence_due_at, false); $due = collect($thresholds)->filter(fn ($t) => $daysLeft <= $t)->count(); if ($due > $d->reminders_sent) { $d->merchant->notify(new DisputeDeadlineApproaching($d)); $d->update(['reminders_sent' => $due]); } }); }
O contador reminders_sent garante que o comando possa rodar de hora em hora sem mandar o mesmo aviso 24 vezes por dia. Duas regras extras que adoto:
- Prazo interno menor que o do provedor. O lojista precisa entregar as evidências pelo menos dois dias antes do
evidence_due_at. Assim sobra tempo para revisar e para o envio falhar e ser refeito. - Fuso horário explícito. O provedor manda o prazo em UTC. Guarde com
timestampTze mostre convertido no painel. Quem vê "vence dia 10" sem saber se é meia-noite de Brasília ou de Greenwich perde o prazo achando que tinha mais um dia.
Que evidências enviar na defesa de um chargeback?
A lista depende do motivo (reason) da contestação, e o sistema deve montar um checklist diferente para cada um, não um formulário genérico. Os padrões típicos:
- Venda presencial na maquininha: comprovante da transação com indicação de chip e senha ou aproximação, dados do terminal, data e hora. Em venda com chip e senha, a defesa por "não reconheço a compra" costuma ser bem mais forte.
- Venda online: comprovante de entrega com rastreio, IP e dispositivo da compra, resultado de autenticação 3DS quando houve, histórico de compras anteriores do mesmo cliente.
- Serviço ou agendamento: registro de que o serviço foi prestado, comunicação com o cliente, política de cancelamento aceita no checkout.
O back-end ajuda muito aqui guardando esses dados no momento da venda. IP, user agent, resultado do 3DS e aceite dos termos precisam ser gravados na transação, porque três meses depois ninguém consegue reconstruí-los.
Conclusão: o que implementar primeiro
Se hoje o seu sistema trata chargeback como estorno ou nem trata, esta é a ordem que eu seguiria:
- Criar a tabela
disputescom unique no ID do provedor e o enum de estados que só avança. - Fazer o handler do webhook reter o valor no ledger em qualquer evento, com chave idempotente por lançamento.
- Bloquear saque quando o disponível ficar negativo e decidir por escrito a política de compensação.
- Colocar o agendador de prazo com prazo interno antes do prazo do provedor.
- Começar a gravar, já na venda, as evidências que você vai precisar na defesa.
Os itens 1 a 3 evitam que você perca dinheiro. Os itens 4 e 5 aumentam a chance de recuperar o que foi contestado.