Create your own
Lesson illustration

Subscription State Machine: Transitions and Invariants

Welcome back. You now have two foundations for a reusable billing module: stable billable-owner references and immutable, versioned plan definitions. The next question is what happens after an owner selects one of those plans.

A subscription is not simply a row with status = 'active'. It is a lifecycle with financial consequences, delayed provider confirmations, recoveries, and terminal outcomes. In this lesson, you will define that lifecycle as an explicit state machine: a small, testable domain model that states exactly which transitions are legal, what causes them, and what must remain true before and after each transition.


1. Keep the lifecycle local, even when billing is external

A billing provider has its own statuses. Stripe, for example, distinguishes states such as incomplete, trialing, active, past_due, unpaid, and canceled. Those details are useful, but they should not become your entire domain model.

Your application needs one provider-neutral lifecycle vocabulary that works whether the eventual adapter is Stripe, Paddle, Chargebee, a marketplace API, or your deterministic fake provider.

The important distinction is:

ConceptResponsibilityExample
Provider statusDescribes a provider’s billing objectStripe reports past_due
Local lifecycle statusDescribes what your subscription module believes operationallypast_due
Access decisionDetermines whether an actor can use a feature right nowAllow during grace; deny after suspension
Cancellation intentDescribes a requested future cancellationcancelAtPeriodEnd = true

Do not collapse all four concepts into one overloaded field.

For example, an owner can request end-of-period cancellation while the subscription remains active. Its cancellation intent changes, but it is still currently paid and should remain usable until the period ends. Similarly, a past_due lifecycle state does not by itself decide access: your grace-period policy will decide that later.

Stripe’s lifecycle documentation is useful here because it makes the key operational point clear: creation, payment confirmation, payment failure, recovery, and cancellation are distinct phases—and payment outcomes can arrive asynchronously.

How subscriptions work

Read Stripe’s “How subscriptions work” as a concrete provider example. Do not copy its status names directly into your model; instead, notice why subscription creation cannot be treated as immediate paid activation and why provider events must be part of the lifecycle design.

In the “Subscription lifecycle” section, read from the lifecycle framing through “Provision access to your product.” Then read the complete “Subscription statuses” table, especially incomplete, past_due, unpaid, and paused. In “Payment statuses,” focus on payment status mapping. Notice the delayed-payment caveat: a provider may report a subscription as active before a payment is finally settled, so your adapter must normalize provider semantics carefully.

The Microsoft marketplace lifecycle image below gives another useful perspective: a customer purchase is not automatically a provisioned subscription, and suspension or cancellation can occur after provisioning. Its labels are provider-specific, but the separation between a pending stage, usable service, suspension, and terminal cancellation is broadly applicable.

A provider-oriented SaaS lifecycle: customer purchase may require activation before the subscription is provisioned; a provisioned subscription can be updated, suspended after failed payment, reinstated, or eventually cancelled. The local state machine below uses provider-neutral names for those ideas.

2. Define a small, explicit local state set

For this course, use these lifecycle states:

Local statusMeaningCan it grant paid access?
pending_activationCheckout or provider subscription creation has begun, but paid activation is not confirmed.No
trialingA confirmed trial is currently valid.Trial access may be granted.
activeThe provider confirms the subscription is in good standing.Yes
past_dueA renewal payment has failed and provider-managed collection or dunning is in progress.Policy-dependent
suspendedThe provider or local grace policy has suspended service.No
cancelledThe subscription has ended through cancellation. Terminal.No
expiredInitial activation lapsed without confirmation. Terminal.No

A few choices here are deliberate.

pending_activation is not active

When a user returns from checkout, the browser may tell you that checkout completed. That is not sufficient evidence to activate access. The user can close the tab, a bank authorization can still fail, or a webhook can be delayed.

Your module should create a local subscription in pending_activation, persist the provider operation, and wait for authoritative provider confirmation. This protects against granting paid features based on an unconfirmed client-side result.

past_due and suspended are different

A failed renewal does not necessarily mean immediate loss of access. Many products provide a configurable grace period while the provider retries payment. Therefore:

  • past_due means there is a payment problem.
  • suspended means access has been disabled due to provider status or local policy.

