Skip to content

/rates

docs/src/snippets/modules.test.ts
// 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 row
await getRate("sms", { country: "TR" }, ctx, at); // { rate: sms-pro-tr (18000) } account row removed → plan
await getRate("sms", { country: "US" }, ctx, at); // { rate: sms-list (50000) } wildcard in list

pricemeter/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.

resolveLayered({ rows, scopes, dims, at, meter }) picks in this order:

  1. Scope order: the array scopes(ctx) returns, most specific first (account:… > plan:… > list). Scope names carry no meaning; you give the order.
  2. Within a scope, the most specific matching row: one point per exactly matched dimension; a missing key or "*" matches anything but scores nothing.
  3. On a tie, the latest effectiveFrom (≤ at).
  4. If the winning row is removed: true, the scope falls through to the next one. No row in any scope returns null → 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
docs/src/snippets/modules.test.ts
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,000
withPolicy({ 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.

getRate is a function; if you already have a price table, query it directly:

docs/src/snippets/modules.test.ts
// 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.