Back to Explore
GuideVibeFix 编辑部Updated Nov 5, 2026

Why 0.1 + 0.2 Is Not 0.3: Money Precision in Vibe Projects

One cent off and the books won't close: why floating point can't count money, the integer-cents rule, rounding exactly once at the boundary, discounts, taxes, splitting and multi-currency traps, plus seven test cases to run before launch.

Real photograph of hands counting coins with a calculator on a wooden desk

One Cent Off, and the Books Won't Close: A Lesson That Feels All Too Real

Start with a fictional teaching example. "Green Sprout Goods" is a one-person shop. Its owner used AI to build a small online ordering page: spend 100, get 10 off, 6 for delivery, WeChat Pay and Alipay accepted. In the first week, a customer sent a screenshot and asked: three items cost 19.99, 29.99 and 49.99. That adds up to 99.97, and the page showed 95.97 after the discount — but the customer was actually charged 95.96. One cent off. The owner went digging and found the same order had three different numbers in three places: the order page said 95.97, the payment gateway received 95.96, and the database stored 95.96999999999999. Customer support could not explain it, the customer felt overcharged, and the owner did not know which number to trust.

That is the most common way money math dies in a small project: not a big number going wrong, but one or two cents after the decimal point being handled differently in different places. Money is not an ordinary number. It is backed by real cash, invoices, refunds and reconciliation reports. One cent off means a customer complaint at best; at worst, the month-end books are off by thousands and nobody can find which order leaked it after three days of searching.

This guide is about making money math correct in vibe projects: why floating-point numbers cannot count money, the iron rule of storing integers, rounding, discounts, taxes, splitting, multi-currency, how frontend and backend should divide the work, and a test checklist to run before launch. All examples below are fictional teaching examples; the shop names and numbers are simplified for illustration.

Real photograph of hands counting coins with a calculator on a wooden desk

1. Why 0.1 + 0.2 Is Not 0.3

Get the root cause straight first, or every fix is guesswork. On a computer, decimals are almost always stored as binary floating-point numbers — IEEE 754 double precision. The decimal 0.1 in binary is an infinitely repeating fraction, and the computer can only keep the first 53 bits. So the number actually stored is not 0.1 but 0.1000000000000000055511151231257827. The same goes for 0.2. Add two "almost" numbers together and you get 0.30000000000000004. This is not a bug in one language: JavaScript, Python, Go and Java's default double all behave this way.

You rarely notice in everyday code because the display hides it: console.log(0.1 + 0.2) prints 0.30000000000000004, but most interfaces use toFixed(2) to iron it flat to 0.30. The problem is that the ironing only happens at the moment of display. The number that actually takes part in the next calculation, the next comparison and the next database write still carries its tail. After a few hundred additions, multiplied by a tax rate and split across several people, the tail grows into a cent — or a few dollars.

Beginners fall into the same three traps, all growing out of this root cause. The first is comparing floating-point numbers for equality: if (total === 99.97) never fires when total is 99.96999999999999, and the order gets stuck in the wrong state. The second is patching things up with toFixed again and again: toFixed returns a string, and feeding that string into the next round of arithmetic means computing on top of an error — and rounding behaviour is not even identical across languages and browsers. The third is assuming "it is only a cent, it does not matter": one cent on one order does not matter, but a thousand orders all drifting in the same direction leave the month-end books off by tens of dollars, and no single order can explain it.

// Wrong: floating-point numbers doing money math
const total = 19.99 + 29.99 + 49.99; // 99.96999999999999
console.log(total === 99.97);        // false, the comparison never passes
console.log(total.toFixed(2));       // "99.97", but that only irons the display

// Right: convert to integer cents first, then calculate
const totalCents = 1999 + 2999 + 4999; // 9997, exact
console.log(totalCents === 9997);      // true

2. The Iron Rule: Store Money as Integers in the Smallest Unit

Money math has exactly one iron rule: from the entry point to the database, money only moves as integers in the smallest unit. For renminbi that is the fen, for dollars the cent, for yen the yen itself — no division needed. 19.99 yuan is stored as 1999, 29.99 as 2999, and every addition, subtraction and multiplication in between works on integers. Only at the very last moment, when the number is shown to a human, do you divide by 100 and format it as "¥99.97". Integer arithmetic is exact. There is no tail, so there is no room for errors to accumulate.

