---
title: Subscriptions
path: concepts/subscriptions
status: published
---

# Subscriptions

A subscription is the active link between a tenant (the buyer) and a `(service, plan)` (what they're paying for). It carries the billing period, the trial date, the current state, and any pending cancellation. This page covers the state machine and the lifecycle.

## States

```
pending      Newly created, not yet activated. Sub-second to seconds.
trialing     Active and consuming, but not yet paid. Has a trial_ends_at.
active       Live, billed at the current_period_end cadence.
past_due     A scheduled payment failed; still functioning.
cancelling   Admin clicked "cancel at period end"; live until current_period_end.
suspended    Admin-driven pause, or sustained past_due. Service should refuse work.
cancelled    Terminal. Cancelled_at is set. Not reversible.
expired      Terminal for time-bound subscriptions that ran their term.
```

## Allowed transitions

The state machine lives in `services/subscription.py:VALID_TRANSITIONS`:

```mermaid
stateDiagram-v2
    [*] --> pending
    pending --> trialing
    pending --> active
    pending --> cancelled
    trialing --> active
    trialing --> cancelled
    active --> past_due
    active --> cancelling
    active --> cancelled
    active --> expired
    past_due --> active
    past_due --> suspended
    past_due --> cancelled
    suspended --> active
    suspended --> cancelled
    cancelling --> cancelled
    cancelling --> active: un-cancel
    cancelled --> [*]
    expired --> [*]
```

For trigger labels and side effects, see [Reference: state machines — subscription](../reference/state-machines).

Attempting an invalid transition raises `BadRequestError` from the service layer.

## Lifecycle endpoints

| Operation | Endpoint | Effect |
|---|---|---|
| Create | `POST /admin/subscriptions` | `pending → active` or `trialing` (depending on plan trial days). Emits `subscription.activated.v1`. |
| Change plan / status | `POST /admin/subscriptions/{id}/override` | Mutates `plan_id` and/or `status`. Emits `subscription.changed.v1` with `change_kind=plan_change` or `status_change`. |
| Cancel at period end | `POST /admin/subscriptions/{id}/cancel` (default) | `→ cancelling`. The reaper flips to `cancelled` once `current_period_end ≤ now`. Emits `subscription.changed.v1` with `change_kind=scheduled_cancellation`. |
| Cancel immediately | `POST /admin/subscriptions/{id}/cancel` with `immediate=true` | `→ cancelled`. Sets `cancelled_at`. Emits `subscription.cancelled.v1`. |
| Resume | `POST /admin/subscriptions/{id}/resume` | `cancelling → active` (un-cancel) or `suspended → active`. Emits `subscription.changed.v1` (`scheduled_cancellation_undone`) or `subscription.resumed.v1`. |
| Suspend | `POST /admin/subscriptions/{id}/suspend` | `active|trialing|past_due → suspended`. Emits `subscription.suspended.v1`. |
| Bulk | `POST /admin/subscriptions/bulk` | Apply any of `cancel`, `cancel_immediate`, `suspend`, `resume` to a list of IDs. Best-effort; returns `succeeded[]` + `failed[{id, error}]`. |

The full request/response shapes are in [Admin — subscriptions](../reference/api/admin-subscriptions).

## Background workers

- **`subscription_reaper`** — hourly. Finds subscriptions in `cancelling` status whose `current_period_end <= now` and flips them to `cancelled`. Emits `subscription.cancelled.v1` for each.
- **`trial_monitor`** — daily at 09:13 UTC. Finds `trialing` subscriptions whose `trial_ends_at` lands at 7, 3, or 1 days from today, and emits `subscription.trial_ending.v1` once per (sub, threshold). Dedup state lives in `subscription.metadata_["trial_ending_fired"]`.

## Pack subscriptions

A subscription can also be a pack subscription — a single billable line that bundles multiple `(service, plan)` pairs. Pack subscriptions live in `pack_subscriptions` and *generate* child rows in `subscriptions` linked via `subscription.pack_subscription_id`. The pack row carries the headline price; child rows exist for state tracking only (they share the parent's state).

External consumers should treat pack subscriptions as one entity — the events fire under `pack_subscription.*` topics, not on the child rows.

See [Service packs](./service-packs).

## Audit trail

Every transition is written to the `audit_log` table (`resource_type='subscription'`, `resource_id=<sub.id>`). The admin UI's per-row "History" button surfaces this via `GET /admin/subscriptions/{id}/history`.

## VAT and pricing snapshots

A subscription is just a *link*. It does NOT freeze pricing. The actual invoice that gets generated at billing time computes VAT freshly via [`services/billing/vat.py`](./vat-and-reverse-charge) based on the buyer's country, VAT number, and the seller's location at finalization. So a plan price change between billing cycles takes effect on the next invoice — but anything already finalised is immutable.
