Skip to content
Product EngineeringSeptember 21, 20269 min read

Storing Money as Integers: How Rinalance Keeps Every Cent Exact

Floating-point money drifts. How Rinalance stores every amount as integer minor units, converts between seven currencies with integer arithmetic and one rounding step, and migrated a live database off floats.

moneytypescriptfinanceconvex

Rinalance is a personal finance tracker I build and run. People type in what they earned and spent, and the app has to show the same numbers back to them, for years, across seven currencies, without ever being off by a cent. That sounds like a low bar. It is not, if you store money as a floating-point number.

This post is about the decision to store every amount as an integer count of the currency's smallest unit, what that looks like in TypeScript and Convex, how currency conversion works when you refuse to touch a float, and how an existing database was moved over. The code below is trimmed from the real modules, but the shapes and the test values are the real ones.

The problem with floats is not that they are imprecise

Everyone has seen 0.1 + 0.2 === 0.30000000000000004. The usual reaction is "so round to two decimals when you display it" and move on. That works right up until it doesn't, and when it doesn't, it fails quietly.

Here is the version that actually bites in a finance app:

Math.round(1.005 * 100); // 100, not 101

1.005 cannot be represented exactly in binary. The nearest double is a hair below it, so 1.005 * 100 is 100.49999999999999 and rounds down. A user who types 1.005 (say, a unit price) gets 1.00 stored and never finds out why their total is a cent short.

Now multiply that by thousands of records, a running balance that is recomputed from those records, budgets that compare against that balance, and a monthly comparison chart that subtracts one derived total from another. The errors do not average out. They accumulate, and they show up as a balance that disagrees with the bank by an amount nobody can explain.

The fix is old and boring: do not store fractions. Store the number of cents.

Minor units, with a type that will not let you cheat

Every ISO currency defines a minor unit. The euro has 2 decimal digits, so €12.34 is 1234 minor units. The yen has 0, so ¥1234 is 1234 minor units. Rinalance supports seven currencies, and the only thing that varies between them for storage purposes is that digit count:

export const CURRENCY_METADATA = [
  { code: "EUR", name: "Euro", symbol: "€", minorUnitDigits: 2 },
  { code: "USD", name: "US Dollar", symbol: "$", minorUnitDigits: 2 },
  { code: "GBP", name: "British Pound", symbol: "£", minorUnitDigits: 2 },
  { code: "CHF", name: "Swiss Franc", symbol: "CHF", minorUnitDigits: 2 },
  { code: "JPY", name: "Japanese Yen", symbol: "¥", minorUnitDigits: 0 },
  { code: "CAD", name: "Canadian Dollar", symbol: "C$", minorUnitDigits: 2 },
  { code: "AUD", name: "Australian Dollar", symbol: "A$", minorUnitDigits: 2 },
] as const satisfies readonly CurrencyMetadata[];

The amount itself is a bigint. Not a number, because a number is still a double and will happily hold 1234.5 without complaint. A bigint cannot hold a fraction at all, which is exactly the guarantee I want from the type system.

It is also a branded bigint, so a random bigint from somewhere else in the codebase cannot be passed where money is expected:

declare const minorUnitsBrand: unique symbol;

export type MinorUnits = bigint & {
  readonly [minorUnitsBrand]: "MinorUnits";
};

export type Money<C extends SupportedCurrency = SupportedCurrency> = {
  currency: C;
  minorUnits: MinorUnits;
};

const INT64_MIN = BigInt("-9223372036854775808");
const INT64_MAX = BigInt("9223372036854775807");

export const createMinorUnits = (value: bigint): MinorUnits => {
  if (value < INT64_MIN || value > INT64_MAX) {
    throw new Error("Minor-unit value exceeds the signed 64-bit range");
  }
  return value as MinorUnits;
};

The int64 check is there because the database is Convex, and Convex stores bigint as a signed 64-bit integer (v.int64()). Ninety-two quadrillion euros is more headroom than a personal finance app will ever need, but the guard means an overflow is a thrown error at the boundary instead of a silently wrapped value in storage.

The Money<C> generic does one more useful thing. Adding two amounts requires them to be the same currency, and that is checked at compile time:

export const addMoney = <C extends SupportedCurrency>(
  left: Money<C>,
  right: Money<NoInfer<C>>,
): Money<C> => {
  assertSameCurrency(left, right); // runtime backup
  return createMoney(
    addMinorUnits(left.minorUnits, right.minorUnits),
    left.currency,
  );
};