This distinction will make dunning and recovery behavior straightforward in the lifecycle module later.

Terminal means terminal

Neither cancelled nor expired should transition back to an active state. If an owner returns after cancellation or failed initial payment, create a new checkout or subscription operation. Keeping terminal records immutable avoids muddled billing history.


3. Treat cancellation scheduling as context, not a lifecycle state

Avoid adding a status such as cancelling merely because the owner has selected “cancel at period end.” That subscription remains usable. Instead, keep cancellation scheduling as context alongside the lifecycle status:

type SubscriptionStatus =
  | 'pending_activation'
  | 'trialing'
  | 'active'
  | 'past_due'
  | 'suspended'
  | 'cancelled'
  | 'expired';

interface SubscriptionLifecycle {
  status: SubscriptionStatus;

  providerName: string | null;
  providerSubscriptionRef: string | null;

  currentPeriodStart: Date | null;
  currentPeriodEnd: Date | null;
  trialEndsAt: Date | null;

  cancellationRequestedAt: Date | null;
  cancelAtPeriodEnd: boolean;

  endedAt: Date | null;
}

There are two different cancellation facts:

  1. cancellationRequestedAt records that your user or administrator requested cancellation.
  2. cancelAtPeriodEnd records a confirmed effective provider schedule.

The provider may reject the request, require asynchronous processing, or accept it and later send an event. Therefore, a cancellation request should not immediately set status = 'cancelled'.

This also means that an active subscription with cancelAtPeriodEnd = true still has active entitlements until its period ends. The eventual provider cancellation event moves it to cancelled.


4. Make transition causes explicit

A useful rule is:

Commands express intent. Provider events confirm commercial reality.

Commands originate from your customer UI, internal administration UI, scheduled policy jobs, or application services. Provider events originate from verified provider webhooks or authoritative provider snapshots.

Command-driven changes

Commands may create records, record intent, or apply your own local policy:

CommandAllowed fromResult
command.checkout_requestedNo existing subscription aggregateCreate pending_activation
command.request_end_period_cancellationtrialing, active, past_dueRecord cancellation request; status remains unchanged
command.request_cancellation_reversalA nonterminal subscription with scheduled cancellationRecord reversal request; status remains unchanged
command.grace_period_expiredpast_dueMove to suspended if local policy requires it

The checkout command is intentionally the only command that creates a commercial lifecycle status. It creates a pending status, not a paid one.

Provider-event-driven changes

Provider events determine paid activation, payment failure, recovery, and effective cancellation:

Current statusNormalized provider eventNew status
pending_activationprovider.initial_payment_confirmedactive
pending_activationprovider.trial_confirmedtrialing
pending_activationprovider.payment_action_requiredpending_activation
pending_activationprovider.activation_expiredexpired
pending_activationprovider.cancellation_confirmedcancelled
trialingprovider.trial_convertedactive
trialingprovider.renewal_payment_failedpast_due
activeprovider.billing_period_renewedactive
activeprovider.renewal_payment_failedpast_due
past_dueprovider.payment_recoveredactive
past_dueprovider.suspension_reportedsuspended
suspendedprovider.payment_recoveredactive
trialing, active, past_due, suspendedprovider.cancellation_confirmedcancelled

Two subtleties matter:

  • A renewal can be a valid self-transition from active to active. The status does not change, but the billing period does. It must still be audited.
  • provider.payment_action_required can be a valid self-transition in pending_activation. The subscription remains unconfirmed while your UI asks the owner to complete authentication or provide a new payment method.

Do not allow an event such as provider.initial_payment_confirmed to activate a subscription already in cancelled or expired. That may be a late or mismatched event, not a reason to resurrect a historical subscription.


5. Represent the state machine in code

Start with transition rules that are readable without interpreting a large service method. The rules become your compact specification.

export const SUBSCRIPTION_STATUSES = [
  'pending_activation',
  'trialing',
  'active',
  'past_due',
  'suspended',
  'cancelled',
  'expired',
] as const;

export type SubscriptionStatus =
  (typeof SUBSCRIPTION_STATUSES)[number];