The rule only counts when it lands in four places; miss one and it leaks. First, the database column: store cents in a BIGINT, or use a fixed-point type like DECIMAL(19,4). Never store money in FLOAT or DOUBLE. DECIMAL is fixed-point: 19.99 goes in as 19.99, with no tail — but you still have to decide, for the whole system, whether DECIMAL holds yuan or fen. Second, API transport: send 1999 between frontend and backend, not 19.99. JSON carries 19.99 as a floating-point number, so the problem survives the trip untouched. Third, frontend state: unit prices, subtotals and totals in the cart are all computed in cents; the "¥" on screen is only a formatting result. Fourth, logs and reconciliation: log amounts in cents too, or log "cents + currency" together, or you will not even be able to reproduce a problem when it happens.

People often ask whether integer cents are enough. One order is at most a few million cents — nowhere near BIGINT's limit. The two cases where cents genuinely run out are: a unit price with more than two decimal places, such as fuel prices or prices converted from an exchange rate; and splitting an amount across many people where each share needs precision to a tenth of a cent. The answer in both cases is not to go back to floating point, but to take the smallest unit one step further down: use the "li" internally (1 yuan = 1,000 li), or a fixed-point DECIMAL(19,4), and only round to fen at display and settlement time. The point is not the fen itself; it is the principle — integers or fixed-point all the way through, rounding only at the boundary.

ApproachWhat is storedResult
Float in yuanDOUBLE 19.990.1 + 0.2 grows a tail; drifts further with every step
Integer in fenBIGINT 1999Addition and subtraction exact; the recommended approach
Fixed-point in yuanDECIMAL(19,4) 19.9900Exact, but the whole system must agree on the unit
Integer in liBIGINT 19990Use when three decimal places of precision are needed

One more detail: never mix units in the same codebase. The classic accident looks like this — the backend stores fen, the frontend calculates in yuan, the payment gateway expects fen, each place does its own conversion, and one refactor updates only two of the three. Order amounts suddenly come out a hundred times wrong. The fix is to put the unit in the name — amountCents, priceCents, totalCents — so the unit is visible at a glance, and to wrap conversion in exactly two functions, fromYuan and toYuan. The whole codebase is only allowed to convert through those two functions; nowhere else may anyone hand-write * 100 or / 100.

Diagram: the money pipeline from user input to display — integer cents throughout, one rounding at the exit

3. Rounding: Do It Once, at the Last Gate, and Say So

Integers solve addition and subtraction, but multiplication and division will eventually hit a number that does not divide evenly. 100 yuan at 10% off is 90 yuan — fine. 99 yuan at 10% off is 89.1 yuan, which is 8,910 fen — also fine. The real trouble is 10 yuan split three ways: 3.333… yuan per person, which cannot be expressed in fen. That is when you must round, and the rule is: round only once, at the last gate, and never in the middle.

Take "spend 100, get 10 off, then 10% off, then add tax". The correct order is: do all the integer math in fen, keep intermediate values exact (using li as a bridge if needed), and round exactly once, when you arrive at the single number called "amount payable". The wrong way is to call toFixed(2) after every step: round after the discount, round after the percentage off, round after tax. Three roundings can all drift in the same direction, leaving you two or three cents off — and each error hides inside an intermediate result, so reconciliation cannot tell which step went wrong.

The rounding mode itself must be chosen deliberately and written down. The most common is round-half-up (HALF_UP): 0.5 rounds up. Banks and accounting systems often use banker's rounding (HALF_EVEN): 0.5 rounds to the nearest even number — 2.5 becomes 2, 3.5 becomes 4 — so positive and negative errors cancel out over time. There is also round-down (DOWN), often used for "discounts given to the user, rounded down", and round-up (UP), often used for "tax, rounded up — better to collect one cent too much than one cent too little". No single mode fits everywhere. What matters is that the whole system uses one default mode, and exceptions are called out explicitly, instead of every developer choosing by feel.

// Wrong: rounding at every step, errors stack up
let t = (99.97 - 10).toFixed(2);      // "89.97"
t = (parseFloat(t) * 0.9).toFixed(2); // "80.97"
t = (parseFloat(t) * 1.06).toFixed(2);// "85.83"

// Right: integer fen all the way, one rounding at the end
// 9997 - 1000 = 8997 fen; 8997 * 9 / 10 = 8097.3 fen;
// 8097.3 * 106 / 100 = 8583.078 fen; HALF_UP once gives 8583 fen = ¥85.83
function roundHalfUp(numerator, denominator) {
  return Math.floor((numerator * 2 + denominator) / (denominator * 2));
  // integer-only round-half-up, no floating point involved
}
const payableCents = roundHalfUp(8997 * 9 * 106, 10 * 100); // 8583