addMoney(eurBalance, usdRecord); // type error before it ever runs

NoInfer stops TypeScript from widening C to "EUR" | "USD" to make the call compile. Without it, the generic would cheerfully infer a union and the check would only exist at runtime.

Getting from a text field to an integer without passing through a float

The user types "12.34" or "12,34" into an input (the input normalizes the separator first). The tempting path is parseFloat and multiply by 100. That reintroduces the exact problem we are trying to avoid, one step earlier.

Instead, the string is parsed into a pair of integers, a coefficient and a count of fractional digits, and the scaling is done with integer arithmetic:

const parseDecimal = (value: string) => {
  const match = value
    .trim()
    .match(/^([+-]?)(?:(\d+)(?:\.(\d*))?|\.(\d+))(?:[eE]([+-]?\d+))?$/);
  if (!match) throw new Error("Money value must be a decimal number");

  const sign = match[1] === "-" ? -1n : 1n;
  const integerPart = match[2] ?? "0";
  const fractionalPart = match[3] ?? match[4] ?? "";
  const exponent = Number(match[5] ?? "0");

  return {
    coefficient: sign * BigInt(`${integerPart}${fractionalPart}`),
    fractionalDigits: fractionalPart.length - exponent,
  };
};

"12.34" becomes { coefficient: 1234n, fractionalDigits: 2 }. To get EUR minor units you compare the fractional digits with the currency's minorUnitDigits. If the input has fewer, multiply by a power of ten. If it has more, you have to round, and that is the only place rounding happens on input:

export const majorUnitsToMinorUnits = (
  value: string | number,
  currency: SupportedCurrency,
  roundingMode: MoneyRoundingMode,
): MinorUnits => {
  const { coefficient, fractionalDigits } = parseDecimal(value);
  const { minorUnitDigits } = getCurrencyMetadata(currency);
  const scaleDifference = minorUnitDigits - fractionalDigits;

  return createMinorUnits(
    scaleDifference >= 0
      ? coefficient * 10n ** BigInt(scaleDifference)
      : divideWithRounding(
          coefficient,
          10n ** BigInt(-scaleDifference),
          roundingMode,
        ),
  );
};

