# Saga com compensação no Laravel: como desfazer uma transferência Pix que falhou no meio

> Como orquestrar reserva de saldo, envio ao provedor e liquidação de um Pix de saída no Laravel, com compensações idempotentes e sem tratar timeout como falha.

Por Lucas Roberto · Arquitetura · publicado em 2026-10-16 · 10 min de leitura
Versão web: https://lucasroberto.com/blog/saga-com-compensacao-no-laravel-como-desfazer-uma-transferencia-pix-que-falhou-no-meio

Uma transferência Pix de saída não cabe em uma transação de banco. Ela passa pelo seu PostgreSQL, pela API do provedor e por um webhook que chega minutos depois. Quando um passo falha no meio, não existe `ROLLBACK` que desfaça o que já aconteceu fora do seu banco. A saída que uso é uma saga orquestrada: cada passo grava seu estado, cada passo tem uma compensação idempotente, e o timeout do provedor nunca é tratado como falha. Abaixo está como eu estruturo isso no Laravel, as decisões que tomo e onde estão os riscos.

## O que é uma saga e por que uma transação de banco não resolve?

Saga é uma sequência de transações locais em que cada passo confirma seu próprio trabalho e, se um passo posterior falhar, os anteriores são desfeitos por compensações. Compensação é uma operação de negócio que anula o efeito de um passo já confirmado. Ela não é um rollback técnico, é um novo lançamento que leva o sistema a um estado consistente.

No cash-out de uma conta digital, o fluxo típico tem quatro passos:

1. Validar o pedido (limites, chave Pix, horário, antifraude).
2. Reservar o saldo no ledger, para que o cliente não gaste o mesmo dinheiro duas vezes enquanto o Pix está em voo.
3. Enviar a ordem ao provedor.
4. Liquidar: transformar a reserva em débito definitivo quando o provedor confirmar, ou liberar a reserva quando ele rejeitar.

O passo 2 está no meu banco. O passo 3 está no banco de outra empresa. O passo 4 depende de um evento assíncrono. Envolver tudo em `DB::transaction()` significaria segurar uma transação aberta durante uma chamada HTTP de alguns segundos, com locks na linha da conta, e ainda assim não resolveria nada: se o provedor aceitar o Pix e o seu commit falhar depois, o dinheiro saiu e o seu banco não sabe.

## Orquestração ou coreografia: qual escolher para fluxos financeiros?

Há dois jeitos de montar uma saga. Na orquestração, um componente central sabe a sequência e manda cada passo acontecer. Na coreografia, cada serviço reage a eventos dos outros e ninguém tem a visão do todo.

| Critério | Orquestração | Coreografia |
|---|---|---|
| Onde está o fluxo | Em uma classe só | Espalhado em listeners |
| Responder "em que pé está esta transferência?" | Uma query na tabela da saga | Montar a história a partir de eventos |
| Acoplamento | Orquestrador conhece todos os passos | Passos se conhecem só por eventos |
| Adicionar um passo | Editar o orquestrador | Novo listener, risco de efeito colateral |
| Depurar em produção | Simples | Trabalhoso |

Para dinheiro, eu escolho orquestração sem pensar muito. O suporte precisa responder ao cliente onde está o Pix dele, a conciliação precisa saber quais sagas ficaram presas, e eu preciso ler o fluxo inteiro em um arquivo. Coreografia faz sentido entre serviços de times diferentes; dentro de um monólito Laravel ela só adiciona indireção.

## Como persistir o estado da saga no Laravel?

A saga é uma linha no banco. Se o worker morrer no meio, outro worker lê a linha e continua de onde parou. Esse é o ponto central: o progresso não vive na memória do job, vive no PostgreSQL.

```php
Schema::create('transfer_sagas', function (Blueprint $t) {
    $t->uuid('id')->primary();
    $t->foreignId('account_id')->constrained();
    $t->bigInteger('amount_cents');
    $t->string('pix_key');
    $t->string('state')->index();
    $t->string('provider_reference')->nullable();
    $t->string('failure_reason')->nullable();
    $t->unsignedInteger('checks')->default(0);
    $t->timestamp('next_check_at')->nullable()->index();
    $t->timestamp('deadline_at');
    $t->timestamps();
});
```

