---
title: State machines
path: reference/state-machines
status: published
---

# 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

```mermaid
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

```mermaid
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

```mermaid
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](./webhooks).

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