Skip to content
Hardcount
Run it locally
Pre-1.0 · one maintainer · Stripe and cards only · no SDKs (client libraries) yet · no production users yet. Docs describe v0.9.1 (29 Oct 2026) · What’s new →

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).

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).

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).

  1. Per-unit rate fields become arbitrary-precision decimals. (Limited since 2026-10-24: see the amendment of that date.) RatingTier.UnitAmountCents, RatingRuleVersion.FlatAmountCents, and RatingRuleVersion.OverageUnitAmountCents (and their mirrors on CustomerPriceOverride, RecipeRatingRule, and the create-inputs) change from int64 to shopspring/decimal.Decimal. DB columns flat_amount_cents / overage_unit_amount_cents on rating_rule_versions and customer_price_overrides go BIGINT → NUMERIC (migration 0108); graduated_tiers is 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).

  2. Fixed fees stay int64 cents. base_amount_cents, package_amount_cents, and — critically — every invoice line amount and total remain whole int64 cents. Only the per-unit RATE (multiplied by a decimal quantity) gains precision; ComputeAmountCents still rounds the final amount to whole cents (banker’s rounding). You cannot charge a customer a fractional cent.

  3. billOnePeriod prices multi-dim meters via AggregateByPricingRules. When a meter has MeterPricingRule rows it emits one invoice line per claimed rule bucket, using the same pricing-rule resolution + per-bucket pricing + line description (usageLineDescription) as previewMeter — 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-scaled usageTotals). Note: preview is 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.

  4. Recipes are re-authored per-token. anthropic_style and openai_style express 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.

  • The wedge bills end-to-end: a realistic LiteLLM payload (claude-3-5-sonnet-20241022, prompt-cache hit) rates to an exact invoice. Guarded by TestLiteLLM_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 (still int64) rounds to 0 for sub-cent rates (e.g. 1M tokens @ 0.0003¢ → line amount 300¢, unit shown 0¢). The line amount_cents is 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-tier unit_amount_cents on rating rules are now JSON strings. Pre-launch, the only consumer is web-v2 (updated in the same change).

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.

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.