Os estados que uso:

1. `started`: pedido validado, nada foi tocado ainda.
2. `funds_reserved`: hold criado no ledger.
3. `submitted`: ordem enviada ao provedor, ou com resultado desconhecido (volto nisso).
4. `completed`: provedor confirmou, débito liquidado. Terminal.
5. `compensating`: algo falhou, precisa liberar o hold.
6. `compensated`: hold liberado. Terminal.
7. `needs_review`: passou do prazo sem resposta conclusiva. Terminal até um humano agir.

As transições entre eles precisam ser travadas da mesma forma que um status de pagamento; eu reaproveito o padrão descrito em [máquina de estados para impedir transições inválidas](/blog/maquina-de-estados-para-status-de-pagamento-no-laravel-como-impedir-transicoes-invalidas), para que nenhum código consiga ir de `compensated` para `completed`.

## Como escrever o orquestrador?

O orquestrador tem um único método público: avançar a saga um passo. Ele é chamado por um job, e o job é disparado por quem quer que tenha motivo para mexer na saga: a criação, o próprio passo anterior, o webhook do provedor, o scheduler.

```php
final class AdvanceTransferSaga implements ShouldQueue
{
    use Dispatchable, Queueable;

    public int $tries = 5;

    public function __construct(public string $sagaId) {}

    public function middleware(): array
    {
        return [(new WithoutOverlapping($this->sagaId))->releaseAfter(10)->expireAfter(120)];
    }

    public function handle(TransferSagaOrchestrator $orchestrator): void
    {
        $orchestrator->advance($this->sagaId);
    }
}
```

O `WithoutOverlapping` com a chave da saga garante que dois workers não avancem a mesma transferência ao mesmo tempo, o que aconteceria fácil quando o webhook chega no mesmo segundo em que o scheduler resolve checar. Ele usa um lock atômico no cache, então em produção esse cache precisa ser Redis ou outro driver com locks de verdade.

```php
final class TransferSagaOrchestrator
{
    public function __construct(
        private Ledger $ledger,
        private PixProvider $provider,
    ) {}

    public function advance(string $id): void
    {
        $saga = TransferSaga::findOrFail($id);

        match ($saga->state) {
            'started'        => $this->reserveFunds($saga),
            'funds_reserved' => $this->submit($saga),
            'submitted'      => $this->check($saga),
            'compensating'   => $this->releaseFunds($saga),
            default          => null, // terminal: nada a fazer
        };
    }
}
```

Repare que o job não recebe "qual passo executar". Ele lê o estado e decide. Isso torna o job idempotente por construção: rodar duas vezes no estado `completed` não faz nada.

## Como reservar o saldo sem perder consistência?

A reserva é o único passo que é 100% local, então ela vai em uma transação junto com a mudança de estado da saga. Ou as duas coisas acontecem, ou nenhuma.

```php
private function reserveFunds(TransferSaga $saga): void
{
    DB::transaction(function () use ($saga) {
        $this->ledger->hold(
            accountId: $saga->account_id,
            amountCents: $saga->amount_cents,
            idempotencyKey: "saga:{$saga->id}:hold",
        );

        $saga->transitionTo('funds_reserved');

        AdvanceTransferSaga::dispatch($saga->id)->afterCommit();
    });
}
```

Três detalhes aqui. O hold usa uma chave de idempotência derivada da saga, então um retry não cria dois holds. O `hold` precisa travar a conta e checar saldo disponível do jeito certo, que é assunto do post sobre [como modelar o saldo com ledger de partidas dobradas](/blog/ledger-de-partidas-dobradas-no-laravel-e-postgresql-como-modelar-o-saldo-de-uma-conta-digital). E o próximo passo só é disparado depois do commit; se eu disparasse dentro da transação, o worker poderia pegar o job antes de o estado `funds_reserved` estar visível.

Se não houver saldo, o `hold` lança exceção, a transação reverte e a saga vai direto para `compensated` com `failure_reason = insufficient_funds`. Não há nada a compensar porque nada foi feito.

