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:

  1. amount próprio, em centavos. Não derive do pagamento, porque a contestação pode ser parcial.
  2. merchant_id denormalizado. 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.
  3. Í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:

  1. 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.
  2. 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.
  3. 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.
  4. 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:

  1. 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.
  2. Fuso horário explícito. O provedor manda o prazo em UTC. Guarde com timestampTz e 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:

  1. Criar a tabela disputes com unique no ID do provedor e o enum de estados que só avança.
  2. Fazer o handler do webhook reter o valor no ledger em qualquer evento, com chave idempotente por lançamento.
  3. Bloquear saque quando o disponível ficar negativo e decidir por escrito a política de compensação.
  4. Colocar o agendador de prazo com prazo interno antes do prazo do provedor.
  5. 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.