Recipes
Each scenario is one test file in examples/recipes/ and runs with bun run test. This page is generated from that folder at build time, so a new file shows up here on its own. The explanations come from each file’s leading comment.
B rows are business scenarios; L rows map Lago’s charge models and concepts to pricemeter.
Layer says where the scenario is solved:
| Layer | Meaning |
|---|---|
| core | With Rate, the catalog and observe/hold alone |
| module | With an optional module (/rates, /gauge, /adjustments, /calendar) |
| recipe | With a choice the application makes in getRate or before observing |
| out of scope | Not the library’s job; the nearest pattern is shown |
| # | Scenario | How | Layer |
|---|---|---|---|
| B1 | SMS priced by country, cheaper above 10,000 messages a month | graduated, dims country | core |
| B2 | LLM tokens at $0.15 per 1M | graduated, per: 1M, cumulative | core |
| B3 | First 500 per month are free | graduated [0: 0, 500: p] | core |
| B4 | SMS and WhatsApp share one monthly quota of 1,000 messages | feeds pool | core |
| B5 | Sold in blocks of 1,000 | package | core |
| B6 | Volume pricing: crossing a threshold reprices the whole month | volume on_crossing or volumeTrueUp | core / module |
| B7 | A conversation is priced when it closes, by how long it was | app classifies, dims.tier | recipe |
| B8 | Charged per started conversation-minute | quantity: minutes | core |
| B9 | A seat costs $10 a month, prorated when added mid-month | integrate + per: monthSeconds | module |
| B10 | Storage billed on the month's peak GB | integrate({ mode: "max" }) | module |
| B11 | A dedicated IP costs a flat $50 a month | single tier flatMicroUsd | core |
| B12 | A $100 monthly minimum commitment | minimumCommit | module |
| B13 | Night rate and a campaign window | getRate(at) | recipe |
| B14 | Enterprise pricing: a plan (group) price, and an even more specific account price | layeredRates | recipe / module |
| B15 | One request touches two meters under one reservation (OTP: SMS, then voice fallback) | multi-line hold | core |
| B16 | The first 1,000 units of an account's lifetime are free | lifetime usedSoFar counter (LIFETIME) | recipe |
| B17 | A customer billed in EUR | getRate by currency; Rate has no currency | recipe |
| B18 | Credits that expire | ledger adapter; flatCredit + expiry charge | out of scope |
| B19 | A customer-specific price, three ways | account row, plan row, applyDiscount | recipe |
| B20 | A custom price ends and the account goes back to list; a hold opened before keeps the old price | tariff embedded in hold | core |
| B21 | A flat fee when usage enters a tier (a platform fee plus a step fee at 10k) | Tier.flatMicroUsd | core |
| B22 | Fintech fee: 2.9% + $0.30 per transaction, capped at $5 | per: 1M + flatMicroUsd + perObservation, usedSoFar: 0 | core |
| B23 | Each event has its own price (SMS priced at the route's cost at send time, plus margin) | getRate(at) per observation | recipe |
| B24 | Monthly active users (MAU) | app counts uniques, one observe | recipe |
| B25 | Reseller pricing: the reseller's price for one customer, else the reseller's default, else list | three layers in getRate | recipe |
| B26 | Postpaid: usage is recorded and invoiced later, whatever the balance | commit doesn't check balance | recipe |
| B27 | A coupon-like credit ($10 welcome credit) | flatCredit | module |
| B28 | Hard quota: the free plan gets 1,000 OTPs a month, then sending stops (it doesn't get pricier) | limit from getRate → quota_exceeded, enforced atomically in commit | core |
| L1 | Lago's standard, graduated, volume and package charge models | the three models | core |
| L2 | Lago "percentage" and "graduated_percentage" | like B22 | core |
| L3 | Lago "dynamic": the price is known only when the event happens (e.g. GPU spot price) | like B23 | recipe |
| L4 | Lago "custom" charge model (arbitrary code computes the fee) | code builds a Rate instead | out of scope |
| L5 | Lago progressive billing (invoice early when usage crosses a threshold) | prepaid gate instead | out of scope |
| L6 | Lago aggregations: count, sum, max, latest, weighted_sum | quantity from app; integrate | core / module |
| L7 | Lago unique_count (distinct users per workspace) | like B24, per dimension | recipe |
| L8 | Lago filters, grouped_by and pricing_group_keys | dimensions, layeredRates | core |
| L9 | Lago min_amount_cents, pay_in_advance and prorated charges | minimumCommit; observe at event time; per | module / core |
| L10 | Lago fixed charges with graduated or volume pricing (e.g. seats) | integrate result into any model | core |
| L11 | Lago plans, subscriptions, trials, wallets, coupons and invoices | one touch point each | out of scope |
B — Business scenarios
Section titled “B — Business scenarios”B1 — SMS priced by country, cheaper above 10,000 messages a month
Layer: core (graduated rate + a country dimension)
How: declare country as a dimension and let getRate return a graduated tariff per country. rate() splits an observation that crosses the 10k threshold between the two tiers; getRate only supplies the whole tariff and how many messages were already sent this month.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { monthKey } from "pricemeter/calendar";import { memoryAdapters } from "pricemeter/testing";
const smsRate = (country: string): Rate => country === "TR" ? { id: "sms-tr", model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 20_000 }, { from: 10_000, unitPriceMicroUsd: 15_000 }] } : { id: "sms-intl", model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 50_000 }, { from: 10_000, unitPriceMicroUsd: 40_000 }] };
function setup() { // usedSoFar is counted per (account, meter, month). const mem = memoryAdapters({ rates: { sms: (dims) => smsRate(dims.country) }, periodOf: (at) => monthKey({ at }) }); const metering = buildMetering().refs(["sms_send"]).meter("sms", ["country"]).bind(mem); return { metering, mem };}
const ctx = { accountId: "acme" };const at = Date.parse("2026-09-10T12:00:00Z");
describe("B1 SMS by country with a 10k discount", () => { it("splits an observation that crosses the threshold", async () => { const { metering } = setup();
// 9,990 × $0.02 = $199.80 expect(await metering.observe("sms", { country: "TR" }, 9_990, { type: "sms_send", id: "batch_1" }, ctx, { at })).toMatchObject({ ok: true, charged: 199_800_000, });
// usedSoFar 9,990: 10 × 20,000 + 10 × 15,000 = 350,000 const r = await metering.observe("sms", { country: "TR" }, 20, { type: "sms_send", id: "batch_2" }, ctx, { at }); expect(r).toMatchObject({ ok: true, charged: 350_000, lines: [{ meter: "sms", tierIndex: 1, breakdown: [{ from: 0, quantity: 10 }, { from: 10_000, quantity: 10 }] }], }); });
it("the volume counter is shared across countries (memoryAdapters counts per meter)", async () => { const { metering, mem } = setup(); await metering.observe("sms", { country: "TR" }, 10_010, { type: "sms_send", id: "1" }, ctx, { at }); expect(mem.store.used("acme", "sms", at)).toBe(10_010);
// Already past 10k this month, so a German SMS is at the discounted international price. expect(await metering.observe("sms", { country: "DE" }, 1, { type: "sms_send", id: "2" }, ctx, { at })).toMatchObject({ charged: 40_000 }); });});B2 — LLM tokens at $0.15 per 1M
Layer: core (graduated, per: 1_000_000, rounding: "cumulative")
How: price per million with per, and use cumulative rounding so that many tiny observations add up to exactly the month's total instead of rounding every call up to one micro-dollar.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
// $0.15 = 150,000 micro-USD per 1,000,000 tokens → 0.15 micro-USD per token.const tokens: Rate = { id: "llm-input", model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 150_000, per: 1_000_000 }], policy: { rounding: "cumulative" },};
describe("B2 LLM $0.15 / 1M tokens", () => { it("small calls do not drift: the running total is floored, not each call rounded up", async () => { const mem = memoryAdapters({ rates: { "llm/input": tokens } }); const metering = buildMetering().refs(["completion"]).meter("llm/input", ["model"]).bind(mem); const ctx = { accountId: "acme" };
const charged: number[] = []; for (const id of ["c1", "c2", "c3"]) { const r = await metering.observe("llm/input", { model: "small" }, 7, { type: "completion", id }, ctx); if (!r.ok) throw new Error(r.reason); charged.push(r.charged); } // Running totals 1.05 → 2.10 → 3.15; floors 1, 2, 3 → one micro-dollar per call. // (per_event_up would charge ceil(1.05) = 2 each, 6 in total.) expect(charged).toEqual([1, 1, 1]);
// 2,000,000 more tokens: floor(300,003.15) − floor(3.15) = 300,000 expect(await metering.observe("llm/input", { model: "small" }, 2_000_000, { type: "completion", id: "c4" }, ctx)).toMatchObject({ charged: 300_000, }); // Whole period: 2,000,021 tokens × 0.15 = 300,003.15 → 300,003 charged, nothing lost or invented. expect(mem.store.ledger.reduce((s, op) => s + op.amount, 0)).toBe(300_003); });});B3 — First 500 per month are free
Layer: core (graduated [0: 0, 500: p])
How: a zero-priced first tier. The free allowance resets because getRate reports usedSoFar for the current month; the library itself has no calendar.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { monthKey } from "pricemeter/calendar";import { memoryAdapters } from "pricemeter/testing";
const verify: Rate = { id: "verify-2026", model: "graduated", tiers: [ { from: 0, unitPriceMicroUsd: 0 }, // first 500 free { from: 500, unitPriceMicroUsd: 10_000 }, // then $0.01 each ], policy: { tierPeriod: "month" },};
describe("B3 first 500 per month free", () => { it("charges only past the allowance, and the allowance resets next month", async () => { const mem = memoryAdapters({ rates: { verify }, periodOf: (at) => monthKey({ at }) }); const metering = buildMetering().refs(["check"]).meter("verify").bind(mem); const ctx = { accountId: "acme" }; const sep = Date.parse("2026-09-05T00:00:00Z"); const oct = Date.parse("2026-10-02T00:00:00Z");
// 400 of 500 free units: 0, but the usage row is written (and counts). expect(await metering.observe("verify", {}, 400, { type: "check", id: "1" }, ctx, { at: sep })).toMatchObject({ ok: true, charged: 0 }); expect(mem.store.usage).toHaveLength(1); expect(mem.store.ledger).toHaveLength(0);
// usedSoFar 400: 100 free + 100 × 10,000 = 1,000,000 expect(await metering.observe("verify", {}, 200, { type: "check", id: "2" }, ctx, { at: sep })).toMatchObject({ charged: 1_000_000 });
// New month, new counter: free again. expect(await metering.observe("verify", {}, 10, { type: "check", id: "3" }, ctx, { at: oct })).toMatchObject({ charged: 0 }); });});B4 — SMS and WhatsApp share one monthly quota of 1,000 messages
Layer: core (feeds into a pool meter)
How: declare a dimensionless pool meter and let both channels feed it. Every observation adds a pool line with its own tariff; the channels keep their own per-message price (carrier cost) and the pool charges the platform fee once the shared quota is used up.
import { describe, expect, it } from "vitest";import { buildMetering } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
function setup() { const mem = memoryAdapters({ rates: { "msg/quota": { model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 0 }, { from: 1_000, unitPriceMicroUsd: 5_000 }] }, "msg/sms": { model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 1_000 }] }, // carrier pass-through "msg/whatsapp": { model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 0 }] }, }, }); const metering = buildMetering() .refs(["send"]) .meter("msg/quota") // pools are declared first, without dims .meter("msg/sms", ["country"], { feeds: { "msg/quota": 1 } }) .meter("msg/whatsapp", ["country"], { feeds: { "msg/quota": 1 } }) .bind(mem); return { metering, mem };}
const ctx = { accountId: "acme" };
describe("B4 shared quota across channels", () => { it("both channels draw from one pool", async () => { const { metering, mem } = setup();
// 800 SMS: 800 × 1,000 carrier cost; pool 0..800 is inside the free quota. expect(await metering.observe("msg/sms", { country: "TR" }, 800, { type: "send", id: "s1" }, ctx)).toMatchObject({ charged: 800_000, lines: [{ meter: "msg/sms", amount: 800_000 }, { meter: "msg/quota", amount: 0, feeder: "msg/sms" }], });
// 300 WhatsApp: pool 800..1,100 → 200 free + 100 × 5,000 = 500,000 expect(await metering.observe("msg/whatsapp", { country: "TR" }, 300, { type: "send", id: "w1" }, ctx)).toMatchObject({ charged: 500_000, lines: [{ meter: "msg/whatsapp", amount: 0 }, { meter: "msg/quota", amount: 500_000, feeder: "msg/whatsapp" }], });
expect(mem.store.used("acme", "msg/quota")).toBe(1_100); const poolRow = mem.store.usage.find((u) => u.refId === "send:w1:msg/whatsapp:msg/quota"); expect(poolRow?.detail).toMatchObject({ feeder: "msg/whatsapp", weight: 1 }); });});B5 — Sold in blocks of 1,000
Layer: core (package model)
How: a package rate charges a whole block the moment its first unit is used: ceil((usedSoFar + q) / size) − ceil(usedSoFar / size) new blocks × block price.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
const emails: Rate = { id: "email-blocks", model: "package", packageSize: 1_000, tiers: [{ from: 0, unitPriceMicroUsd: 2_000_000 }] }; // $2 / 1,000
describe("B5 blocks of 1,000", () => { it("buys a block on its first unit, and the next block only after 1,000", async () => { const mem = memoryAdapters({ rates: { email: emails } }); const metering = buildMetering().refs(["send"]).meter("email").bind(mem); const ctx = { accountId: "acme" }; const send = (id: string, n: number) => metering.observe("email", {}, n, { type: "send", id }, ctx);
expect(await send("1", 1)).toMatchObject({ charged: 2_000_000 }); // ceil(1/1000) − 0 = 1 block expect(await send("2", 999)).toMatchObject({ charged: 0 }); // ceil(1000/1000) − 1 = 0 expect(await send("3", 1)).toMatchObject({ charged: 2_000_000 }); // ceil(1001/1000) − 1 = 1 expect(await send("4", 2_500)).toMatchObject({ charged: 4_000_000 }); // ceil(3501/1000) − 2 = 2
expect(mem.store.account("acme").balance).toBe(-8_000_000); // 4 blocks });});B6 — Volume pricing: crossing a threshold reprices the whole month
Layer: core (volume + adjustmentTiming: "on_crossing") or module (volumeTrueUp from /adjustments)
How: either let the observation that crosses the tier emit a correction for the earlier units right away (on_crossing, a separate …:adj row), or charge each observation at the tier it lands in and settle the difference once at period end with volumeTrueUp().
import { describe, expect, it } from "vitest";import { buildMetering, defineRate, type Rate } from "pricemeter";import { volumeTrueUp } from "pricemeter/adjustments";import { memoryAdapters } from "pricemeter/testing";
// All units at $0.01 below 1,000 a month, all units at $0.008 from 1,000.const tiers: Rate["tiers"] = [ { from: 0, unitPriceMicroUsd: 10_000 }, { from: 1_000, unitPriceMicroUsd: 8_000 },];
function setup(rate: Rate) { const mem = memoryAdapters({ rates: { api: rate } }); const metering = buildMetering().refs(["req", "period"]).meter("api").bind(mem); return { metering, mem };}const ctx = { accountId: "acme" };
describe("B6 volume: crossing a tier reprices earlier units", () => { it("on_crossing: the crossing observation carries the correction", async () => { const { metering, mem } = setup({ model: "volume", tiers, policy: { adjustmentTiming: "on_crossing" } });
expect(await metering.observe("api", {}, 900, { type: "req", id: "1" }, ctx)).toMatchObject({ charged: 9_000_000 }); // 900 × 10,000
// 200 × 8,000 = 1,600,000, plus 900 × (8,000 − 10,000) = −1,800,000 for the earlier units. expect(await metering.observe("api", {}, 200, { type: "req", id: "2" }, ctx)).toMatchObject({ charged: -200_000, lines: [{ amount: 1_600_000, adjustment: -1_800_000 }], }); expect(mem.store.usage.map((u) => u.refId)).toEqual(["req:1:api", "req:2:api", "req:2:api:adj"]); // Month total = 1,100 × 8,000 expect(mem.store.account("acme").balance).toBe(-8_800_000); });
it("none + volumeTrueUp: settle once at period end", async () => { const rate = defineRate({ id: "api-volume", model: "volume", tiers, policy: { adjustmentTiming: "none" } }); const { metering, mem } = setup(rate);
await metering.observe("api", {}, 900, { type: "req", id: "1" }, ctx); // 9,000,000 await metering.observe("api", {}, 200, { type: "req", id: "2" }, ctx); // 1,600,000 (tier it landed in) const charged = -mem.store.account("acme").balance; expect(charged).toBe(10_600_000);
// Period close: 1,100 × 8,000 = 8,800,000 → credit 1,800,000. const plan = volumeTrueUp({ account: "acme", meter: "api", rate, quantity: mem.store.used("acme", "api"), chargedMicroUsd: charged, refType: "period", refId: "volume_true_up:api:2026-09", at: Date.parse("2026-10-01T00:00:00Z"), }); expect(plan.ledger).toMatchObject([{ op: "charge", amount: -1_800_000 }]); expect(await metering.commit(plan)).toEqual({ ok: true }); expect(await metering.commit(plan)).toEqual({ ok: true }); // safe to re-run: same refId is a no-op expect(mem.store.account("acme").balance).toBe(-8_800_000); });});B7 — A conversation is priced when it closes, by how long it was
Layer: recipe (the app classifies; the tier is a dimension)
How: the library does not know what a "long" conversation is. When the conversation closes, the app maps its length to dims.tier and observes one unit; getRate prices by tier.
import { describe, expect, it } from "vitest";import { z } from "zod";import { buildMetering, type Rate } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
type Tier = "short" | "medium" | "long";const classify = (minutes: number): Tier => (minutes < 5 ? "short" : minutes < 20 ? "medium" : "long");
const prices: Record<Tier, number> = { short: 10_000, medium: 25_000, long: 60_000 };const conversationRate = (tier: Tier): Rate => ({ id: `conv-${tier}`, model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: prices[tier] }] });
describe("B7 conversation priced at close by length", () => { it("the app classifies, the tier dimension selects the price", async () => { const mem = memoryAdapters({ rates: { "cc/conversation": (dims) => conversationRate(dims.tier) } }); const metering = buildMetering() .refs(["conversation"]) .meter("cc/conversation", { tier: z.enum(["short", "medium", "long"]) }) .bind(mem); const ctx = { accountId: "acme" };
const close = (id: string, minutes: number) => metering.observe("cc/conversation", { tier: classify(minutes) }, 1, { type: "conversation", id }, ctx);
expect(await close("c1", 3)).toMatchObject({ charged: 10_000 }); expect(await close("c2", 12)).toMatchObject({ charged: 25_000 }); expect(await close("c3", 45)).toMatchObject({ charged: 60_000 }); expect(mem.store.usage.map((u) => [u.dims.tier, u.rateId])).toEqual([ ["short", "conv-short"], ["medium", "conv-medium"], ["long", "conv-long"], ]);
// The dimension schema guards the classification. expect(await metering.observe("cc/conversation", { tier: "epic" as Tier }, 1, { type: "conversation", id: "c4" }, ctx)).toMatchObject({ ok: false, reason: "invalid_dims", }); });});B8 — Charged per started conversation-minute
Layer: core (the quantity is minutes)
How: quantities are integers in whatever unit you price. The app turns seconds into billable minutes (its rounding rule, here "every started minute") and observes that.
import { describe, expect, it } from "vitest";import { buildMetering } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
const billableMinutes = (seconds: number) => Math.ceil(seconds / 60);
describe("B8 per conversation-minute", () => { it("observes started minutes", async () => { const mem = memoryAdapters({ rates: { "voice/minutes": { model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 12_000 }] } } }); const metering = buildMetering().refs(["call"]).meter("voice/minutes", ["direction"]).bind(mem); const ctx = { accountId: "acme" };
// 125 s → 3 minutes × 12,000 expect(await metering.observe("voice/minutes", { direction: "out" }, billableMinutes(125), { type: "call", id: "a" }, ctx)).toMatchObject({ charged: 36_000, }); // 60 s → exactly 1 minute expect(await metering.observe("voice/minutes", { direction: "out" }, billableMinutes(60), { type: "call", id: "b" }, ctx)).toMatchObject({ charged: 12_000, }); expect(mem.store.used("acme", "voice/minutes")).toBe(4); });});B9 — A seat costs $10 a month, prorated when added mid-month
Layer: module (integrate from /gauge, monthWindow from /calendar) + core (per)
How: the app records seat-count changes. At period end integrate({ mode: "time_weighted" }) turns them into seat-seconds, and the tariff prices $10 per <seconds in this month>, so a seat present for half the month costs $5. One observation per period.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { monthWindow } from "pricemeter/calendar";import { integrate } from "pricemeter/gauge";import { memoryAdapters } from "pricemeter/testing";
// The denominator depends on the month, so the tariff is built for the month of `at`.const seatRate = (at: number): Rate => { const w = monthWindow({ at }); return { id: "seat-10", model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 10_000_000, per: (w.to - w.from) / 1000 }] };};
describe("B9 seats prorated mid-month", () => { it("prices seat-seconds against the month's length", async () => { const mem = memoryAdapters({ rates: { seats: (_dims, _ctx, at) => seatRate(at) } }); const metering = buildMetering().refs(["period"]).meter("seats").bind(mem);
const september = monthWindow({ at: Date.parse("2026-09-15T00:00:00Z") }); // 30 days = 2,592,000 s const samples = [ { at: Date.parse("2026-08-20T00:00:00Z"), value: 2 }, // two seats since August { at: Date.parse("2026-09-16T00:00:00Z"), value: 3 }, // third seat for the last 15 days ]; // 2 × 30 days + 1 × 15 days = 75 seat-days = 6,480,000 seat-seconds const seatSeconds = integrate({ samples, window: september, mode: "time_weighted" }); expect(seatSeconds).toBe(6_480_000);
// 6,480,000 × 10,000,000 / 2,592,000 = 25,000,000 → $20 for two full seats + $5 for half a month. // `at` is inside the period (its last millisecond) so getRate picks September's length. const r = await metering.observe("seats", {}, seatSeconds, { type: "period", id: "2026-09" }, { accountId: "acme" }, { at: september.to - 1 }); expect(r).toMatchObject({ ok: true, charged: 25_000_000 }); });});B10 — Storage billed on the month's peak GB
Layer: module (integrate({ mode: "max" }) from /gauge)
How: the app keeps level samples (GB stored). At period end integrate reduces them to the highest level in the window, and that single number is observed once for the period.
import { describe, expect, it } from "vitest";import { buildMetering } from "pricemeter";import { monthWindow } from "pricemeter/calendar";import { integrate } from "pricemeter/gauge";import { memoryAdapters } from "pricemeter/testing";
describe("B10 storage: month's max GB", () => { it("observes the peak level once per month", async () => { const mem = memoryAdapters({ rates: { "storage/gb": { id: "gb-month", model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 20_000 }] } } }); // $0.02 / GB-month const metering = buildMetering().refs(["period"]).meter("storage/gb").bind(mem);
const september = monthWindow({ at: Date.parse("2026-09-01T00:00:00Z") }); const samples = [ { at: Date.parse("2026-09-01T00:00:00Z"), value: 10 }, { at: Date.parse("2026-09-10T00:00:00Z"), value: 45 }, // peak { at: Date.parse("2026-09-20T00:00:00Z"), value: 20 }, ]; const peakGb = integrate({ samples, window: september, mode: "max" }); expect(peakGb).toBe(45);
// 45 GB × 20,000 = 900,000 expect(await metering.observe("storage/gb", {}, peakGb, { type: "period", id: "2026-09" }, { accountId: "acme" }, { at: september.to - 1 })).toMatchObject({ charged: 900_000, }); });});B11 — A dedicated IP costs a flat $50 a month
Layer: core (single tier, unit price 0, flatMicroUsd)
How: flatMicroUsd is added once, the first time usage enters the tier in the period. With a monthly usedSoFar the fee is charged on the first observation of each month, however many times the app observes.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { monthKey } from "pricemeter/calendar";import { memoryAdapters } from "pricemeter/testing";
const dedicatedIp: Rate = { id: "ip-50", model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 0, flatMicroUsd: 50_000_000 }], policy: { tierPeriod: "month" } };
describe("B11 dedicated IP $50/mo flat", () => { it("charges once per month", async () => { const mem = memoryAdapters({ rates: { "ip/dedicated": dedicatedIp }, periodOf: (at) => monthKey({ at }) }); const metering = buildMetering().refs(["daily"]).meter("ip/dedicated").bind(mem); const ctx = { accountId: "acme" }; const tick = (day: string) => metering.observe("ip/dedicated", {}, 1, { type: "daily", id: day }, ctx, { at: Date.parse(`${day}T00:00:00Z`) });
expect(await tick("2026-09-01")).toMatchObject({ charged: 50_000_000 }); // enters the tier expect(await tick("2026-09-02")).toMatchObject({ charged: 0 }); // already in it expect(await tick("2026-10-01")).toMatchObject({ charged: 50_000_000 }); // new month });
it("a flat fee needs usedSoFar, so a silent double charge is impossible", async () => { const metering = buildMetering() .refs(["daily"]) .meter("ip/dedicated") .bind({ getRate: async (_meter, _dims, _ctx, _at) => ({ rate: dedicatedIp }), commit: async () => {} }); expect(await metering.observe("ip/dedicated", {}, 1, { type: "daily", id: "x" }, { accountId: "acme" })).toMatchObject({ ok: false, reason: "usage_required", }); });});B12 — A $100 monthly minimum commitment
Layer: module (minimumCommit from /adjustments)
How: at period close the app sums what the account spent and asks minimumCommit for the shortfall. It returns a plan with refId minimum_commit:{account}:{period}, so the job can run twice.
import { describe, expect, it } from "vitest";import { buildMetering } from "pricemeter";import { minimumCommit } from "pricemeter/adjustments";import { memoryAdapters } from "pricemeter/testing";
function setup() { const mem = memoryAdapters({ rates: { "api/calls": { model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 10_000 }] } } }); const metering = buildMetering().refs(["req"]).meter("api/calls").bind(mem); const spent = () => mem.store.ledger.filter((op) => op.op === "charge").reduce((s, op) => s + op.amount, 0); return { metering, mem, spent };}const ctx = { accountId: "acme" };const close = Date.parse("2026-10-01T00:00:00Z");
describe("B12 monthly minimum commitment", () => { it("charges the shortfall once", async () => { const { metering, mem, spent } = setup(); await metering.observe("api/calls", {}, 3_000, { type: "req", id: "1" }, ctx); // 3,000 × 10,000 = $30
// $100 − $30 = $70 const plan = minimumCommit({ account: "acme", minimumMicroUsd: 100_000_000, spentMicroUsd: spent(), period: "2026-09", at: close }); expect(plan.ledger).toMatchObject([{ op: "charge", amount: 70_000_000, refType: "period", refId: "minimum_commit:acme:2026-09" }]);
await metering.commit(plan); await metering.commit(plan); // the close job retried expect(mem.store.account("acme").balance).toBe(-100_000_000); });
it("nothing to charge when usage already covers the minimum", async () => { const { metering, spent } = setup(); await metering.observe("api/calls", {}, 12_000, { type: "req", id: "1" }, ctx); // $120 expect(minimumCommit({ account: "acme", minimumMicroUsd: 100_000_000, spentMicroUsd: spent(), period: "2026-09", at: close })).toEqual({ ledger: [], usage: [], }); });});B13 — Night rate and a campaign window
Layer: recipe (getRate reads at)
How: the library passes the observation time to getRate and never interprets it. Time-of-day prices and campaigns are just getRate choosing a different tariff for that instant.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
const flat = (id: string, price: number): Rate => ({ id, model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: price }] });const DAY = flat("day", 10_000);const NIGHT = flat("night", 5_000); // 22:00–06:00 UTCconst BLACK_FRIDAY = flat("black-friday", 2_000);const campaign = { from: Date.parse("2026-11-27T00:00:00Z"), to: Date.parse("2026-11-28T00:00:00Z") };
async function getRate(_meter: "sms", _dims: { country: string }, _ctx: { accountId: string }, at: number) { if (at >= campaign.from && at < campaign.to) return { rate: BLACK_FRIDAY }; const hour = new Date(at).getUTCHours(); return { rate: hour >= 22 || hour < 6 ? NIGHT : DAY };}
describe("B13 night rate / campaign", () => { it("prices by observation time", async () => { const mem = memoryAdapters(); const metering = buildMetering().refs(["send"]).meter("sms", ["country"]).bind({ getRate, commit: mem.commit }); const ctx = { accountId: "acme" }; const send = (id: string, iso: string) => metering.observe("sms", { country: "TR" }, 10, { type: "send", id }, ctx, { at: Date.parse(iso) });
expect(await send("noon", "2026-09-10T12:00:00Z")).toMatchObject({ charged: 100_000 }); // 10 × 10,000 expect(await send("night", "2026-09-10T23:30:00Z")).toMatchObject({ charged: 50_000 }); // 10 × 5,000 expect(await send("bf", "2026-11-27T12:00:00Z")).toMatchObject({ charged: 20_000 }); // 10 × 2,000 expect(mem.store.usage.map((u) => u.rateId)).toEqual(["day", "night", "black-friday"]); });});B14 — Enterprise pricing: a plan (group) price, and an even more specific account price
Layer: recipe / module (layeredRates from /rates)
How: keep one price table with scoped rows (account:…, plan:…, list), wildcard dimensions and effectiveFrom versions. layeredRates resolves it into a getRate: scopes are tried most specific first; inside a scope the most specific matching row wins, then the latest version.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { layeredRates, type PriceVersion } from "pricemeter/rates";import { memoryAdapters } from "pricemeter/testing";
const flat = (price: number): Rate => ({ model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: price }] });const rows: PriceVersion[] = [ { id: "list-any", meter: "sms", scope: "list", dims: { country: "*" }, effectiveFrom: 0, rate: flat(50_000) }, { id: "list-tr", meter: "sms", scope: "list", dims: { country: "TR" }, effectiveFrom: 0, rate: flat(20_000) }, { id: "enterprise-any", meter: "sms", scope: "plan:enterprise", dims: { country: "*" }, effectiveFrom: 0, rate: flat(30_000) }, { id: "acme-tr", meter: "sms", scope: "account:acme", dims: { country: "TR" }, effectiveFrom: 0, rate: flat(10_000) }, { id: "acme-tr-2027", meter: "sms", scope: "account:acme", dims: { country: "TR" }, effectiveFrom: Date.parse("2027-01-01T00:00:00Z"), rate: flat(8_000) },];
type Ctx = { accountId: string; plan: string };
describe("B14 enterprise: group price, account price more specific", () => { it("resolves account > plan > list", async () => { const getRate = layeredRates<Ctx>({ loadRows: (meter) => rows.filter((r) => r.meter === meter), scopes: (ctx) => [`account:${ctx.accountId}`, `plan:${ctx.plan}`, "list"], }); const metering = buildMetering().context<Ctx>().refs(["send"]).meter("sms", ["country"]).bind({ getRate, commit: memoryAdapters().commit });
const quote = async (accountId: string, plan: string, country: string, iso = "2026-09-10T00:00:00Z") => { const { result, plan: written } = await metering.plan.observe("sms", { country }, 1, { type: "send", id: "q" }, { accountId, plan }, { at: Date.parse(iso) }); return [result.ok ? result.charged : result.reason, written.usage[0]?.rateId]; };
expect(await quote("acme", "enterprise", "TR")).toEqual([10_000, "acme-tr"]); // account row expect(await quote("acme", "enterprise", "DE")).toEqual([30_000, "enterprise-any"]); // no account row for DE → plan expect(await quote("globex", "enterprise", "TR")).toEqual([30_000, "enterprise-any"]); // scope beats specificity expect(await quote("smallco", "free", "TR")).toEqual([20_000, "list-tr"]); // most specific list row expect(await quote("smallco", "free", "DE")).toEqual([50_000, "list-any"]); // wildcard expect(await quote("acme", "enterprise", "TR", "2027-02-01T00:00:00Z")).toEqual([8_000, "acme-tr-2027"]); // new version });});B15 — One request touches two meters under one reservation (OTP: SMS, then voice fallback)
Layer: core (multi-line hold, capture, release)
How: hold both lines at request time; the hold reserves the worst case and is a value object the app stores. Capture what was actually delivered, then release the rest. Captures are priced with the tariff stored in the hold.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
const flat = (price: number): Rate => ({ model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: price }] });
describe("B15 one request, two meters, one hold", () => { it("reserves the worst case, captures the actual, releases the rest", async () => { const mem = memoryAdapters({ prepaid: true, balances: { acme: 1_000_000 }, rates: { "otp/sms": flat(20_000), "otp/voice": flat(50_000) } }); const metering = buildMetering().refs(["otp"]).meter("otp/sms", ["country"]).meter("otp/voice", ["country"]).bind(mem); const ctx = { accountId: "acme" };
// Up to 3 SMS attempts + 1 voice call: 3 × 20,000 + 50,000 = 110,000 reserved. const h = await metering.hold( [ { meter: "otp/sms", dims: { country: "TR" }, quantity: 3 }, { meter: "otp/voice", dims: { country: "TR" }, quantity: 1 }, ], { type: "otp", id: "otp_42" }, ctx, ); if (!h.ok) throw new Error(h.reason); expect(h.upperBound).toBe(110_000); expect(mem.store.available("acme")).toBe(890_000);
// Delivered on the second SMS; voice never used. const c = await metering.capture(h.hold, [{ meter: "otp/sms", quantity: 2 }], ctx); expect(c).toMatchObject({ ok: true, charged: 40_000 }); if (!c.ok) throw new Error(c.reason);
await metering.release(c.hold, ctx); expect(mem.store.account("acme")).toEqual({ balance: 960_000, reserved: 0 }); });});B16 — The first 1,000 units of an account's lifetime are free
Layer: recipe (usedSoFar from a lifetime counter; tierPeriod: "lifetime", LIFETIME from /calendar)
How: the tiers look like any free allowance. What makes it "lifetime" is which counter getRate reads usedSoFar from: one that never resets. tierPeriod records that choice on the tariff.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { LIFETIME } from "pricemeter/calendar";import { memoryAdapters } from "pricemeter/testing";
const trialUnits: Rate = { id: "lifetime-1000-free", model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 0 }, { from: 1_000, unitPriceMicroUsd: 1_000 }], policy: { tierPeriod: "lifetime" },};
describe("B16 lifetime first 1,000 free", () => { it("the allowance does not reset across months", async () => { // Every observation falls into the same period key, so the counter spans the account's lifetime. const mem = memoryAdapters({ rates: { renders: trialUnits }, periodOf: () => LIFETIME }); const metering = buildMetering().refs(["job"]).meter("renders").bind(mem); const ctx = { accountId: "acme" };
const jan = Date.parse("2026-01-15T00:00:00Z"); const mar = Date.parse("2026-03-15T00:00:00Z"); expect(await metering.observe("renders", {}, 600, { type: "job", id: "1" }, ctx, { at: jan })).toMatchObject({ charged: 0 }); // usedSoFar 600 (from January): 400 free + 200 × 1,000 = 200,000 expect(await metering.observe("renders", {}, 600, { type: "job", id: "2" }, ctx, { at: mar })).toMatchObject({ charged: 200_000 }); expect(mem.store.used("acme", "renders", LIFETIME)).toBe(1_200); });});B17 — A customer billed in EUR
Layer: recipe (getRate returns the EUR tariff; Rate carries no currency)
How: amounts are integer micro-units of whatever currency the tariff is in. getRate picks the tariff by the account's currency, and the app keeps one ledger account per currency (here the currency is part of accountId), so EUR and USD never add up.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
type Ctx = { accountId: string; currency: "USD" | "EUR" };const SMS_USD: Rate = { id: "sms-usd", model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 20_000 }] }; // $0.020const SMS_EUR: Rate = { id: "sms-eur", model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 18_000 }] }; // €0.018
describe("B17 EUR tariff", () => { it("picks the tariff by currency and books into a per-currency account", async () => { const mem = memoryAdapters({ rates: { sms: (_dims, ctx: Ctx) => (ctx.currency === "EUR" ? SMS_EUR : SMS_USD) } }); const metering = buildMetering().context<Ctx>().refs(["send"]).meter("sms").bind(mem);
await metering.observe("sms", {}, 10, { type: "send", id: "1" }, { accountId: "acme:EUR", currency: "EUR" }); await metering.observe("sms", {}, 10, { type: "send", id: "2" }, { accountId: "acme:USD", currency: "USD" });
// 10 × 18,000 micro-EUR and 10 × 20,000 micro-USD, in separate accounts. expect(mem.store.account("acme:EUR").balance).toBe(-180_000); expect(mem.store.account("acme:USD").balance).toBe(-200_000); expect(mem.store.usage.map((u) => [u.account, u.rateId, u.amount])).toEqual([ ["acme:EUR", "sms-eur", 180_000], ["acme:USD", "sms-usd", 200_000], ]); });});B18 — Credits that expire
Layer: out of scope — expiry belongs to the ledger adapter
Why: the library never reads a balance. Which grant a charge draws from (FIFO, soonest expiry first) and what is left of a grant on its expiry date are ledger questions, answered atomically where the balance lives. The library only produces charges and credits. Nearest supported pattern: grant with flatCredit; at expiry the app (its ledger) works out the unused remainder and commits an ordinary charge for it, with an idempotent refId.
import { describe, expect, it } from "vitest";import { buildMetering, microUsd, type Plan } from "pricemeter";import { flatCredit } from "pricemeter/adjustments";import { memoryAdapters } from "pricemeter/testing";
describe("B18 expiring credits (out of scope)", () => { it("grant with flatCredit, expire the remainder from the ledger", async () => { const mem = memoryAdapters({ prepaid: true, rates: { api: { model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 1_000 }] } } }); const metering = buildMetering().refs(["req", "grant"]).meter("api").bind(mem); const ctx = { accountId: "acme" };
// $5 promotional grant, valid through September. await metering.commit(flatCredit({ account: "acme", amountMicroUsd: 5_000_000, refType: "grant", refId: "promo-sep", at: Date.parse("2026-09-01T00:00:00Z") })); await metering.observe("api", {}, 3_000, { type: "req", id: "1" }, ctx); // 3,000 × 1,000 = $3 expect(mem.store.available("acme")).toBe(2_000_000);
// Expiry job (ledger-side knowledge: this account has no other credit, so all $2 left is from the grant). const unused = Math.min(5_000_000, mem.store.available("acme")); const expire: Plan = { ledger: [{ op: "charge", account: "acme", amount: microUsd(unused), refType: "grant", refId: "promo-sep:expire", at: Date.parse("2026-10-01T00:00:00Z") }], usage: [], }; await metering.commit(expire); await metering.commit(expire); // idempotent expect(mem.store.available("acme")).toBe(0);
// The prepaid gate now refuses usage. expect(await metering.observe("api", {}, 1, { type: "req", id: "2" }, ctx)).toMatchObject({ ok: false, reason: "insufficient_credit" }); });});B19 — A customer-specific price, three ways
Layer: recipe (getRate sees the account; applyDiscount from /rates)
How: getRate receives the context, so "this customer's price" is a lookup there: (1) an account row with its own tariff, (2) a plan row shared by a group of accounts, (3) a CRM discount applied on top of whatever tariff would otherwise apply.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { applyDiscount } from "pricemeter/rates";import { memoryAdapters } from "pricemeter/testing";
type Ctx = { accountId: string; plan: string };const flat = (id: string, price: number): Rate => ({ id, model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: price }] });
const LIST = flat("list", 10_000);const accountRates: Record<string, Rate> = { acme: flat("acme-contract", 7_000) }; // (1)const planRates: Record<string, Rate> = { pro: flat("pro", 8_000) }; // (2)const crmDiscounts: Record<string, number> = { initech: 15 }; // (3) percent
async function getRate(_meter: "api", _dims: {}, ctx: Ctx, _at: number) { const own = accountRates[ctx.accountId]; if (own) return { rate: own }; const base = planRates[ctx.plan] ?? LIST; const discount = crmDiscounts[ctx.accountId]; return { rate: discount ? applyDiscount({ rate: base, percent: discount }) : base };}
describe("B19 customer-specific price", () => { it("account row, plan row, CRM discount, list", async () => { const mem = memoryAdapters(); const metering = buildMetering().context<Ctx>().refs(["req"]).meter("api").bind({ getRate, commit: mem.commit }); const call = (accountId: string, plan: string) => metering.observe("api", {}, 1, { type: "req", id: accountId }, { accountId, plan });
expect(await call("acme", "free")).toMatchObject({ charged: 7_000 }); // own contract expect(await call("globex", "pro")).toMatchObject({ charged: 8_000 }); // plan price expect(await call("initech", "free")).toMatchObject({ charged: 8_500 }); // 10,000 − 15% expect(await call("hooli", "free")).toMatchObject({ charged: 10_000 }); // list expect(mem.store.usage.map((u) => u.rateId)).toEqual(["acme-contract", "pro", "list-15pct", "list"]); });});B20 — A custom price ends and the account goes back to list; a hold opened before keeps the old price
Layer: core (the tariff is embedded in the hold)
How: hold stores the tariff it was priced with on each line, and capture uses it without calling getRate again. New observations see the new tariff. (To price a capture at capture time instead, pass { rate } to capture.)
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
const CUSTOM: Rate = { id: "acme-custom", model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 5_000 }] };const LIST: Rate = { id: "list", model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 10_000 }] };
describe("B20 open hold keeps the old tariff", () => { it("captures at hold-time price, new usage at list", async () => { const mem = memoryAdapters({ rates: { transcode: CUSTOM } }); const metering = buildMetering().refs(["job"]).meter("transcode").bind(mem); const ctx = { accountId: "acme" };
const h = await metering.hold("transcode", {}, 10, { type: "job", id: "long_video" }, ctx); if (!h.ok) throw new Error(h.reason); expect(h.upperBound).toBe(50_000); // 10 × 5,000
mem.store.setRate("transcode", LIST); // contract ended while the job was running
const c = await metering.capture(h.hold, [{ meter: "transcode", quantity: 10 }], ctx); expect(c).toMatchObject({ ok: true, charged: 50_000 }); // still 5,000 each expect(await metering.observe("transcode", {}, 1, { type: "job", id: "next" }, ctx)).toMatchObject({ charged: 10_000 }); expect(mem.store.usage.map((u) => u.rateId)).toEqual(["acme-custom", "list"]); });});B21 — A flat fee when usage enters a tier (a platform fee plus a step fee at 10k)
Layer: core (Tier.flatMicroUsd)
How: each tier may carry flatMicroUsd, added once, by the observation whose units first enter that tier. Units are priced as usual on top.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
const rate: Rate = { id: "stepped", model: "graduated", tiers: [ { from: 0, unitPriceMicroUsd: 1_000, flatMicroUsd: 5_000_000 }, // $5 to start, then $0.001 each { from: 10_000, unitPriceMicroUsd: 800, flatMicroUsd: 20_000_000 }, // $20 on reaching 10k, then $0.0008 each ],};
describe("B21 flat fee per tier", () => { it("adds each tier's flat fee once, on entry", async () => { const mem = memoryAdapters({ rates: { events: rate } }); const metering = buildMetering().refs(["batch"]).meter("events").bind(mem); const ctx = { accountId: "acme" }; const send = (id: string, n: number) => metering.observe("events", {}, n, { type: "batch", id }, ctx);
expect(await send("1", 1)).toMatchObject({ charged: 5_001_000 }); // 5,000,000 + 1 × 1,000 expect(await send("2", 9_999)).toMatchObject({ charged: 9_999_000 }); // no flat: tier 0 already entered const r = await send("3", 1); // 20,000,000 + 1 × 800 expect(r).toMatchObject({ charged: 20_000_800, lines: [{ breakdown: [{ from: 10_000, quantity: 1, flatMicroUsd: 20_000_000 }] }] }); });});B22 — Fintech fee: 2.9% + $0.30 per transaction, capped at $5
Layer: core (per: 1_000_000 for the percentage, flatMicroUsd for the fixed part, perObservation cap)
How: the quantity is the transaction amount in micro-USD, and 29,000 per 1,000,000 is 2.9%. The fixed fee is a flat fee on the only tier; since it must apply to every transaction, each transaction is its own period: getRate returns usedSoFar: 0 (A97, tierPeriod: "observation").
import { describe, expect, it } from "vitest";import { buildMetering, usd, type Rate } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
const cardFee: Rate = { id: "card-2.9+30", model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 29_000, per: 1_000_000, flatMicroUsd: 300_000 }], policy: { perObservation: { maxMicroUsd: 5_000_000 }, tierPeriod: "observation" },};
describe("B22 percent + fixed per transaction + cap", () => { it("prices each transaction on its own", async () => { const mem = memoryAdapters({ rates: { "card/fee": () => ({ rate: cardFee, usedSoFar: 0 }) } }); const metering = buildMetering().refs(["payment"]).meter("card/fee").bind(mem); const ctx = { accountId: "merchant_1" }; const pay = (id: string, amount: number) => metering.observe("card/fee", {}, amount, { type: "payment", id }, ctx);
expect(await pay("p1", usd(100))).toMatchObject({ charged: 3_200_000 }); // 2,900,000 + 300,000 expect(await pay("p2", usd(100))).toMatchObject({ charged: 3_200_000 }); // fixed fee again: usedSoFar 0 expect(await pay("p3", usd(5))).toMatchObject({ charged: 445_000 }); // 145,000 + 300,000 expect(await pay("p4", usd(500))).toMatchObject({ charged: 5_000_000 }); // 14,800,000 capped expect(mem.store.usage.at(-1)?.detail.clamped).toBe("max"); });});B23 — Each event has its own price (SMS priced at the route's cost at send time, plus margin)
Layer: recipe (getRate per observation, reading at)
How: getRate is called for every observation with its dimensions and time, so it can look up the cost of that route at that moment and return a one-tier tariff built from it. The tariff id records which cost priced the row.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
// Carrier cost history per route (in the app's database).const routeCosts: Record<string, { from: number; cost: number }[]> = { "tr-turkcell": [ { from: Date.parse("2026-09-01T00:00:00Z"), cost: 8_000 }, { from: Date.parse("2026-09-15T00:00:00Z"), cost: 9_000 }, ],};const costAt = (route: string, at: number) => routeCosts[route]!.filter((c) => c.from <= at).at(-1)!.cost;
const routeRate = (route: string, at: number): Rate => { const cost = costAt(route, at); return { id: `${route}@${cost}`, model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: Math.round(cost * 1.25) }] }; // +25%};
describe("B23 per-event price from route cost", () => { it("prices with the route cost in force at send time", async () => { const mem = memoryAdapters({ rates: { sms: (dims, _ctx, at) => routeRate(dims.route, at) } }); const metering = buildMetering().refs(["send"]).meter("sms", ["route"]).bind(mem); const ctx = { accountId: "acme" }; const send = (id: string, iso: string) => metering.observe("sms", { route: "tr-turkcell" }, 1, { type: "send", id }, ctx, { at: Date.parse(iso) });
expect(await send("m1", "2026-09-10T00:00:00Z")).toMatchObject({ charged: 10_000 }); // 8,000 × 1.25 expect(await send("m2", "2026-09-20T00:00:00Z")).toMatchObject({ charged: 11_250 }); // 9,000 × 1.25 expect(mem.store.usage.map((u) => u.rateId)).toEqual(["tr-turkcell@8000", "tr-turkcell@9000"]); });});B24 — Monthly active users (MAU)
Layer: recipe (the app counts uniques; one observe per period)
How: counting distinct users needs the event history, which lives in the app. At period end the app counts them and makes a single observation with a period ref, so a retried close job is a no-op in commit.
import { describe, expect, it } from "vitest";import { buildMetering } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
const logins = [ { user: "u1", at: "2026-09-02" }, { user: "u2", at: "2026-09-03" }, { user: "u1", at: "2026-09-10" }, // repeat { user: "u3", at: "2026-09-12" }, { user: "u4", at: "2026-09-30" }, { user: "u5", at: "2026-10-01" }, // next month];
describe("B24 MAU", () => { it("observes the month's distinct users once", async () => { const mem = memoryAdapters({ rates: { mau: { id: "mau", model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 50_000 }] } } }); const metering = buildMetering().refs(["period"]).meter("mau").bind(mem); const ctx = { accountId: "acme" };
const mau = new Set(logins.filter((l) => l.at.startsWith("2026-09")).map((l) => l.user)).size; expect(mau).toBe(4);
const close = () => metering.observe("mau", {}, mau, { type: "period", id: "2026-09" }, ctx); expect(await close()).toMatchObject({ ok: true, charged: 200_000 }); // 4 × 50,000 await close(); // retried: same (refType, refId), nothing new is written expect(mem.store.ledger).toHaveLength(1); expect(mem.store.account("acme").balance).toBe(-200_000); });});B25 — Reseller pricing: the reseller's price for one customer, else the reseller's default, else list
Layer: recipe (three lookups in getRate)
How: a hand-written getRate that walks three layers, most specific first. (The same could be a layeredRates table with scopes account:…, reseller:…, list; see B14.)
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
type Ctx = { accountId: string; resellerId?: string };const flat = (id: string, price: number): Rate => ({ id, model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: price }] });
const LIST = flat("list", 10_000);const resellerDefaults: Record<string, Rate> = { r1: flat("r1-default", 12_000) };const resellerCustomerPrices: Record<string, Record<string, Rate>> = { r1: { c1: flat("r1-c1", 9_000) } };
async function getRate(_meter: "sms", _dims: {}, ctx: Ctx, _at: number) { const r = ctx.resellerId; const rate = (r && resellerCustomerPrices[r]?.[ctx.accountId]) || (r && resellerDefaults[r]) || LIST; return { rate };}
describe("B25 reseller → customer → list", () => { it("resolves the most specific layer", async () => { const mem = memoryAdapters(); const metering = buildMetering().context<Ctx>().refs(["send"]).meter("sms").bind({ getRate, commit: mem.commit }); const send = (ctx: Ctx) => metering.observe("sms", {}, 1, { type: "send", id: ctx.accountId }, ctx);
expect(await send({ accountId: "c1", resellerId: "r1" })).toMatchObject({ charged: 9_000 }); // customer price expect(await send({ accountId: "c2", resellerId: "r1" })).toMatchObject({ charged: 12_000 }); // reseller default expect(await send({ accountId: "c3" })).toMatchObject({ charged: 10_000 }); // direct customer: list });});B26 — Postpaid: usage is recorded and invoiced later, whatever the balance
Layer: recipe (commit does not check the balance)
How: the balance gate lives in commit, not in the library. A postpaid commit never throws InsufficientCredit, so the balance simply goes negative; a prepaid one refuses atomically.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
const api: Rate = { model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 1_000 }] };const setup = (prepaid: boolean) => { const mem = memoryAdapters({ prepaid, rates: { api } }); return { mem, metering: buildMetering().refs(["req"]).meter("api").bind(mem) };};const ctx = { accountId: "acme" };
describe("B26 postpaid", () => { it("postpaid commit accepts usage with no balance", async () => { const { mem, metering } = setup(false); expect(await metering.observe("api", {}, 500, { type: "req", id: "1" }, ctx)).toMatchObject({ ok: true, charged: 500_000 }); expect(mem.store.account("acme").balance).toBe(-500_000); // owed, to be invoiced });
it("the same catalog with a prepaid commit refuses and writes nothing", async () => { const { mem, metering } = setup(true); expect(await metering.observe("api", {}, 500, { type: "req", id: "1" }, ctx)).toMatchObject({ ok: false, reason: "insufficient_credit" }); expect(mem.store.usage).toHaveLength(0); });});B27 — A coupon-like credit ($10 welcome credit)
Layer: module (flatCredit from /adjustments)
How: flatCredit returns a plan with one negative charge. Write it with metering.commit(plan); the refId (e.g. coupon code + account) makes redeeming twice a no-op.
import { describe, expect, it } from "vitest";import { buildMetering } from "pricemeter";import { flatCredit } from "pricemeter/adjustments";import { memoryAdapters } from "pricemeter/testing";
describe("B27 coupon-like credit", () => { it("credits once, usage draws it down", async () => { const mem = memoryAdapters({ prepaid: true, rates: { api: { model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 2_000 }] } } }); const metering = buildMetering().refs(["req", "coupon"]).meter("api").bind(mem); const ctx = { accountId: "acme" };
const coupon = flatCredit({ account: "acme", amountMicroUsd: 10_000_000, refType: "coupon", refId: "WELCOME10:acme", at: Date.parse("2026-09-01T00:00:00Z") }); expect(coupon.ledger).toMatchObject([{ op: "charge", amount: -10_000_000 }]); await metering.commit(coupon); await metering.commit(coupon); // redeemed twice → once expect(mem.store.available("acme")).toBe(10_000_000);
// 1,500 × 2,000 = 3,000,000 paid from the credit. expect(await metering.observe("api", {}, 1_500, { type: "req", id: "1" }, ctx)).toMatchObject({ ok: true, charged: 3_000_000 }); expect(mem.store.available("acme")).toBe(7_000_000); });});B28 — Hard quota: the free plan gets 1,000 OTPs a month, then sending stops (it doesn't get pricier)
Layer: core (limit from getRate → quota_exceeded; the adapter enforces it atomically in commit)
How: getRate returns limit next to rate and usedSoFar, counted over the same period. A call that would pass it returns quota_exceeded with { limit, used, requested } and writes nothing. Because the limit also travels on the usage row, two concurrent requests that both saw 999 can't both get through: the adapter re-checks inside its transaction. Pools take limits too: a limit on the pool caps every channel.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
const otp: Rate = { model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 0 }] }; // free, but cappedconst LIMIT: Record<string, number | undefined> = { free: 1_000, pro: undefined };
describe("B28 hard quota", () => { it("stops the free plan at 1,000 a month; pro is unlimited", async () => { const mem = memoryAdapters(); const metering = buildMetering() .context<{ accountId: string; plan: "free" | "pro" }>() .refs(["otp_send"]) .meter("otp/sms", ["country"]) .getRate(async (meter, _dims, ctx, _at) => { const limit = LIMIT[ctx.plan]; return { rate: otp, usedSoFar: mem.store.used(ctx.accountId, meter), ...(limit === undefined ? {} : { limit }) }; }) .commit(mem.commit); const free = { accountId: "acme", plan: "free" as const };
expect(await metering.observe("otp/sms", { country: "TR" }, 999, { type: "otp_send", id: "bulk" }, free)).toMatchObject({ ok: true }); expect(await metering.observe("otp/sms", { country: "TR" }, 1, { type: "otp_send", id: "m1000" }, free)).toMatchObject({ ok: true }); expect(await metering.observe("otp/sms", { country: "TR" }, 1, { type: "otp_send", id: "m1001" }, free)).toEqual({ ok: false, reason: "quota_exceeded", meter: "otp/sms", cause: { meter: "otp/sms", limit: 1_000, used: 1_000, requested: 1 }, }); expect(await metering.observe("otp/sms", { country: "TR" }, 5_000, { type: "otp_send", id: "p1" }, { accountId: "big", plan: "pro" })).toMatchObject({ ok: true }); });});L — Lago equivalents
Section titled “L — Lago equivalents”L1 — Lago's standard, graduated, volume and package charge models
Layer: core (the three Rate models)
How: Lago "standard" is a one-tier graduated rate; "graduated", "volume" and "package" map one-to-one. The same 1,500 units priced four ways, in one multi-line observation.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
const tiers: Rate["tiers"] = [ { from: 0, unitPriceMicroUsd: 1_000 }, { from: 1_000, unitPriceMicroUsd: 500 },];const rates: Record<string, Rate> = { standard: { model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 1_000 }] }, graduated: { model: "graduated", tiers }, volume: { model: "volume", tiers }, package: { model: "package", packageSize: 100, tiers: [{ from: 0, unitPriceMicroUsd: 50_000 }] },};
describe("L1 Lago standard / graduated / volume / package", () => { it("prices 1,500 units in each model", async () => { const mem = memoryAdapters({ rates }); const metering = buildMetering().refs(["period"]).meter("standard").meter("graduated").meter("volume").meter("package").bind(mem);
const r = await metering.observe( [ { meter: "standard", quantity: 1_500 }, { meter: "graduated", quantity: 1_500 }, { meter: "volume", quantity: 1_500 }, { meter: "package", quantity: 1_500 }, ], { type: "period", id: "2026-09" }, { accountId: "acme" }, ); if (!r.ok) throw new Error(r.reason); expect(r.lines.map((l) => [l.meter, l.amount])).toEqual([ ["standard", 1_500_000], // 1,500 × 1,000 ["graduated", 1_250_000], // 1,000 × 1,000 + 500 × 500 ["volume", 750_000], // 1,500 × 500 (all units at the tier reached) ["package", 750_000], // ceil(1,500 / 100) = 15 blocks × 50,000 ]); expect(r.charged).toBe(4_250_000); });});L2 — Lago "percentage" and "graduated_percentage"
Layer: core (like B22: quantity is the amount, per: 1_000_000 is a fraction)
How: percentage = one tier, unitPriceMicroUsd / per is the rate, flatMicroUsd the fixed fee per transaction (usedSoFar 0, A97), perObservation its min/max. Graduated percentage = several tiers over the month's running amount, each tier's flatMicroUsd charged when it is entered.
import { describe, expect, it } from "vitest";import { buildMetering, usd, type Rate } from "pricemeter";import { monthKey } from "pricemeter/calendar";import { memoryAdapters } from "pricemeter/testing";
// 1% + $0.10 per transaction, at least $0.50const percentage: Rate = { model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 10_000, per: 1_000_000, flatMicroUsd: 100_000 }], policy: { perObservation: { minMicroUsd: 500_000 } },};// 2% of the first $1,000 a month, 1% above plus a $0.50 fee on entering that tierconst graduatedPercentage: Rate = { model: "graduated", tiers: [ { from: 0, unitPriceMicroUsd: 20_000, per: 1_000_000 }, { from: usd(1_000), unitPriceMicroUsd: 10_000, per: 1_000_000, flatMicroUsd: 500_000 }, ],};
describe("L2 Lago percentage / graduated_percentage", () => { const mem = memoryAdapters({ rates: { "pay/percentage": () => ({ rate: percentage, usedSoFar: 0 }), "pay/graduated": graduatedPercentage }, periodOf: (at) => monthKey({ at }), }); const metering = buildMetering().refs(["payment"]).meter("pay/percentage").meter("pay/graduated").bind(mem); const ctx = { accountId: "acme" };
it("percentage with fixed fee and minimum", async () => { // $200: 2,000,000 + 100,000 expect(await metering.observe("pay/percentage", {}, usd(200), { type: "payment", id: "a" }, ctx)).toMatchObject({ charged: 2_100_000 }); // $10: 100,000 + 100,000 = 200,000 → raised to the 500,000 minimum expect(await metering.observe("pay/percentage", {}, usd(10), { type: "payment", id: "b" }, ctx)).toMatchObject({ charged: 500_000 }); });
it("graduated percentage over the month's volume", async () => { // $800 at 2% = 16,000,000 expect(await metering.observe("pay/graduated", {}, usd(800), { type: "payment", id: "c" }, ctx)).toMatchObject({ charged: 16_000_000 }); // $400 more: $200 at 2% (4,000,000) + $200 at 1% (2,000,000) + tier fee 500,000 expect(await metering.observe("pay/graduated", {}, usd(400), { type: "payment", id: "d" }, ctx)).toMatchObject({ charged: 6_500_000 }); });});L3 — Lago "dynamic": the price is known only when the event happens (e.g. GPU spot price)
Layer: recipe (like B23: getRate per observation, at at)
How: Lago takes the amount from the event. Here the app keeps the price source (a spot price feed) and getRate turns the price in force at at into a one-tier tariff, so the usage row still records which tariff priced it and the amount stays explainable.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
const spot = [ { from: Date.parse("2026-09-10T00:00:00Z"), perHour: 1_800_000 }, // $1.80 / GPU-hour { from: Date.parse("2026-09-10T12:00:00Z"), perHour: 2_400_000 }, // $2.40 / GPU-hour];const spotRate = (at: number): Rate => { const p = spot.filter((s) => s.from <= at).at(-1)!; return { id: `spot@${p.from}`, model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: p.perHour }] };};
describe("L3 Lago dynamic", () => { it("prices each event at the spot price of its time", async () => { const mem = memoryAdapters({ rates: { "gpu/hours": (_dims, _ctx, at) => spotRate(at) } }); const metering = buildMetering().refs(["job"]).meter("gpu/hours", ["gpu"]).bind(mem); const ctx = { accountId: "acme" }; const run = (id: string, hours: number, iso: string) => metering.observe("gpu/hours", { gpu: "h100" }, hours, { type: "job", id }, ctx, { at: Date.parse(iso) });
expect(await run("j1", 3, "2026-09-10T08:00:00Z")).toMatchObject({ charged: 5_400_000 }); // 3 × 1,800,000 expect(await run("j2", 3, "2026-09-10T18:00:00Z")).toMatchObject({ charged: 7_200_000 }); // 3 × 2,400,000 expect(new Set(mem.store.usage.map((u) => u.rateId)).size).toBe(2); });});L4 — Lago "custom" charge model (arbitrary code computes the fee)
Layer: out of scope
Why: every amount pricemeter writes must be reproducible from a tariff and a breakdown in the usage row; a function cannot be logged or explained, so a Rate is data only. Nearest supported pattern: run your code in getRate and have it *produce a tariff* (tiers, flat fees, clamps). The code decides the shape; the library prices it and logs a breakdown.
import { describe, expect, it } from "vitest";import { buildMetering, validateRate, type Rate } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
describe("L4 Lago custom (out of scope)", () => { it("a tariff cannot be code", () => { const custom = { model: "custom", compute: (q: number) => q * 42 }; const v = validateRate(custom); expect(v.ok).toBe(false); if (!v.ok) expect(v.errors[0]?.code).toBe("invalid_model"); });
it("instead, code builds a tariff and the row explains the amount", async () => { // "Custom" logic: loyal customers get a cheaper second tier that starts earlier. const buildRate = (loyaltyYears: number): Rate => ({ id: `loyalty-${loyaltyYears}`, model: "graduated", tiers: [ { from: 0, unitPriceMicroUsd: 1_000 }, { from: Math.max(100, 1_000 - loyaltyYears * 300), unitPriceMicroUsd: 600 }, ], }); const mem = memoryAdapters({ rates: { api: (_dims, ctx) => buildRate(ctx.loyaltyYears) } }); const metering = buildMetering().context<{ accountId: string; loyaltyYears: number }>().refs(["req"]).meter("api").bind(mem);
// 2 years → tier 2 from 400: 400 × 1,000 + 600 × 600 = 760,000 expect(await metering.observe("api", {}, 1_000, { type: "req", id: "1" }, { accountId: "acme", loyaltyYears: 2 })).toMatchObject({ charged: 760_000 });
const row = mem.store.usage[0]!; expect(row.rateId).toBe("loyalty-2"); const explained = row.detail.breakdown.reduce((s, b) => s + (b.quantity * b.unitPriceMicroUsd) / b.per + b.flatMicroUsd, 0); expect(explained).toBe(row.amount); });});L5 — Lago progressive billing (invoice early when usage crosses a threshold)
Layer: out of scope — it is an invoicing feature
Why: pricemeter has no invoices. Its job ends at a priced plan; when to bill is the app's. Nearest supported pattern: the prepaid gate. Progressive billing exists to cap exposure to unpaid usage; a prepaid commit caps it at zero, atomically, by refusing what the balance cannot cover. (A threshold-triggered invoice is an app job that sums usage rows, like any other.)
import { describe, expect, it } from "vitest";import { buildMetering } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
describe("L5 Lago progressive billing (out of scope)", () => { it("a prepaid commit bounds exposure instead of an early invoice", async () => { const mem = memoryAdapters({ prepaid: true, balances: { acme: 100_000 }, rates: { api: { model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 1_000 }] } } }); const metering = buildMetering().refs(["req", "topup"]).meter("api").bind(mem); const ctx = { accountId: "acme" };
expect(await metering.observe("api", {}, 60, { type: "req", id: "1" }, ctx)).toMatchObject({ ok: true, charged: 60_000 }); // 60 more would need 60,000 with 40,000 left: refused, nothing written. expect(await metering.observe("api", {}, 60, { type: "req", id: "2" }, ctx)).toMatchObject({ ok: false, reason: "insufficient_credit" }); expect(mem.store.usage).toHaveLength(1);
// The app tops up (its payment flow), then the same request goes through. mem.store.credit("acme", 100_000); expect(await metering.observe("api", {}, 60, { type: "req", id: "2" }, ctx)).toMatchObject({ ok: true, charged: 60_000 }); expect(mem.store.available("acme")).toBe(80_000); });});L6 — Lago aggregations: count, sum, max, latest, weighted_sum
Layer: core (the quantity comes from the app) / module (integrate from /gauge)
How: pricemeter prices a quantity; it does not aggregate events. count and sum are one line of app code; max, latest and weighted_sum are integrate over level samples with mode max, last and time_weighted. Here all five are one period-end observation.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { integrate } from "pricemeter/gauge";import { memoryAdapters } from "pricemeter/testing";
const flat = (price: number, per = 1): Rate => ({ model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: price, per }] });const h = (hours: number) => Date.parse("2026-09-01T00:00:00Z") + hours * 3_600_000;const day = { from: h(0), to: h(24) };
describe("L6 Lago count / sum / max / latest / weighted_sum", () => { it("app aggregates, one observation prices all", async () => { const requests = [{ mb: 1_500 }, { mb: 700 }, { mb: 2_100 }]; const storageGb = [{ at: h(0), value: 10 }, { at: h(6), value: 45 }, { at: h(12), value: 20 }]; const cores = [{ at: h(0), value: 4 }, { at: h(10), value: 8 }];
const count = requests.length; // 3 const sumMb = requests.reduce((s, r) => s + r.mb, 0); // 4,300 const maxGb = integrate({ samples: storageGb, window: day, mode: "max" }); // 45 const latestGb = integrate({ samples: storageGb, window: day, mode: "last" }); // 20 const coreHours = integrate({ samples: cores, window: day, mode: "time_weighted", unit: "h" }); // 4 × 10 + 8 × 14 = 152
const mem = memoryAdapters({ rates: { "api/requests": flat(1_000), "egress/mb": flat(90_000, 1_000), // $0.09 per 1,000 MB "storage/peak_gb": flat(20_000), "storage/gb": flat(20_000), "cpu/core_hours": flat(30_000), }, }); const metering = buildMetering() .refs(["period"]) .meter("api/requests") .meter("egress/mb") .meter("storage/peak_gb") .meter("storage/gb") .meter("cpu/core_hours") .bind(mem);
const r = await metering.observe( [ { meter: "api/requests", quantity: count }, { meter: "egress/mb", quantity: sumMb }, { meter: "storage/peak_gb", quantity: maxGb }, { meter: "storage/gb", quantity: latestGb }, { meter: "cpu/core_hours", quantity: coreHours }, ], { type: "period", id: "2026-09-01" }, { accountId: "acme" }, ); if (!r.ok) throw new Error(r.reason); expect(r.lines.map((l) => [l.meter, l.quantity, l.amount])).toEqual([ ["api/requests", 3, 3_000], ["egress/mb", 4_300, 387_000], // 4,300 × 90,000 / 1,000 ["storage/peak_gb", 45, 900_000], ["storage/gb", 20, 400_000], ["cpu/core_hours", 152, 4_560_000], // 152 × 30,000 ]); });});L7 — Lago unique_count (distinct users per workspace)
Layer: recipe (like B24: the app counts distinct values)
How: the app counts distinct ids per group from its own event store and makes one multi-line observation at period end, one line per workspace (a dimension), so each row can be invoiced per group.
import { describe, expect, it } from "vitest";import { buildMetering } from "pricemeter";import { memoryAdapters } from "pricemeter/testing";
const events = [ { workspace: "design", user: "ana" }, { workspace: "design", user: "bo" }, { workspace: "design", user: "ana" }, // repeat { workspace: "sales", user: "cy" }, { workspace: "sales", user: "ana" }, // same person, other workspace: counted there too];
describe("L7 Lago unique_count", () => { it("one line per workspace with its distinct users", async () => { const mem = memoryAdapters({ rates: { "users/active": { model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 100_000 }] } } }); const metering = buildMetering().refs(["period"]).meter("users/active", ["workspace"]).bind(mem);
const uniques = new Map<string, Set<string>>(); for (const e of events) uniques.set(e.workspace, (uniques.get(e.workspace) ?? new Set()).add(e.user)); const lines = [...uniques].map(([workspace, users]) => ({ meter: "users/active" as const, dims: { workspace }, quantity: users.size }));
const r = await metering.observe(lines, { type: "period", id: "2026-09" }, { accountId: "acme" }); if (!r.ok) throw new Error(r.reason); expect(r.charged).toBe(400_000); // (2 + 2) × 100,000 expect(mem.store.usage.map((u) => [u.dims.workspace, u.quantity, u.amount, u.refId])).toEqual([ ["design", 2, 200_000, "period:2026-09:users/active"], ["sales", 2, 200_000, "period:2026-09:users/active#2"], // same meter twice in one call → #2 ]); });});L8 — Lago filters, grouped_by and pricing_group_keys
Layer: core (dimensions) + layeredRates from /rates
How: filters = a price per dimension value, wildcard rows for the rest (layeredRates). grouped_by = every usage row carries its dims, so invoice lines are a group-by on rows. pricing_group_keys = tiers counted per group: usedSoFar counted per project instead of per meter.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { layeredRates, type PriceVersion } from "pricemeter/rates";import { memoryAdapters } from "pricemeter/testing";
const tiered = (first: number, after100: number): Rate => ({ model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: first }, { from: 100, unitPriceMicroUsd: after100 }],});const rows: PriceVersion[] = [ { id: "eu", meter: "api", scope: "list", dims: { region: "eu" }, effectiveFrom: 0, rate: tiered(1_000, 500) }, // filter region=eu { id: "default", meter: "api", scope: "list", effectiveFrom: 0, rate: tiered(2_000, 1_000) }, // any other region];
describe("L8 Lago filters / grouped_by / pricing_group_keys", () => { it("prices by filter, tiers per project, groups rows per project", async () => { const mem = memoryAdapters(); const getRate = layeredRates({ loadRows: (meter) => rows.filter((r) => r.meter === meter), scopes: () => ["list"], // pricing_group_keys: ["project"] → usage counted per project, across regions usedSoFar: (meter, dims, ctx) => mem.store.usage.filter((u) => u.account === ctx.accountId && u.meter === meter && u.dims.project === dims.project).reduce((s, u) => s + u.quantity, 0), }); const metering = buildMetering().refs(["req"]).meter("api", ["region", "project"]).bind({ getRate, commit: mem.commit }); const ctx = { accountId: "acme" }; const call = (id: string, region: string, project: string, n: number) => metering.observe("api", { region, project }, n, { type: "req", id }, ctx);
expect(await call("1", "eu", "p1", 90)).toMatchObject({ charged: 90_000 }); // 90 × 1,000 expect(await call("2", "eu", "p1", 20)).toMatchObject({ charged: 15_000 }); // p1 at 90: 10 × 1,000 + 10 × 500 expect(await call("3", "eu", "p2", 20)).toMatchObject({ charged: 20_000 }); // p2 starts at 0 expect(await call("4", "us", "p2", 10)).toMatchObject({ charged: 20_000 }); // default filter: 10 × 2,000
// grouped_by: ["project"] const byProject: Record<string, number> = {}; for (const u of mem.store.usage) byProject[u.dims.project as string] = (byProject[u.dims.project as string] ?? 0) + u.amount; expect(byProject).toEqual({ p1: 105_000, p2: 40_000 }); });});L9 — Lago min_amount_cents, pay_in_advance and prorated charges
Layer: module (minimumCommit) / core (observe at event time, per for proration)
How: a charge minimum is minimumCommit at period close. Paying in advance means observing when the event happens (the ledger moves then) instead of at period end. Proration is the remaining seconds of the period priced per the period's length.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { minimumCommit } from "pricemeter/adjustments";import { monthWindow } from "pricemeter/calendar";import { memoryAdapters } from "pricemeter/testing";
const seatRate = (at: number): Rate => { const w = monthWindow({ at }); return { id: "seat-30", model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 30_000_000, per: (w.to - w.from) / 1000 }] }; // $30 / month};
describe("L9 Lago min_amount / pay_in_advance / prorated", () => { it("pay in advance, prorated: a seat added on Sep 21 is charged 10/30 of the month right away", async () => { const mem = memoryAdapters({ rates: { seats: (_dims, _ctx, at) => seatRate(at) } }); const metering = buildMetering().refs(["seat_added"]).meter("seats").bind(mem);
const added = Date.parse("2026-09-21T00:00:00Z"); const remainingSeconds = (monthWindow({ at: added }).to - added) / 1000; // 10 days = 864,000 // 864,000 × 30,000,000 / 2,592,000 = 10,000,000 const r = await metering.observe("seats", {}, remainingSeconds, { type: "seat_added", id: "seat_7" }, { accountId: "acme" }, { at: added }); expect(r).toMatchObject({ ok: true, charged: 10_000_000 }); expect(mem.store.ledger[0]?.at).toBe(added); // money moved at event time, not at period end });
it("min_amount: top the charge up to its minimum at close", () => { const plan = minimumCommit({ account: "acme", minimumMicroUsd: 10_000_000, spentMicroUsd: 4_000_000, period: "2026-09:api", at: Date.parse("2026-10-01T00:00:00Z") }); expect(plan.ledger).toMatchObject([{ amount: 6_000_000, refId: "minimum_commit:acme:2026-09:api" }]); });});L10 — Lago fixed charges with graduated or volume pricing (e.g. seats)
Layer: core (an integrate result fed into any model)
How: a fixed charge is a level (seats) reduced to one quantity per period with integrate, then observed like any other quantity, so every model applies. Each period starts at usedSoFar 0.
import { describe, expect, it } from "vitest";import { buildMetering, type Rate } from "pricemeter";import { monthKey, monthWindow } from "pricemeter/calendar";import { integrate } from "pricemeter/gauge";import { memoryAdapters } from "pricemeter/testing";
const tiers: Rate["tiers"] = [ { from: 0, unitPriceMicroUsd: 10_000_000 }, // seats 1–10: $10 { from: 10, unitPriceMicroUsd: 8_000_000 }, // seats 11–20: $8 { from: 20, unitPriceMicroUsd: 5_000_000 }, // seats 21+: $5];
describe("L10 Lago fixed charge, graduated / volume", () => { it("prices the period's seat count in either model", async () => { const mem = memoryAdapters({ rates: { "seats/graduated": { model: "graduated", tiers }, "seats/volume": { model: "volume", tiers } }, periodOf: (at) => monthKey({ at }), }); const metering = buildMetering().refs(["period"]).meter("seats/graduated").meter("seats/volume").bind(mem);
const september = monthWindow({ at: Date.parse("2026-09-01T00:00:00Z") }); const seats = integrate({ samples: [{ at: september.from, value: 18 }, { at: Date.parse("2026-09-12T00:00:00Z"), value: 25 }], window: september, mode: "last" }); expect(seats).toBe(25);
const r = await metering.observe( [ { meter: "seats/graduated", quantity: seats }, { meter: "seats/volume", quantity: seats }, ], { type: "period", id: "2026-09" }, { accountId: "acme" }, { at: september.to - 1 }, ); if (!r.ok) throw new Error(r.reason); expect(r.lines.map((l) => l.amount)).toEqual([ 205_000_000, // graduated: 10 × 10M + 10 × 8M + 5 × 5M 125_000_000, // volume: all 25 × 5M ]); });});L11 — Lago plans, subscriptions, trials, wallets, coupons and invoices
Layer: out of scope
Why: these are billing-system objects with their own lifecycles; pricemeter only prices usage and hands back a plan to write. Each one touches the library at a single point: plan/subscription → the context getRate sees; trial → getRate returns a free tariff; wallet → the ledger behind commit (prepaid gate); coupon → flatCredit; invoice → a query over usage rows.
import { describe, expect, it } from "vitest";import { buildMetering, FREE_RATE, type Rate } from "pricemeter";import { flatCredit } from "pricemeter/adjustments";import { memoryAdapters } from "pricemeter/testing";
type Ctx = { accountId: string; plan: "starter" | "pro"; trialEndsAt: number };const PLAN_RATES: Record<Ctx["plan"], Rate> = { starter: { id: "starter", model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 2_000 }] }, pro: { id: "pro", model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 1_000 }] },};
describe("L11 Lago billing objects (out of scope)", () => { it("each object maps to one touch point", async () => { const mem = memoryAdapters({ prepaid: true, // wallet: the ledger enforces the balance balances: { acme: 1_000_000 }, rates: { api: (_dims, ctx: Ctx, at) => (at < ctx.trialEndsAt ? FREE_RATE : PLAN_RATES[ctx.plan]) }, // plan + trial }); const metering = buildMetering().context<Ctx>().refs(["req", "coupon"]).meter("api").bind(mem); const ctx: Ctx = { accountId: "acme", plan: "pro", trialEndsAt: Date.parse("2026-09-15T00:00:00Z") };
// Trial: counted, priced 0. expect(await metering.observe("api", {}, 100, { type: "req", id: "1" }, ctx, { at: Date.parse("2026-09-10T00:00:00Z") })).toMatchObject({ charged: 0 }); // After trial: pro plan price, 100 × 1,000. expect(await metering.observe("api", {}, 100, { type: "req", id: "2" }, ctx, { at: Date.parse("2026-09-20T00:00:00Z") })).toMatchObject({ charged: 100_000 }); // Coupon: a credit plan. await metering.commit(flatCredit({ account: "acme", amountMicroUsd: 50_000, refType: "coupon", refId: "SAVE5:acme", at: Date.parse("2026-09-21T00:00:00Z") })); expect(mem.store.available("acme")).toBe(950_000); // 1,000,000 − 100,000 + 50,000
// Invoice: the app's query over usage rows. const invoiceLines = mem.store.usage.map((u) => ({ rate: u.rateId, quantity: u.quantity, amount: u.amount })); expect(invoiceLines).toEqual([ { rate: undefined, quantity: 100, amount: 0 }, // FREE_RATE has no id { rate: "pro", quantity: 100, amount: 100_000 }, ]); });});