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.

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, 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:

  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.

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:

  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:

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.