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:
- Validar o pedido (limites, chave Pix, horário, antifraude).
- Reservar o saldo no ledger, para que o cliente não gaste o mesmo dinheiro duas vezes enquanto o Pix está em voo.
- Enviar a ordem ao provedor.
- 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.
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:
started: pedido validado, nada foi tocado ainda.funds_reserved: hold criado no ledger.submitted: ordem enviada ao provedor, ou com resultado desconhecido (volto nisso).completed: provedor confirmou, débito liquidado. Terminal.compensating: algo falhou, precisa liberar o hold.compensated: hold liberado. Terminal.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, 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.
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.
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.
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. 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:
- Aceito: o provedor devolveu 2xx com um identificador. Vou para
submitted. - 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. - 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.
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. 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.
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 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:
- Idempotente. Chave
saga:{id}:release. Rodar duas vezes não pode devolver o saldo duas vezes. - 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.
- 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.
- 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:
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?
- 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.
- 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.
- 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.
- 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.