Rounding mode is a required argument, not a default. Three are implemented: half away from zero (what a human expects), half to even (banker's rounding), and truncate. Today every caller in the app uses half away from zero, and that is fine. The point of making it a required argument is that the next person who needs a different rule has to say so at the call site, rather than discovering a hidden default after the fact.

And the 1.005 case from earlier? With integer arithmetic there is no representation error to begin with:

majorUnitsToMinorUnits("1.005", "EUR", "halfAwayFromZero"); // 101n
majorUnitsToMinorUnits("1.005", "EUR", "halfEven"); // 100n
majorUnitsToMinorUnits("1.5", "JPY", "halfAwayFromZero"); // 2n

Those are real test cases from the codebase. Both answers for 1.005 are correct; they are just different rounding rules, and now the rule is explicit.

Converting between currencies without a single float multiplication

This is the part I expected to be painful and was not. An exchange rate arrives from the provider as a decimal like 1.0847. Multiplying a bigint by a number is not allowed in JavaScript, which turns out to be a feature: it forces you to treat the rate as a decimal string too.

export function convertMinorUnits(
  amount: MinorUnits,
  source: SupportedCurrency,
  target: SupportedCurrency,
  rate: number,
): MinorUnits {
  const { coefficient, fractionalDigits } = parseDecimal(rate);
  const numerator =
    amount *
    coefficient *
    10n ** BigInt(getCurrencyMetadata(target).minorUnitDigits);
  const denominator =
    10n **
    BigInt(fractionalDigits + getCurrencyMetadata(source).minorUnitDigits);
  return createMinorUnits(
    divideWithRounding(numerator, denominator, "halfAwayFromZero"),
  );
}

The whole conversion is one multiplication and one division on integers, with exactly one rounding step at the end. Converting 1234 EUR minor units at 1.0847 to USD works out to 1234 × 10847 × 100 / 10^(4+2), which is 1338.5198 and rounds to 1339 USD cents. There is nowhere in that chain for a binary fraction to sneak in.

The rate itself is stored separately from any amount, with the provider, the date it was published, and the date it was requested for. A weekend request gets Friday's rate, and both dates are kept; the Friday rate is never relabeled as Saturday's. If the provider has no rate within seven days of the requested date, the capture fails loudly rather than falling back to today's rate. Historical reports use the rate captured for the record's date, so a category comparison from March does not change because the euro moved in September.

Formatting is the only place a float is allowed to exist

Somebody still has to render 1234n as €12.34 or 12,34 € depending on whether the user picked US or European formatting. Intl.NumberFormat does that well, and it takes a number.

So there is one function whose job is to cross that boundary, and its name is deliberately unpleasant:

/** Temporary boundary for old float fields and display libraries. Never use it for ledger arithmetic. */
export const minorUnitsToLegacyMajorNumber = (
  value: MinorUnits,
  currency: SupportedCurrency,
): number => Number(minorUnitsToDecimalString(value, currency));

It converts the integer to a decimal string first ("12.34", by slicing digits, never by dividing), and only then to a number for the formatter or the chart library. By the time a float exists, the arithmetic is finished. If someone ever reaches for that function inside a balance calculation, the name and the comment are there to make them think twice, and code review does the rest.

Migrating an existing database off floats

Rinalance did not start this way. The first version stored amounts as decimal number fields, which is how most apps start. Changing the storage type of money in a live product is the kind of thing that keeps you up at night, so it was split into three releases across nine tables (assets, records, balance history, subscriptions, subscription payments, budgets, recurring transactions, debts, and debt payments).

Release one: shadow fields. Every mutation started writing a new amountMinorUnits field next to the old float. Queries kept serving the float, so nothing user-facing changed. The float stayed the source of truth; the integer was a shadow that had to agree with it.

Release two: discover, apply, reconcile. A migration script first ran a read-only discovery pass that classified every money field in every row:

export const inspectLegacyMoney = (
  majorUnits: number,
  currency: string,
  storedMinorUnits?: bigint,
) => {
  if (!isSupportedCurrency(currency)) {
    return { status: "invalid", reason: "unsupported_currency" };
  }
  const expectedMinorUnits = majorUnitsToMinorUnits(
    majorUnits,
    currency,
    "halfAwayFromZero",
  );

  if (storedMinorUnits === undefined) {
    return { status: "missing", expectedMinorUnits };
  }
  return {
    status: storedMinorUnits === expectedMinorUnits ? "ready" : "mismatched",
    expectedMinorUnits,
  };
};

The apply pass then wrote the expected integer into missing rows, repaired any mismatched rows from the float, left ready rows alone, and stopped outright on invalid ones (an unsupported currency, or a debt payment with no debt). It worked in batches of 100 documents per transaction and was idempotent, so the runbook required running it twice: the second run has to report zero updated documents or something is wrong. Before and after, a full data export for a representative account was taken and the two archives were diffed by a reconcile script. The output of all of this is counts and IDs, never amounts, so the logs are safe to paste into a PR.

Release three: retire the floats. Mutations switched to exact integer arithmetic, query boundaries temporarily converted integers back to decimals for clients that had not updated, and a second migration stripped the legacy fields before the schema dropped them. The number-typed money validators were deleted at the same time, so nothing can write a float amount by accident again.

The reason for the ceremony is that a money migration has no partial-success mode. Either every balance survives to the cent or the product is broken in a way users will notice before you do.

What I would tell someone starting a finance app today

  • Store money as an integer count of minor units from day one. The migration is doable, but not having to do it is better.
  • Use bigint, not number, and brand it. The compiler is cheaper than a bug report.
  • Put the currency next to the amount and make cross-currency arithmetic a type error.
  • Parse user input as a string into integers. parseFloat is where the precision goes to die.
  • Make rounding mode an explicit argument everywhere rounding can happen, and make it happen in as few places as possible.
  • Treat exchange rates as decimal strings and convert with one integer multiply, one integer divide, one round.
  • Allow exactly one function to produce a float, name it so nobody wants to call it, and only call it for display.

None of this is novel. Banks and payment processors have done it for decades. It is just easy to skip when you are moving fast, and expensive to add later. If you want to see the result, the multi-currency handling is one of the things Rinalance is built around, and there is a longer write-up of the product itself in the case study.

If you are having something built that touches money

You do not need to understand any of the code above. You need to know that this decision is nearly free on day one and genuinely expensive in year two, and that it is the first thing I look at when someone asks me to audit or take over an existing application. If invoices, balances, prices, or payouts go through the system, ask whoever is building it how amounts are stored. "As a number with two decimals" is the answer that costs you later.