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

Accepted

2026-04-14

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.

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.

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 now decimal.Decimal (PostgreSQL NUMERIC), not int64 cents (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 Stripe unit_amount_decimal model. 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 remain int64 cents, and ComputeAmountCents still 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 (was FlatAmountCents, mode per_unit, was flat), RatingTier.UnitAmountCents and RatingRuleVersion.PackageAmountCents. A package’s price is a decimal in PostgreSQL NUMERIC, like the rates, because a package can cost a fraction of a cent ($0.002 per 1,000 tokens is "0.2"). OverageUnitAmountCents is gone. A customer price override has the same fields. Fixed fees such as base_amount_cents, all invoice line amounts and totals, credit-ledger amounts and tax amounts stay int64 cents, and ComputeAmountCents still rounds the charge to a whole cent.

Amendment (2026-10-25, #1383). int64 cents holds about $92 quadrillion, and plain int64 + 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 is domain.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 inside int64 is unchanged, except a rounding that was one cent off when it divided by more than about 4.6 × 10^18 cents (RoundHalfToEven doubled its remainder in int64). Amounts this large are not realistic.

  • 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, no NUMERIC type needed
  • Stripe’s API uses integer cents natively, so no conversion needed at the payment boundary
  • 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)
  • int64 gives us a maximum of ~$92 quadrillion in cents, which is sufficient. We trade human readability in the database (4999 vs 49.99) for computational correctness.
  • 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 / NUMERIC type: Arbitrary-precision decimals avoid IEEE 754 issues but add complexity (Go has no native decimal type; libraries like shopspring/decimal introduce allocations and API friction). PostgreSQL NUMERIC works but is slower than BIGINT for 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.