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#
1 2 3 4 5 6 7 8 | |
Allowed transitions#
The state machine lives in services/subscription.py:VALID_TRANSITIONS:
For trigger labels and side effects, see Reference: state machines — subscription.
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 |
| 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.
Background workers#
subscription_reaper— hourly. Finds subscriptions incancellingstatus whosecurrent_period_end <= nowand flips them tocancelled. Emitssubscription.cancelled.v1for each.trial_monitor— daily at 09:13 UTC. Findstrialingsubscriptions whosetrial_ends_atlands at 7, 3, or 1 days from today, and emitssubscription.trial_ending.v1once per (sub, threshold). Dedup state lives insubscription.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.
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 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.