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:
| Field | Type | Default | Description |
|---|---|---|---|
kind | "spend" | — | Selects this operation. |
idempotencyKey | string | — | Makes a retried request run at most once. A repeat with the same key returns duplicate. |
actor | Actor | — | Who is asking. A user actor must be the buyer. |
orderId | string | — | The unique key for this purchase. A refund looks the sale up by it. |
buyerId | string | — | The user who pays. |
sku | string | — | The item granted on success. |
price | Amount | — | The total charge. Must be positive and in CREDIT. |
recipients | Recipient[] | [] | Sellers and their shares in bps. An empty list means the platform keeps the whole net. |
ageRestricted | boolean | false | Tags the posting for audit. The core does not block on age. |
giftTo | string | buyerId | Grants 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:
| Account | Side | Amount |
|---|---|---|
spendable(buyerId) | debit | the spendable part of the price |
earned(sellerId) | credit | each seller's share of the net |
SYSTEM.REVENUE | credit | the 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:
| Account | Side | Amount |
|---|---|---|
promo(buyerId) | debit | the promo part of the price |
SYSTEM.PROMO_FLOAT | credit | the same promo part |
SYSTEM.REVENUE | debit | the total paid out to sellers |
earned(sellerId) | credit | each 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:
| Code | When |
|---|---|
INSUFFICIENT_FUNDS | The buyer can't cover the price from promo plus spendable. |
FUNDS_IMMATURE | The buyer has the credits, but the spendable part would dip into funds still in a settlement hold. |
DUPLICATE_ORDER | A sale already exists for this orderId under a different idempotencyKey, so a second charge is refused. |
RISK_DENIED | The purchase would push the buyer past their recent-spending velocity limit. |
ECONOMY_PAUSED | A 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
priceis non-positive or not inCREDIT. - The
sku,orderId, orgiftTois 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:
- Authorizes the buyer.
- Drops exact retries by
idempotencyKey. - Screens risk and funds.
- 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.