Top-up

Buyer cash becomes spendable credit, valued at the buy rate.

Source src/operations/topUp.ts#L39 · topUpsrc/operations/registry.ts#L43 · REGISTRYsrc/contract.ts#L68 · kind 'topUp'src/webhooks.ts#L160 · toTopUp

Top-up

top-up turns a buyer's cleared cash into spendable credit. It raises a user's spendable balance and records the USD that paid for it.

It runs after a card or wallet charge clears, so the caller is the trusted payment service, not the buyer. You name the user, the credit amount, and the funding source:

let outcome = await economy.submit({
  kind: "topUp",
  idempotencyKey: "idem_0",
  actor: { kind: "system", service: "payments" },
  userId: "usr_buyer",
  amount: decodeAmount("50.00", "CREDIT"),
  source: "card",
});
// → { status: "committed", transaction: { … } }
// usr_buyer's spendable balance rose by 50.00 credits.

The credit amount is what the user receives. What they paid in USD is derived from the buy rate, not passed in.

Parameters

The payload fields, beyond the kind tag, are:

FieldTypeDefaultDescription
idempotencyKeystring— (required)A retried submit with the same key runs at most once. See idempotency.
actorActor— (required)Who is asking. Must be system or operator.
userIdstring— (required)The user whose spendable balance is credited.
amountAmount— (required)The credit to issue. Must be CREDIT and strictly positive.
sourcestring— (required)The funding rail (card, steam, …). Selects the credits' maturity horizon and must be non-empty after trimming whitespace.

The source is more than a label. It sets how long the new credits are held before they can be spent or cashed out — see maturity. An unrecognized source falls back to the long default horizon, never a fast one.

Returns

It returns an Outcome:

  • committed — the credit was issued. The transaction carries the issuance posting (the buyer's credits going up), the leg the buyer cares about.
  • duplicate — the idempotencyKey was already used. The earlier transaction is returned unchanged, and nothing new posts.
  • rejected — a velocity or maintenance-window decline; see reason codes.

Postings

A top-up posts two balanced transactions, because one posting can't mix CREDIT and USD. The returned transaction is the first.

The first posting issues the credit. It raises the buyer's spendable balance and records the same amount against STORED_VALUE, the running count of all credits in circulation:

LegAccountAmount
DebitSTORED_VALUEamount (CREDIT)
Creditspendable(userId)amount (CREDIT)

The second posting accounts for the cash the buyer paid. The USD splits two ways. The backing value (amount × par) is held in TRUST_CASH to cover the new credits. The buy-vs-par gap — the platform spread — is recognized as revenue in REVENUE_USD:

LegAccountAmount
DebitTRUST_CASHbacking = ceil(amount × par) (USD)
DebitREVENUE_USDmargin = gross − backing (USD)
CreditUSD_CLEARINGgross = ceil(amount × buy) (USD)

The REVENUE_USD leg posts only when the margin is positive. An exact-par purchase (no spread) stays a two-leg cash move.

Both conversions round up. Rounding the backing up keeps TRUST_CASH covering the whole spendable balance valued at par, so the books never read unbacked. Rounding the gross up to match keeps the margin at or above zero. The cost is at most one minor unit over the exact price.

Authorization

top-up is restricted to a privileged Actor: a trusted system service (the verified payment path) or a human operator. It mints spendable credit, so an end user can never issue one — a user principal is rejected with UNAUTHORIZED.

In production, the system top-up is driven by a verified processor webhook. toTopUp builds the operation from a cleared PurchaseEvent, keyed off the provider's eventId so a replayed webhook dedupes.

Reason codes

A top-up can return these reason codes as a rejected outcome:

CodeWhen
RISK_DENIEDThe user's recent top-up volume crossed the configured velocity limit. A top-up counts against the same per-user velocity window as a spend.
ECONOMY_PAUSEDA maintenance window is in effect. Only a user actor is paused — and a user can't top up at all — so a system or operator top-up is never paused.

Structurally invalid requests throw faults rather than returning a rejected outcome. A blank source or a non-CREDIT amount throws MALFORMED_OPERATION. A zero or negative amount throws INVALID_AMOUNT. These are broken requests, not expected declines.

Preconditions and invariants

A top-up holds the books straight on both currencies:

  • The amount must be CREDIT and strictly positive; a wrong currency or non-positive amount throws.
  • Both postings net to zero within their currency, so the ledger stays balanced and conserved.
  • TRUST_CASH rises by at least amount × par, so the credits are backed the moment they're issued — trust cash always covers the spendable balance valued at par.
  • The new credits carry their source and top-up time as a lot, which fixes when they mature and become spendable and cashable.

See also