price() and the plan
price() is the library’s real core: pure, adapter-free, no I/O. observe is only the “get the rate → price() → commit” wrapper. An opinionated product can use price() directly.
price()
Section titled “price()”const sms = defineRate({ model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 20_000 }, { from: 10_000, unitPriceMicroUsd: 15_000 }] });const { result, plan, rated } = price( [{ meter: "otp/sms", dims: { country: "TR" }, quantity: 20, rate: sms, usedSoFar: 9_990 }], { type: "otp_send", id: "m9" }, { accountId: "acme" }, { at: Date.parse("2026-09-28T12:00:00Z") },);result; // { ok: true, charged: 350000, lines: [{ tierIndex: 1, breakdown: [{ from: 0, quantity: 10 }, { from: 10000, quantity: 10 }] }] }rated[0]; // { totalMicroUsd: 350000, carry: 0, holdUpperBound: 400000, … }The input is PricedLine[]: lines with their tariff (ValidRate) and usedSoFar attached. The output is { result, plan, rated }; rated[i] is rate()‘s answer for line i (breakdown, tierIndex, carry, holdUpperBound, clamped). The root price() does no pool expansion; the catalog’s metering.price(lines, ref, ctx, { pools }) applies feeds and takes missing pool lines’ tariffs from opts.pools.
The plan
Section titled “The plan”The plan is the only thing the library produces. commit must write it all or nothing and silently skip a repeated (refType, refId).
/** * Everything the library wants written, and the only thing it produces. * `commit` must apply it all or nothing, and treat a repeated `(refType, refId)` as a no-op. */export interface Plan { ledger: LedgerOp[]; usage: UsageRow[];}
export type LedgerOp = ChargeOp | HoldOp | ExtendOp | CaptureOp | ReleaseOp;
export interface UsageRow { account: string; meter: string; dims: Record<string, unknown>; quantity: number; amount: MicroUsd; rateId?: string; detail: UsageDetail; refType: string; refId: string; at: number; /** * The period's hard cap for this meter (from `getRate`). `commit` must refuse the whole plan with * `QuotaExceeded` if the meter's counter for the row's period plus `quantity` would exceed it. */ limit?: number;}LedgerOp.op |
Meaning | amount |
|---|---|---|
charge |
Moves money | positive = debit; negative = credit (volume adjustment, credits) |
hold |
Reserves for a hold | the hold’s upper bound |
extend |
Grows a reservation | the upper-bound difference |
capture |
Charges against a reservation | the captured amount |
release |
Frees the remainder | upperBound − captured |
UsageRow.detail records how the row was priced: breakdown (tier slices), feeder and weight on pool rows, clamped, holdId on hold rows, adjustment: true on adjustment rows, carry with cumulative. rateId is the tariff’s id, so every row traces back to the tariff that priced it.
refId rules
Section titled “refId rules”const { plan } = await metering.plan.observe( [ { meter: "otp/sms", dims: { country: "TR" }, quantity: 1 }, { meter: "otp/sms", dims: { country: "TR" }, quantity: 1 }, // same meter again → #2 ], { type: "otp_send", id: "m1" }, ctx,);plan.usage.map((u) => [u.refId, u.amount]);// [ ["otp_send:m1:otp/sms", 20000],// ["otp_send:m1:otp/sms#2", 20000],// ["otp_send:m1:otp/sms:msg/free_pool", 0], ← pool line: {refType}:{id}:{feeder}:{pool}// ["otp_send:m1:otp/sms:msg/free_pool#2", 0] ]plan.ledger.map((l) => l.refId);// [ "otp_send:m1:otp/sms", "otp_send:m1:otp/sms#2" ] ← zero amounts: usage only, no ledger row| Row | refId |
|---|---|
| Observation line | {refType}:{ref.id}:{meter} |
| The same meter again in one call | …#2, …#3 (A84) |
| Pool line | {refType}:{ref.id}:{feeder}:{pool} |
| Volume adjustment | {the line's refId}:adj (A81) |
| Hold | {refType}:{ref.id}:hold — holdId = {refType}:{ref.id} |
| Extend | {holdId}:extend:{n} |
| Capture | {refType}:{ref.id}:{meter}:capture:{n} (A87); pool line …:{feeder}:{pool}:capture:{n} |
| Release | {holdId}:release |
refIds derive only from the ref and the line order, so the same call yields the same plan. That is what makes application retries safe: commit turns the second plan into a no-op.
Volume adjustment
Section titled “Volume adjustment”// usedSoFar 900, volume on_crossing: 200 × 8,000 and −900 × 2,000 for the earlier unitsconst { result, plan } = await metering.plan.observe("api", {}, 200, { type: "req", id: "r2" }, ctx);result; // { ok: true, charged: -200000, lines: [{ amount: 1600000, adjustment: -1800000, … }] }plan.ledger; // [{ op: "charge", amount: 1600000, refId: "req:r2:api" },// { op: "charge", amount: -1800000, refId: "req:r2:api:adj" }]Rated.totalMicroUsd excludes the adjustment (A81): the usage row is the observation’s own amount; the adjustment is a separate usage row with refId …:adj (quantity: 0, detail.adjustment: true) and a separate charge. result.charged is their sum and can be negative.
Zero amounts
Section titled “Zero amounts”A row with amount 0 goes to usage, not to the ledger (A85). “Free but counted”: a zero-priced tariff, a pool’s free slice and ifMissing: "free" all advance the usedSoFar counters without empty ledger items. When both ledger and usage are empty, commit is not called at all.
Embedded mode
Section titled “Embedded mode”When counters and balance live in one atomic place (the account’s Durable Object on Cloudflare), moving pricing there too closes the race: the Worker plans with its view, the Durable Object re-prices the lines with its own counters and applies them in the same step.
- Worker:
metering.plan.observe(...)→{ result, plan, lines }.linesare the priced lines (pool lines included, with their tariffs; A91). - Durable Object: fills
usedSoFarfrom its counters, callscatalog.price(lines, ref, ctx), applies the plan insidetransactionSync. Nothing is awaited in between; the DO’s input gate makes it atomic.
export class AccountCore implements AccountApi { constructor( private storage: DurableStorageLike, private sink: (rows: readonly UsageRow[]) => Promise<void>, private opts: { prepaid: boolean } = { prepaid: true }, ) { migrateAccount(storage.sql); }
async commit(plan: Plan): Promise<CommitReply> { return commitReply(this.storage, plan, { prepaid: this.opts.prepaid, periodOf }); }
async used(meter: string, period: string): Promise<number> { return accountUsed(this.storage.sql, meter, period); }
async observeEmbedded(lines: CatalogLine[], ref: CatalogRef, ctx: Context): Promise<Result> { // Nothing awaits between reading counters and writing: the DO's input gate makes this atomic. const fresh = lines.map((l) => ({ ...l, usedSoFar: accountUsed(this.storage.sql, l.meter, periodOf({ at: l.at ?? Date.now() })) })); const { result, plan } = catalog.price(fresh, ref, ctx); if (!result.ok) return result; const reply = commitReply(this.storage, plan, { prepaid: this.opts.prepaid, periodOf }); return reply.ok ? result : { ok: false, reason: "insufficient_credit" }; }
async credit(amountMicroUsd: number): Promise<void> { creditAccount(this.storage.sql, amountMicroUsd); }
async state(): Promise<AccountState> { return accountState(this.storage.sql); }
async flush(): Promise<number> { let total = 0; for (let n = -1; n !== 0; total += n) n = await flushOutbox(this.storage.sql, this.sink); return total; }}See the messaging example and the Cloudflare adapter.