Skip to content

Getting started

The code on this page comes from docs/src/snippets/getting-started.test.ts and runs with bun run test, so the page cannot show code that does not work.

Terminal window
bun add pricemeter zod # or npm i / pnpm add

zod is optional: dimension and context schemas can use any Standard Schema library (zod, valibot, arktype), or no schema at all. pricemeter has no runtime dependencies; it ships ESM + CJS and runs on Node ≥ 20, Bun and Workers.

docs/src/snippets/getting-started.test.ts
import { buildMetering, type Rate } from "pricemeter";
import { memoryAdapters } from "pricemeter/testing";
import { z } from "zod";
// $0.02 per SMS; from the 10,000th message on, $0.015. Amounts are integer micro-units.
const sms: Rate = {
id: "sms-2026",
model: "graduated",
tiers: [
{ from: 0, unitPriceMicroUsd: 20_000 },
{ from: 10_000, unitPriceMicroUsd: 15_000 },
],
};
// getRate + commit kept in memory: prepaid, "acme" starts with $10.
const mem = memoryAdapters({ rates: { "otp/sms": sms }, prepaid: true, balances: { acme: 10_000_000 } });
const metering = buildMetering()
.context(z.object({ accountId: z.string() }))
.refs(["otp_send"])
.meter("otp/sms", { country: z.string().length(2) })
.bind(mem); // same as .getRate(mem.getRate).commit(mem.commit)
const ctx = { accountId: "acme" };
  1. A tariff (Rate) carries every tier. rate() decides which tier an observation lands in; amounts are integer micro-units (20_000 = $0.02).

  2. memoryAdapters (pricemeter/testing) is an in-memory getRate + commit for tests and examples: it counts usedSoFar from the rows it wrote and, with prepaid: true, throws InsufficientCredit when the balance is short.

  3. buildMetering() is one chain; there is no build() step. .bind(mem) binds the catalog to these adapters; in production you pass your own getRate and commit instead.

docs/src/snippets/getting-started.test.ts
const r = await metering.observe("otp/sms", { country: "TR" }, 2, { type: "otp_send", id: "msg_1" }, ctx);
// → { ok: true, charged: 40000, lines: [{ meter: "otp/sms", quantity: 2, amount: 40000, tierIndex: 0, … }] }
mem.store.available("acme"); // 9_960_000
mem.store.usage.length; // 1 usage row (refId "otp_send:msg_1:otp/sms")
mem.store.ledger.length; // 1 ledger row (a "charge" of 40000)

observe validates the context and dimensions, asks getRate for the tariff and usedSoFar, computes the amount with price(), hands the plan to commit and returns the result. The plan is two lists: ledger rows that move money and usage rows that record consumption.

docs/src/snippets/getting-started.test.ts
// A retry with the same ref computes the same plan; commit sees (refType, refId) again and writes nothing.
await metering.observe("otp/sms", { country: "TR" }, 2, { type: "otp_send", id: "msg_1" }, ctx);
mem.store.available("acme"); // still 9_960_000

Every row’s refId is derived from the ref (otp_send:msg_1:otp/sms). When commit sees the same (refType, refId) again it writes nothing. The library does not know it is a retry — the result still says charged: 40000, “the price of this call”, not “the amount written” (A99) — but the balance does not move.

docs/src/snippets/getting-started.test.ts
await metering.observe("otp/sms", { country: "TUR" }, 1, { type: "otp_send", id: "msg_2" }, ctx);
// → { ok: false, reason: "invalid_dims", meter: "otp/sms", cause: [...] } (zod: length 2)
await metering.observe("otp/sms", { country: "TR" }, 1_000, { type: "otp_send", id: "msg_3" }, ctx);
// → { ok: false, reason: "insufficient_credit", … } ($20 > $9.96; nothing written)
mem.store.setRate("otp/sms", undefined); // the price table no longer has this meter
await metering.observe("otp/sms", { country: "TR" }, 1, { type: "otp_send", id: "msg_4" }, ctx);
// → { ok: false, reason: "no_price", meter: "otp/sms" }

When the result is { ok: false, reason }, nothing was written. The balance check is not in the library but inside commit: only the ledger can check “balance ≥ amount” in the same atomic step as the write. All reasons: observe and hold → Result.

docs/src/snippets/getting-started.test.ts
const { result, plan } = await metering.plan.observe("otp/sms", { country: "TR" }, 3, { type: "otp_send", id: "msg_5" }, ctx);
// result.charged → 60000
// plan.ledger → [{ op: "charge", account: "acme", amount: 60000, refType: "otp_send", refId: "otp_send:msg_5:otp/sms", at }]
// plan.usage → [{ account: "acme", meter: "otp/sms", dims: { country: "TR" }, quantity: 3, amount: 60000, rateId: "sms-2026", … }]

metering.plan.* takes the same arguments, never calls commit, and returns { result, plan, lines }. Use it in tests, or when the plan is applied somewhere else (e.g. inside a Durable Object).