## Por que timeout do provedor não é falha?

Esse é o erro que mais vejo em implementações de saga para pagamentos, e é o que causa prejuízo de verdade. O passo de envio ao provedor tem três resultados possíveis, não dois:

1. **Aceito**: o provedor devolveu 2xx com um identificador. Vou para `submitted`.
2. **Rejeitado de forma definitiva**: 4xx de validação, chave Pix inexistente, conta de destino bloqueada. O dinheiro com certeza não saiu. Vou para `compensating`.
3. **Desconhecido**: timeout, conexão resetada, 502, 503. O provedor pode ter processado ou não.

Se eu tratar o caso 3 como falha e liberar o hold, e o provedor tiver de fato enviado o Pix, o cliente recebe o saldo de volta e o dinheiro já saiu. É saldo negativo para a empresa. Por isso o resultado desconhecido vai para `submitted` também, e quem decide é a consulta de status.

```php
private function submit(TransferSaga $saga): void
{
    try {
        $result = $this->provider->sendPix(
            key: $saga->pix_key,
            amountCents: $saga->amount_cents,
            idempotencyKey: $saga->id,
        );

        $saga->update(['provider_reference' => $result->reference]);
        $saga->transitionTo('submitted');
    } catch (ProviderRejected $e) {
        $saga->update(['failure_reason' => $e->code]);
        $saga->transitionTo('compensating');
    } catch (ProviderUnavailable) {
        // Resultado desconhecido: não compensa, vai checar depois.
        $saga->transitionTo('submitted');
    }

    $saga->scheduleCheck(seconds: 30);
    AdvanceTransferSaga::dispatch($saga->id)->delay(30);
}
```

O envio usa o id da saga como chave de idempotência no provedor. Se o worker morrer depois de chamar a API e antes de gravar o estado, o retry reenvia com a mesma chave e o provedor devolve a mesma transação em vez de criar outra. Detalhei esse mecanismo em [como evitar cobrança duplicada com Idempotency-Key](/blog/timeout-ao-chamar-o-gateway-de-pagamento-como-evitar-cobranca-duplicada-com-idempotency-key-no-laravel). Se o seu provedor não aceita chave de idempotência, o mínimo é mandar um identificador externo seu e consultar por ele antes de reenviar.

Na literatura de sagas, esse passo de envio é o pivô: o ponto depois do qual não dá mais para desfazer só com compensação local. Tudo antes do pivô é compensável; tudo depois precisa seguir em frente até um resultado conclusivo.

## Como decidir entre liquidar e compensar depois do envio?

No estado `submitted`, a saga espera uma resposta conclusiva. Ela pode vir pelo webhook ou pela consulta ativa, e eu uso as duas, porque webhook atrasa e se perde.

```php
private function check(TransferSaga $saga): void
{
    $status = $this->provider->pixStatus(idempotencyKey: $saga->id);

    match ($status) {
        PixStatus::Confirmed => $this->settle($saga),
        PixStatus::Rejected, PixStatus::NotFound => $this->startCompensation($saga),
        PixStatus::Processing => $this->waitMore($saga),
    };
}

private function waitMore(TransferSaga $saga): void
{
    if (now()->greaterThan($saga->deadline_at)) {
        $saga->transitionTo('needs_review');
        return;
    }

    $delay = min(30 * (2 ** $saga->checks), 900);
    $saga->increment('checks');
    $saga->scheduleCheck(seconds: $delay);
    AdvanceTransferSaga::dispatch($saga->id)->delay($delay);
}
```

O `NotFound` merece cuidado. Ele só significa "não foi enviado" se o provedor garante que a consulta por chave de idempotência é consistente logo após a criação. Alguns provedores têm atraso de indexação; nesses, trato `NotFound` como `Processing` até passar uma janela de alguns minutos desde o envio.

O webhook não decide nada sozinho. O controller valida a assinatura (se quiser conferir o HMAC de um payload real, o [testador de webhook](/ferramentas/testador-webhook) faz isso no navegador), localiza a saga pela referência e dispara `AdvanceTransferSaga`. O orquestrador então consulta o status e segue o mesmo caminho. Isso significa uma chamada a mais na API, mas me dá um único ponto de decisão, e um webhook forjado ou fora de ordem não consegue liquidar nada.

