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

State machines

Quick reference for every formal state machine in ScaiControl, with allowed transitions, the actor that drives each transition, and the side effects that fire on entry.


Subscription#

Source of truth: backend/src/scaicontrol/services/subscription.py:11 (VALID_TRANSITIONS).

States: pending, trialing, active, past_due, cancelling, suspended, cancelled, expired.

Diagram#

stateDiagram-v2 [*] --> pending pending --> trialing: trial plan pending --> active: paid plan pending --> cancelled: provisioning failed trialing --> active: trial converts trialing --> cancelled: cancel during trial active --> past_due: payment failed active --> cancelling: schedule cancellation active --> cancelled: admin cancel active --> expired: term ended past_due --> active: payment recovered past_due --> suspended: dunning timer past_due --> cancelled: admin cancel suspended --> active: resume suspended --> cancelled: admin cancel cancelling --> cancelled: at period_end cancelling --> active: undo cancelled --> [*] expired --> [*]

Transitions#

From To Trigger Notes
pending trialing provisioning activates trial plan trial_end_date set
pending active provisioning activates paid plan current_period_end set
pending cancelled provisioning failure terminal
trialing active trial ends, payment succeeds, OR customer converts
trialing cancelled customer cancels during trial terminal
active past_due scheduled payment fails past_due_since set
active cancelling customer schedules cancellation pending_cancellation_at set; still entitled until then
active cancelled admin immediate cancel terminal
active expired term ended (fixed-term plans) terminal
past_due active payment retry succeeds
past_due suspended dunning timer fires service revoked
past_due cancelled admin cancel terminal
suspended active admin resume + payment caught up
suspended cancelled admin cancel terminal
cancelling cancelled reaper at pending_cancellation_at terminal
cancelling active customer undoes the scheduled cancel clears pending_cancellation_at

Terminal states#

cancelled, expired — no transitions out. A new subscription is required to re-onboard.

Side effects on entry#

Target state Side effects
trialing, active subscription.activated.v1 event emitted on first entry; provisioning DAG run
past_due subscription.payment_failed.v1 emitted; dunning timer scheduled
cancelling subscription.changed.v1 with change_kind=scheduled_cancellation
suspended subscription.suspended.v1; entitlement revoked at integration
cancelled subscription.cancelled.v1; rollback provisioning if applicable
expired subscription.cancelled.v1 with terminal_state=expired

Workers driving transitions#

  • reaper (cron, 5-minute interval) — flips cancelling → cancelled when pending_cancellation_at <= now(); flips active → expired when term-end reached.
  • trial_monitor (cron, daily) — emits subscription.trial_ending.v1 at 7/3/1 days; flips trialing → cancelled if no payment method captured.
  • dunning — flips past_due → suspended after configurable threshold.

Invoice#

Source of truth: backend/src/scaicontrol/services/billing/invoice.py (finalize/credit-note flow).

States: draft, finalized, sent, paid, past_due, void.

Diagram#

stateDiagram-v2 [*] --> draft draft --> finalized: finalize_invoice() finalized --> sent: send_invoice() sent --> paid sent --> past_due: due date passed sent --> void: admin void past_due --> paid: belated payment past_due --> void: admin void paid --> [*] void --> [*] note right of draft editable; no number yet end note note right of finalized immutable; SCAI-YYYY-NNNNNN assigned; PDF in S3; snapshots frozen end note

Transitions#

From To Trigger Notes
draft finalized finalize_invoice() Assigns number; snapshots buyer + seller; renders PDF; runs determine_vat()
finalized sent email dispatched sent_at set
sent paid payment confirmation webhook paid_at set
sent past_due due date passed scheduled job
past_due paid belated payment
sent void admin voids terminal (no money owed)
past_due void admin voids terminal

Immutability rule#

After finalized, an invoice cannot be edited. Corrections happen via credit notes — a separate invoice with document_type='credit_note' and referenced_invoice_id pointing to the original. Credit notes also flow through the same draft → finalized → sent transitions, but typically auto-finalize on creation.

Numbering#

Gap-free per fiscal year:

  • Invoices: SCAI-{YEAR}-{SEQ:06d} from invoice_sequence
  • Credit notes: SCAI-CN-{YEAR}-{SEQ:06d} from credit_note_sequence

Acquired via SELECT … FOR UPDATE on the sequence row inside the finalize transaction. Gaps mean the finalize transaction rolled back — they're caught by the year-end audit.

Snapshotting#

On finalize, four blobs freeze in the row:

  • buyer_snapshot (JSON) — tenant_billing_profiles at this instant
  • seller_snapshot (JSON) — partner config at this instant
  • vat_details (JSON) — per-rate breakdown
  • template_html_snapshot — full rendered HTML for re-printing without re-running the template

Re-printing an invoice 5 years from now produces byte-identical output regardless of how the database has since evolved.


Provisioning DAG step#

Source of truth: services/provisioning/.

States per step: pending, running, completed, failed, compensated.

Diagram#

stateDiagram-v2 [*] --> pending pending --> running running --> completed running --> failed failed --> compensated: if compensating action defined completed --> [*] compensated --> [*]

Notes#

  • Each DAG node has a step_type: sync, async_callback, async_poll.
  • sync — runs inline; transitions through running → completed/failed in one shot.
  • async_callback — emits a request, waits for the target service to POST back to a callback URL.
  • async_poll — emits a request, then the runner polls a status endpoint.
  • On failed, the orchestrator walks dependents and executes compensating actions in reverse order. Compensated steps move to compensated; if compensation also fails, manual intervention is required.

Service registry — registration status#

Source of truth: models/service_registry.py.

States: pending, approved, rejected.

From To Trigger
(new row) pending service self-registers via /registry/register
(new row) approved service slug in AUTO_APPROVED_APP_IDS
pending approved admin approves via /admin/registry/{id}/approve
pending rejected admin rejects

Only approved services can heartbeat or accept provisioning calls. rejected is terminal — re-register to retry.


Service registry — health status#

Computed continuously by REGISTRY_HEARTBEAT_MONITOR_INTERVAL cron.

States: healthy, degraded, unreachable.

From To Trigger
healthy degraded REGISTRY_HEARTBEAT_DEGRADED_THRESHOLD consecutive missed heartbeats (default 3)
degraded unreachable REGISTRY_HEARTBEAT_UNREACHABLE_THRESHOLD consecutive misses (default 10)
degraded / unreachable healthy next successful heartbeat

unreachable services are still queryable in the registry but provisioning routes prefer healthy peers (load-balanced where multiple instances exist).


Webhook delivery row#

Source of truth: workers/event_dispatcher.py. See Webhooks reference.

States: pending, dispatched, dead.

From To Trigger
(new row) pending fan-out pass creates it
pending dispatched 2xx (or 409) response from subscriber
pending pending 5xx/timeout — next_attempt_at set per backoff schedule
pending dead 6th retry fails, OR non-409 4xx response
dead (no transition) terminal; rows retained for audit

Where each lives#

Machine Code path
Subscription services/subscription.py
Invoice services/billing/invoice.py
Provisioning step services/provisioning/
Registry — registration services/registry.py + api/v1/admin/registry.py
Registry — health workers/heartbeat_monitor.py
Webhook delivery workers/event_dispatcher.py
Updated 2026-05-18 01:48:40 View source (.md) rev 2