Dev.to WebDev 🛠 Dev 👁 0 📖 2 min read

No floats near money: how our free estimate template matches the app to the cent

CloseQuotely has a free estimate template: a page where a contractor types lines, quantities and prices, sees a total and prints it. No account, nothing saved on our side. It's a marketing page, and it would be easy to w

CloseQuotely has a free estimate template: a page where a contractor types lines, quantities and prices, sees a total and prints it. No account, nothing saved on our side. It's a marketing page, and it would be easy to write its math in ten minutes with parseFloat.

We didn't, because the app's real quotes use the same lines, and a free page that's a cent off the product is a bad first impression. So the page imports the product's own quote math. Here's what that math does differently.

Floats get money wrong in ordinary cases

parseFloat("1.15") * 100          // 114.99999999999999
Math.round(1.005 * 100) / 100      // 1, not 1.01
0.1 + 0.2                          // 0.30000000000000004

The first one is the dangerous one: floor it to get cents and $1.15 becomes $1.14.

Parse text straight into integers

Money is integer cents, and it never passes through a float. The typed string is checked with a pattern and split, so "1.15" becomes 1 × 100 + 15:

const MONEY_PATTERN = /^(0|[1-9]\d{0,6})(\.\d{1,2})?$/;

export function parseMoneyToCents(value: string): number | null {
  const trimmed = value.trim().replaceAll(",", "");
  if (!MONEY_PATTERN.test(trimmed)) return null;
  const [whole = "0", fraction = ""] = trimmed.split(".");
  const cents = Number.parseInt(whole, 10) * 100 + Number.parseInt(fraction.padEnd(2, "0"), 10);
  return Number.isSafeInteger(cents) ? cents : null;
}

Quantities get the same treatment in thousandths ("1.5" hours is 1500), and percentages in thousandths of a percent (8.875% tax is 8875), so every number in a quote is an integer.

Round once, half up, with BigInt

A line total is quantity × price, which lands in thousandths of a cent, so it has to be rounded somewhere. We round once per line, half up, in BigInt so a big job can't overflow:

function divRound(numerator: bigint, denominator: bigint): number {
  if (denominator === 0n) return 0;
  return Number((numerator * 2n + denominator) / (denominator * 2n));
}

export function lineTotalCents(quantityMilli: number, unitPriceCents: number): number {
  return divRound(BigInt(quantityMilli) * BigInt(unitPriceCents), 1000n);
}

1.5 hours at $85.50 is 1500 × 8550 / 1000 = 12,825 cents, exactly $128.25. Discounts and tax are percentages of a cent amount, rounded the same way, each at its own documented step. Writing down where each rounding happens matters as much as the rounding itself: two pages that round at different steps disagree by a cent on some totals.

The free page shares the module

The estimate page doesn't have its own math. It calls the same calculateQuoteTotals the app uses for real quotes, so a contractor who types the same lines into both sees the same total.

The rest of the page is small: the draft lives in localStorage under one key per trade (wrapped in try/catch, since private windows can refuse storage), and printing is window.print() with Tailwind's print:hidden on the form so only the estimate prints.

Takeaways

  • Keep money in integer cents, and parse it from the string, not through parseFloat.
  • Pick one rounding rule and one place it happens, and write it down.
  • If a marketing page does product math, import the product's code instead of rewriting it.

The page is at closequotely.com/free-estimate-template. I build CloseQuotely, quoting and invoicing for small trade businesses, free for 10 quotes a month.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.