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.

Subscription lifecycle state machine States trialing, active, past_due, paused, and canceled with the legal transitions between them. trialing active past_due paused canceled convert charge fails recovered pause resume cancel dunning exhausted
Every edge is a named event; any pair not drawn here is rejected by a guard before it touches the row.

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

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.