Workflow de Assinaturas (SaaS billing)
Exemplo de máquina de estados para assinaturas SaaS, demonstrando transições disparadas por webhooks de provider de pagamento (Stripe), side-effects em cache/quotas e uso intensivo de
metadatapara idempotência.
Visão geral
Em SaaS billing, a máquina de estados de uma assinatura é o ponto onde o domínio do produto encontra o sistema de pagamento externo. A maior parte das transições não é iniciada por um humano — Stripe (ou Mercado Pago, ou Adyen) envia webhooks dizendo "pagamento confirmado", "tentativa falhou", "cliente cancelou no portal". Isso muda a forma como pensamos autorização: o "ator" típico é o próprio sistema, e o que precisamos garantir é (a) idempotência — o mesmo evento não pode ser processado duas vezes; (b) side-effects consistentes — quando a assinatura entra em PastDue, o cache de feature flags daquele tenant precisa ser invalidado antes da próxima request HTTP do usuário; e (c) rastreabilidade total — auditores precisam ver "qual webhook do Stripe causou esta transição" para reconciliar com extratos.
O workflow é Trialing → Active → PastDue → Canceled, com a bifurcação Active → Paused (pause manual pelo cliente, retomada via Paused → Active). Trialing é o estado inicial após signup; Active significa pagamento em dia; PastDue é cobrança falhou (mas ainda dentro da janela de retry); Canceled é terminal (qualquer downgrade futuro cria nova subscription, não reativa esta). A transição PastDueToActive (recovery — Stripe consegue cobrar no retry) é a mais sensível: precisa restaurar quotas, reativar features, e NÃO deve disparar email de "bem-vindo" porque o cliente já é cliente.
A escolha de design central deste exemplo é usar metadata da transição como contrato de auditoria. Toda transição vinda de webhook carrega no mínimo subscription_id (id do provider), webhook_event_id (id único Stripe), event_type (invoice.payment_succeeded, invoice.payment_failed, etc.) e processed_at. Antes de processar, o controller faz StateTransition::where('metadata->webhook_event_id', $eventId)->exists() para rejeitar duplicatas. Esse padrão substitui idempotency keys em Redis e é mais auditável.
Diagrama de estados
graph LR
Trialing -->|TrialingToActive<br/>Stripe webhook| Active
Active -->|ActiveToPastDue<br/>payment failed| PastDue
PastDue -->|PastDueToActive<br/>recovery| Active
PastDue -->|ActiveToCanceled<br/>retry exhausted| Canceled
Active -->|ActiveToCanceled<br/>user opt-out| Canceled
Active -->|ActiveToPaused<br/>user request| Paused
Paused -->|PausedToActive<br/>user resume| Active
Trialing -->|ActiveToCanceled<br/>trial declined| CanceledActiveToCanceled é alcançável de Trialing, Active e PastDue — implementado como uma única transition class com from(): [Trialing, Active, PastDue]. Usuários podem cancelar imediatamente (transição direta) ou no fim do período (a UI agenda um job para now()->addDays($daysRemaining) que dispara a transition). Ambos os caminhos passam pela mesma classe — o que muda é o context.
Model Eloquent
<?php
declare(strict_types=1);
namespace App\Models;
use App\Models\SubscriptionState;
use App\Workflows\Subscriptions\Transitions;
use Arqel\Workflow\Concerns\HasWorkflow;
use Arqel\Workflow\WorkflowDefinition;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class Subscription extends Model
{
use HasWorkflow;
protected $fillable = [
'tenant_id',
'plan_id',
'subscription_state',
'stripe_subscription_id',
'current_period_end',
'trial_ends_at',
'canceled_at',
'paused_until',
];
protected $casts = [
'subscription_state' => SubscriptionState::class,
'current_period_end' => 'datetime',
'trial_ends_at' => 'datetime',
'canceled_at' => 'datetime',
'paused_until' => 'datetime',
];
public function arqelWorkflow(): WorkflowDefinition
{
return WorkflowDefinition::make('subscription_state')
->states([
SubscriptionState\Trialing::class => ['label' => 'Em teste', 'color' => 'info', 'icon' => 'gift'],
SubscriptionState\Active::class => ['label' => 'Ativa', 'color' => 'success', 'icon' => 'check-circle'],
SubscriptionState\PastDue::class => ['label' => 'Pagamento atrasado', 'color' => 'warning', 'icon' => 'alert-triangle'],
SubscriptionState\Paused::class => ['label' => 'Pausada', 'color' => 'secondary', 'icon' => 'pause-circle'],
SubscriptionState\Canceled::class => ['label' => 'Cancelada', 'color' => 'destructive', 'icon' => 'x-octagon'],
])
->transitions([
Transitions\TrialingToActive::class,
Transitions\ActiveToPastDue::class,
Transitions\PastDueToActive::class,
Transitions\ActiveToCanceled::class,
Transitions\ActiveToPaused::class,
Transitions\PausedToActive::class,
]);
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function plan(): BelongsTo
{
return $this->belongsTo(Plan::class);
}
}Resource
<?php
declare(strict_types=1);
namespace App\Arqel\Resources;
use App\Models\Subscription;
use Arqel\Core\Resource;
use Arqel\Fields\DateTime;
use Arqel\Fields\Text;
use Arqel\Workflow\Fields\StateTransitionField;
final class SubscriptionResource extends Resource
{
protected static string $model = Subscription::class;
protected static ?string $navigationGroup = 'Billing';
public function fields(): array
{
return [
Text::make('tenant.name')->label('Tenant')->searchable(),
Text::make('plan.name')->label('Plano'),
Text::make('stripe_subscription_id')->label('Stripe ID')->copyable(),
StateTransitionField::make('subscription_state')
->label('Status')
->showDescription()
->showHistory(),
DateTime::make('current_period_end')->label('Fim do período atual'),
DateTime::make('trial_ends_at')->label('Fim do trial')->placeholder('—'),
DateTime::make('canceled_at')->label('Cancelada em')->placeholder('—'),
];
}
}Transition class com authorizeFor — webhook only
<?php
declare(strict_types=1);
namespace App\Workflows\Subscriptions\Transitions;
use App\Models\Subscription;
use App\Models\SubscriptionState;
use Illuminate\Contracts\Auth\Authenticatable;
final class TrialingToActive
{
public function __construct(
private readonly Subscription $subscription,
) {}
/** @return list<class-string> */
public static function from(): array
{
return [SubscriptionState\Trialing::class];
}
public static function to(): string
{
return SubscriptionState\Active::class;
}
/**
* Esta transição NUNCA é iniciada por humanos — só pelo webhook handler do Stripe.
* Negamos para qualquer usuário autenticado para esconder o botão da UI; o webhook
* controller chama transitionTo() fora do contexto de Auth, o que bypassa esta checagem
* (TransitionAuthorizer aceita user null como "system actor" quando authorizeFor o autoriza).
*/
public static function authorizeFor(?Authenticatable $user, mixed $record): bool
{
return $user === null; // só sistema
}
public function handle(): Subscription
{
$this->subscription->subscription_state = SubscriptionState\Active::class;
$this->subscription->trial_ends_at = null;
$this->subscription->save();
return $this->subscription;
}
}Webhook controller (Stripe)
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Subscription;
use App\Models\SubscriptionState;
use Arqel\Workflow\Models\StateTransition;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class StripeWebhookController
{
public function __invoke(Request $request): \Illuminate\Http\Response
{
$payload = $this->verifyAndParse($request);
$eventId = $payload['id'];
// Idempotência: já processamos este event?
if (StateTransition::where('metadata->webhook_event_id', $eventId)->exists()) {
return response('already_processed', 200);
}
$subscription = Subscription::where('stripe_subscription_id', $payload['data']['object']['subscription'])
->firstOrFail();
$context = [
'subscription_id' => $payload['data']['object']['subscription'],
'webhook_event_id' => $eventId,
'event_type' => $payload['type'],
'processed_at' => now()->toIso8601String(),
];
DB::transaction(function () use ($subscription, $payload, $context): void {
match ($payload['type']) {
'invoice.payment_succeeded' => $subscription->subscription_state instanceof SubscriptionState\Trialing
? $subscription->transitionTo(SubscriptionState\Active::class, $context)
: ($subscription->subscription_state instanceof SubscriptionState\PastDue
? $subscription->transitionTo(SubscriptionState\Active::class, $context + ['recovery' => true])
: null),
'invoice.payment_failed' => $subscription->transitionTo(SubscriptionState\PastDue::class, $context + [
'attempt_count' => $payload['data']['object']['attempt_count'] ?? 1,
]),
'customer.subscription.deleted' => $subscription->transitionTo(SubscriptionState\Canceled::class, $context),
default => null,
};
});
return response('ok', 200);
}
/** @return array<string,mixed> */
private function verifyAndParse(Request $request): array
{
// Stripe signature verification (omitido por brevidade)
return $request->json()->all();
}
}Note três coisas: (1) idempotência via metadata — a query where('metadata->webhook_event_id', ...) aproveita índice JSON do Postgres/MySQL; (2) transaction wrapping — a transição e os side-effects do listener rodam num único commit; (3) o match decide a transição com base no estado atual + tipo de evento, mas a complexidade fica contida no controller.
Filter por estado na Table
use App\Models\Subscription;
use App\Models\SubscriptionState;
use Arqel\Workflow\Filters\StateFilter;
public function table(): Table
{
return Table::make()
->columns([
TextColumn::make('tenant.name'),
TextColumn::make('plan.name'),
BadgeColumn::make('subscription_state')->colorsFromWorkflow(Subscription::class),
DateTimeColumn::make('current_period_end'),
])
->filters([
StateFilter::make('subscription_state', Subscription::class)
->label('Status'),
])
->defaultFilters([
'subscription_state' => [
SubscriptionState\PastDue::class,
SubscriptionState\Active::class,
],
])
->actions([
// Ações de billing-ops apoiadas em filtros do StateFilter
Action::make('retry_failed_payments')
->visible(fn () => request('filter.subscription_state') === SubscriptionState\PastDue::class)
->action(fn () => RetryFailedPaymentsJob::dispatch()),
]);
}Listener — invalidar cache + ajustar quotas
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Models\Subscription;
use App\Models\SubscriptionState;
use App\Services\FeatureFlagCache;
use App\Services\QuotaManager;
use Arqel\Workflow\Events\StateTransitioned;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;
final class ApplySubscriptionStateSideEffects implements ShouldQueue
{
public function __construct(
private readonly FeatureFlagCache $flags,
private readonly QuotaManager $quotas,
) {}
public function handle(StateTransitioned $event): void
{
if (! $event->record instanceof Subscription) {
return;
}
$subscription = $event->record;
// 1. Sempre invalida o cache de feature flags do tenant — qualquer mudança de
// estado pode alterar o que ele pode/não pode usar.
$this->flags->invalidateForTenant($subscription->tenant_id);
// 2. Ajusta quotas conforme o estado destino.
match ($event->to) {
SubscriptionState\Active::class => $this->quotas->restorePlanQuotas($subscription),
SubscriptionState\PastDue::class => $this->quotas->applyGracePeriodLimits($subscription),
SubscriptionState\Canceled::class => $this->quotas->revokeAll($subscription),
SubscriptionState\Paused::class => $this->quotas->freezeUsage($subscription),
default => null,
};
// 3. Email de retenção quando entra em PastDue (não em Canceled — tarde demais).
if ($event->to === SubscriptionState\PastDue::class && $subscription->tenant?->billingContact !== null) {
Mail::to($subscription->tenant->billingContact)
->send(new \App\Mail\PaymentFailedRetention(
subscription: $subscription,
attemptCount: (int) ($event->context['attempt_count'] ?? 1),
webhookEventId: $event->context['webhook_event_id'] ?? null,
));
}
// 4. Recovery (PastDue → Active): NÃO envia email de boas-vindas.
// Loga para métrica de recovery rate.
if ($event->from === SubscriptionState\PastDue::class && $event->to === SubscriptionState\Active::class) {
\App\Metrics\BillingMetrics::recordRecovery(
subscriptionId: $subscription->id,
webhookEventId: $event->context['webhook_event_id'] ?? null,
);
}
}
}Pontos a notar:
- O listener é
ShouldQueue— side-effects podem ser lentos (invalidação de cache distribuída, envio de email), e atrasos não devem bloquear o webhook ACK ao Stripe. - A invalidação de cache de feature flags acontece sempre, independente do estado destino — é mais barato invalidar do que tentar deduzir quando exatamente um flag mudou.
event->context['webhook_event_id']é propagado para métricas e emails — permite reconciliar tudo com o dashboard do Stripe depois.
Metadata no histórico — exemplo prático
Após uma transição via webhook, a entrada em arqel_state_transitions fica:
{
"id": 1042,
"model_type": "App\\Models\\Subscription",
"model_id": 17,
"from_state": "App\\Models\\SubscriptionState\\Trialing",
"to_state": "App\\Models\\SubscriptionState\\Active",
"transitioned_by_user_id": null,
"metadata": {
"subscription_id": "sub_1NxYzABC123",
"webhook_event_id": "evt_1NxYzABC123",
"event_type": "invoice.payment_succeeded",
"processed_at": "2026-04-30T14:32:01+00:00"
},
"created_at": "2026-04-30 14:32:01"
}Um auditor pesquisando por evt_1NxYzABC123 no Stripe Dashboard pode bater diretamente com este registro, e vice-versa. Para recovery: true, a coluna distingue um pagamento bem-sucedido normal de uma recuperação de inadimplência — métrica chave em SaaS.
Resumo das decisões
- Sistema como ator:
authorizeForretornatruequando user énullpara webhooks; humanos vêem botões apenas paraCancelePause/Resume. - Idempotência via
metadata->webhook_event_id: substitui Redis idempotency keys; é mais auditável. - Side-effects no listener
ShouldQueue: webhook ACK rápido para Stripe; trabalho real assíncrono. recovery: trueno context: distingue retry-success de pagamento inicial — importante para emails e métricas.- Sem retorno de
Canceled: estado terminal. Reativação cria nova subscription.