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#
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) — flipscancelling → cancelledwhenpending_cancellation_at <= now(); flipsactive → expiredwhen term-end reached.trial_monitor(cron, daily) — emitssubscription.trial_ending.v1at 7/3/1 days; flipstrialing → cancelledif no payment method captured.dunning— flipspast_due → suspendedafter 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#
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}frominvoice_sequence - Credit notes:
SCAI-CN-{YEAR}-{SEQ:06d}fromcredit_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_profilesat this instantseller_snapshot(JSON) — partner config at this instantvat_details(JSON) — per-rate breakdowntemplate_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#
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 tocompensated; 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 |