Skip to content

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 a metadata.corrects_entry_id pointer.
  • 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