DEV Community

ShipInAnHour
ShipInAnHour

Posted on

Your AI-built budget app says you're -$0.00 over budget: 6 money bugs in JavaScript (and the prompts that fix them)

AI coding assistants usually store money in a budget app as a plain JavaScript number, and that one choice causes most of the small, embarrassing bugs people find after the app "works." None of them crash anything. The totals just come out a fraction of a cent wrong, and the UI turns that fraction into a red label.

The budget app prompts I started from are in a free sample if you want them.

Here are six of them, what the assistant usually writes, and a prompt line for each fix. We ran the first five in Node (v24). The examples come from the kind of expense tracker you'd build in an afternoon with Cursor or Claude: expenses, categories, monthly limits, a "remaining" number, and a progress bar.

1. $1.10 + $2.20 is over a $3.30 budget

You've probably seen 0.1 + 0.2 before. It's 0.30000000000000004, because numbers are binary floating point and most decimal cents can't be stored exactly. The part that matters for a budget app is what happens next.

The typical generated code looks like this:

const spent = expenses.reduce((t, e) => t + e.amount, 0);
const remaining = limit - spent;
const pct = (spent / limit) * 100;

<span className={remaining < 0 ? "text-red-600" : ""}>{formatMoney(remaining)}</span>
<div className={pct > 100 ? "bg-red-500" : "bg-green-500"} style={{ width: `${Math.min(pct, 100)}%` }} />
Enter fullscreen mode Exit fullscreen mode

The budget page in our test app does exactly this. We ran the same math in Node with its own formatMoney and parseAmount helpers, using two expenses of $1.10 and $2.20 against a $3.30 limit:

spent:     3.3000000000000003
remaining: -4.440892098500626e-16   -> red
shown as:  "-$0.00"
pct:       100.00000000000003        -> red bar
Enter fullscreen mode Exit fullscreen mode

So the user spent exactly their budget and the app says they're over it, by negative zero dollars. It goes the other way too: ten $0.10 charges add up to 0.9999999999999999, so a "you've hit $1.00" check never fires.

The fix is to stop doing arithmetic on fractional dollars. Store integer cents (1999 for $19.99), add and compare integers, and only divide by 100 when you format. Integers up to Number.MAX_SAFE_INTEGER add exactly, which covers any personal budget.

Store every amount as an integer number of cents (amountCents: number). All sums, comparisons, and "remaining" math use cents. Convert to dollars only inside formatMoney(cents).

If you already have data stored in dollars, a migration step can do Math.round(amount * 100) once on load. That's safe for values that were already rounded to 2 decimals.

2. Rounding with Math.round(n * 100) / 100 or toFixed

When you ask for "round to 2 decimals," you'll usually get one of these two:

Math.round(1.005 * 100) / 100   // 1, not 1.01
(1.005).toFixed(2)              // "1.00"
(2.675).toFixed(2)              // "2.67"
Enter fullscreen mode Exit fullscreen mode

1.005 * 100 is 100.49999999999999, so it rounds down. Our test app's parseAmount uses that exact round2 helper, and parseAmount("1.005") returns 1. For a budget app that's a minor surprise, but it's a sign the rounding happens in the wrong place.

toFixed has a second trap: it returns a string. Once a string amount gets into state, you get things like:

["10.00", "9.50", "100.00"].sort()   // ["10.00", "100.00", "9.50"]
"10.00" + "9.50"                     // "10.009.50"
Enter fullscreen mode Exit fullscreen mode

The fix is to never round a float to get cents. Parse the text the user typed straight into cents, and use toFixed or Intl only for display.

Never use toFixed or Math.round(n * 100) / 100 for stored values. toFixed is display-only and returns a string.

3. parseFloat accepts things that aren't amounts

The quick version of "read the amount field" is parseFloat(value) or Number(value). Both are more forgiving than you want:

parseFloat("12abc")      // 12
parseFloat("1,299.00")   // 1
Number("")               // 0
Number("  ")             // 0
Number("1e3")            // 1000
Number("0x10")           // 16
Number("12,50")          // NaN
Enter fullscreen mode Exit fullscreen mode

The 1,299.00 one hurts. Someone pastes a price from a receipt and the app records $1. An empty field becoming 0 is how you get $0.00 expenses that slip past an isNaN check.

A small parser that only accepts the shapes you expect, and returns cents directly, fixes both this and #2:

