None of My Billing Bugs Crashed Anything

Software Development

Type 125.40 into a payment field and the app recorded $12,540.

No error, no validation warning. A hundred times the money, formatted neatly with a thousands separator, sitting there looking completely reasonable.

I found that on Sunday, a few hours into auditing the billing side of a forecasting app I build for a property client. They buy, renovate, build and sell, so the app tracks feasibility on each job, progress claims against builder contracts, and what the whole portfolio is owed at any given moment. Every number in it has real money behind it.

The bug was one character class.

const digits = raw.replace(/\D/g, '');
const parsed = digits === '' ? 0 : parseInt(digits, 10);

\D means “not a digit”. A decimal point is not a digit. So the field stripped the dot, glued the cents onto the dollars, and parseInt returned a number a hundred times too big. Paste 1,234.56 straight out of a spreadsheet and you get 123,456.

I wrote that to stop people typing letters into a money field, and it did stop them. It also did this, for as long as the field had existed, without ever once looking broken. There’s no exception to catch. The input formats a wrong number with exactly the same care it would have given the right one.

That turned out to be the theme of the whole day. Every bug I found had the same shape. Nothing crashed, nothing landed in a log, the figure was just wrong.

The next one was that the Billing page and the Forecast page disagreed about how much a single job owed. Billing worked out the money owed by taking everything invoiced on the job and subtracting everything paid, which sounds correct and isn’t. Payments in this app get recorded against a stage, and a stage can be paid before anyone has invoiced it. When that happened the payment subtracted itself from the amount owing on unrelated stages, and the job looked healthier than it was.

Both pages now accumulate per stage off one shared stageIsClaimed predicate. Two pages doing their own arithmetic over the same data is how you end up with two answers, and you believe whichever one you happen to be looking at.

Then I found that marking a job Sold deleted its debt.

The job could have invoiced claims still sitting unpaid, and the moment someone flipped the status to Sold, the portfolio debt figure dropped by that exact amount. Debt nobody had paid, gone from the total the business uses to know where it stands. Sold jobs with unpaid claims now stay in the awaiting-payment queue with a Sold tag on them, where somebody can actually chase them.

The holding costs one is subtler and cost me the most time. When the app models a unit purchase, it spreads interest and land tax over a number of months you enter. The feasibility summary charged the full entered count. The cash flow schedule ended the spread at the sale month instead.

Line those up and they agree, so it looked fine every time I checked it. A unit that sat on the market longer than planned drained up to double the holding costs the summary had charged for it, and a quick sale drained almost none. The bug only appears when reality diverges from the plan, which is the exact moment you need the forecast to be right.

There was double-counting too. The working capital reserve was being lifted by outstanding claims on units, except a unit’s renovation cash is already scheduled in full as build outflows further down. Same money, counted twice, making the reserve look bigger than it was.

And the one that would have been ugliest in practice: two stages on a contract sharing a name could both inherit the same payment ledger during a rebuild. Duplicate stage names are rejected outright now, and a rebuild consumes each existing stage at most once. Saving an active job also re-reads it inside a transaction, so a save can no longer clobber a payment someone recorded while the job was open in another tab.

None of these were exotic. They’re what happens when the same arithmetic gets written twice in two places, or a status field gets treated as an ending. You write it on a Tuesday and never think about it again, because the app keeps working.

That’s what makes money software different to most of what I build. When a page throws, I find out. Something goes red, somebody tells me, it’s in a log. When a page confidently reports a number that’s out by a hundred, the app looks perfect and the mistake propagates into a decision someone makes about hundreds of thousands of dollars.

The holding cost bug is pinned by a property test now, one that varies the sale date independently of the month count. That’s the only sort of test that would have caught it. Any example I’d have written by hand would have put the sale exactly where the plan said, because that’s where I was picturing it, and it would have passed happily for years.

I’ve also stopped treating “does it throw” as the bar for the calculation layer. The useful question is which two places compute the same figure, and whether anything makes them agree apart from me having written both of them carefully on the same afternoon.

None of this is in an error log anywhere. I only found it by going number by number and asking each one where it came from.