Billing & ledger¶
Implemented Contract
Source:
packages/services/billing/ · cmd/billing-worker/ · packages/services/payments/ · cmd/webhook-worker/
Money in GPUaaS lives in an immutable ledger. No mutable balance column. Balance is always computed by summing ledger entries. Corrections are new entries, not updates.
States and transitions¶
stateDiagram-v2
[*] --> healthy: balance > threshold
healthy --> low_balance: balance ≤ threshold
low_balance --> auto_release_pending: projected depletion in window
low_balance --> depleted: balance ≤ 0
auto_release_pending --> depleted: balance ≤ 0
depleted --> healthy: top-up posted
low_balance --> healthy: top-up posted
auto_release_pending --> healthy: top-up posted
depleted --> [*]
Per the PRD §9, after top-up users must manually reprovision. Auto-restart is explicit future policy.
Accrual loop¶
sequenceDiagram
autonumber
participant TICK as billing-worker ticker
participant DB as Postgres
participant NATS as NATS
participant PW as provisioning-worker
TICK->>DB: every billing.window_seconds (default 60)
TICK->>DB: SELECT active allocations
loop per active allocation
TICK->>DB: compute interval cost (sku rate × duration × gpus)
TICK->>DB: INSERT ledger_entries (debit) + usage_records
TICK->>DB: recompute balance
alt balance ≤ threshold && not yet warned
TICK->>DB: INSERT low_balance_events (idempotency lock)
TICK->>DB: INSERT outbox: billing.low_balance_warning
end
alt balance ≤ 0
TICK->>DB: INSERT outbox: billing.balance_depleted
TICK->>DB: INSERT outbox: provisioning.force_release_requested
end
end
NATS-->>PW: deliver force_release_requested
PW->>PW: graceful release flow
Payment flow¶
sequenceDiagram
autonumber
participant U as User
participant API as cmd/api
participant STRIPE as Stripe
participant WW as webhook-worker
participant DB as Postgres
U->>API: POST /payments/checkout {amount}
API->>DB: INSERT payment_sessions
API->>STRIPE: create Checkout Session
STRIPE-->>API: session URL
API-->>U: redirect to Stripe
U->>STRIPE: complete payment
STRIPE->>API: POST /payments/webhook (signed)
Note over API: raw-body-first buffer<br/>before any JSON parse
API->>API: verify Stripe signature on raw bytes
API->>DB: queue payment_webhook_events
WW->>DB: pull queued events (FOR UPDATE SKIP LOCKED)
WW->>DB: INSERT ledger_entries (credit)
WW->>DB: INSERT outbox: payments.balance_credited
Note over WW: dedupe by event_id —<br/>duplicate webhook never double-credits
Ledger row shape¶
CREATE TABLE ledger_entries (
id uuid PRIMARY KEY,
user_id uuid NOT NULL,
amount_minor bigint NOT NULL, -- positive=credit, negative=debit
currency text NOT NULL,
reason text NOT NULL, -- usage|topup|refund|adjustment|credit_grant
reference_type text NULL, -- allocation|payment_session|refund_record
reference_id uuid NULL,
correlation_id text NOT NULL,
metadata jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL
-- NO updated_at, NO deleted_at — immutable
);
Hard rules:
- Never UPDATE.
- Never DELETE.
- Corrections add a new entry with
reason='adjustment'and ametadata.corrects_entry_idpointer. - Audit row written for every adjustment.
Refund hybrid policy¶
flowchart TB
R[Refund request] --> W{within<br/>refund_window_days?}
W -- yes --> P[Stripe provider refund]
W -- no --> C[Internal balance credit]
P --> L1[ledger_entry: refund/credit]
C --> L2[ledger_entry: credit_grant]
L1 & L2 --> A[audit_logs row<br/>+ outbox notification]
refund_window_days is policy-driven (policy_values row, default 30) — not hardcoded.
Money discipline¶
| Rule | Why |
|---|---|
| All values in minor units (integer) | No float drift |
| Currency always explicit on the row | Multi-currency-ready |
| Ledger immutable | Audit + reconciliation |
| Balance computed from ledger | Single source of truth |
| Webhook signature on raw body | Stripe verifies exact bytes |
Webhook events deduped by event_id |
Stripe retries are common |
| Refunds go through dedicated API | Not generic adjustment |
| Audit on every privileged adjustment | Compliance |
Policy keys¶
| Key | Default | Owner |
|---|---|---|
billing.window_seconds |
60 | billing-worker |
billing.low_balance_threshold_minor |
500 (cents) | billing-worker |
billing.minimum_deposit_minor |
1000 | payments / API |
billing.maximum_deposit_minor |
100000 | payments / API |
allocation.refund_window_days |
30 | payments |
notification.low_balance_enabled |
true | notification-relay |
notification.balance_depleted_enabled |
true | notification-relay |
Full list: Policy keys reference.
Events emitted¶
| Subject | Producer | Payload |
|---|---|---|
billing.low_balance_warning |
billing-worker | {user_id, balance_minor, threshold_minor} |
billing.auto_release_pending |
billing-worker | {user_id, projected_depletion_at} |
billing.balance_depleted |
billing-worker | {user_id, balance_minor} |
payments.balance_credited |
webhook-worker | {user_id, amount_minor, source} |
Where to look next¶
- Outbox & event flow
- Allocation lifecycle —
provisioning.activetriggers accrual;provisioning.release_failedstops billing - Policy keys reference
- Runbooks: