A course advertised at 100 times its price
The page said ₹9,900 and 90% off. Checkout then asked for ₹99. One service reports money in paise, another divides by 100, and only one client converted.
By Meet ModiStoring money in minor units (paise, cents) as an integer is the right call, and it is also how you get a 100x display bug: the moment two services in the same response disagree about whether a number is already converted, every client has to know which side of that boundary it is on. Ours did not, so a course priced at ₹99 advertised itself at ₹9,900 with a 90% discount badge.
What the user saw
A course detail page reading "₹9,900, 90% OFF". Tap buy, and the payment sheet asks for ₹99. Nobody complained, which tells you something about who benefits from a mistake in that direction, but it is still a page making a false claim about a price and a discount that does not exist.
Why integers, and why this follows from them
Money is stored in the smallest unit as an integer because binary floating point cannot represent 0.1, so a float balance drifts the moment you add enough of them together. ₹99.00 is stored as the integer 9900 paise. This is correct and I would not change it.
The cost is that every integer now needs to be accompanied by the knowledge of which unit it is in, and that knowledge does not travel in the JSON. 9900 is a perfectly good rupee amount and a perfectly good paise amount.
The boundary nobody had written down
Two upstream services fed the same screen.
- The course-detail service forwarded its own storage verbatim: paise.
- The catalogue service built its products through a shared builder that divides by 100: rupees.
So the unit depended on which endpoint a number came from, and nothing in either payload said so. The native client happened to be correct because, buried in one screen, it divided by a divisor at exactly this boundary and nowhere else. That line was the entire specification for the conversion, and it existed in one client, in one file.
// What the web page did: passed it straight through.
formatPrice(detail.amount) // 9900 paise -> "₹9,900"
// What the one correct client did, in one screen, with no comment:
formatPrice(detail.amount / divisor) // -> "₹99"The conversion, with the cases that bite
// Not every currency has two decimal places. JPY and KRW have zero,
// so dividing by 100 turns ¥9900 into ¥99.
const MINOR_UNIT_DIGITS: Record<string, number> = { JPY: 0, KRW: 0, VND: 0 };
export function minorToMajor(amount: number | null | undefined, currency = "INR") {
// null, not 0. An absent price is not a free course, and rendering
// "₹0" is a worse lie than rendering nothing.
if (amount == null) return null;
const digits = MINOR_UNIT_DIGITS[currency] ?? 2;
return amount / 10 ** digits;
}The zero-decimal currencies are the part people miss. There are about two dozen of them and ISO 4217 lists the exponent for each, so this is a table lookup rather than a judgement call.
Returning null for an absent amount rather than 0 is the other piece. A missing price and a free course are different facts, and only one of them should render a number.
The half I did not fix
The catalogue cards show the same course at ₹9,900 too, and that one is not a client bug. The backend forwards that vendor's paise verbatim while every other vendor goes through the dividing builder, so the data is already wrong before any client sees it. The native app has the identical mismatch between its card and its detail screen.
I left it, deliberately. Fixing it server-side changes what the shipped mobile app renders, including versions already installed, and a client that was silently compensating would start showing ₹0.99. That is a release-coordination decision, not a patch, so it got written up and handed over rather than slipped into a frontend PR.
How to not have this bug
- Name the unit in the field: amount_minor, not amount. A reader should never need the service's source to know what they are holding.
- Send the currency with every amount. The exponent depends on it and you cannot assume two.
- Convert at exactly one layer, in one shared function, and make every client call it.
- In a response that merges several upstreams, normalise before merging. A payload where field A is paise and field B is rupees is a trap with a long fuse.
This is the same failure as one field in three different envelopes, with arithmetic instead of nesting: the shape was fine and the meaning was not, and nothing in the type system had an opinion either way.
More Posts
Two services, one topic, either deploy order
A shared topic is only safe if the old consumer ignores what the new producer adds. One field with a default, and either side can roll back alone.
Scale on what's queued and you'll remove the pod mid-job
An autoscaler counting queued work drops to zero the moment pods claim it, so it scales into running jobs and each removal hands the work back to start over.
The model was starting and the first job didn't know
A state flag set inside the background thread instead of before launching it, so the caller that asked for the model read 'stopped' and took the slow path.