DEV Community

KMJ Tire Calgary
KMJ Tire Calgary

Posted on

Money Math for a Tire Quote Engine: Integer Cents, One Rounding Policy, and a Paper Trail You Can Replay

Money Math for a Tire Quote Engine: Integer Cents, One Rounding Policy, and a Paper Trail You Can Replay

Quoting four winter tires sounds like arithmetic a ten-year-old could do: unit price times four, add the install labour, add the per-tire fees, add tax, done. Then you try to build it as software, and the ten-year-old's version starts disagreeing with the accountant's version by a cent. Sometimes two cents. Sometimes it disagrees with itself depending on which order the line items were entered. This article is about making that impossible by construction.

The ground rules: real constraints, illustrative system

Before anything else, the framing, stated plainly so nobody mistakes design for deployment. KMJ Tire is a real, working tire business in Calgary — the operational constraints in this article (tire quantities sold in twos and fours, per-tire environmental fees, 5% GST, "out-the-door" totals quoted to customers before any work happens) are its genuine daily reality. The quoting system described here, however, is a design exercise: a worked-through architecture for how such a system should handle money, not a description of live production infrastructure. Every dollar figure, every tire price, every fee amount in this article is illustrative — invented for the worked examples, not pulled from a price list. There are no incident reports here, no production metrics, no adoption numbers, because none exist. What you get instead is the interesting part: the reasoning.

Why is a tire quote a good vehicle for this topic? Because it is small enough to hold in your head and gnarly enough to exercise every classic money-handling failure. A single quote for winter tires composes at least five kinds of amounts — a unit price, a quantity multiplier, a flat per-unit levy, a labour charge, and a percentage tax — and the customer expects one number at the end that will still be the same number when they arrive to pay. That expectation, "the total you quoted is the total I pay," is the entire correctness contract. Everything below serves it.

Floating point is the wrong tool, and here is the receipt

Start with the demonstration everyone has seen and half of us have shipped anyway:

>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False
Enter fullscreen mode Exit fullscreen mode

IEEE 754 doubles represent numbers in binary fractions. One tenth has no finite binary expansion, the same way one third has no finite decimal expansion. So 0.1 is actually the nearest representable double, which is 0.1000000000000000055511151231257827…. Each arithmetic operation rounds its result to the nearest representable value, and those tiny errors are not random noise that cancels out — they accumulate directionally depending on the magnitudes and order of your operations.

For a quote engine the failure mode is subtler than the meme. You will not see $1,203.30000000000004 on an invoice, because everyone formats output to two decimals. The formatting hides the drift until two independently correct code paths disagree. Consider an illustrative quote line: a tire at $247.99 times 4 is 991.96. In doubles:

>>> 247.99 * 4
991.9599999999999
>>> round(247.99 * 4, 2)
991.96
Enter fullscreen mode Exit fullscreen mode

Fine so far — round rescued it. Now let a percentage enter the picture. Apply an illustrative 3.5% adjustment to that extended amount, then 5% GST, and compare against computing the same thing in a different but algebraically equivalent order:

>>> a = round(round(247.99 * 4 * 0.965, 2) * 1.05, 2)
>>> b = round(247.99 * 4 * 0.965 * 1.05, 2)
>>> (a, b)
(1005.11, 1005.1)
Enter fullscreen mode Exit fullscreen mode

One path says $1,005.11, the other $1,005.10. Both are "correct" implementations of an underspecified calculation. Neither is buggy in the compiler's eyes. The bug is that the specification never said where rounding happens, so two developers made two defensible choices and now the emailed quote disagrees with the point-of-sale screen by one cent. A cent sounds trivial until the accounting export refuses to reconcile, or until a customer notices the number changed between the estimate and the invoice — for a small business whose whole pitch is straight-dealing, that cent is reputational, not financial.

Two lessons fall out of this before we write a single type:

  1. Binary floating point cannot faithfully represent decimal money. This is not a precision problem you can out-round; it is a representation mismatch.
  2. Even with exact representation, the order and location of rounding is a policy decision that must be written down once and enforced everywhere, or equivalent formulas will diverge.

The rest of the article is those two lessons, taken seriously.

Integer cents: the representation that cannot lie