export type LifecycleEventType =
  | 'provider.initial_payment_confirmed'
  | 'provider.trial_confirmed'
  | 'provider.payment_action_required'
  | 'provider.activation_expired'
  | 'provider.trial_converted'
  | 'provider.billing_period_renewed'
  | 'provider.renewal_payment_failed'
  | 'provider.payment_recovered'
  | 'provider.suspension_reported'
  | 'provider.cancellation_confirmed'
  | 'command.grace_period_expired';

type TransitionRules = {
  [S in SubscriptionStatus]:
    Partial<Record<LifecycleEventType, SubscriptionStatus>>;
};

export const TRANSITIONS: TransitionRules = {
  pending_activation: {
    'provider.initial_payment_confirmed': 'active',
    'provider.trial_confirmed': 'trialing',
    'provider.payment_action_required': 'pending_activation',
    'provider.activation_expired': 'expired',
    'provider.cancellation_confirmed': 'cancelled',
  },

  trialing: {
    'provider.trial_converted': 'active',
    'provider.renewal_payment_failed': 'past_due',
    'provider.cancellation_confirmed': 'cancelled',
  },

  active: {
    'provider.billing_period_renewed': 'active',
    'provider.renewal_payment_failed': 'past_due',
    'provider.suspension_reported': 'suspended',
    'provider.cancellation_confirmed': 'cancelled',
  },

  past_due: {
    'provider.payment_recovered': 'active',
    'provider.suspension_reported': 'suspended',
    'command.grace_period_expired': 'suspended',
    'provider.cancellation_confirmed': 'cancelled',
  },

  suspended: {
    'provider.payment_recovered': 'active',
    'provider.cancellation_confirmed': 'cancelled',
  },

  cancelled: {},
  expired: {},
};

The table intentionally does not include customer commands such as command.request_end_period_cancellation. Those commands change cancellation-request context rather than the main lifecycle status. Keep them in a separate command handler with their own guards.

A small transition function should reject absent rules rather than silently doing nothing:

export class DomainError extends Error {
  constructor(
    public readonly code: string,
    details?: Record<string, unknown>,
  ) {
    super(code);
    this.name = 'DomainError';
    Object.assign(this, { details });
  }
}

export interface LifecycleEvent {
  type: LifecycleEventType;
  occurredAt: Date;
  providerEventId?: string;
  reason?: string;
}

export function nextStatus(
  current: SubscriptionStatus,
  event: LifecycleEvent,
): SubscriptionStatus {
  const target = TRANSITIONS[current][event.type];

  if (target === undefined) {
    throw new DomainError(
      'SUBSCRIPTION_TRANSITION_NOT_ALLOWED',
      {
        from: current,
        eventType: event.type,
      },
    );
  }

  return target;
}

The actual application service will update more than status. For example, a provider.billing_period_renewed event must update currentPeriodStart and currentPeriodEnd; a cancellation event must set endedAt; and a payment failure should store a normalized reason appropriate for support and customer UI.

But the lifecycle permission check should stay this simple.


6. Define invariants separately from transitions

A legal state change can still create an invalid subscription if required context is missing. That is why transition rules and invariants are complementary:

  • Transition rule: Can this event move the subscription from this status?
  • Invariant: Is the resulting subscription record internally coherent?

Use invariants such as these.

InvariantWhy it matters
pending_activation never grants paid access.Prevents checkout success pages from becoming a billing authority.
active, past_due, and suspended require a provider subscription reference.These states represent an established provider-managed subscription.
active, past_due, and suspended require a valid billing period where start is before end.Renewal, cancellation scheduling, and entitlement evaluation need a real period.
trialing requires trialEndsAt.A trial without an end is an accidental free subscription.
cancelled and expired require endedAt.Terminal records need a durable end timestamp.
Terminal statuses cannot have cancelAtPeriodEnd = true.There is no future period left to cancel.
Scheduled end-of-period cancellation requires a current period end.The request needs a concrete effective boundary.
Every accepted transition creates an audit record.Support, reconciliation, and incident analysis require history.

A focused invariant function can run after every state-changing operation:

