Modelling real data without a browser · Lesson 59 of 77 · project
Project: modelling an invoice
About 65 minutes.
By the end of this lesson you can
- Design a shape for a real document and justify each level of nesting.
- Explain why money is stored in whole pence rather than in pounds.
- Compute totals from a nested shape without mutating it.
- Produce a changed version of a nested document, leaving the original intact.
The brief
Explanation
An invoice has an id, a currency, a VAT rate, and one or more lines. Each line has its own id, a description, a quantity and a unit price. The program has to total it, apply a discount to a single line, and be able to save the result as text and read it back unchanged.
Say the shape out loud before typing, as in lesson 1: different kinds of fact about one document, so the invoice is an object; the lines are all the same kind and you will count and total them, so lines is an array; each line is different kinds of fact about one item, so a line is an object. An object containing an array of objects — the same three shapes as everything else in this module.
One design decision is not obvious and is the subject of the next section: how to store the money.
Never store money as a decimal number
Why it matters
JavaScript numbers are IEEE 754 double-precision floating point values. They store binary fractions, and most decimal fractions — including 0.1 and 0.2 — have no exact binary form, so they are stored as the nearest value that does. That is why 0.1 + 0.2 does not equal 0.3.
The error is tiny. It is also cumulative and unforgiving: total a few hundred lines of pounds-and-pence and the answer can end up a penny off, which is the kind of bug that gets noticed by an accountant rather than by a test.
The fix is not a rounding trick, it is a shape decision. Store money as a whole number of the smallest unit — pence, cents, satoshi — and never store a fraction at all. 10 + 20 === 30 is exactly true, because whole numbers up to about nine quadrillion are stored exactly. Convert to pounds only at the very last moment, for display.
Name the field so the unit is impossible to miss: unitPricePence, not price. A field called price invites someone to put 6.5 in it, and the day they do, nothing will complain.
Where rounding is allowed to happen
A mental model
Whole pence in and whole pence out, but a percentage in the middle produces a fraction, so exactly one rounding step is unavoidable: VAT at 20% on 1999 pence is 399.8. Math.round turns that into 400, rounding to the nearest integer with halves going up towards positive infinity.
Round once, at the point the fraction appears, and never again. Rounding a value that is already whole is harmless but it is also a sign that you have lost track of which values are exact — and rounding twice at different points is how two parts of a system come to disagree by a penny.
For display, toFixed(2) is the usual tool, but be aware it returns a STRING, not a number. That is normally what you want for showing a total; it is never what you want for storing one.
There is a real, deliberate choice hidden here that this project does not make for you: should VAT be worked out on the whole subtotal, or per line and then summed? The two can differ by a penny. Whichever you pick, pick it once and write it down, because a rule that is written down can be checked and a rule that lives in someone's head cannot.
Derive totals, do not store them
A common mistake
It is tempting to put a total property on the invoice. Resist it. A stored total is a second copy of a fact that is already implied by the lines, and the moment a line changes, the two disagree — and nothing in the program will tell you which one is right.
Compute totals from the lines whenever you need them. It is cheap, and it cannot go stale. This is the same reasoning as the derived-index warning in lesson 3: a value computed from other values is a view, not a truth, and views belong close to where they are used.
If a total genuinely must be stored — because a customer was shown that number and it is now a historical fact — then that is a different fact from "the sum of the current lines", and it deserves its own honest name, such as totalAtIssuePence.
What this project pulls together
Recap
Lesson 1 chose the shape: an object containing an array of records. Lesson 2 makes it saveable, and warns that a Date in an invoice will come back as text. Lesson 3 would give you a by-id lookup of lines when you need one. Lesson 5 supplies the way to change a single line without disturbing the document. Money in whole pence and a single deliberate rounding step are the parts specific to this problem.
The two exercises below are the two operations every document model needs: read it to produce a summary, and write it to produce a new version. Neither is allowed to change what it was given.
Worked examples
Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.
Why the money is in pence
Four lines that show the problem and one that shows it disappearing. Nothing here is a rounding trick; the last line simply never creates a fraction.
1console.log(0.1 + 0.2 === 0.3);2console.log(0.1 + 0.2);3console.log((0.1 + 0.2).toFixed(2));4console.log(typeof (0.1 + 0.2).toFixed(2));5console.log(10 + 20 === 30);- line 1
false, and this is not a bug in JavaScript — it is what binary fractions do. Any language using IEEE 754 doubles gives the same answer.
- line 2
The actual stored value, printed in full. The error is about one part in ten thousand million million, which is invisible until you add up a few hundred of them.
- line 3
toFixed(2) hides it for display, giving "0.30". Useful at the edge of the program, useless as a storage strategy.
- line 4
And note what toFixed returns: a string. Storing that back into a total field would turn later arithmetic into string concatenation.
- line 5
Whole numbers are exact. Ten pence plus twenty pence is thirty pence, with no qualification. That is the entire argument for storing pence.
What it prints
false0.300000000000000040.30stringtrue
The shape, and totalling it
The full document, then a total derived from it rather than stored on it. Every amount stays a whole number of pence until the very last line, which formats for display.
1const invoice = {2 id: "INV-1042",3 currency: "GBP",4 vatRate: 0.2,5 lines: [6 { id: "L1", description: "Design", quantity: 3, unitPricePence: 6500 },7 { id: "L2", description: "Hosting", quantity: 1, unitPricePence: 1200 },8 ],9};10 11function lineTotalPence(line) {12 return line.quantity * line.unitPricePence;13}14 15function formatPence(pence) {16 return "GBP " + Math.floor(pence / 100) + "." + String(pence % 100).padStart(2, "0");17}18 19const subtotalPence = invoice.lines.reduce((sum, line) => sum + lineTotalPence(line), 0);20const vatPence = Math.round(subtotalPence * invoice.vatRate);21 22console.log(subtotalPence);23console.log(vatPence);24console.log(formatPence(subtotalPence + vatPence));25console.log(JSON.stringify(invoice.lines.map((line) => line.id)));- line 4
The VAT rate is a genuine fraction and belongs on the document, because a different invoice may be at a different rate. It is the one non-integer here.
- line 11
A whole number times a whole number is a whole number, so a line total is exact with no rounding anywhere.
- line 15
Formatting lives in its own function, at the edge. Math.floor gives the whole units and the remainder gives the pence, padded to two digits.
- line 19
reduce walks the lines and accumulates. Starting at 0 matters: it is what an empty invoice totals to, and without it reduce throws on an empty array.
- line 20
The single rounding step, at the only place a fraction appeared. 20700 x 0.2 is 4140 exactly here, but the round is still correct to write.
- line 24
No total was ever stored on the invoice. Both numbers were derived, so neither can be stale.
What it prints
207004140GBP 248.40["L1","L2"]
Changing one line, leaving the document alone
Lesson 5's pattern applied to a nested document: a new lines array, a new object for the one line that changed, and everything else exactly as it was.
1const invoice = {2 id: "INV-1042",3 lines: [4 { id: "L1", description: "Design", unitPricePence: 6500 },5 { id: "L2", description: "Hosting", unitPricePence: 1200 },6 ],7};8 9const discounted = {10 ...invoice,11 lines: invoice.lines.map((line) =>12 line.id === "L1"13 ? { ...line, unitPricePence: Math.round(line.unitPricePence * 0.9) }14 : line,15 ),16};17 18console.log(invoice.lines[0].unitPricePence);19console.log(discounted.lines[0].unitPricePence);20console.log(discounted.lines[1] === invoice.lines[1]);21console.log(JSON.stringify(Object.keys(discounted.lines[0])));- line 10
Copy the document first. Without this the new lines array would have to be attached to the old object, which means mutating it.
- line 11
Override lines with a NEW array. Spreading the invoice alone would have shared the original array, and mapping it is what breaks that share.
- line 13
A new object for the line that changed, with one property overridden. The multiplication produces a fraction in general, so it is rounded here — once.
- line 14
Lines that did not match are reused unchanged. Copying them would be wasted work and would destroy the comparison on the third output line.
- line 20
true: the untouched line is literally the same object in both documents, which is how a caller can tell at a glance what changed.
- line 21
Overriding an existing property leaves it in its original position, so the record's field order survives the update.
What it prints
65005850true["id","description","unitPricePence"]
Check yourself
Not scored, not stored. Getting one wrong is the useful part.
Two facts about numbers that every money calculation depends on. What do these three lines print?
console.log(0.1 + 0.2 === 0.3);console.log(10 + 20 === 30);console.log(Math.round(0.5), Math.round(1.5), Math.round(2.5), Math.round(-1.5));
Why should an invoice object not carry a totalPence property alongside its lines?
What is (1250 / 100).toFixed(2), and what type is it?
Write it yourself
Exercise · core · 7 tests
Total the invoice
Write invoiceTotals(invoice) that reads an invoice of the shape { vatRate, lines: [{ quantity, unitPricePence }, ...] } and returns a summary object { subtotalPence, vatPence, totalPence } in that property order. The subtotal is the sum of quantity times unitPricePence over every line. VAT is the subtotal times vatRate, rounded to the nearest whole penny. The total is the subtotal plus the VAT. Do not store anything on the invoice and do not change it.
Returns a new object with exactly the properties subtotalPence, vatPence and totalPence, in that order, all whole numbers. An invoice with no lines totals to zero for all three. VAT is rounded exactly once, with Math.round, at the subtotal. The invoice passed in is not modified in any way. The tests compare JSON text, so property order is part of the contract.
Define invoiceTotals at the top level of your code — the tests call it by name.
Exercise · stretch · 8 tests
Discount one line of the document
Write withDiscountedLine(invoice, lineId, percentOff) that returns a NEW invoice in which the line with the matching id has its unitPricePence reduced by percentOff percent, rounded to the nearest whole penny. Everything else about the invoice is unchanged, and the invoice you were handed — including every line object inside it — must be exactly as it was when the function returns. If no line matches, return an equal copy.
Returns a new invoice object, never the one passed in, carrying all the original top-level properties in their original order and a new lines array. The matching line is a new object with unitPricePence set to Math.round(unitPricePence * (100 - percentOff) / 100) and its property order preserved. Non-matching lines are the very same objects as before. An unknown id gives an equal copy. The original invoice and its lines are untouched.
Define withDiscountedLine at the top level of your code — the tests call it by name.
Worth remembering
- Why store money as whole pence rather than as pounds?
JavaScript numbers are IEEE 754 doubles, so decimal fractions such as 0.1 are not exact and errors accumulate. Whole numbers are exact. Convert to pounds only for display, and name fields so the unit is unmissable.
- Where should rounding happen in a money calculation?
Once, at the single point a fraction is created — such as applying a percentage. Sums of whole numbers are already exact, and rounding them again hides which values you still know to be exact.
- Why not store a total on the document?
It duplicates a fact the lines already imply, so it goes stale silently the moment a line changes. Derive it. A total that records what a customer was shown is a different fact and needs a different name.
Check this lesson against the source
We wrote the explanation; we did not invent the facts. This is the page that backs them.
Check there: The Description section, which states that JavaScript numbers are double-precision 64-bit IEEE 754 values, so decimal fractions such as 0.1 cannot be represented exactly.
Check there: That Math.round returns the value rounded to the nearest integer, and that a value exactly half way is rounded up, towards positive infinity.
MDN — Number.prototype.toFixed()
Check there: That toFixed returns a string representation of the number, not a number.
Prefer to read the source in a structured breakdown? Turn this doc into a breakdown