A liquidação, assim como a reserva, é local e transacional: converte o hold em débito no ledger com chave `saga:{id}:settle` e muda o estado para `completed` na mesma transação.

## O que uma compensação precisa garantir?

A compensação desta saga é liberar o hold. Parece trivial, mas ela tem requisitos mais rígidos que os passos normais:

1. **Idempotente.** Chave `saga:{id}:release`. Rodar duas vezes não pode devolver o saldo duas vezes.
2. **Não pode falhar de forma definitiva.** Um passo pode falhar e acionar compensação. Uma compensação que falha não tem para onde ir. Por isso ela só depende do meu banco, sem chamadas externas, e é reexecutada até dar certo.
3. **Semântica, não técnica.** Eu não apago o hold; registro um lançamento de liberação. O histórico precisa mostrar que houve reserva e liberação, porque a conciliação e o suporte vão olhar para isso.
4. **Visível ao cliente.** O app mostra "transferência não realizada" com o motivo, e o saldo volta a ficar disponível no mesmo instante em que a compensação é confirmada.

Uma distinção importante: devolução de Pix depois de `completed` não é compensação desta saga. Se o recebedor devolve o valor dias depois, isso é um novo evento de crédito, com sua própria trilha no ledger. Reabrir uma saga terminal para "desfazer" mistura dois fatos diferentes e quebra a conciliação.

## Como encontrar sagas presas?

Jobs atrasados se perdem: deploy no meio, Redis reiniciado, worker morto por OOM. Como o estado está no banco, recuperar é uma query. Um comando agendado a cada minuto pega o que deveria ter andado e não andou:

```php
Schedule::call(function () {
    TransferSaga::query()
        ->whereNotIn('state', ['completed', 'compensated', 'needs_review'])
        ->where('next_check_at', '<', now()->subMinute())
        ->limit(200)
        ->pluck('id')
        ->each(fn ($id) => AdvanceTransferSaga::dispatch($id));
})->everyMinute()->withoutOverlapping();
```

O `WithoutOverlapping` do job impede que esse disparo extra cause processamento duplo. Na pior das hipóteses, uma saga é avançada um minuto depois do que deveria.

O estado `needs_review` alimenta uma fila de atendimento e um alerta. Com o hold mantido, o cliente não consegue gastar aquele valor, e a empresa não fica exposta: o dinheiro está reservado até alguém confirmar com o provedor o que aconteceu. É mais seguro deixar uma transferência travada por algumas horas do que decidir no escuro.

## Quais são os trade-offs dessa abordagem?

1. **Mais chamadas ao provedor.** Consultar status após webhook e em backoff custa requisições. Para a maioria dos provedores isso fica longe do rate limit, mas vale medir.
2. **Latência percebida.** O cliente vê "processando" por alguns segundos a mais do que veria se eu confiasse direto na resposta síncrona. Em troca, nunca mostro "enviado" para algo que falhou.
3. **Mais código que um fluxo linear.** Uma classe de orquestrador, uma tabela, um job, um scheduler. Para um Pix de saída isso se paga no primeiro incidente.
4. **Prazo de `needs_review` é uma decisão de negócio.** Curto demais gera trabalho manual à toa; longo demais deixa saldo do cliente preso. Defina junto com quem atende o cliente, não só no código.

## Conclusão: por onde começar

Se hoje o seu cash-out é um controller que debita o saldo e chama o provedor em sequência, o caminho que eu seguiria é este: crie a tabela da saga e mova o fluxo para o orquestrador sem mudar a lógica; troque o débito direto por hold mais liquidação; separe as exceções do cliente HTTP do provedor em "rejeitado" e "indisponível"; e adicione o comando que reprocessa sagas paradas. A regra que segura tudo é uma só: antes do pivô, compense; depois do pivô, consulte até ter certeza. Quem segue essa regra pode perder worker, Redis e webhook no mesmo dia e ainda fechar o saldo no centavo.

---

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