Python's Decimal and Java's BigDecimal exist for exactly this job: Decimal("99.97") built from a string is a fixed-point number, and its arithmetic is exact, with the rounding mode set explicitly through quantize. But watch out: Decimal(0.1) built from a float carries the float's tail in with it — always build from a string, Decimal("0.1"). Go has no built-in Decimal; most teams use a library like shopspring/decimal, or simply stick to integer cents. Languages and libraries can change. The principle does not: money arithmetic must never pass through a binary floating-point number.

4. Discounts, Taxes and Splitting: Settle the Total First, Then Divide the Money

Discounts, taxes and splitting are where money bugs concentrate, because they all ask the same question: how do you break one total into parts? The shared principle is: settle the total first, then divide the money; when a remainder is left over, it may only land in one place, and you must decide in advance where that is.

Discounts first. "Spend 100, get 10 off" combined with "10% off" is most often wrong because of stacking order. Take 100 yuan: subtract 10 first and you get 90, then 10% off gives 81; apply 10% off first and you get 90, then subtract 10 and you get 80. One yuan apart — and a user with a screenshot will spot it. Write the stacking order down as an explicit rule, in code comments and in the help docs — for example, "fixed-amount discounts before percentage discounts, coupons last" — and lock every combination in with tests. Never let the order hide inside the sequence in which the code happens to run; whatever is hidden will be changed by some future refactor.

Then taxes. The biggest trap is "is the listed price tax-inclusive or tax-exclusive?" A listed price of 100 yuan at a 6% rate means 100 − 100 / 1.06 = 5.66 yuan of tax if the price includes tax, but 100 × 0.06 = 6 yuan if it does not. The two answers differ by 0.34 yuan, and the invoice will not reconcile. The fix: store both "amount excluding tax" and "tax amount" in the database, make tax-inclusive vs exclusive a configuration item rather than an unspoken convention, and always derive the tax shown to users from those two stored fields instead of reverse-engineering it on the spot. Cross-border sales add another wrinkle: tax rates differ by region, and whether tax is charged by the buyer's location or the seller's must be settled as a business rule before it is hard-coded.

Finally, splitting — the easiest place to lose a cent. 10 yuan split three ways is 3.33 each, and three times 3.33 is 9.99. One cent has gone missing. Where should it go? The correct method is the largest-remainder method: give everyone 333 fen first, then hand the remaining 1 fen to one person according to a fixed, reproducible rule — the first person, the largest share, or a random pick — but the rule must be fixed. Red packets, group orders, commission splits and refund splits are all the same problem. Never round each person's share independently: three people at 3.33 yuan each is a rounding result, but the three shares no longer add up to 10 yuan, and the ledger will never balance against the total.

// Largest-remainder method: split 1000 fen among 3 people
function splitCents(total, n) {
  const base = Math.floor(total / n); // 333 fen each
  const rest = total - base * n;      // 1 fen left over
  const parts = Array(n).fill(base);
  for (let i = 0; i < rest; i++) parts[i] += 1; // hand the remainder to the first people
  return parts; // [334, 333, 333], adds up to exactly 1000 fen
}

5. Multi-Currency: Decimal Places Are Not the Same Everywhere

The moment a project takes more than one currency, money precision problems double. The first trap is decimal places: most currencies use two, but yen and won use zero — 100 yen is 100, there is no such thing as "100.00 yen" — and some currencies have historically used three. The fix: every amount row must store both the currency and the amount in minor units (amountMinor), and formatting must look up the decimal places for that currency. Never assume every currency divides by 100. ISO 4217 currency codes and their decimal places are a public standard — use an established currency library instead of hand-writing your own mapping table, which will sooner or later miss a currency.

The second trap is the exchange rate. A rate is itself a high-precision decimal, and converting requires two decisions up front: which point in time the rate comes from, and how many places the converted result is rounded to. The right approach: take the rate from a named source and store a snapshot of it (which day, which API, which value). One order uses the same snapshot from placement to settlement — never let the rate change mid-flow. Convert with high-precision fixed-point math, and round only when the result lands in the target currency's smallest unit. And never let the frontend's freshly fetched rate drive the backend's settlement amount: the two sides can fetch rates a point apart, and the order amount will not match.