export function parseCents(input: string): number | null {
  const m = /^\s*(\d+)(?:\.(\d{1,2}))?\s*$/.exec(input);
  if (!m) return null;
  const cents = Number(m[1]) * 100 + Number((m[2] ?? "").padEnd(2, "0"));
  return cents > 0 ? cents : null;
}
Enter fullscreen mode Exit fullscreen mode

In Node, "19.99" gives 1999, "12.5" gives 1250, and "1.005", "12abc", "", "1e3" and "0" all give null. And $1.10 plus $2.20 becomes 110 + 220, which is exactly 330.

Parse amounts with one parseCents(input) helper: digits, optional dot, at most 2 decimals, greater than zero. Reject everything else with an inline error. Don't use parseFloat or Number() on raw input.

Whether commas count as thousands separators or as a decimal mark depends on your users, so decide that on purpose instead of letting parseFloat decide.

4. Formatting: negative zero, and currencies that aren't USD

Even with cents, it's worth knowing what Intl.NumberFormat does with edge cases, since the assistant tends to reach for it:

const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
usd.format(-0)    // "-$0.00"
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", signDisplay: "negative" }).format(-0)  // "$0.00"
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", currencySign: "accounting" }).format(-12.5)  // "($12.50)"
Enter fullscreen mode Exit fullscreen mode

signDisplay: "negative" drops the sign on zero, which is a cheap safety net. The bigger issue shows up when the app grows a currency setting. Not every currency has 2 decimals:

new Intl.NumberFormat("en-US", { style: "currency", currency: "JPY" }).resolvedOptions().maximumFractionDigits  // 0
// KWD -> 3, USD and TWD -> 2
Enter fullscreen mode Exit fullscreen mode

If the code hard-codes / 100 and .toFixed(2), a yen amount is off by a factor of 100 or shows decimals the currency doesn't have. Formatting 1234.5 as JPY gives ¥1,235, so a fractional yen amount gets rounded silently too. For a personal tracker, the simple rule is to store the minor-unit count and read the digit count from Intl rather than assuming 2.

Create one formatter per currency with Intl.NumberFormat, signDisplay: "negative". Don't hard-code 2 decimals; use resolvedOptions().maximumFractionDigits if the app supports more than USD.

5. Percentages with no budget set

Progress bars divide by the limit, and new categories don't have one yet:

50 / 0 * 100            // Infinity
0 / 0 * 100             // NaN
Math.round(NaN) + "%"   // "NaN%"
Enter fullscreen mode Exit fullscreen mode

Math.min(Infinity, 100) quietly caps the first case at a full bar, which looks like the user blew a budget they never set. NaN ends up as width: NaN% and a "NaN%" label. Our test app guards this (limit ? (spent / limit) * 100 : 0, and the summary shows a dash when there are no limits) because the prompt asked for "No budget set" explicitly. If yours didn't say that, check it.

If a category has no limit (or a limit of 0), don't compute a percentage. Show "No budget set" and no bar. Never render NaN or Infinity.

6. Adding up different currencies

A common "next feature" is letting each expense have its own currency. Adding a currency code field is easy. The trap is that every total, summary card, and chart still does reduce((t, e) => t + e.amount, 0) across all of them, so $20 plus €20 shows as "$40.00."

Unless you add exchange rates (which is a bigger decision than it looks: which rate, from which date, and what happens offline), the honest version is to group by currency.

Every total, budget, and chart is per currency. Never add amounts with different currency values. If a month has more than one currency, show one total per currency.

Put the rules where the assistant reads them every time

Each of these fixes tends to get undone the next time you ask for a feature, because the assistant falls back to its default habits. Put them in your project rules file (.cursor/rules, CLAUDE.md, or AGENTS.md) as one block:

Money rules for this project: store amounts as integer cents. Parse input only with parseCents. Do all sums and comparisons in cents. Format only with formatMoney(cents, currency) using Intl.NumberFormat with signDisplay: "negative". No parseFloat, no toFixed for stored values, no percentages when the limit is missing or 0, and no totals across different currencies.

Then ask it to check its own work with numbers that break floats: add $1.10 and $2.20 against a $3.30 limit and confirm the remaining amount reads $0.00 with no red and the bar isn't red. Ten $0.10 expenses should hit a $1.00 limit exactly. It's a quick check, and it catches the float bug from #1 before a user does.

If you want the step-by-step prompts for the whole budget app these examples come from, the free sample of the Vibe Budget App prompt pack has the first few prompts and checkpoints.

Top comments (0)