Skip to content

pricemeter

A dumb, typed pricing engine. It owns one question — how much — and nothing else.

You give it the tariff and the usage so far; it computes the amount and returns the plan to write. When, where and who — counting, storage, calendars, buffers, queues, balances, HTTP, UI — stay in your application.

README.md
import { buildMetering } from "pricemeter";
import { z } from "zod";
export const metering = buildMetering()
.context(z.object({ accountId: z.string() }))
.refs(["otp_send"])
.meter("msg/free_pool")
.meter("otp/sms", { country: z.string().length(2) }, { feeds: { "msg/free_pool": 1 } })
.getRate(async (meter, dims, ctx, at) => ({ rate: await myTariff(meter, dims, ctx, at), usedSoFar: await myCounter(ctx, meter) }))
.commit(async (plan) => myLedger.apply(plan)); // all-or-nothing, idempotent on (refType, refId)
const r = await metering.observe("otp/sms", { country: "TR" }, 2, { type: "otp_send", id: msgId }, { accountId });
// { ok: true, charged: 40000, lines: [...] } or { ok: false, reason: "insufficient_credit" | "no_price" | ... }

Three models

graduated, volume (with on_crossing adjustments), package. Per-tier per, flat fees, min/max per observation, per_event_up or drift-free cumulative rounding.

Pure core

rate() and price() do no I/O. observe is a thin wrapper: getRate → price → commit.

Holds

Multi-line reservations as value objects: partial captures, extend, release. The tariff travels inside the hold.

Typed catalog

Meter ids, dimensions (zod / valibot / arktype via Standard Schema) and pools (feeds) are checked at compile time. A 500-meter catalog type-checks in ~0.6 s.

Dependencies Zero at runtime
Format ESM + CJS, with types
Runtimes Node ≥ 20, Bun, Cloudflare Workers
Money MicroUsd: an integer number, 1 unit = 1,000,000; 2^53 ≈ 9 billion major units. A Rate carries no currency
Package One package: pricemeter. Subpaths: /rates, /gauge, /adjustments, /calendar, /testing, /sqlite, /cloudflare