export interface SubscriptionSnapshot {
  status: SubscriptionStatus;
  providerSubscriptionRef: string | null;

  currentPeriodStart: Date | null;
  currentPeriodEnd: Date | null;
  trialEndsAt: Date | null;

  cancelAtPeriodEnd: boolean;
  endedAt: Date | null;
}

export function assertLifecycleInvariants(
  subscription: SubscriptionSnapshot,
): void {
  const requiresProviderRef = [
    'active',
    'past_due',
    'suspended',
  ] as const;

  if (
    requiresProviderRef.includes(subscription.status) &&
    subscription.providerSubscriptionRef === null
  ) {
    throw new DomainError(
      'LIFECYCLE_STATUS_REQUIRES_PROVIDER_REFERENCE',
    );
  }

  const requiresBillingPeriod = [
    'active',
    'past_due',
    'suspended',
  ] as const;

  if (requiresBillingPeriod.includes(subscription.status)) {
    const start = subscription.currentPeriodStart;
    const end = subscription.currentPeriodEnd;

    if (start === null || end === null || start >= end) {
      throw new DomainError(
        'LIFECYCLE_STATUS_REQUIRES_VALID_BILLING_PERIOD',
      );
    }
  }

  if (
    subscription.status === 'trialing' &&
    subscription.trialEndsAt === null
  ) {
    throw new DomainError(
      'TRIALING_SUBSCRIPTION_REQUIRES_TRIAL_END',
    );
  }

  if (
    ['cancelled', 'expired'].includes(subscription.status) &&
    subscription.endedAt === null
  ) {
    throw new DomainError(
      'TERMINAL_SUBSCRIPTION_REQUIRES_END_TIMESTAMP',
    );
  }

  if (
    ['cancelled', 'expired'].includes(subscription.status) &&
    subscription.cancelAtPeriodEnd
  ) {
    throw new DomainError(
      'TERMINAL_SUBSCRIPTION_CANNOT_HAVE_PENDING_CANCELLATION',
    );
  }

  if (
    subscription.cancelAtPeriodEnd &&
    subscription.currentPeriodEnd === null
  ) {
    throw new DomainError(
      'SCHEDULED_CANCELLATION_REQUIRES_PERIOD_END',
    );
  }
}

In your real service, call nextStatus, apply the event-specific updates, run assertLifecycleInvariants, then persist the subscription and transition audit record in one database transaction.


7. Audit every accepted transition

A transition audit should record both external facts and local decisions. Conceptually, each accepted transition needs:

interface SubscriptionTransitionAudit {
  id: string;
  subscriptionId: string;

  fromStatus: SubscriptionStatus;
  toStatus: SubscriptionStatus;
  eventType: string;

  source: 'command' | 'provider_event' | 'policy_job';
  providerEventId: string | null;

  reasonCode: string | null;
  occurredAt: Date;
  recordedAt: Date;
}

Self-transitions belong in this history. An active subscription that receives provider.billing_period_renewed remains active, but the event establishes a new commercial period. Omitting that audit entry makes invoices, renewals, and support investigations much harder to reconstruct.

Do not use free-form event labels as your primary audit mechanism. Use machine-readable event types and reason codes; add provider payload references separately. Later, the webhook inbox will provide durable deduplication and retry behavior for provider event IDs.


8. Test the machine as domain logic

Because the transition function is pure, your most valuable tests do not need NestJS, TypeORM, HTTP requests, or a real provider. They should execute in milliseconds and precisely document the policy.

The Stately testing guidance is relevant even if you do not adopt XState. Its main point applies directly: state logic should be tested separately from external effects, and external calls should be mocked when testing orchestration.

Testing

Read the “Mocking effects” portion of Stately’s testing guide. It uses XState, but the testing principle applies to your plain TypeScript transition functions and later NestJS services: verify state outcomes deterministically, and isolate provider or persistence effects behind mocks.

Under “Testing machines with eventless transitions,” find the “Mocking effects” subsection. Read the mocking rationale and scan both examples. Focus on the separation between asserting a resulting state and testing an external operation; your provider adapter tests will use the same separation.

Here is a Vitest-style test suite for core lifecycle behavior:

import { describe, expect, it } from 'vitest';