The third trap is mixing currencies. Amounts in different currencies must never be added directly: what does 100 dollars plus 100 yen equal? Nothing meaningful. The correct approach: do all arithmetic within one currency, and when you need a cross-currency summary, convert everything to the base currency using the snapshot rate first, then add. Reports should show both the original-currency amount and the base-currency amount. Give the amount table a currency column, and add validation so a migration can never write a dollar figure into a yen column.

6. Who Does What: The Backend Is the Single Source of Truth

Money often goes wrong not because the algorithm is wrong, but because the frontend and the backend each calculate it once, differently. The frontend computes a subtotal and total locally so the page feels instant; the backend recomputes from its own logic when the order arrives. If the discount order, the rounding mode or the tax source differ in even one place, the number the user saw and the number actually charged will be a cent apart. The principle: the frontend only displays; the backend is the single source of truth. The frontend may calculate for display, but the displayed number is only a preview — the server's returned amount is the real one. An order request should only carry "what was bought and which coupon was used", never "the total I calculated". The backend recomputes from the items and the rules, and that computed number is what gets charged.

Formatting has its own rules. It does exactly one job: turn integer fen into something a human can read. 1,999 fen displays as "¥19.99", 1,999 US cents as "$19.99", 1,999 yen as "¥1,999". Use a built-in formatter like Intl.NumberFormat, never hand-assembled strings: thousands separators, decimal points and the position of the currency symbol all differ by locale, and hand-built formatting will get one of them wrong eventually. Another common mistake is feeding a formatted string back into arithmetic — "¥19.99" is a string for humans, not an input for code. Money is always calculated in integer fen.

One more word on database types. In PostgreSQL, store money as BIGINT in fen — simple and direct; reach for NUMERIC(19,4) only when the business genuinely needs more than four decimal places of precision. In MySQL the equivalents are BIGINT or DECIMAL(19,4). Whichever you choose, migrate old FLOAT/DOUBLE columns away in one pass, and write a validation script: recompute every historical order under the new rules, and list every record whose recomputed value differs from the stored value by more than a cent for manual review. Do not assume old data is correct — old data was computed with the wrong method.

7. Seven Test Cases to Run Before Launch

There is no such thing as "roughly right" for a money module. Tests must cover every boundary. The seven cases below belong in your automated test suite, run every time the money logic changes:

CaseInputExpected
The classic float trap0.1 + 0.230 fen by integer math — never 0.30000000000000004
Half-cent rounding2.5 fen under HALF_UP / HALF_EVENMode decided in advance; both outcomes written into tests
10 yuan, three people1000 fen / 3 people[334, 333, 333], summing to exactly 1000
Discount stacking order10 off over 100, then 10% off81 yuan, matching the documented order
Zero-decimal currency100 yenDisplays as ¥100, never .00
Refunds and negativesRefund one 19.99 itemA negative integer number of fen, never −1998.9999
Large-amount boundaryA single order of 999,999,999 fenNo overflow; total equals the sum of the line items

Beyond these seven, two habits are worth building. First, a reconciliation test: a scheduled job each day recomputes the day's order total, refund total and actually-received total in integer fen. The bookkeeping relationships between the three must hold; a single cent off triggers an alert. Second, a display test: keep screenshots of formatted results and look at them with human eyes for stray tails like "¥19.9900000001" — automated tests catch logic errors, but display errors are often only visible to a person.

8. Pre-Launch Checklist

Here is a checklist you can tick off directly. One: are all amount columns integer fen or fixed-point, with every FLOAT/DOUBLE cleared out? Two: is the unit written into variable and column names, with * 100 and / 100 appearing only inside the two wrapper functions? Three: does rounding happen exactly once, at the last gate, under one system-wide mode that is written down? Four: are the three business rules — discount stacking order, tax-inclusive or exclusive, and where the remainder goes — written down in black and white? Five: are multi-currency decimal places looked up per currency, with the exchange-rate snapshot stored? Six: does the order API only send items and coupons, never a frontend-computed total? Seven: are the seven test cases in the automated suite, and is the reconciliation job running?

If any item on the list has no answer, the money module is not ready to ship. The cost of wrong money math never shows up in the code — it shows up in support tickets, in finance, and in trust. One cent that does not add up, and what users remember is "this shop cannot do its sums". Hold the three lines instead — integers all the way through, one rounding at the boundary, the backend as the single source of truth — and the calculated number, the displayed number, the charged number and the booked number will always agree. That is the whole point of money precision: not a cleverer algorithm, but every step traceable and every cent accounted for.

Views 0Comments 0

Comments (0)

ME
0/1000
Loading comments...
Browse projectsPublish your project

Related articles