dimesum

Home / Blog / Money

Money

Six money bugs in multi-currency expense splitting

· 6 min read ·

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.

The rule we adopted

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.

A 1,200 yen receipt converted by a hardcoded multiplier of 100 and by the ISO 4217 exponent table ¥1,200 currency: JPY BEFORE minor = major x 100 constant in the module 120000 minor units reads as ¥120,000 AFTER minor = major x 10^exp JPY exponent = 0 1200 minor units reads as ¥1,200
The exponent trap in one picture. A constant multiplier of 100 is correct for INR and wrong by a factor of 100 for JPY, which has no minor unit; the same constant is off by ten for KWD, which has three decimals.

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.

The same four balances routed by the currency-blind simplify engine and by the currency-aware settle engine BALANCES Asha +¥300,000 Bhavna -¥300,000 Chetan +₹500 Dev -₹500 simplify: Balance{MemberID, Minor} no currency on the struct, so all four net flat 300000 + (-300000) + 500 + (-500) = 0 plan: Bhavna pays Chetan a yen debtor sent to a rupee creditor settle: Balance{MemberID, money.Amount} partition by currency, then route inside each JPY: Bhavna pays Asha ¥300,000 INR: Dev pays Chetan ₹500 the settlement states the currency it clears simplify is deleted, not fixed
Four balances, two engines. Per-currency zero-sum implies a flat sum of zero, so a currency-blind engine sees a balanced group and confidently routes a payment between two people who owe each other nothing.

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.

Every defect the 2026-08-21 multi-currency audit found, what a yen group got, and what shipped.
DefectWhereWhat a JPY group gotFix
A Balance struct with no currency on itplatform/simplifya yen creditor paired with a rupee debtorsimplify deleted; settle partitions by currency
The group default stamped on every settlementsettlement servicea ¥300,000 debt authorised an INR journala settlement states the currency it clears
outstanding > 0 in the overpay guardsettlement servicea cap of zero became no cap at all*int64: nil means unknown, zero means zero
Divide by 100 with a rupee signgateway formatMinora ¥6,000 debt printed as -₹60.00symbol and decimals from the currency
Per-currency balances flattened to one row/v1/claimswhichever currency the map wrote lastone entry per currency, matching GET /balances
Postings summed per member, currency droppedend-to-end ledger verifiera doubly-broken group reported as balancedgroup 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.