CodeCrafting All articles
Build & Delivery

Where the Pennies Go: Modelling Sterling in Code Without Lying to Anyone

CodeCrafting
Where the Pennies Go: Modelling Sterling in Code Without Lying to Anyone

A finance colleague once spent a whole morning on a one-penny gap. Five line items, one discount, a partial refund, and an invoice that insisted the VAT was £8.01 when the ledger said £8.00. Nobody had written bad maths. Everybody had written slightly different maths, in four different places, and none of it was tested.

That is the shape of most billing bugs. They are quiet, they compound slowly, and they cost you credibility long before they cost you money. The good news is that they are almost always a modelling problem, and modelling problems are fixable.

Money is not a number

It looks like a number. It formats like a number. It is not one.

Floating point gives you the classic 0.1 + 0.2 = 0.30000000000000004, which is funny until it lands in a receipt. If your codebase has a column called price with type numeric(10,2) and your application layer turns it into a double, you have introduced a rounding decision that nobody owns.

Store pence. Integers. 1999, not 19.99.

type Pence = number; // still just a number, but now everyone agrees what it means

const coffee = 349;   // £3.49
const cake = 275;     // £2.75
const total = coffee + cake; // 624 → £6.24, no surprises

That is the whole trick, and it works until someone forgets. Which is why you should wrap it.

Give it a type and let the type do the work

A Money value object earns its keep in about a week. It carries the currency, refuses to add pence to pounds, and owns the rounding rule so that rule exists in exactly one place.

export class Money {
  private constructor(readonly pence: number, readonly currency: 'GBP' = 'GBP') {
    if (!Number.isInteger(pence)) throw new Error('Money must be whole pence');
  }

  static ofPence(p: number): Money { return new Money(p); }

  static parse(input: string): Money {
    const m = input.trim().match(/^£?(\d+)(?:\.(\d{1,2}))?$/);
    if (!m) throw new Error(`Not sterling: ${input}`);
    const pounds = Number(m[1]);
    const pence = Number((m[2] ?? '').padEnd(2, '0'));
    return new Money(pounds * 100 + pence);
  }

  plus(other: Money): Money { return new Money(this.pence + other.pence); }

  format(): string {
    const sign = this.pence < 0 ? '−' : '';
    const abs = Math.abs(this.pence);
    return `${sign}£${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;
  }
}

Note the minus sign. −£1.20 reads correctly; £-1.20 looks like a bug in the template. Once the type owns formatting, you fix that once.

VAT: inclusive or exclusive is a modelling decision

Here in the UK we have a 20% standard rate, prices that consumers expect to see inclusive of VAT, and a legal requirement for VAT invoices to show the VAT separately. That means you need both views, and you need to know which one is the source of truth.

Pick one and store it. We store VAT-exclusive amounts and derive the inclusive total, because that is what the accountant needs. Shops do the opposite. Either is fine; drifting between them is not.

const VAT_RATE = 0.2;
const roundHalfUp = (x: number) => Math.floor(x + 0.5);

// inc → net, rounding half up
const netFromGross = (gross: number) => roundHalfUp(gross / 1.2);

// Three £9.99 items, VAT inclusive
const line = 999;
const net = netFromGross(line);        // 833
const vat = line - net;                // 166
console.log(net * 3, vat * 3);         // 2499, 498 → total 2997 ✓

HMRC lets you round down to the penny per line or per invoice, as long as you are consistent. Consistency is the bit people skip. Write the choice down in an ADR and link it from the class.

The drift lives in the aggregation

Here is the one that bites. Three lines of £9.99 inclusive, each with VAT rounded down:

Same total, different split. Both legitimate. But if your invoice page computes VAT per line and your ledger computes it per invoice, your reconciliation report will show a penny that does not exist, and someone will open a ticket.

So write the invariant as a test:

// sum of line net + sum of line VAT must equal the invoice total
for (const basket of baskets) {
  const net = basket.lines.reduce((a, l) => a + l.net, 0);
  const vat = basket.lines.reduce((a, l) => a + l.vat, 0);
  expect(net + vat).toBe(basket.totalIncVat);
}

If that test fails, you have found the bug before finance has.

Discounts and refunds are where it really goes wrong

Percentage discounts are rounding events in disguise. A 10% discount on £19.99 is 199.9 pence. Do you round to 200 or 199? Now apply it to five lines and see the gap widen.

const discount = (amount: number, percent: number) => roundHalfUp(amount * percent / 100);

Then the rule that matters more than any of it: a refund must never create money. Refund per line, cap at the original captured amount, and recalculate VAT from the refunded net rather than reversing a percentage.

const refundLine = (line: Money, captured: Money) =>
  line.pence > captured.pence ? captured : line;

Display is not storage

Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }) is genuinely good — use it for user-facing labels. Just never let it anywhere near a calculation, and do not assume it agrees with your hand-rolled formatter on negatives, grouping, or the invisible space before the pound sign.

On printed receipts, use tabular figures so the columns line up. Small thing. Noticed by everyone.

The tests worth keeping

None of this is exotic. It is maybe half a day of work. The alternative is a morning of Alan from finance squinting at a spreadsheet, and that is the expensive version.

All Articles

Related Articles

When the Trains Stop, the Runbook Starts: Rehearsing Incidents on Disrupted Days

When the Trains Stop, the Runbook Starts: Rehearsing Incidents on Disrupted Days

The Tuesday That Wasn't: Teaching Your Scheduler About the UK Calendar

The Tuesday That Wasn't: Teaching Your Scheduler About the UK Calendar

Two Tills and a Back Room: Sizing Software for the Team You've Got

Two Tills and a Back Room: Sizing Software for the Team You've Got