/rates
// import { layeredRates, type PriceVersion } from "pricemeter/rates";const rows: PriceVersion[] = [ { id: "sms-list", meter: "sms", scope: "list", dims: { country: "*" }, effectiveFrom: 0, rate: flat(50_000) }, { id: "sms-list-tr", meter: "sms", scope: "list", dims: { country: "TR" }, effectiveFrom: 0, rate: flat(20_000) }, { id: "sms-pro-tr", meter: "sms", scope: "plan:pro", dims: { country: "TR" }, effectiveFrom: Date.parse("2026-09-01"), rate: flat(18_000) }, { id: "sms-acc1", meter: "sms", scope: "account:acc_1", dims: { country: "DE" }, effectiveFrom: 0, rate: flat(30_000) }, { id: "sms-acc1-tr-off", meter: "sms", scope: "account:acc_1", dims: { country: "TR" }, effectiveFrom: 0, removed: true },];
const getRate = layeredRates<{ accountId: string; plan: string }>({ loadRows: (meter) => rows.filter((r) => r.meter === meter), // your DB query scopes: (ctx) => [`account:${ctx.accountId}`, `plan:${ctx.plan}`, "list"], // most specific first cacheMs: 60_000,});
await getRate("sms", { country: "DE" }, ctx, at); // { rate: sms-acc1 (30000) } account rowawait getRate("sms", { country: "TR" }, ctx, at); // { rate: sms-pro-tr (18000) } account row removed → planawait getRate("sms", { country: "US" }, ctx, at); // { rate: sms-list (50000) } wildcard in listpricemeter/rates is an optional price table that produces a ready getRate. Its seam in the module test is “producing a getRate”; the core never sees it.
Resolution
Section titled “Resolution”resolveLayered({ rows, scopes, dims, at, meter }) picks in this order:
- Scope order: the array
scopes(ctx)returns, most specific first (account:…>plan:…>list). Scope names carry no meaning; you give the order. - Within a scope, the most specific matching row: one point per exactly matched dimension; a missing key or
"*"matches anything but scores nothing. - On a tie, the latest
effectiveFrom(≤at). - If the winning row is
removed: true, the scope falls through to the next one. No row in any scope returnsnull→no_price.
If the winning tariff has no id, the row’s id is used, so the usage row’s rateId points at the price-table row.
| API | What it does |
|---|---|
layeredRates({ loadRows, scopes, cacheMs?, usedSoFar?, now? }) |
A ready getRate. loadRows(meter, ctx) is your query; cached per (meter, scopes) for cacheMs. When usedSoFar(meter, dims, ctx, at, rate) is given, its answer is added |
resolveLayered({ rows, scopes, dims, at, meter? }) |
The pure resolver; use it inside your own getRate |
validateVersion(row) |
Validates a price row, tariff included; for an admin UI before saving |
applyDiscount, scaleTiers, withPolicy |
Pure helpers that derive a new tariff from one |
const list: Rate = { id: "api", model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 1_000 }, { from: 10_000, unitPriceMicroUsd: 800 }] };applyDiscount({ rate: list, percent: 15 }); // prices × 0.85, id "api-15pct"scaleTiers({ rate: list, factor: 0.5 }); // the cheaper tier starts at 5,000withPolicy({ rate: list, policy: { rounding: "cumulative" } });applyDiscount rounds unit prices and flat fees to the nearest micro-unit and appends -{percent}pct to the id (a CRM discount, B19). scaleTiers multiplies tier thresholds (an enterprise account reaches the cheaper tier sooner).
/rates knows prices, not usedSoFar: the counter lives in your store. Examples: SaaS seats uses layeredRates + a cycle window; messaging uses resolveLayered with a route-cost fallback.
How you’d do it without this
Section titled “How you’d do it without this”getRate is a function; if you already have a price table, query it directly:
// Without /rates: a getRate over your own table, in whatever order your product needs.const byHand = async (meter: string, dims: { country: string }, c: typeof ctx, t: number): Promise<RateAnswer | null> => { for (const scope of [`account:${c.accountId}`, `plan:${c.plan}`, "list"]) { const candidates = rows .filter((r) => r.meter === meter && r.scope === scope && r.effectiveFrom <= t) .filter((r) => r.dims?.country === dims.country || r.dims?.country === "*") .sort((a, b) => Number(b.dims?.country !== "*") - Number(a.dims?.country !== "*") || b.effectiveFrom - a.effectiveFrom); const best = candidates[0]; if (best && !best.removed) return { rate: { ...best.rate!, id: best.id } }; } return null;};Specificity, wildcards, versions and “removed” rules differ per product; this module only ships one common shape. If your table resolves in a different order (say channel first, then account), write your own getRate; the library cannot tell the difference.