The boring, bulletproof fix: store money as an integer count of the smallest currency unit. In Canada that is the cent. $247.99 becomes 24799. Integer addition and multiplication are exact — a 64-bit signed integer holds about 92 quadrillion cents, which comfortably exceeds the gross domestic product of tires.

But a naked number or int labelled "cents" in a comment is an invitation to disaster, because someone will eventually add a cents value to a dollars value, or multiply two money values together (dollars-squared is not a currency). The representation wants a type. In TypeScript, a branded type plus a tiny algebra:

// A branded integer type: structurally a number, nominally Money.
type Cents = number & { readonly __brand: "Cents" };

function cents(n: number): Cents {
  if (!Number.isSafeInteger(n)) {
    throw new RangeError(`Money must be an integer cent count, got ${n}`);
  }
  return n as Cents;
}

const add = (a: Cents, b: Cents): Cents => cents(a + b);

// Multiplying money by a scalar quantity is legal…
const times = (a: Cents, qty: number): Cents => {
  if (!Number.isSafeInteger(qty)) throw new RangeError("quantity must be integral");
  return cents(a * qty);
};

// …but note there is deliberately no `multiply(Money, Money)`.
Enter fullscreen mode Exit fullscreen mode

The point of the brand is that add(unitPriceCents, taxRateBasisPoints) fails to type-check. The compiler now enforces dimensional analysis: money plus money is money; money times a dimensionless integer is money; money times money is a compile error. Rates get their own representation — and here is a second trap worth naming. If you store 5% as the float 0.05, you have reintroduced binary fractions through the back door. Store rates as integer basis points (5% = 500 bp) or as an integer numerator over a fixed denominator, and define exactly one function that applies a rate to a money value:

type BasisPoints = number & { readonly __brand: "BasisPoints" };

/** The ONLY place a rate touches money. Rounding policy lives here. */
function applyRate(amount: Cents, rate: BasisPoints): Cents {
  const numerator = amount * rate;         // exact: integer * integer
  return roundDiv(numerator, 10_000);      // one documented rounding, see below
}
Enter fullscreen mode Exit fullscreen mode

amount * rate is exact integer arithmetic; the only inexact step is the division by 10,000, and that step is centralized in one function whose rounding behaviour is documented and tested. This is the structural move that makes the whole design work: push every rounding decision into a single choke point. When rounding can only happen in one function, "which rounding mode do we use?" has exactly one answer, and "where does rounding happen?" is answerable by grepping for one name.

A note on alternatives, because they deserve a fair hearing. Python's decimal.Decimal and Java's BigDecimal are decimal floating point: they represent 0.1 exactly and let you set rounding modes explicitly. They are excellent, and for a system doing currency conversion or fractional-cent unit pricing (fuel at 154.9 cents/litre, say) they may be the better call-site ergonomics. For a single-currency quote engine where every externally visible amount is whole cents, integer cents win on three counts: they serialize trivially (JSON, SQL BIGINT, wire protocols — no locale or precision ambiguity), they are impossible to construct inexactly, and they make the rounding choke point obvious because division is the only operation that can lose information. Decimal makes rounding configurable per-context; integers make it impossible except where you built the door.

Anatomy of an out-the-door tire quote

