Para impedir que um pagamento vá de refunded para paid, ou de canceled para authorized, o status precisa deixar de ser um campo livre e virar uma máquina de estados. Isso significa uma lista fechada de estados, uma tabela explícita de transições permitidas e um único ponto do código autorizado a mudar o status, com lock e histórico. No Laravel, um enum do PHP 8.1 com as transições, um serviço de transição e uma tabela de histórico resolvem o problema sem pacote nenhum.
Por que o campo status vira um problema em sistemas de pagamento?
O caminho natural é começar com uma coluna status do tipo string e ir fazendo $payment->update(['status' => 'paid']) onde for preciso: no controller do checkout, no job que processa webhook, no comando de conciliação e no painel administrativo. Funciona até o dia em que dois desses caminhos discordam.
Os sintomas típicos são estes:
- Um webhook atrasado de
paidchega depois do estorno e o pagamento "volta" a ficar pago, liberando um pedido que já foi devolvido. - Um operador cancela manualmente uma cobrança que o provedor já liquidou, e ninguém percebe que existe dinheiro sem dono.
- Um chargeback, que é a contestação da compra feita pelo portador do cartão junto ao emissor, sobrescreve um estorno parcial e o valor devolvido some do histórico.
- Ninguém consegue responder "quem mudou esse status, quando e por quê".
O problema não é a coluna. O problema é que qualquer código pode escrever qualquer valor nela. Em pagamentos, o status carrega consequências financeiras: liberar produto, creditar saldo, repassar split, emitir nota. Uma transição errada não é um bug de interface, é dinheiro no lugar errado.
Quais estados um pagamento realmente precisa ter?
Uma máquina de estados é um modelo em que a entidade está sempre em exatamente um estado de um conjunto finito, e só pode passar para outro estado por transições declaradas previamente. Um estado terminal é aquele do qual não existe transição de saída.
O erro mais comum aqui é copiar os status do provedor. Stripe, Asaas e Pagar.me têm vocabulários diferentes e mais granulares do que o seu domínio precisa. O seu estado deve refletir o que importa para o negócio, não o que o gateway expõe. Na prática, este conjunto cobre a maioria dos casos de cobrança por cartão e Pix:
| Estado | Significado no domínio | Terminal? |
|---|---|---|
pending |
Cobrança criada, aguardando pagamento | Não |
authorized |
Valor reservado no cartão, ainda não capturado | Não |
paid |
Dinheiro confirmado, pedido pode ser liberado | Não |
partially_refunded |
Parte do valor devolvida | Não |
refunded |
Valor total devolvido | Sim |
failed |
Recusado ou expirado | Sim |
canceled |
Cancelado antes de liquidar | Sim |
chargeback |
Contestação aberta pelo portador | Não |
chargeback não é terminal porque a disputa pode ser ganha pelo lojista, e aí o pagamento volta a valer. Se o seu negócio precisa diferenciar "disputa em andamento" de "disputa perdida", vale quebrar em dois estados. Comece pequeno: adicionar um estado depois é fácil, remover um que já está gravado em milhares de linhas não é.
Como modelar as transições de status com enum no PHP?
O enum é o lugar certo para a regra, porque ela pertence ao estado e não ao model. Cada caso declara para onde pode ir:
enum PaymentStatus: string { case Pending = 'pending'; case Authorized = 'authorized'; case Paid = 'paid'; case PartiallyRefunded = 'partially_refunded'; case Refunded = 'refunded'; case Failed = 'failed'; case Canceled = 'canceled'; case Chargeback = 'chargeback'; /** @return list<self> */ public function allowedTransitions(): array { return match ($this) { self::Pending => [self::Authorized, self::Paid, self::Failed, self::Canceled], self::Authorized => [self::Paid, self::Failed, self::Canceled], self::Paid => [self::PartiallyRefunded, self::Refunded, self::Chargeback], self::PartiallyRefunded => [self::PartiallyRefunded, self::Refunded, self::Chargeback], self::Chargeback => [self::Paid], self::Refunded, self::Failed, self::Canceled => [], }; } public function canTransitionTo(self $to): bool { return in_array($to, $this->allowedTransitions(), true); } public function isTerminal(): bool { return $this->allowedTransitions() === []; } }
Dois detalhes importam. Primeiro, o match sem braço default faz o PHP lançar UnhandledMatchError se alguém adicionar um caso novo e esquecer de declarar as transições dele; a omissão aparece no primeiro teste, não em produção. Segundo, PartiallyRefunded pode transicionar para ele mesmo, porque um segundo estorno parcial é um evento legítimo que precisa ficar registrado no histórico.
No model, o cast garante que ninguém trabalhe com string solta:
protected function casts(): array { return ['status' => PaymentStatus::class]; }
No PostgreSQL, adicione um CHECK na coluna com os valores válidos. Ele não valida transição, mas impede que um script de manutenção grave pago em vez de paid.
Onde a mudança de status deve acontecer?
Em um único serviço. Webhook, checkout, conciliação e painel administrativo chamam o mesmo ponto de entrada, informando a origem da mudança. É ali que ficam o lock, a validação e o histórico:
enum TransitionResult { case Applied; case AlreadyInState; case Rejected; } final class TransitionPayment { public function handle( int $paymentId, PaymentStatus $to, string $source, array $context = [], ): TransitionResult { return DB::transaction(function () use ($paymentId, $to, $source, $context) { $payment = Payment::query()->lockForUpdate()->findOrFail($paymentId); $from = $payment->status; if ($from->canTransitionTo($to)) { $payment->status = $to; $payment->save(); $payment->transitions()->create([ 'from' => $from, 'to' => $to, 'source' => $source, 'context' => $context, 'applied' => true, ]); event(new PaymentStatusChanged($payment->id, $from, $to)); return TransitionResult::Applied; } if ($from === $to) { return TransitionResult::AlreadyInState; } $payment->transitions()->create([ 'from' => $from, 'to' => $to, 'source' => $source, 'context' => $context, 'applied' => false, ]); return TransitionResult::Rejected; }); } }
O lockForUpdate() gera um SELECT ... FOR UPDATE no PostgreSQL, que trava a linha até o fim da transação. Sem ele, dois workers do Horizon processando eventos do mesmo pagamento leem o mesmo estado de origem, ambos validam a transição como permitida e o último a gravar vence. A validação da máquina de estados só é confiável se for feita sobre um estado que ninguém mais pode mudar naquele instante.
O evento PaymentStatusChanged deve implementar ShouldDispatchAfterCommit, assim como os listeners enfileirados que ele dispara. Caso contrário, um listener pode liberar o pedido antes do commit, ou mesmo depois de um rollback. Escrevi sobre esse risco em detalhe no post sobre jobs disparados dentro de transação e o afterCommit no Laravel.
Repare também que os efeitos colaterais (creditar saldo, liberar pedido, notificar cliente) não ficam dentro do serviço. Eles reagem ao evento. O serviço de transição só decide se a mudança é válida e registra que ela aconteceu. Isso mantém a regra de transição pequena e testável, e permite adicionar reações sem mexer nela.
Como impedir que alguém mude o status fora do serviço?
Convenção de equipe não basta, porque o atalho $payment->update(['status' => ...]) sempre vai parecer inofensivo em algum hotfix. Uma proteção barata é um guard no próprio model:
protected static function booted(): void { static::updating(function (Payment $payment) { if ($payment->isDirty('status') && ! app()->bound('payment.transitioning')) { throw new LogicException('Use TransitionPayment para alterar o status.'); } }); }
No serviço, você registra app()->instance('payment.transitioning', true) antes do save() e chama app()->forgetInstance('payment.transitioning') logo depois, em um finally. É um mecanismo simples, não um cofre: DB::table('payments')->update() passa por fora dos eventos do Eloquent. Se você precisa de garantia absoluta, a alternativa é um trigger no PostgreSQL que consulte uma tabela de transições permitidas. Eu só iria por esse caminho se houver outros sistemas escrevendo direto no banco, porque duplica a regra em dois lugares.
Como registrar o histórico de transições de status?
A tabela payment_transitions é append-only: só recebe inserts, nunca update ou delete. Colunas que fazem diferença na hora de investigar:
frometo: os dois estados, inclusive em transições rejeitadas.source: quem pediu a mudança (webhook:stripe,webhook:asaas,reconciliation,admin:{user_id},checkout).contextemjsonb: id do evento do provedor, payload resumido, motivo informado pelo operador.applied: se a transição foi aplicada ou rejeitada.created_atcom precisão de microssegundos, para ordenar eventos que chegam no mesmo segundo.
Gravar as rejeitadas é o que transforma a tabela em ferramenta de diagnóstico. Quando alguém pergunta por que um pedido não foi liberado, a resposta costuma estar numa linha com applied = false.
O histórico não substitui o ledger. Ele registra a vida do pagamento como objeto de negócio; o ledger registra o movimento do dinheiro. Os dois se cruzam pelo id do pagamento, mas têm responsabilidades diferentes.
Como traduzir o status do provedor para o status do domínio?
Cada gateway tem um mapper que converte o status dele para o seu enum, ou para null quando o status do provedor não tem significado no seu domínio:
final class StripeStatusMapper { public function map(string $intentStatus): ?PaymentStatus { return match ($intentStatus) { 'requires_capture' => PaymentStatus::Authorized, 'succeeded' => PaymentStatus::Paid, 'canceled' => PaymentStatus::Canceled, 'requires_payment_method', 'requires_confirmation', 'requires_action', 'processing' => null, default => throw new UnexpectedValueException("Status Stripe desconhecido: {$intentStatus}"), }; } } final class AsaasStatusMapper { public function map(string $status): ?PaymentStatus { return match ($status) { 'PENDING', 'OVERDUE' => null, 'CONFIRMED', 'RECEIVED' => PaymentStatus::Paid, 'REFUNDED' => PaymentStatus::Refunded, 'CHARGEBACK_REQUESTED' => PaymentStatus::Chargeback, default => throw new UnexpectedValueException("Status Asaas desconhecido: {$status}"), }; } }
O null significa "nada mudou para o negócio": o cliente ainda está autenticando o 3-D Secure, ou o boleto venceu mas pode ser pago. O default que lança exceção é proposital: se o provedor passar a enviar um status novo, você quer que o job falhe e apareça no monitoramento, não que ele seja ignorado em silêncio. Esse mapeamento fica na camada de adaptador de cada gateway, junto com o resto da integração, como descrevi em como integrar vários gateways de pagamento sem amarrar o domínio a um provedor.
Nem todo evento do provedor vira transição. Um estorno parcial exige olhar o valor estornado acumulado para decidir entre PartiallyRefunded e Refunded. Essa decisão é do domínio, não do mapper.
O que fazer quando chega uma transição inválida?
Uma transição rejeitada não é sempre erro. Vale separar em três categorias, porque cada uma tem uma resposta diferente:
| Situação | Exemplo | Resposta |
|---|---|---|
| Duplicata | paid chega com o pagamento já paid |
Retornar AlreadyInState, responder 200 ao webhook |
| Evento atrasado | authorized chega depois de paid |
Registrar como rejeitada, não alertar |
| Conflito real | paid chega com o pagamento canceled ou refunded |
Registrar, alertar e abrir pendência para revisão |
As duas primeiras são consequência natural de webhooks entregues mais de uma vez e fora de ordem. A máquina de estados absorve boa parte desse ruído sozinha, porque um evento atrasado tenta levar o pagamento para um estado anterior, e essa transição não existe. Ela complementa, mas não substitui, a deduplicação por id de evento que mostrei no post sobre webhooks de pagamento duplicados e fora de ordem.
A terceira é a que importa. canceled seguido de paid geralmente significa que o cliente pagou um Pix ou boleto depois de você cancelar localmente, e agora existe dinheiro recebido sem pedido correspondente. Não tente resolver isso com uma transição automática. Abra uma pendência, avise alguém e deixe a decisão (estornar ou reativar o pedido) para um fluxo explícito. Para classificar, basta uma regra simples: se o destino é um estado que envolve dinheiro entrando (paid) e a origem é terminal, é conflito.
Vale usar o spatie/laravel-model-states ou implementar do zero?
O pacote spatie/laravel-model-states modela cada estado como uma classe que estende uma classe base, registra as transições permitidas na configuração dessa classe e expõe $payment->status->transitionTo(Paid::class). Ele é bem feito e resolve o mesmo problema. A escolha depende do que você valoriza:
| Critério | Enum + serviço próprio | spatie/laravel-model-states |
|---|---|---|
| Código para entender | Um enum e uma classe | Classes de estado e de transição, convenções do pacote |
| Comportamento por estado | match no enum |
Métodos nas classes de estado |
| Lock e histórico | Você escreve | Você também escreve, via classes de transição customizadas |
| Dependência externa | Nenhuma | Uma, que precisa acompanhar versões do Laravel |
| Melhor quando | Poucos estados, regra centralizada | Muitos estados com comportamento próprio |
Para status de pagamento eu prefiro o enum. São menos de dez estados, o comportamento específico de cada um é pequeno, e a parte difícil, que é lock, histórico e tratamento de conflito, você escreve de qualquer jeito. Se a entidade tivesse dezenas de estados com lógica própria, como um fluxo de onboarding com KYC, as classes de estado começariam a compensar.
Como testar a máquina de estados de pagamento?
O teste mais valioso é a matriz completa: todos os pares de estados, comparados com uma lista explícita de transições esperadas. Com Pest, um dataset gerado resolve:
dataset('pares', function () { foreach (PaymentStatus::cases() as $from) { foreach (PaymentStatus::cases() as $to) { yield "{$from->value} -> {$to->value}" => [$from, $to]; } } }); it('respeita a tabela de transições', function (PaymentStatus $from, PaymentStatus $to) { $esperadas = [ 'pending' => ['authorized', 'paid', 'failed', 'canceled'], 'authorized' => ['paid', 'failed', 'canceled'], 'paid' => ['partially_refunded', 'refunded', 'chargeback'], 'partially_refunded' => ['partially_refunded', 'refunded', 'chargeback'], 'chargeback' => ['paid'], 'refunded' => [], 'failed' => [], 'canceled' => [], ]; expect($from->canTransitionTo($to)) ->toBe(in_array($to->value, $esperadas[$from->value], true)); })->with('pares');
A lista duplicada no teste é intencional. Ela obriga quem altera uma transição a alterar o teste também, o que transforma qualquer mudança de regra financeira em uma decisão explícita no code review. Além da matriz, vale um teste de integração do serviço que confirma que uma transição rejeitada grava a linha com applied = false e não dispara o evento.
Conclusão prática
Se hoje o seu sistema tem update(['status' => ...]) espalhado, a migração pode ser incremental. Primeiro, crie o enum com as transições e o cast no model, sem bloquear nada. Depois, faça todos os caminhos passarem pelo serviço de transição, começando pelo processamento de webhooks, que é onde chegam mais eventos concorrentes e fora de ordem. Com o histórico gravando, rode alguns dias olhando as transições rejeitadas: elas vão mostrar exatamente quais fluxos dependiam de transições que você não sabia que aconteciam. Só então ligue o guard no model. A partir daí, cada conflito real vira uma pendência visível em vez de um saldo errado descoberto na conciliação diária com o extrato do provedor, e o status do pagamento passa a ser uma regra que o código garante, não uma convenção que a equipe precisa lembrar.