Skip to main content

6. Money representation: integer minor units with branded types

Date: 2026-06-06

Status

Accepted

Context

This is a payroll and invoicing system. Money correctness is the product's core promise. JavaScript's number is an IEEE-754 float, where 0.1 + 0.2 !== 0.3; doing arithmetic on dollar/peso decimals accumulates rounding error that, over thousands of transactions, becomes real money lost or invented. The project conventions already mandate "money always in integer cents or Decimal, never raw floats."

There are also two currencies that must never be mixed: facilities are invoiced in USD, contractors are paid in PHP. A plain number for both invites accidentally adding a USD amount to a PHP amount.

Decision

Represent all monetary values as integer minor units with branded types (src/lib/money):

  • Cents — USD minor unit (1 USD = 100 cents).
  • Centavos — PHP minor unit (1 PHP = 100 centavos).

Both are number at runtime but carry a distinct compile-time brand (number & { [symbol]: true }). Constructors (cents(), centavos()) throw on non-integer or unsafe-integer input. Arithmetic goes through helpers (addMinor, subMinor, sumMinor, mulRatioMinor, allocateByWeights) that preserve the brand, so the compiler rejects mixing currencies or passing a raw float where money is expected.

Proportional splits (e.g. a bi-monthly rate across weeks) use allocateByWeights, which guarantees the parts sum exactly to the total (the last weighted bucket absorbs the rounding remainder). Database columns are numeric(12,2); conversion to/from minor units happens only at trust boundaries (DB, UI display, integration payloads).

Consequences

  • Whole classes of float-rounding and currency-mixing bugs become compile errors or constructor throws, not silent production drift.
  • All domain math (pay, interest, reconciliation) operates on integers; rounding is explicit and tested.
  • A small amount of ceremony: callers construct money via cents()/centavos() rather than using bare numbers. This is intentional friction at the boundary.
  • A future lint/grep gate should reject Math.round(x * 100) / 100-style float-money patterns in src/.