Contents

Subscribe

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

Source src/operations/subscribe.ts#L53subscribesrc/worker/subscriptions.ts#L68sweepDueSubscriptions

API 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:

const 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,
});
// → { status: "committed", transaction: { id: "txn_…", … } }
curl -s https://economy.example/submit \
  -H 'content-type: application/json' \
  -d '{
    "kind": "subscribe",
    "idempotencyKey": "idem_1",
    "actor": { "kind": "user", "userId": "usr_a" },
    "userId": "usr_a",
    "sellerId": "usr_s",
    "sku": "club_pass",
    "price": "CREDIT:500.00",
    "periodMs": 2592000000
  }'

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. The payload fields, beyond the kind tag, are:

FieldTypeDefaultDescription
idempotencyKeystring(required)A retried submit with the same key runs at most once. See idempotency.
actorPrincipal(required)Who is asking. A user actor may run subscribe only for their own wallet: the buyer userId must match the actor.
userIdstring(required)The buyer. Must differ from sellerId.
sellerIdstring(required)The seller who earns the price.
skustring(required)The item or feature the subscription grants. Non-blank.
priceAmount(required)Per-period charge, in CREDIT.
periodMsnumber(required)Period length in milliseconds.

The price must be in CREDIT and fall inside the configured price band — 100 to 10000 credits per period by default, set by SUBSCRIPTION_PRICE_MIN_MINOR and SUBSCRIPTION_PRICE_MAX_MINOR in the configuration. 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 resolves to an Outcome.

  • committed: success, carrying the first-period Transaction.
  • duplicate: a repeat of the same idempotencyKey, carrying the original transaction.
  • rejected: a valid request the system declines, 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 platform fee is charged on the spendable-funded part. The buyer’s spendable is debited that part in full. The seller’s earned is credited the net, and REVENUE takes the fee:

AccountSideAmount
spendable(userId)debitspendable part
earned(sellerId)creditspendable part − fee
SYSTEM.REVENUEcreditfee

The promo-funded part pays the seller real earnings out of platform revenue, with the buyer’s drawn-down grant offset against the outstanding promo float. Promo credit never reaches the seller as promo. The fee applies only to the spendable part, matching spend:

AccountSideAmount
promo(userId)debitpromo part
SYSTEM.PROMO_FLOATcreditpromo part
SYSTEM.REVENUEdebitpromo part
earned(sellerId)creditpromo part

The fee comes from feeForPrice at platformFeeBps, rounded up to a whole credit and capped at the charge — the same call spend and every renewal make, so the rounding is identical.

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:

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.
FUNDS_IMMATUREThe spendable-funded part draws on credits still inside their maturity window — the same gate spend runs. The decline carries availableAt.
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.

A malformed request throws instead:

CodeWhen
OP.MALFORMEDA buyer subscribing to themselves.
OP.MALFORMEDA price that isn’t CREDIT, or one outside the credit band.
OP.MALFORMEDA non-positive or oversized periodMs.
OP.MALFORMEDA 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 payable 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.

See also