# Chargeback no Laravel: como modelar disputas, reter saldo e não perder o prazo de defesa

> Chargeback não é estorno. Ele exige tabela própria, estados que só andam para frente, retenção de saldo no ledger e controle rígido do prazo para mandar a defesa.

Por Lucas Roberto · Pagamentos · publicado em 2026-10-24 · 10 min de leitura
Versão web: https://lucasroberto.com/blog/chargeback-no-laravel-como-modelar-disputas-reter-saldo-e-nao-perder-o-prazo-de-defesa

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.

```php
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](/blog/maquina-de-estados-para-status-de-pagamento-no-laravel-como-impedir-transicoes-invalidas).

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`.

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

```php
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](/blog/webhooks-de-pagamento-duplicados-e-fora-de-ordem-como-processar-cada-evento-uma-unica-vez-no-laravel). Aqui a proteção é em duas camadas: lock na linha da disputa e chave idempotente em cada lançamento do ledger.

```php
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](/ferramentas/testador-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](/blog/ledger-de-partidas-dobradas-no-laravel-e-postgresql-como-modelar-o-saldo-de-uma-conta-digital).

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

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

```php
// routes/console.php
Schedule::command('disputes:remind-deadlines')->hourly()->withoutOverlapping();
```

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

---

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