Subscription Lifecycle States
A subscription is not a row you mutate at will β it is a contract that walks a deterministic path, and the moment two code paths disagree on which state it is in, you leak revenue or bill a canceled customer. This page is part of Subscription Billing Architecture & Pricing Models and treats the lifecycle as an explicit finite state machine (FSM): a small set of canonical states, a fixed transition table, and guard clauses that make every illegal edge unrepresentable. Get the FSM right and downstream concerns β Proration Logic & Calculations, dunning, and ledger sync β become mechanical consequences of state changes rather than ad-hoc branches scattered through controllers.
Prerequisites
Architecture & Data Flow
The FSM has five canonical states. Inputs are domain events (a checkout completes, a charge fails, a customer pauses). Processing is a guarded transition under a row lock. Outputs are an updated row, an audit event, and outbox rows that fan out to the ledger and webhook bus.
The states carry precise meaning. trialing grants entitlements with no charge yet booked. active means the current period is paid and recognized. past_due means a renewal charge failed and dunning is running while access is usually retained. paused means entitlements are suspended by customer or operator action with no billing accruing. canceled is terminal β the contract is closed and no further automatic transition can revive it (reactivation creates a new subscription, never an edge back into the graph).
Implementation Walkthrough
1. Declare the state set and legal transition table
Encode the FSM as data, not as nested if statements. A transition is legal only if the (from, event) pair maps to a target state.
from dataclasses import dataclass
# Canonical states
STATES = {"trialing", "active", "past_due", "paused", "canceled"}
# (from_state, event) -> to_state β every edge that is NOT here is illegal
TRANSITIONS: dict[tuple[str, str], str] = {
("trialing", "trial_converted"): "active",
("trialing", "trial_canceled"): "canceled",
("active", "renewal_failed"): "past_due",
("active", "paused_by_customer"): "paused",
("active", "canceled"): "canceled",
("past_due", "payment_recovered"): "active",
("past_due", "dunning_exhausted"): "canceled",
("paused", "resumed"): "active",
}
def target_state(current: str, event: str) -> str:
try:
return TRANSITIONS[(current, event)]
except KeyError:
raise IllegalTransition(f"{current} --{event}--> (no legal edge)") # β reject
class IllegalTransition(Exception):
pass
2. Lock the row, evaluate the guard, transition atomically
Read-modify-write on a subscription must be serialized. Take a row lock, compute the target, and write the new state, the audit event, and the outbox row in one transaction so they commit or roll back together.
from django.db import transaction
from django.utils import timezone
def apply_event(subscription_id: str, event: str, payload: dict) -> str:
with transaction.atomic():
sub = (
Subscription.objects
.select_for_update() # serialize concurrent transitions
.get(id=subscription_id)
)
to_state = target_state(sub.current_state, event) # raises on illegal edge
sub.current_state = to_state
sub.version += 1
sub.updated_at = timezone.now()
sub.save(update_fields=["current_state", "version", "updated_at"])
SubscriptionEvent.objects.create( # β
append-only audit row
subscription_id=subscription_id,
from_state=sub.current_state,
event=event,
payload=payload,
occurred_at=sub.updated_at,
)
Outbox.objects.create( # β
same-txn fan-out
aggregate_id=subscription_id,
event_type=f"subscription.{to_state}",
payload=payload,
)
return to_state
3. Make event ingestion idempotent
Gateways redeliver webhooks. Derive an idempotency key from the providerβs event id and short-circuit before the FSM runs, so a redelivered renewal_failed never double-advances state. The deduplication mechanics live in Building Idempotent Webhook Handlers In Nodejs.
def ingest(provider_event_id: str, subscription_id: str, event: str, payload: dict):
_, created = ProcessedEvent.objects.get_or_create(
provider_event_id=provider_event_id, # unique constraint
defaults={"subscription_id": subscription_id},
)
if not created:
return # β οΈ already applied β idempotent skip
apply_event(subscription_id, event, payload)
4. Drive time-based transitions from a clock, not from webhooks
Trial expiry and pause windows are deadlines, not external events. A reconciliation job scans for subscriptions whose deadline has passed and injects the corresponding internal event, so a missing customer.subscription.trial_will_end webhook never strands a row.
-- Subscriptions whose trial deadline has passed but were never converted
SELECT id
FROM subscriptions
WHERE current_state = 'trialing'
AND trial_ends_at <= now()
FOR UPDATE SKIP LOCKED
LIMIT 500;
Edge Cases & Failure Modes
| Scenario | Why it breaks | Mitigation |
|---|---|---|
Out-of-order webhooks (renewal_failed after payment_recovered) |
Late event re-enters past_due and triggers dunning on a healthy account |
Carry a monotonic event_sequence; reject events older than the applied sequence |
canceled β active attempted on reactivation |
Revives a closed contract, corrupting revenue history | No such edge exists; reactivation creates a new subscription row |
| Concurrent pause + cancel | Two locks race; final state is ambiguous | SELECT ... FOR UPDATE serializes; the second transition sees the new state and its guard rejects it |
| Trial-expiry webhook never arrives | Row stuck in trialing, entitlements granted indefinitely |
Clock-driven reconciliation job injects trial_converted/trial_canceled |
Dunning marks canceled while a late recovery succeeds |
Customer pays but loses access | Make dunning_exhausted the only edge to canceled from past_due; recovery after cancel routes to a new subscription |
Performance & Scale
The hot path is a single locked row read plus three inserts, so per-transition cost is bounded by lock contention, not row count. Index subscriptions(current_state, trial_ends_at) and (current_state, current_period_end) so reconciliation scans hit a partial index rather than the full table. Use FOR UPDATE SKIP LOCKED in batch jobs to let multiple workers drain deadlines in parallel without blocking each other. The subscription_events audit table grows unbounded β partition it by month and archive partitions older than your retention requirement to keep the index hot. Outbox rows should be deleted (or moved to a outbox_archive) by the relay after successful publish so the polling query stays cheap.
Testing Strategy
Test the transition table exhaustively: assert every legal edge succeeds and assert that a representative sample of the (states Γ events) product that is not in the table raises IllegalTransition. Inject a mock clock so trial-expiry and pause-window tests are deterministic rather than wall-clock dependent. Replay the same provider_event_id twice and assert the state advances exactly once and only one audit row is written. Finally, run a concurrency test that fires pause and cancel against the same row from two threads and assert the final state is deterministic and only one transition committed.
Frequently Asked Questions
Why model this as an explicit FSM instead of status flags? Status flags let any code path set any value, so illegal combinations (canceled and past_due) become representable and eventually occur. An explicit transition table makes the set of reachable states finite and auditable, and concentrates every business rule about ordering in one place you can unit-test.
Should dunning logic live inside the state machine?
No. Dunning is a parallel workflow that observes past_due and emits payment_recovered or dunning_exhausted back into the FSM. Keeping it separate means the state machine stays a pure function of events and the retry schedule can change without touching transition guards.
How do I handle a gateway that sends paused I never requested?
Map it through the same ingestion path and let the guard decide. If (active, paused_by_customer) is legal it applies; if the row is already canceled, the guard rejects it and you log a reconciliation discrepancy rather than silently overwriting a terminal state.
What about incomplete/incomplete_expired states from Stripe?
Treat them as pre-active states that resolve into active or canceled. Model them explicitly if your checkout can leave a subscription provisional; otherwise collapse them into trialing/canceled at the ingestion boundary so your internal FSM stays small.