ADR-005: Integer Cents for Money
Date: 2026-04-15 Status: Accepted, amended twice. ADR-045 made per-unit rates decimal (2026-06-01). ADR-130 §3 made a package’s price decimal too (built in #1381).
Status
Section titled “Status”Accepted
2026-04-14
Summary
Section titled “Summary”Money amounts are stored and computed as int64 cents (the currency’s smallest unit), never as floats, so sums and comparisons are exact. This covers invoice and line amounts and totals, fixed fees and credit-ledger amounts. Amended 2026-06-01: per-unit price rates are now decimals, so sub-cent prices such as $3 per million tokens can be expressed (ADR-045). Amended by ADR-130 (#1381): a package’s price is a decimal too, and the rate fields have new names (below). The charged amount is still rounded to a whole cent, and tax rates are NUMERIC(7,4) decimals (ADR-042), so the float tax formula below is no longer used. Amended 2026-10-25: every money sum and product is checked against int64; an amount past it refuses the invoice or fails the read, and is never wrapped. Status: active, amended.
Context
Section titled “Context”A billing engine performs thousands of arithmetic operations on monetary values: multiplying unit prices by quantities, computing tiered/graduated pricing, applying tax rates, summing line items, applying credits. The representation of money must be correct under all these operations.
IEEE 754 floating-point arithmetic is inherently imprecise for decimal values. 0.1 + 0.2 = 0.30000000000000004 in every language that uses doubles. In a billing system, these rounding errors compound across line items, tax calculations, and credit applications — producing invoices that are off by a cent, failing reconciliation, and eroding customer trust.
Decision
Section titled “Decision”All monetary values in Hardcount are stored and computed as int64 representing cents (or the smallest currency unit). The Invoice struct uses SubtotalCents, TaxAmountCents, TotalAmountCents, AmountDueCents, AmountPaidCents, CreditsAppliedCents. Line items use UnitAmountCents, AmountCents, TotalAmountCents. Credit ledger entries use AmountCents and BalanceAfter in cents.
Conversion to display format (dollars, euros) happens exclusively at the API boundary — JSON serialization in handlers. All internal computation, storage, and comparison uses integer cents. Tax calculation is the one place where floating-point appears: int64(math.Round(float64(subtotal) * taxRate / 100)), converting back to integer immediately after the multiplication.
Amendment (2026-06-01, ADR-045 — partial supersession). The per-unit RATE fields multiplied by a (decimal) quantity —
RatingRuleVersion.FlatAmountCents,RatingTier.UnitAmountCents,RatingRuleVersion.OverageUnitAmountCents— are nowdecimal.Decimal(PostgreSQLNUMERIC), notint64cents (a rate written since 2026-10-24 is limited to 12 decimal places and under 10^18 cents; ADR-045’s amendment of that date). Sub-cent-per-unit rates (e.g. $3.00 / 1M tokens = 0.0003¢/token) are inexpressible as integer cents; ADR-045 adopts the Stripeunit_amount_decimalmodel. Everything else in this ADR stands: fixed fees (base_amount_cents,package_amount_cents), all invoice line amounts and totals, credit-ledger amounts, and tax remainint64cents, andComputeAmountCentsstill rounds the final charge to a whole cent. Only the RATE gained precision — you still cannot bill a fractional cent.
Amendment (ADR-130 §3, built in #1381). The amendment above names the fields as they were. Now the decimal fields are
RatingRuleVersion.UnitAmountCents(wasFlatAmountCents, modeper_unit, wasflat),RatingTier.UnitAmountCentsandRatingRuleVersion.PackageAmountCents. A package’s price is a decimal in PostgreSQLNUMERIC, like the rates, because a package can cost a fraction of a cent ($0.002 per 1,000 tokens is"0.2").OverageUnitAmountCentsis gone. A customer price override has the same fields. Fixed fees such asbase_amount_cents, all invoice line amounts and totals, credit-ledger amounts and tax amounts stayint64cents, andComputeAmountCentsstill rounds the charge to a whole cent.
Amendment (2026-10-25, #1383).
int64cents holds about $92 quadrillion, and plainint64+and×wrap silently past it. Every money sum and product is now checked: a product such as a prorated fee (full fee × days / period days) or a tax share is worked in 128 bits (internal/platform/money) and is exact whenever its result fits; the full fee (base fee × quantity) must itself fit, so a prorated fee is refused when its full fee does not; a sum is checked as it is added. An amount that does not fit isdomain.ErrAmountOverflow. On an invoice path (the period close, an immediate cancel, the day-1 invoice, a threshold fire, a proration) the invoice is refused and nothing is written; a period close is retried (ADR-119). On a read path (previews, the spend-threshold check, the live credit balance, the cost dashboard, analytics) the read fails. The spend-threshold check reads usage only, as a threshold counts and bills usage only, so a base fee does not stop its fire. An amount insideint64is unchanged, except a rounding that was one cent off when it divided by more than about 4.6 × 10^18 cents (RoundHalfToEvendoubled its remainder inint64). Amounts this large are not realistic.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Zero floating-point drift across any chain of additions, subtractions, and comparisons
- Equality checks on monetary values are exact (
==not approximate) - Database storage as
BIGINT— no precision/scale configuration, noNUMERICtype needed - Stripe’s API uses integer cents natively, so no conversion needed at the payment boundary
Negative
Section titled “Negative”- Developers must remember to work in cents and convert at boundaries, which is unintuitive
- Division operations (e.g., computing unit price from total and quantity) can lose precision — we accept truncation and document where it occurs
- Multi-currency support requires knowing the smallest unit per currency (cents for USD/EUR, but yen for JPY has no subunit)
Trade-offs
Section titled “Trade-offs”int64gives us a maximum of ~$92 quadrillion in cents, which is sufficient. We trade human readability in the database (4999vs49.99) for computational correctness.
Alternatives Considered
Section titled “Alternatives Considered”float64: The default in many systems. Rejected outright — billing is one domain where floating-point errors are not acceptable. A 1-cent error on 10,000 invoices is a $100 reconciliation problem.decimal/NUMERICtype: Arbitrary-precision decimals avoid IEEE 754 issues but add complexity (Go has no native decimal type; libraries likeshopspring/decimalintroduce allocations and API friction). PostgreSQLNUMERICworks but is slower thanBIGINTfor aggregations. Rejected for v1 in favor of the simpler int64 approach that Stripe, Square, and most payment processors use.- String-encoded decimals: Used by some financial APIs. Avoids precision issues but makes arithmetic impossible without parsing. Rejected.