Subscribe

Start a subscription, charging the first period's fee.

Source src/operations/subscribe.ts#L55 · handleSubscribesrc/worker/subscriptions.ts#L60 · sweepDueSubscriptions

Subscribe

subscribe does three things in one transaction. It charges a buyer for the first period of a recurring plan, grants them the SKU, and saves a Subscription record. The background worker renews that record every later period.

You submit it with the buyer, the seller, the SKU, the per-period price, and the period length in milliseconds:

let outcome = await economy.submit({
  kind: "subscribe",
  idempotencyKey: "idem_1",
  actor: { kind: "user", userId: "usr_a" },
  userId: "usr_a",
  sellerId: "usr_s",
  sku: "club_pass",
  price: toAmount("CREDIT", 50_000n),
  periodMs: 2_592_000_000,
});
// outcome.status === "committed"

The handler bills period one only. Every period after that, the worker's renewal sweep bills the buyer again.

Parameters

Every field below is required; subscribe carries no optional payload.

FieldTypeDefaultDescription
idempotencyKeystringMakes a retried request run at most once.
actorPrincipalWho is asking.
userIdstringThe buyer. Must differ from sellerId.
sellerIdstringThe seller who earns the price.
skustringThe item or feature the subscription grants. Non-blank.
priceAmountPer-period charge, in CREDIT.
periodMsnumberPeriod length in milliseconds.

The price must be in CREDIT and fall between 100 and 10000 credits per period, inclusive. The periodMs must be a positive integer no larger than ten 365-day years (315360000000 ms). Anything outside those bounds is a wiring error, not a business decline — see Preconditions below.

Returns

subscribe returns an Outcome.

On success the status is committed, carrying the first-period Transaction. A repeat of the same idempotencyKey returns duplicate with the original transaction. A valid request the system declines returns rejected with one of the reason codes below.

Postings

The first-period charge posts as one balanced Transaction. Its legs depend on how the price splits between the buyer's promo grant and their spendable balance. Promo covers as much as it can, and spendable covers the rest.

The spendable-funded part is the part that carries the platform fee. The buyer's spendable is debited the full part. The seller's earned is credited the net, and REVENUE takes the fee:

LegAccountDirection
Buyer paysuserId:spendabledebit
Seller earns netsellerId:earnedcredit
Platform feeplatform:revenuecredit

The promo-funded part pays the seller real earnings out of platform revenue. Promo credit never reaches the seller as promo. The fee applies only to the spendable part, matching spend:

LegAccountDirection
Buyer's grant drawn downuserId:promodebit
Outstanding promo offsetplatform:promo_floatcredit
Revenue funds the sellerplatform:revenuedebit
Seller earns the promo partsellerId:earnedcredit

The fee comes from feeForPrice at platformFeeBps, rounded up to a whole credit and capped at the charge. The first period, every renewal, and spend all call it, so they round the fee identically.

The handler grants the buyer the SKU in the same transaction, through the end of the period just billed. A rolled-back charge rolls back the grant too.

Authorization

A user actor may run subscribe only for their own wallet — the buyer userId must match the actor. The ownership check covers the two accounts the charge debits: userId:promo and userId:spendable.

A system or operator actor may subscribe on any buyer's behalf. subscribe is not a privileged-only operation.

Reason codes

subscribe returns these RejectionCode values as a rejected outcome — a normal "no", not a thrown fault:

CodeWhen
ALREADY_SUBSCRIBEDAn ACTIVE subscription already exists for the same userId, sku, and sellerId. A second one would double-bill.
INSUFFICIENT_FUNDSThe buyer's spendable balance can't cover its share of the first period.
RISK_DENIEDThe charge would push the buyer past their recent-spending velocity limit. subscribe counts against the same window as spend.
ECONOMY_PAUSEDA maintenance window is in effect, so a user actor's subscribe is declined. The decline carries resumesAt; a system or operator actor is never paused.

Malformed input throws a fault instead of declining. Each of these throws OP.MALFORMED:

  • A buyer subscribing to themselves.
  • A price that isn't CREDIT, or one outside the credit band.
  • A non-positive or oversized periodMs.
  • A blank sku.

Preconditions and invariants

The handler enforces these before it posts anything:

  • The buyer is not the seller. userId must differ from sellerId; a self-subscription would turn the buyer's own promo credit into cashable earnings.
  • No active duplicate. At most one ACTIVE subscription exists per (userId, sku, sellerId).
  • Funds cover the spendable share. The pre-check declines a short balance with INSUFFICIENT_FUNDS before posting; the database's per-user non-negative constraint is the backstop.

Like every operation, the posting is balanced: debits and credits sum to zero in CREDIT.

The charge, the Subscription record, and the SKU grant all commit in one transaction. So a paying buyer always owns the SKU, and a rolled-back charge leaves no record behind.

See also