Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

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#

scdoc
1
2
3
4
5
6
7
8
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:

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.

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 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.

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.

Updated 2026-05-18 01:48:39 View source (.md) rev 2