ADR-045: Decimal per-unit pricing rates + multi-dim cycle billing
Status: Accepted; amended by ADR-130 (decimal package rates, flat becomes per_unit, overage goes; built in #1381) and by the 2026-10-24 amendment below (rate limits; built)
Date: 2026-06-01
Relates to: ADR-044 (canonical AI token metering model). Follows the
precedent of ADR-042 (tax rate bigint bp → NUMERIC).
Supersedes: the int64-cents per-unit rate fields introduced in
migration 0001, and the single-rule-only usage-billing path in
billing.billOnePeriod.
Amended by ADR-130, decided 2026-10-23 and built in #1381 (PR-3). Where this ADR says otherwise, ADR-130 describes the code. package_amount_cents becomes a decimal like the per-unit rates, flat_amount_cents becomes unit_amount_cents (mode per_unit) and overage_unit_amount_cents goes (PR-3). Invoice amounts stay whole cents. ADR-130’s limit of 12 decimal places is built already (the 2026-10-24 amendment).
Summary
Section titled “Summary”Per-unit price rates (flat_amount_cents, graduated-tier unit_amount_cents, overage_unit_amount_cents, and the same fields on customer price overrides) are decimals. A rate written since 2026-10-24 is 0 or more, under 10^18 cents and at most 12 decimal places (see the amendment of that date). They are stored as NUMERIC and sent as JSON strings such as "0.0003", so sub-cent prices such as $3 per million tokens can be expressed. Fixed fees and every invoice amount stay whole int64 cents, and the charge is rounded to a whole cent once. The cycle close also prices multi-dimension meters through their pricing rules, the same path the preview uses. Status: active; showing a sub-cent unit price on invoice lines came later (ADR-054).
Context
Section titled “Context”ADR-044 made the AI wedge a one-meter / N-rule model: a tokens meter
priced per {model, token_type}. Wiring that end-to-end (the headline
demo: point a LiteLLM proxy at Hardcount and the AI plan bills automatically)
surfaced two defects that meant the wedge could not actually bill.
Gap 1 — per-unit rates were integer cents, so sub-cent prices were inexpressible
Section titled “Gap 1 — per-unit rates were integer cents, so sub-cent prices were inexpressible”Hardcount stored every per-unit rate as int64 cents and flat mode prices
quantity × flat_amount_cents. AI token pricing is sub-cent-per-unit:
$3.00 per 1,000,000 tokens = 0.0003 cents/token. Integer cents cannot
represent that. The shipped anthropic_style recipe authored
flat_amount_cents: 300 and commented it “$3.00 per million” — but in
per-unit flat mode that bills $3.00 per token, a 1,000,000× overcharge.
Peers price sub-cent usage with decimal unit prices, not integers:
| Platform | Per-unit rate format |
|---|---|
| Stripe | unit_amount_decimal — decimal string, up to 12 decimal places (verified) |
| Lago (open source) | BigDecimal amounts (verified) |
| Orb / Metronome | usage-based AI billers (tokens/compute); decimal-precision internals are sales-gated / not publicly documented |
| Hardcount (pre-ADR-045) | int64 cents — whole-cent floor |
Stripe is the load-bearing anchor and the closest fit: its docs state
unit_amount_decimal is for sub-cent usage rates (e.g. “0.05 cents per MB”)
and that the customer is still charged an integer minor-unit amount — the
exact “decimal rate, integer-cent line total” split this ADR adopts.
Gap 2 — the cycle billing path ignored multi-dim meters entirely
Section titled “Gap 2 — the cycle billing path ignored multi-dim meters entirely”billOnePeriod priced usage by reading each meter’s single linked
rating_rule_version_id (AggregateForBillingPeriodByAgg → one rate ×
one summed quantity). A multi-dim meter (the AI tokens meter) has no
single linked rule — it is priced by MeterPricingRule rows resolved via
AggregateByPricingRules. So billOnePeriod skipped the meter and
emitted a $0 invoice (“no billable lines”). Only preview and the
threshold scan used the pricing-rules path — preview.go even claimed
“the cycle scan calls the same path,” which was untrue (doc-code drift).
Decision
Section titled “Decision”-
Per-unit rate fields become arbitrary-precision decimals. (Limited since 2026-10-24: see the amendment of that date.)
RatingTier.UnitAmountCents,RatingRuleVersion.FlatAmountCents, andRatingRuleVersion.OverageUnitAmountCents(and their mirrors onCustomerPriceOverride,RecipeRatingRule, and the create-inputs) change fromint64toshopspring/decimal.Decimal. DB columnsflat_amount_cents/overage_unit_amount_centsonrating_rule_versionsandcustomer_price_overridesgoBIGINT → NUMERIC(migration 0108);graduated_tiersis JSONB and needs no DDL. JSON wire form is a string ("0.0003") — shopspring/decimal’s native marshaling, matching how Hardcount already serializes decimal quantities (usage_gte, usage quantities). -
Fixed fees stay
int64cents.base_amount_cents,package_amount_cents, and — critically — every invoice line amount and total remain wholeint64cents. Only the per-unit RATE (multiplied by a decimal quantity) gains precision;ComputeAmountCentsstill rounds the final amount to whole cents (banker’s rounding). You cannot charge a customer a fractional cent. -
billOnePeriodprices multi-dim meters viaAggregateByPricingRules. When a meter hasMeterPricingRulerows it emits one invoice line per claimed rule bucket, using the same pricing-rule resolution + per-bucket pricing + line description (usageLineDescription) aspreviewMeter— so rate buckets and line text match the preview. Meters with no pricing rules keep the legacy single-rule path. Cap-scaling is applied to the rule buckets directly (they are raw, pre-cap quantities, unlike the pre-scaledusageTotals). Note:previewis a full-period estimate and (as before this change) does not replicate usage-cap scaling or mid-period segment proration — for a sub with neither (the common case, incl. the AI recipes) preview equals the invoice; closing that gap for capped/segmented subs is a separate, pre-existing follow-up. -
Recipes are re-authored per-token.
anthropic_styleandopenai_styleexpress each rate as cents-per-token decimal (published $/1M ÷ 1,000,000); decimal precision also lets the derived cache multiples (0.1× / 1.25× / 2×) be exact instead of cent-rounded.replicate_style(per-second whole-cent rates) is unchanged.
Consequences
Section titled “Consequences”- The wedge bills end-to-end: a realistic LiteLLM payload
(
claude-3-5-sonnet-20241022, prompt-cache hit) rates to an exact invoice. Guarded byTestLiteLLM_WedgeE2E(1,830¢ for 1M input + 1M cache_read + 1M output) — the CI test that would have caught both gaps. - Display artifact: an invoice line’s
unit_amount_cents(stillint64) rounds to0for sub-cent rates (e.g. 1M tokens @ 0.0003¢ → line amount 300¢, unit shown 0¢). The lineamount_centsis exact; surfacing a decimal per-unit on the line is a deferred display-only follow-up (invoice line amounts intentionally stay integer cents). - Migration 0108 down (
NUMERIC → BIGINT) is lossy for any fractional rate (rounds to whole cent) — inherent to reverting a precision upgrade; acceptable pre-launch. - API contract change:
flat_amount_cents/ graduated-tierunit_amount_centson rating rules are now JSON strings. Pre-launch, the only consumer is web-v2 (updated in the same change).
Amendment 2026-10-22
Section titled “Amendment 2026-10-22”The block usage cap is cut (#973), so decision 3’s cap-scaling of rule buckets, and the preview’s warning about it, no longer exist.
Amendment 2026-10-24
Section titled “Amendment 2026-10-24”Rates are no longer arbitrary precision (#1378). Every rate field above, on rating rules, price overrides and recipes, is checked when it is written: 0 or more, under 10^18 cents, and at most 12 decimal places (Stripe’s unit_amount_decimal precision). Through the API, a rate outside these limits is a 422 naming the field. The check is on write only, with no migration: a rate stored before it is kept, returned and billed as stored. Publishing a new version of such a rule needs rates inside the limits. A rate under 10^18 cents prices one unit inside int64 cents. What a rate bills is checked separately: a line (rate × quantity), an invoice’s sum of lines, or its total with tax that does not fit int64 cents fails the invoice with an overflow error instead of wrapping.