Skip to content

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.

docs/src/snippets/plan-and-hold.test.ts
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 is the only thing the library produces. commit must write it all or nothing and silently skip a repeated (refType, refId).

packages/core/src/plan.ts
/**
* 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.

docs/src/snippets/plan-and-hold.test.ts
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.

docs/src/snippets/plan-and-hold.test.ts
// usedSoFar 900, volume on_crossing: 200 × 8,000 and −900 × 2,000 for the earlier units
const { 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.

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.

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.

  1. Worker: metering.plan.observe(...) → { result, plan, lines }. lines are the priced lines (pool lines included, with their tariffs; A91).
  2. Durable Object: fills usedSoFar from its counters, calls catalog.price(lines, ref, ctx), applies the plan inside transactionSync. Nothing is awaited in between; the DO’s input gate makes it atomic.
examples/messaging/src/account.ts
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.