The worst bugs in a finance app are not the ones that crash. They are the ones where the console stays clean, the page renders, the tests are green, and a number on the screen is simply wrong. We build a portfolio tracker for the Iranian market (gold, crypto, stocks, currencies, all in Toman), and these three bugs taught us the most.
1. Deposits that look like profit
The obvious way to compute a return over a period is (valueEnd - valueStart) / valueStart. It is also wrong the moment the user adds or removes money: a deposit shows up as a gain, a withdrawal as a loss.
Our first fix reconstructed cash flows from the user's current transaction journal and subtracted them. That was wrong too, for a subtle reason: the journal describes what the user holds now, but a historical snapshot is a record of the past. Deleting a transaction, editing it, back-dating one, or recording an off-market price all silently changed history.
What finally worked is using only facts stored inside each daily snapshot: the total value and the market-driven change of that day (price move times quantity held, which a trade cannot fake). Simplified:
// per day t, from stored snapshot facts only
const bookChange = v[t] - v[t - 1] - dayChange[t]; // money in/out, not the market
const base = v[t - 1] + Math.max(0, bookChange); // what the market move acted on
const r = dayChange[t] / base; // pure market return
growth *= 1 + r; // chain the days
A percentage return must be built from market movement, never from a difference of values.
2. "Last 7 days" that is sometimes 8
Daily rows are stored as day labels stamped at 00:00:00Z. Tehran is UTC+3:30, so between 00:00 and 03:30 UTC the local calendar day is still "yesterday" while the UTC clock has moved on. A filter like date >= now() - interval '7 days' then admits one extra day-key.
Same button, same user, same local day, two different answers depending on the hour. In an audit across user and range combinations, the row count differed between two moments of the same local day for most of them.
-- wall-clock window: leaks an extra day near midnight UTC
WHERE day >= now() - interval '7 days'
// day-grid window: start at the local day boundary
const start = startOfLocalDay(now, "Asia/Tehran") - (days - 1) * DAY;
The lesson that stuck: a range over day-labelled rows must be cut on the day grid, not the wall clock. The bug came back in six different places before we stopped trusting memory and wrote a guard test that fails on any now() - interval or Date.now() - N * DAY inside the range-building files.
3. Saying nothing is better than a guess, but silence needs a reason
When there is too little history for a statistic (a benchmark comparison, an engagement score, a seasonality hint), printing a confident number is a lie, and printing nothing looks like a bug. We settled on a rule: every code path that stays silent returns a human-readable reason, and the UI shows it. A dash with no sentence gets reported as broken.
What actually prevented regressions
- Break it on purpose. A new guard test is not proven until you break the thing it guards and watch it go red. Several of our "green" tests had never seen a failure.
- Compare against the real product input. Our audit script once computed the range start from the first row in the response instead of from the product's own range function, and reported fourteen phantom inconsistencies. Verify the tool's inputs before believing its output.
- Equivalence tests for duplicated logic. Where a rule exists twice (for example once in JS and once in SQL), a test asserts they agree.
- Silent failure first. When changing anything ask: if this breaks, who finds out and how? If the answer is "nobody", add a test right there.
If you are curious what this looks like in a real product, the tracker is called Mitori. Disclosure: I work on it, the interface is in Persian (right-to-left), and it is a paid app after a 7-day trial. The lessons above apply to any app that turns stored numbers into percentages.
Top comments (0)