To design the calculation, first inventory what actually goes into the number a Calgary tire counter gives a customer. When a shopper is comparing tires before a purchase, the meaningful figure is never the sticker price of one tire — it is the out-the-door total: everything, installed, taxes in. Illustratively, a four-tire quote composes:

  • Product lines. Unit price × quantity for each tire model. Usually one line of quantity 4, but a staggered fitment (different sizes front and rear) produces two lines of quantity 2 — which is exactly why per-line behaviour matters.
  • Per-unit environmental fees. Alberta operates a tire recycling program funded by a levy collected on new tire sales; the mechanics that matter to the engine are that the fee is per tire, is a flat amount rather than a percentage, and is itemized. (The actual current rate is set by the program, varies by tire class, and changes over time — the amounts used in this article's examples, like $5.00 per passenger tire, are illustrative placeholders, not a statement of the official rate.)
  • Labour and service lines. Mounting and wheel balancing, valve stems or TPMS service kits, possibly a disposal charge for the old set. Some are per-tire, some are flat.
  • Adjustments. A percentage or fixed-amount change to some subset of lines — the most policy-sensitive component, covered in its own section.
  • GST. Alberta has no provincial sales tax, so the only sales tax on a quote like this is the federal GST at 5%. One rate applied to a defined base — but which base, computed when, is precisely the kind of thing that must be specified rather than assumed.

Notice what is absent: nothing here requires fractional cents as an output. Intermediate math produces fractions (5% of $1,151.96 is $57.598), but every line a customer sees, and every number that posts to a ledger, is whole cents. That asymmetry — exact integers at the boundaries, controlled rounding in the middle — is the shape of the whole design.

Rounding is a policy, not an implementation detail

Here is the uncomfortable truth about rounding money: for amounts this size there is rarely a single legally mandated answer, but there is always a mandated consistency. Canada's GST guidance permits normal commercial rounding practices; what gets a business into trouble is not choosing half-up over half-even, it is being unable to say which one it uses, or using different ones in different code paths. So the deliverable is not "the correct rounding mode." The deliverable is one written sentence, enforced by one function, verified by tests. Something like:

Policy (illustrative): all rounding is round-half-up on the absolute value (half-away-from-zero), applied per tax-calculation and per-adjustment at the moment that value is produced; line extensions (integer unit price × integer quantity) never round because they are exact; the invoice total is the exact integer sum of already-rounded parts and is itself never rounded.

Every clause in that sentence forecloses a category of divergence. "Half-away-from-zero" pins the tie-breaking rule and handles negative adjustments symmetrically (a -$0.125 adjustment rounds to -$0.13, mirroring the positive case). "Per-calculation at the moment produced" pins where. "Extensions never round" documents that multiplication of integers needs no policy. "The total is a sum of rounded parts" means the bottom line is derived, never independently computed — so it cannot disagree with its own line items.

The implementation of the choke point, continuing the TypeScript sketch:

/** Round-half-away-from-zero integer division. The system's ONLY rounding site. */
function roundDiv(numerator: number, denominator: number): Cents {
  const q = Math.trunc(numerator / denominator);
  const r = Math.abs(numerator % denominator) * 2;
  const bump = r >= denominator ? Math.sign(numerator) : 0;
  return cents(q + bump);
}
Enter fullscreen mode Exit fullscreen mode

Ten lines, and every cent of ambiguity in the system routes through them. If the policy ever changes — a jurisdiction mandates something, an auditor prefers something — it changes in one place, with a version stamp (more on that in the audit section, because "we changed rounding modes on date X" is exactly the sort of fact a replayable history must capture).

Per-line or per-invoice: pick where the tax rounds

The classic composition question: do you compute tax on each line and sum the rounded results, or sum the untaxed lines and compute tax once on the subtotal? Both are legitimate; they can differ by a cent or two per invoice; the difference is structural, not moral.

Watch them diverge on an illustrative staggered fitment — two front tires at $189.99, two rears at $204.49, GST 5%:

Line Extension 5% of line Rounded per line
Front pair $379.98 $18.999 $19.00
Rear pair $408.98 $20.449 $20.45
Sum of line taxes $39.45

Per-invoice instead: subtotal $788.96, times 5% is $39.448, rounds to $39.44. One cent apart, both defensible, and a system that computes per-line in the quote screen but per-invoice in the export will disagree with itself forever, one reconciliation ticket at a time.

Considerations that actually decide it:

  • Per-invoice minimizes accumulated rounding steps (one rounding event per rate per invoice) and matches how a person with a calculator checks the math: subtotal, times 1.05, done. For a single-rate, single-jurisdiction quote engine it is the simpler mental model.
  • Per-line is what you want when lines can carry different tax treatments — a taxable product line next to an exempt one — or when lines must be independently exportable to systems that expect each row to carry its own tax. It also localizes the effect of voiding one line: remove the line, remove exactly its tax.
  • Hybrid reality: many engines compute per tax group — sum the lines sharing a tax treatment, apply the rate once per group. This gets per-invoice's minimal rounding with per-line's ability to handle mixed treatments, and it is the choice the worked example below uses.

Whichever you choose, encode it in the calculation-order document and in the property tests, because this is the single most common source of the off-by-a-cent class of bug.

Half-up, half-even, and why the tie-break matters less than you think

Round-half-up (2.5 → 3) is what humans expect from school. Round-half-to-even, "banker's rounding" (2.5 → 2, 3.5 → 4), exists because always rounding ties upward introduces a systematic upward bias: over many rounding events, half-up drifts totals up by a fraction of a cent per event on average, while half-even's ties alternate direction and cancel in aggregate. IEEE 754 defaults to half-even for exactly this reason, and Python's built-in round follows it — a genuine gotcha if you assumed schoolbook behaviour:

>>> round(2.5), round(3.5), round(0.125, 2)
(2, 4, 0.12)   # half-even, and 0.125 isn't even exactly 0.125 in binary
Enter fullscreen mode Exit fullscreen mode

(That last value is a compound gotcha: the literal 0.125 is exact in binary, but 2.675 famously is not — round(2.675, 2) gives 2.67, not because of the tie rule but because the stored value is fractionally below the tie. Binary representation and tie-breaking failures interleave, which is one more argument for integers, where the tie is genuinely a tie.)

For a quote engine, here is the honest assessment: at retail scale the statistical bias between half-up and half-even is economically irrelevant — fractions of a cent across the number of rounding events a small operation generates. What is not irrelevant:

  • Explainability. Half-up is explainable at a counter in one sentence. Half-even requires explaining why $0.125 became $0.12 this time and $0.135 became $0.14. When a human will re-check the math on paper, matching human arithmetic has real value.
  • Cross-system agreement. If the accounting package, the payment terminal, and the tax authority's examples all use half-up (most retail-facing tooling does), choosing half-even buys you a permanent trickle of one-cent explanations.
  • Symmetry under negation. Whatever you choose, specify behaviour for negative amounts (refund lines, negative adjustments). "Half away from zero" makes a refund the exact mirror of the sale, which is what reversal logic wants.

So: half-away-from-zero for this design, chosen for legibility, documented in the policy sentence, enforced in roundDiv, and — critically — testable, because the tie cases (…5 at the last digit) are enumerable and belong in the unit suite as table-driven cases.

The quote as a pure function with a declared order

Everything so far becomes real in one place: a pure function from a quote's inputs to its computed totals. Pure meaning: no clock reads, no database reads, no config lookups inside — every input that affects the output arrives as an argument, including the tax rate, the fee schedule, and the policy version. Same inputs, same cents, forever. That property is what later makes auditability and idempotent recalculation nearly free.

interface QuoteInput {
  lines: ProductLine[];        // unit cents, integer qty, fee class, tax group
  services: ServiceLine[];     // flat or per-unit labour, in cents
  adjustments: Adjustment[];   // see the discount section
  feeScheduleVersion: string;  // e.g. "fees-2026-illustrative-v1"
  gstBasisPoints: BasisPoints; // 500 — passed in, never read from globals
  policyVersion: "rounding-half-away-v1";
}

interface QuoteResult {
  lineExtensions: Cents[];   // exact, unrounded-because-exact
  perUnitFees: Cents;        // Σ (feePerTire × qty), exact
  serviceTotal: Cents;       // exact sum of service lines
  adjustmentApplied: Cents;  // rounded once at computation, signed
  taxableBase: Cents;        // exact sum of taxable parts
  gst: Cents;                // applyRate(taxableBase, 500) — rounds once
  total: Cents;              // exact sum of the above — NEVER rounded
}

function computeQuote(input: QuoteInput): QuoteResult { /* … */ }
Enter fullscreen mode Exit fullscreen mode

The declared calculation order — the second half of the specification, ranking with the rounding policy itself:

  1. Extend each product line: unitCents × qty. Exact.
  2. Extend per-unit fees: for each line, feeCents(feeClass) × qty; sum. Exact.
  3. Sum service lines. Exact.
  4. Apply adjustments to their declared targets (defined below), each producing a signed cents amount, rounded at the moment of computation. This is rounding site #1.
  5. Assemble the taxable base: product extensions + fees + services + adjustments, per tax group. Exact sum of integers.
  6. Apply GST per tax group: applyRate(base, 500). Rounding site #2.
  7. Total: exact integer sum. No rounding exists at this step by construction — there is nothing left to round.

Two structural notes. First, the order is data, not folklore: policyVersion names it, and changing the order means minting a new version string. Second, purity is load-bearing. Because computeQuote cannot observe the world, the quote a customer received last month can be recomputed today by replaying last month's inputs — the fee schedule as versioned then, the rate as passed then — and it must produce identical cents. A function that secretly read "current GST rate" from config would make historical quotes unreproducible the moment anything changed.

One quote, end to end, in exact integers

Concreteness time. All figures illustrative. A customer wants four all-weather tires installed — the sort of no-swap-twice-a-year setup covered on the all-weather tires overview — with an install package and a seasonal-changeover-style mount-and-balance labour line.

Inputs, in cents:

Product:  4 × 21,499  (tire @ $214.99, tax group: GST)
Fee:      4 ×    500  (illustrative per-tire recycling levy, GST group)
Service:  1 ×  8,000  (install labour package, $80.00, GST group)
Adjust:   −250 bp on product lines only (illustrative 2.5% off tires)
GST:      500 bp
Enter fullscreen mode Exit fullscreen mode

Following the declared order:

  1. Product extension: 21_499 × 4 = 85_996. Exact.
  2. Fees: 500 × 4 = 2_000. Exact.
  3. Services: 8_000. Exact.
  4. Adjustment: targets product extension only. 85_996 × (−250) = −21_499_000; divide by 10,000 → −2_149.9 → rounds away from zero → −2_150. (This is −$21.50.) One rounding event, recorded.
  5. Taxable base: 85_996 + 2_000 + 8_000 − 2_150 = 93_846$938.46. Exact.
  6. GST: 93_846 × 500 = 46_923_000; over 10,000 → 4_692.34_692 (no tie, plain truncation-side case, .3 rounds down). $46.92. Second and final rounding event.
  7. Total: 93_846 + 4_692 = 98_538$985.38 out the door.

Exactly two rounding events, both at named sites, both reconstructible. Re-enter the same lines in any order, on any machine, in any decade: 98_538. Compare that determinism with the floating-point version from earlier, where algebraically equivalent orderings produced different formatted totals — the difference is not diligence, it is architecture.

Discounts: percentages and absolutes are different animals

Adjustments deserve their own type discipline because they are where well-meaning flexibility destroys invariants. The safe representation is a tagged union that makes the dangerous cases unrepresentable:

type Adjustment =
  | { kind: "percentage"; basisPoints: BasisPoints;  // e.g. −250 = 2.5% off
      target: "product-lines" | "services" | "line"; lineId?: string;
      reason: string }                                // mandatory, audited
  | { kind: "absolute"; amount: Cents;                // e.g. −1_500 = $15.00 off
      target: "line" | "quote-pre-tax"; lineId?: string;
      reason: string };
Enter fullscreen mode Exit fullscreen mode

The rules this encoding enforces, and why each exists:

  • A percentage must name its base. "2.5% off" is meaningless until you say off what — the tire lines? Including levies? Including labour? The target field makes the base explicit, so the computed cents are a function of declared data. (Per-tire environmental levies are typically not discountable — the amount is remitted onward regardless of what the seller charges — which is an excellent illustration of why "percentage off everything" must not be the only representable adjustment.)
  • Percentages compute once, into cents, at step 4, and the cents are what's stored on the result. Never store "2.5% off" and re-derive it at display time from whatever the lines happen to be now — that re-derivation is how a later line edit silently changes an already-communicated figure.
  • Absolutes are already cents and never round, but they must declare whether they apply pre-tax (reducing the GST base — the normal case for a seller-funded price reduction) or post-tax (a payment against the total, which is not a price change at all and arguably belongs in a different structure entirely). Pre-tax is the default; post-tax should be rare and loudly typed.
  • Stacking order is policy. Two percentage adjustments: do they compose on the original base (additive: 10% + 5% = 15% off) or sequentially (compound: 14.5% off)? Neither is wrong; unspecified is wrong. The design picks sequential-in-list-order and the property tests pin it.
  • reason is mandatory. Not for the math — for the humans and the audit trail. An adjustment with no recorded rationale is the line item every future reader distrusts.

One more guard worth its weight: clamp. An absolute adjustment larger than its target line, or stacked percentages exceeding 100%, should fail validation rather than produce a negative line. Negative totals from over-discounting are a classic silent-corruption path into every downstream system.

Composing GST and per-tire levies without double-taxing or under-taxing

Tax composition questions are really "what is in the base?" questions, and the tire case has a genuinely instructive one: is the recycling levy itself subject to GST? In Canada, environmental handling fees of this style are generally treated as part of the consideration for the sale — meaning GST applies on top of the levy — but the correct move for a design article is not to bake a legal conclusion into code, it is to make the question a data question. Hence tax groups: every line, including fee lines, carries a tax-group tag, and the engine taxes whatever the group says to tax. If the treatment ever needs to change, it changes in the versioned fee schedule, not in the arithmetic.

The fee schedule as data, versioned like the policy:

interface FeeSchedule {
  version: string;               // "fees-2026-illustrative-v1"
  effectiveFrom: string;         // ISO date
  perUnit: Record<FeeClass, { amountCents: Cents; taxGroup: "gst" | "exempt" }>;
  // e.g. passenger: { amountCents: 500, taxGroup: "gst" }  ← illustrative
}
Enter fullscreen mode Exit fullscreen mode

This shape handles the realities gracefully: levy amounts differ by tire class (a passenger tire and the tires on the trucks served by a commercial tire program carry different fee classes), amounts change when the program changes them, and historical quotes must keep pointing at the schedule version that priced them. GST composition then follows mechanically from the tax-group partition built in step 5 of the calculation order: sum each group exactly, apply each group's rate once, sum the results. No line is ever taxed twice because it belongs to exactly one group; no taxable line escapes because group membership is mandatory, not defaulted.

A brief word on the 5% itself: Alberta's lack of a provincial sales tax makes this the easy version of the problem — one rate, one group in the common case. The design still carries the tax-group machinery, because "we only have one tax" is exactly the assumption that hurts when a quote engine meets a second jurisdiction, an exempt fleet account, or a zero-rated line. The machinery costs one field and one loop; the assumption costs a rewrite.

Property-based tests: state the invariants, let the machine hunt

Example-based tests check the arithmetic you thought of. Property-based tests check the algebra you claimed. For a money engine the claims are crisp enough to be executable, which makes this one of the best property-testing domains there is. Using Python and Hypothesis for the sketches (fast-check is the TypeScript equivalent):

from hypothesis import given, strategies as st

lines = st.lists(
    st.tuples(st.integers(1, 100_000),   # unit cents ($0.01–$1,000)
              st.integers(1, 12)),       # quantity
    min_size=1, max_size=20)

@given(lines, st.randoms())
def test_total_invariant_under_reordering(ls, rnd):
    shuffled = ls[:]; rnd.shuffle(shuffled)
    assert compute_quote(ls).total == compute_quote(shuffled).total

@given(lines)
def test_total_equals_sum_of_parts(ls):
    r = compute_quote(ls)
    assert r.total == sum(r.line_extensions) + r.per_unit_fees \
                    + r.service_total + r.adjustment_applied + r.gst
Enter fullscreen mode Exit fullscreen mode

The properties worth encoding for this engine, roughly in order of bug-finding yield:

  • Permutation invariance. Reordering lines never changes any total. This property is false in naive floating-point implementations (float addition is not associative) and true by construction here — which makes it the perfect canary: if it ever fails, someone reintroduced a float.
  • Parts-sum identity. The displayed total always equals the exact sum of displayed parts. This is the "invoice adds up on paper" guarantee, and it holds because the total is defined as that sum — the test guards against anyone "helpfully" recomputing it another way.
  • No-drift round-trip. Serialize a quote result to JSON and back, or write and re-read it through the persistence layer: every cents field is bit-identical. Trivial with integers, historically violated by anything that transits a float column or a locale-formatting layer.
  • Recompute stability. compute_quote(x) == compute_quote(x), and more usefully: recomputing from the stored inputs of a historical quote reproduces the stored outputs. This is the executable form of purity.
  • Bounded rounding error. Per-group tax differs from the unrounded real-number tax by strictly less than one cent, and the count of rounding events equals (number of adjustments + number of tax groups). Pinning the count is underrated: it catches a refactor that sneaks a third rounding site in.
  • Adjustment symmetry. Applying an adjustment and its exact negation yields the original totals — the refund-mirrors-sale property that half-away-from-zero was chosen to provide.
  • Monotonicity sanity. Adding a line with positive amounts never decreases the total; a clamped discount never produces a negative line. Cheap to state, embarrassing to fail.

Hypothesis will find the tie-break edges (amounts ending in exactly half a cent after rate application), the qty-zero and single-line degenerates, and adjacency effects between stacked adjustments faster than any hand-written table. Keep the hand-written table too — the worked example above belongs in it verbatim, as an executable specification.

The audit trail: append-only events, replayable prices

Now the operational half. A quoted price is a small promise, and promises need provenance: what was quoted, when, under which fee schedule, and why did revision 3 differ from revision 2? The design answer is an append-only event log — quote state is never updated in place; every change is a new immutable record, and "the quote" is a fold over its events.

CREATE TABLE quote_events (
  event_id      BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  quote_id      UUID        NOT NULL,
  revision      INT         NOT NULL,             -- 1, 2, 3… per quote
  event_type    TEXT        NOT NULL,             -- 'created' | 'line_added'
                                                  -- | 'line_removed' | 'adjustment_applied'
                                                  -- | 'reprice_requested' | 'superseded'
  payload       JSONB       NOT NULL,             -- full input delta, typed
  policy_version   TEXT     NOT NULL,             -- 'rounding-half-away-v1'
  fee_schedule_ver TEXT     NOT NULL,             -- 'fees-2026-illustrative-v1'
  computed_total   BIGINT   NOT NULL,             -- cents, result AFTER this event
  actor         TEXT        NOT NULL,             -- who or what made the change
  reason        TEXT        NOT NULL,             -- human-entered rationale
  occurred_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (quote_id, revision)
);
-- No UPDATE, no DELETE. Enforce with permissions, not discipline:
-- REVOKE UPDATE, DELETE ON quote_events FROM app_role;
Enter fullscreen mode Exit fullscreen mode

What this structure buys, concretely:

  • Reconstruction. To answer "why was this customer quoted $985.38 on the 3rd?", replay events 1..n through the pure computeQuote with the recorded schedule and policy versions. Because the function is pure and the inputs are fully captured, the replay must land on computed_total — and storing that expected total in the event row turns every replay into a checksum. A mismatch means either data corruption or an unversioned behaviour change, both of which you urgently want to know about.
  • Diffable revisions. Revision 2 versus revision 3 is a structured diff of payloads: "rear line quantity 2 → 4, actor: counter-staff, reason: customer switched from pair to full set." The why travels with the what — this is the difference between an audit trail and a mere backup.
  • Supersession, not mutation. When a quote is re-priced because a new fee schedule took effect, the old revision remains fully intact and reproducible under its original schedule; a reprice_requested event creates the next revision under the new one. Nothing historical ever becomes false.
  • Honest timestamps. occurred_at is when the event was recorded, and it is the only clock in the system. The pure function takes no clock, so "what would this quote be if entered today" versus "what was it then" are both answerable, distinctly, without ambiguity about which question you asked.

The same discipline scales down and up. A single retail quote for a flat repair might have two events in its whole life; the multi-vehicle estimates a fleet account generates might accumulate dozens across weeks of back-and-forth. The event model does not care — the fold is the fold, and cheap immutable rows are the least expensive insurance a money system can buy.

Idempotent recalculation: same request, same cents, no surprises

"Idempotent" gets used loosely; here it means something specific and testable: recalculating a quote any number of times, from any starting point, converges on identical output, and requesting the same mutation twice produces one event, not two. Both halves matter and they are different mechanisms.

The first half is purity again, now with teeth. Because computeQuote is a function of (lines, services, adjustments, schedule version, rate, policy version) and nothing else, recalculation is safe to run anywhere, any time: on every event append, in a nightly verification sweep that replays recent quotes and compares checksums, in a migration dry-run. There is no "recalculate" button that might change the number — recalculation is verification, and if it would produce a different total, that is definitionally a new revision requiring a new event with a recorded cause, never a silent overwrite.

The second half is transport-level: the counter's browser retries a save; a webhook fires twice; a queued job redelivers. Standard fix, worth writing down because money systems are where duplicate mutations hurt most: every mutating request carries a client-generated idempotency key, and the event append is conditional on that key being unseen.

ALTER TABLE quote_events ADD COLUMN idempotency_key UUID NOT NULL;
CREATE UNIQUE INDEX ON quote_events (idempotency_key);
-- Retry with the same key → unique violation → return the ORIGINAL result, HTTP 200.
Enter fullscreen mode Exit fullscreen mode

The retry does not error out from the client's perspective — it receives the same response the first attempt earned, which is the whole point: the caller cannot tell (and need not care) whether it was the first or the fourth delivery. Combined with the UNIQUE (quote_id, revision) constraint doing optimistic concurrency (two staff editing the same quote race to claim revision n+1; the loser re-reads and re-applies), the event log stays linear, duplicate-free, and calm under exactly the flaky-network conditions a real counter with a tablet on retail Wi-Fi will produce.

Schema sketches: the whole design on one page

Pulling the persistent shapes together, trimmed to essentials. Money columns are BIGINT cents everywhere — no NUMERIC(10,2) (which invites application-layer float conversion at the boundary) and absolutely no FLOAT/REAL (which is the original sin the whole article exists to prevent):

CREATE TABLE fee_schedules (
  version        TEXT PRIMARY KEY,
  effective_from DATE   NOT NULL,
  body           JSONB  NOT NULL     -- {class: {amount_cents, tax_group}}
);

CREATE TABLE quotes (
  quote_id         UUID PRIMARY KEY,
  current_revision INT  NOT NULL DEFAULT 0,
  status           TEXT NOT NULL CHECK (status IN
                     ('draft','issued','accepted','superseded','expired'))
);
-- quotes is a THIN pointer; all substance lives in quote_events.
-- Rebuilding `quotes` from the event log must always be possible.
Enter fullscreen mode Exit fullscreen mode

And the TypeScript view of a materialized line, the shape the counter UI renders:

interface RenderedLine {
  description: string;
  unitCents: Cents; qty: number;
  extensionCents: Cents;      // exact product
  feeClass?: FeeClass;        // levy attaches per unit if present
  taxGroup: "gst" | "exempt";
}
Enter fullscreen mode Exit fullscreen mode

Three deliberate omissions, each a design statement. There is no discount_percent column on any persistent line — percentages exist only inside adjustment events, computed to cents at application time. There is no total column on quotes — totals live on events, where they were computed, with the versions that computed them. And there is no nullable tax_rate defaulting to "current" — rates are always explicit arguments, because a default that tracks the present is a time bomb for the past.

Where the design meets the driveway

Strip away the tire-and-levy particulars and the design is five commitments, each small, each load-bearing:

  1. Represent money as typed integer cents, with rates as integer basis points, so representation error is impossible rather than managed.
  2. Round in exactly one function, under exactly one written policy, at named sites in a declared calculation order — and version both the policy and the fee data.
  3. Compute quotes with a pure function whose every input is explicit, so any historical figure is reproducible from its recorded inputs.
  4. Record changes as append-only events carrying actor, reason, versions, and the computed result — provenance and checksum in one row.
  5. Make recalculation idempotent at both the math layer (purity) and the transport layer (idempotency keys), so retries and verification sweeps are boring.

None of this is exotic. All of it is the difference between a quoting tool that a one-location tire business could actually trust and a spreadsheet with delusions. The pattern generalizes far past tires — it applies to any system that promises a person a number and expects them to still believe it later — but the grounding matters: constraints like whole-cent totals, per-unit levies, single-rate GST, and quantities of two and four are what make the design tractable enough to get provably right. If the domain itself interests you — what actually distinguishes an all-season from an all-weather compound, or how seasonal changeovers are scheduled around the first snowfall rush, or how a storefront like this Calgary tire operation organizes the services these quotes describe — the operational side has its own depth, from reading a sidewall to decoding load indexes, and even the humble online scheduling flow is downstream of exactly one requirement: the number quoted is the number paid.

One final restatement of the frame, because it belongs at both ends of an article like this: the operational constraints above are real, the illustrative figures are not real prices, and the system is a design on paper — a target worth aiming at, described precisely enough to build, tested in the only way that counts before production exists: by construction.

Top comments (0)