describe('subscription lifecycle transitions', () => {
  it('activates only after initial payment confirmation', () => {
    const status = nextStatus('pending_activation', {
      type: 'provider.initial_payment_confirmed',
      occurredAt: new Date('2026-04-01T10:00:00Z'),
      providerEventId: 'evt_initial_paid_1',
    });

    expect(status).toBe('active');
  });

  it('does not activate when payment requires customer action', () => {
    const status = nextStatus('pending_activation', {
      type: 'provider.payment_action_required',
      occurredAt: new Date('2026-04-01T10:00:00Z'),
      providerEventId: 'evt_action_required_1',
    });

    expect(status).toBe('pending_activation');
  });

  it('moves an active subscription to past due on renewal failure', () => {
    const status = nextStatus('active', {
      type: 'provider.renewal_payment_failed',
      occurredAt: new Date('2026-05-01T00:00:00Z'),
      providerEventId: 'evt_renewal_failed_1',
    });

    expect(status).toBe('past_due');
  });

  it('allows recovery from past due', () => {
    const status = nextStatus('past_due', {
      type: 'provider.payment_recovered',
      occurredAt: new Date('2026-05-03T14:00:00Z'),
      providerEventId: 'evt_payment_recovered_1',
    });

    expect(status).toBe('active');
  });

  it('rejects attempts to resurrect a cancelled subscription', () => {
    expect(() =>
      nextStatus('cancelled', {
        type: 'provider.payment_recovered',
        occurredAt: new Date('2026-05-03T14:00:00Z'),
        providerEventId: 'evt_late_payment_1',
      }),
    ).toThrow('SUBSCRIPTION_TRANSITION_NOT_ALLOWED');
  });
});

Test invariants independently as well:

it('rejects active state without a provider reference', () => {
  expect(() =>
    assertLifecycleInvariants({
      status: 'active',
      providerSubscriptionRef: null,
      currentPeriodStart: new Date('2026-04-01T00:00:00Z'),
      currentPeriodEnd: new Date('2026-05-01T00:00:00Z'),
      trialEndsAt: null,
      cancelAtPeriodEnd: false,
      endedAt: null,
    }),
  ).toThrow('LIFECYCLE_STATUS_REQUIRES_PROVIDER_REFERENCE');
});

For complete coverage, add a table-driven test that walks every entry in TRANSITIONS and asserts that each declared transition succeeds. Then maintain hand-written tests for critical business promises:

  • checkout never grants paid activation by itself;
  • an initial payment failure remains pending;
  • a renewal failure reaches past_due;
  • only recovery returns past_due or suspended to active;
  • cancellation and expiration are terminal;
  • each valid terminal transition sets endedAt;
  • a billing renewal records an active to active audit transition.

Implementation checkpoint

Before moving on, add the following to your billing domain layer:

  • the SubscriptionStatus union;
  • the TRANSITIONS matrix as the readable lifecycle specification;
  • a transition function that rejects undeclared transitions;
  • a separate invariant validator;
  • command handling for checkout creation and cancellation intent;
  • a transition audit abstraction that records self-transitions too;
  • unit tests for valid transitions, invalid transitions, terminal-state behavior, and invariant violations.

Keep this logic independent of TypeORM entities for now. The domain rules should remain easy to test without a database; the persistence layer should enforce and record their results.


Key takeaways

An explicit state machine makes billing behavior reviewable instead of burying it in conditionals across controllers, webhook handlers, and cron jobs.

Use a small provider-neutral lifecycle: pending_activation, trialing, active, past_due, suspended, cancelled, and expired. Treat paid activation and recovery as provider-confirmed facts, not browser claims or optimistic command results. Keep scheduled cancellation as context rather than inventing a misleading cancelling status.

Finally, pair transition rules with invariants and transition audits. The rules determine whether a change is legal; invariants ensure the resulting record makes sense; audits preserve the operational history.

Next, you will turn the domain model into PostgreSQL migrations for subscriptions, billing periods, provider references, and transition audit records, with database constraints that protect these rules even when application code is bypassed.

Can't find a good explanation? Sign up and we'll make it for you

Sign up