Six money bugs in multi-currency expense splitting
A 2026-08-21 audit found six currency defects our tests had been green through, and the cause was one sentence in a doc that had quietly stopped being true.
One stale sentence in a doc cost six money bugs. A multi-currency audit of Dimesum on 2026-08-21 found them in code our test suites had been green through, including a claim page that rendered a ¥6,000 debt as -₹60.00. The sentence was "groups are INR-pinned". True until W7, false the moment it shipped, and still sitting in FOLLOWUPS.md a week later.
A stale sentence is more dangerous than no documentation. A later audit believes it and skips the paths it covers, so a wrong line does damage that silence never could. An empty doc sends you to the source. A wrong one sends you somewhere else entirely.
A follow-up that came true is worse than an open one
Dimesum parks deferred work in FOLLOWUPS.md, one row per decision with the reason it was parked. Entry A17 said groups were INR-pinned, that a trip abroad could not be recorded in the currency it happened in, and that the fix waited on a founder decision. W7 shipped multi-currency anyway: an expense may be in any currency, and balances are keyed (group_id, member_id, currency) by migration ledger/00005. Nobody struck the row.
The next audit read A17, concluded the area was unbuilt, and never opened the settlement, claims or landing code. Three currency-blind money paths kept shipping on the strength of one sentence. No hard problem stood in the way. The docstring of app/amount.py carried the same claim, so a reader got told twice.
Strike a follow-up in the same commit that closes it. A row describing code that stopped working that way is a redirect away from the file you needed to read, which costs more than saying nothing.
The same hardcoded 100 turned up in three different files
Money here is int64 minor units plus an ISO 4217 code, and floats never touch it. Integers are exact by construction, so the only risky arithmetic left is the conversion between major and minor units. The minor unit is not always a hundredth. JPY has none at all, KWD has three decimals, and every hardcoded 100 is a bet that the user stayed home.
The Python sidecar's app/amount.py held the first sighting, pulled before the audit as a landmine rather than a live defect. It multiplied whatever figure it read out of a sentence by 100, so a ¥1,200 receipt became 120,000 minor units. That reads as ¥120,000. On a currency with no minor unit the constant inflates somebody's bill a hundredfold, and the function now looks up the exponent for the request currency instead.
The audit found the same constant on the page where a person decides whether to accept a balance. formatMinor, behind the claim landing at /m/{token}, divided by 100 and glued a rupee sign to the front. A ¥6,000 debt printed as -₹60.00: wrong symbol, a hundredth of the amount. Its JSON sibling /v1/claims failed alongside it, flattening a member's per-currency rows into one and keeping whichever currency the map wrote last.
The third file is internal/ingestion/store.go, and W8 found it while extending duplicate detection rather than the audit. Its "plus or minus one rupee" tolerance was the constant 100, one rupee counted in paise, a band of plus or minus ¥100 where JPY has no minor unit at all. All three read the exponent now.
Comparing bare integers made a yen look like a rupee
Dimesum's dedupe layer checks a new capture against recent expenses so an imported receipt cannot double-charge an order somebody typed by hand. The query beside that tolerance band had no currency filter at all. So ¥1,000 matched ₹1,000, and an import offered to supersede an expense it had nothing to do with. Groups had been multi-currency since ledger/00005, so the case was reachable rather than theoretical.
Bare minor units are not comparable across currencies, and neither are tolerances. The projection filters on currency now, and derives its band from one major unit of the currency compared.
Two engines answered "who pays whom", and the second was currency-blind
The expensive defect is structural, not arithmetic. The settlement write path planned over internal/platform/simplify, a second min-cash-flow engine whose Balance struct had no currency field. Per-currency zero-sum makes the flat sum zero too, so a ¥300,000 debt and a ₹500 debt cancelled to nothing and no guard refused the plan. The plan then paired a yen creditor with a rupee debtor.
The write made it worse. The service stamped Currency: g.DefaultCurrency on every settlement, so a ¥300,000 debt authorised an INR journal, and the yen debt could not be cleared at all.
simplify is deleted and /settle-plan is retired. internal/platform/settle is the only engine now: it partitions currencies into convertible and unconvertible, converts net balances once, and routes each unconvertible currency in its own denomination. A settlement states the currency it clears, and that field is required once a group holds more than one.
A cap of zero read as no cap
The overpay guard refuses a payment larger than the debt it settles. The guard read outstanding > 0 && amount > outstanding, so a cap of zero skipped the comparison entirely. Somebody recording a payment against a debt that does not exist is the one case the rule exists for, and it was the case that sailed through.
The fix is a type, not a condition. OutstandingMinor is now a *int64, so "nobody computed this" cannot be spelled the same way as "the answer is zero". Nil skips the check and means genuinely unknown; any caller with ledger access passes a real number.
Why a green suite proved nothing
Every fixture in these paths used INR. A currency-blind comparison is invisible under a single-currency test, because with one currency there is nothing to confuse. The suites were not weak, they were narrow, and the audit that would have widened them had been sent away by A17.
The end-to-end ledger verifier shared the blindness, which is the part worth keeping. The verifier summed postings per member without grouping by currency, so it cried wolf on a healthy two-currency group and summed to zero across a group that was broken twice. A false negative is the dangerous direction. A checker built on the same assumption as the code will always agree with the code.
| Defect | Where | What a JPY group got | Fix |
|---|---|---|---|
A Balance struct with no currency on it | platform/simplify | a yen creditor paired with a rupee debtor | simplify deleted; settle partitions by currency |
| The group default stamped on every settlement | settlement service | a ¥300,000 debt authorised an INR journal | a settlement states the currency it clears |
outstanding > 0 in the overpay guard | settlement service | a cap of zero became no cap at all | *int64: nil means unknown, zero means zero |
| Divide by 100 with a rupee sign | gateway formatMinor | a ¥6,000 debt printed as -₹60.00 | symbol and decimals from the currency |
| Per-currency balances flattened to one row | /v1/claims | whichever currency the map wrote last | one entry per currency, matching GET /balances |
| Postings summed per member, currency dropped | end-to-end ledger verifier | a doubly-broken group reported as balanced | group by member and currency |
Grep your money code for the literal 100
Search it for that constant, and for every comparison that puts two amounts side by side without a currency beside them. Then fix the cheaper thing, the one that prevents the next six: strike a follow-up in the same commit that closes it. And delete a duplicate engine rather than repairing it. Two answers to "who pays whom" is how one of them stays wrong.
Common questions
What is multi-currency expense splitting?
Multi-currency expense splitting records each expense in the currency it happened in and holds a separate balance per currency instead of converting everything into one. Dimesum keys balances on group, member and currency, so a yen debt and a rupee debt never merge into a single number. Conversion is a view used for a settlement plan, never a stored amount.
Why is a hardcoded 100 dangerous in money code?
A hardcoded 100 assumes every currency has two decimal places, and several do not. JPY has no minor unit, so multiplying by 100 turns a ¥1,200 receipt into 120,000 minor units, a hundredfold inflation rather than a rounding error. KWD has three decimals, so the same constant is off by ten. Read the ISO 4217 exponent instead.
How do you stop two settlement engines from disagreeing?
Delete one of the two engines rather than reconciling them, because a second answer to who pays whom is how the first one stays wrong. Dimesum ran simplify and settle side by side until an audit found the first had no currency on its balance type, which let a plan pair a yen creditor with a rupee debtor. simplify was removed and /settle-plan retired rather than patched.
Why did the test suite stay green through six money bugs?
The test suite stayed green because every fixture in the affected paths used a single currency, and a currency-blind comparison cannot fail when there is only one currency. The end-to-end ledger verifier held the same assumption: it summed postings per member without grouping by currency, so it reported a doubly-broken group as balanced. A checker built on the code's own assumption agrees with the code.
What should you do with a follow-up entry once the feature ships?
Strike a follow-up entry in the same commit that closes the work it describes. A follow-up that has silently come true is worse than an open one, because it points a later reader away from the code it used to describe. Dimesum left an entry saying groups were INR-pinned for a week after multi-currency shipped, and the next audit skipped three money paths on its word.
Popular posts
- The append-only ledger that keeps split-expense balances exact6 min read
- Why editing a shared expense must restate the split5 min read
- Settle up: how to clear group expenses in fewer transfers6 min read
- How to split an itemized bill when one dish wasn't shared6 min read
- The rent split guide: divide rent with roommates fairly6 min read