Credits and ledger

One payment-event model and one append-only credit ledger for every way money enters the platform.

Every payment rail (x402, cards, stablecoins, on-chain transfers) records the same kind of payment event, and value that becomes credits is written to a single append-only, USDC-denominated ledger. The goal is "accept anywhere, settle once": idempotency, reconciliation and double-payment protection exist once, not once per rail. It was added in #79.

Four collections

CollectionHoldsUnique on
payment_eventsOne document per payment, with its status historyidempotency_key
credit_ledgerAppend-only entries: credit, hold, capture, releaseidempotency_key
credit_holdsA guard: how much of a hold can still be captured or releasedhold_id
credit_balancesA guard: cached available and held per accountaccount_id

Money is stored as Decimal128, never as a float.

Payment events

Replaying an idempotency key returns the stored event. Replaying it with a different rail, purpose, asset, amount or account is an error. An event moves through these states:

  • Confirming an event credits its ledger account, and confirming twice is harmless: it finishes a credit a crash interrupted.
  • Uncertain is for a payment whose outcome is unknown, for example a timeout after a transaction may have been broadcast. An uncertain payment is never retried automatically. An administrator resolves it after checking the rail.
  • Keys name the payment itself, so a retry makes the same key: stripe:checkout:<session id> for a credit pack, or fragment_payout:<proposal id>:<fragment id>:<attempt> for a payout.

The credit ledger

Entries are never updated or deleted. A balance is derived by adding them up: a credit adds to available, a hold moves value from available to held, a capture spends held value, and a release gives it back.

submit a job  ->  hold the cost
job completes ->  capture it
job fails     ->  release it

MongoDB here is a standalone server without multi-document transactions. So each hold, capture or release changes its guard document with one atomic conditional update (a hold succeeds only if available >= amount), and the entry's unique key decides between concurrent replays. Writes are ordered so a crash fails closed: value can end up reserved until reconciled, but it is never spendable twice.

Reconciliation

A sweep runs every QB2_PAYMENT_RECONCILE_INTERVAL_SECONDS (default 300, 0 disables it) and on demand. It marks pending events older than 30 minutes that have no external reference as uncertain, and it compares each account's cached balance with the sum of its ledger entries and reports any drift. It does not repair drift, so an operator can find the cause first.

Admin API

All routes need the admin role, and none can write ledger entries.

Method and pathPurpose
GET /api/v1/admin/payments/eventsList events. Filters: status, job_id, proposal_id, limit
GET /api/v1/admin/payments/events/{key}One event with its status history
POST /api/v1/admin/payments/events/{key}/reconcileResolve a pending or uncertain event as confirmed or failed, with a reason
POST /api/v1/admin/payments/reconciliationRun the sweep now
GET /api/v1/admin/payments/accounts/{account_id}/balanceA balance derived from the ledger

Users read their own balance at GET /api/v1/credits/balance. See Billing for how credits are bought and spent.

On this page