Spend

A marketplace sale: the buyer spends, the seller earns, and the platform's fee is split out by the pricing policy.

Source src/operations/spend.ts#L69 · spendsrc/pricing.ts#L48 · splitLegs

Spend

spend runs a marketplace purchase. It charges the buyer and pays the sellers as one balanced double-entry posting.

The same transaction also records a sale under orderId and grants the buyer the sku. Because the charge and the grant post together, paying always confers ownership.

A purchase with a single seller taking the whole price looks like this:

let outcome = await economy.submit({
  kind: "spend",
  idempotencyKey: "idem_0",
  actor: { kind: "user", userId: "usr_buyer" },
  orderId: "ord_1",
  buyerId: "usr_buyer",
  sku: "wrld_pass",
  price: toAmount("CREDIT", 400n),
  recipients: [{ sellerId: "usr_seller", shareBps: 10_000 }],
});
// → outcome.status === "committed"

The buyer's promo balance is drawn first, then spendable. The seller is credited to their earned balance, and the platform keeps its fee.

A purchase can split the price across several sellers. Each Recipient takes a share in basis points (bps), and the shares must sum to 10_000:

recipients: [
  { sellerId: "usr_creator_a", shareBps: 6_000 },
  { sellerId: "usr_creator_b", shareBps: 4_000 },
]

Parameters

Every field of the spend payload, with the types each one links to its canonical page:

FieldTypeDefaultDescription
kind"spend"Selects this operation.
idempotencyKeystringMakes a retried request run at most once. A repeat with the same key returns duplicate.
actorActorWho is asking. A user actor must be the buyer.
orderIdstringThe unique key for this purchase. A refund looks the sale up by it.
buyerIdstringThe user who pays.
skustringThe item granted on success.
priceAmountThe total charge. Must be positive and in CREDIT.
recipientsRecipient[][]Sellers and their shares in bps. An empty list means the platform keeps the whole net.
ageRestrictedbooleanfalseTags the posting for audit. The core does not block on age.
giftTostringbuyerIdGrants the sku to this user instead of the buyer. The buyer still pays.

Each Recipient is { sellerId: string; shareBps: number }. A shareBps must be > 0 and ≤ 10_000, and the shares must sum to exactly 10_000.

Returns

spend resolves to an Outcome.

On success it returns committed with the posted Transaction. A well-formed request that can't proceed returns rejected with a RejectionCode. A retry under the same idempotencyKey returns duplicate.

Postings

spend posts one balanced Transaction. The price is split across the buyer's two balances, so the legs come in two parts that each balance on their own.

The spendable-funded part debits the buyer and lets the pricing policy split the credit across sellers and the house:

AccountSideAmount
spendable(buyerId)debitthe spendable part of the price
earned(sellerId)crediteach seller's share of the net
SYSTEM.REVENUEcreditthe platform fee, plus any rounding leftover

The promo-funded part spends down the buyer's marketing grant and funds the sellers from house revenue, since a promo grant is not money the buyer paid:

AccountSideAmount
promo(buyerId)debitthe promo part of the price
SYSTEM.PROMO_FLOATcreditthe same promo part
SYSTEM.REVENUEdebitthe total paid out to sellers
earned(sellerId)crediteach seller's share of the promo part

Authorization

spend is open to an end user: the buyer runs it on their own wallet. A system or operator actor may also call it.

A user actor must own every account the operation debits, so a buyer may only spend from their own promo and spendable balances. Submitting a spend whose buyerId is someone else's wallet is unauthorized.

Reason codes

A well-formed spend on a healthy system can be declined for these reasons, each returned as a rejected Outcome:

CodeWhen
INSUFFICIENT_FUNDSThe buyer can't cover the price from promo plus spendable.
FUNDS_IMMATUREThe buyer has the credits, but the spendable part would dip into funds still in a settlement hold.
DUPLICATE_ORDERA sale already exists for this orderId under a different idempotencyKey, so a second charge is refused.
RISK_DENIEDThe purchase would push the buyer past their recent-spending velocity limit.
ECONOMY_PAUSEDA maintenance window is in effect, so an end user's purchase is declined. The decline carries resumesAt.

A structurally invalid request is different: it's a fault, thrown rather than returned as a rejected Outcome. A request is malformed when any of these hold:

  • The price is non-positive or not in CREDIT.
  • The sku, orderId, or giftTo is blank.
  • The recipient shares don't sum to 10_000.
  • Two recipients name the same sellerId.
  • A recipient names a house account rather than a user wallet.
  • A recipient equals the buyerId (self-dealing).

Preconditions and invariants

The pipeline does the shared work before the handler runs. In order, it:

  1. Authorizes the buyer.
  2. Drops exact retries by idempotencyKey.
  3. Screens risk and funds.
  4. Locks the affected accounts.

The handler then validates its own fields and posts.

The posted transaction is balanced: every leg's debits and credits sum to zero, both for the promo-funded part and the spendable-funded part.

The buyer always pays from promo first, then spendable. The up-front funds check and the posting use the same split, so they can't disagree.

The sku entitlement is granted in the same transaction as the charge. It goes to the buyer, or to giftTo when that field is present. Because the grant shares the transaction, a rolled-back charge grants nothing.

See also