Grant promo

Issue promotional credit that expires.

Source src/operations/promo.ts#L37 · grantPromosrc/worker/promos.ts#L63 · sweepExpiredPromos

Grant promo

grantPromo drops marketing credit into a user's promo balance with an expiry date. A campaign can hand out credit that reverts to the platform if it goes unspent.

Here a marketing service grants a user 500 credits that expire 24 hours from now:

let outcome = await economy.submit({
  kind: "grantPromo",
  idempotencyKey: "promo_2026_06_27_usr_buyer",
  actor: { kind: "system", service: "marketing" },
  userId: "usr_buyer",
  amount: toAmount("CREDIT", 500n),
  expiresAt: Date.now() + 86_400_000,
});
// outcome.status === "committed"

The grant lands immediately. Promo credit carries no maturity hold, and a buyer's promo balance is drawn before their own money on the next spend.

Parameters

Every field is required. grantPromo takes no optional payload.

FieldTypeDefaultDescription
idempotencyKeystringA retried request with the same key runs at most once and returns the original Outcome.
actorPrincipalWho is asking. Must be a system or operator principal.
userIdstringThe user whose promo balance the grant raises.
amountAmountHow much credit to grant. Must be CREDIT and positive.
expiresAtnumberWhen the grant expires, in epoch milliseconds. Must be a whole-millisecond timestamp strictly in the future, and no more than five years out.

Returns

grantPromo returns an Outcome.

On success the status is committed, carrying the posted transaction. A repeat of the same idempotencyKey returns duplicate with the original transaction. The repeat moves no new money.

grantPromo returns no rejected outcome — see Reason codes below.

Postings

The grant posts one balanced entry. It raises the user's promo balance and offsets it on the platform's PROMO_FLOAT account, so the two legs balance.

LegAccountDirection
1PROMO_FLOAT (platform)debit
2promo(userId)credit

PROMO_FLOAT is debit-normal, so debiting it raises its balance to mirror the credit added to the user's promo account.

The handler also records the grant in the same unit of work, reusing the posting's id. That id is how the expiry sweep later finds the grant and reverses any unspent portion.

Authorization

grantPromo is privileged: only a system or operator Actor may call it.

An end-user principal is refused. The operation mints credit into an arbitrary user's account, so it sits in the engine's restricted-to-privileged set alongside topUp and the entitlement grants. A user principal raises an AUTH.UNAUTHORIZED fault before any work runs.

Reason codes

grantPromo returns no RejectionCode.

It declines no well-formed request. There is no affordability check to fail. The maintenance-window pause gate applies only to user principals, which can never call this privileged operation. So every failure path is a thrown fault, not a rejected outcome.

The faults it can throw, all from malformed input, are:

CodeWhen
OP.MALFORMEDamount is not CREDIT, or expiresAt is not a whole-millisecond timestamp, is not in the future, or is more than five years out.
MONEY.INVALID_AMOUNTamount is zero or negative.
AUTH.UNAUTHORIZEDThe caller is a user principal.

Preconditions and invariants

Every grant must expire. The five-year ceiling on expiresAt stops a caller from minting effectively never-expiring credit with an absurd far-future timestamp, which keeps the expiry sweep able to reclaim it.

The grant and its bookkeeping row commit or roll back together. Because the recorded grant reuses the posting's id, and the row write is idempotent on that id, a retried grant never duplicates the row.

When a grant expires, the worker's promo sweep reverses whatever is unspent. It reads the user's live promo balance and claws back min(grant amount, balance). A partly-spent grant returns only the remainder, and a fully-spent one posts nothing.

The reversal mirrors the original entry: it debits the user's promo account and credits PROMO_FLOAT. Grant and reversal cancel out.

See also