Engineering
GST plus QST: tax lines that add up to the cent
One tax rate per business was enough until Canada. Quebec charges GST and QST, British Columbia charges GST and PST, and some services carry only one of them. Here is how our tax engine works each tax out on its own, rounds so the cents always add up, freezes the result on the sale, and hands the same figures to the tax report, the CSV exports and QuickBooks.
The short version
- A business has up to four tax lines (GST and QST in Quebec, GST and PST in British Columbia, one HST in Ontario), and each service or product says which of them apply: every line, none, or a named few.
- Each tax is worked out on its own, on each line, and rounded to the cent with half a cent going up. Never once on the combined rate. Ten cents in Quebec carries one cent of GST and one cent of QST, where 14.975% in one go would say one cent in all.
- Everything is whole cents and whole thousandths of a percent, worked in BigInt. Our earlier arithmetic ran in floating point and got the QST on $20.00 wrong by a cent.
- When prices include tax, the net is whatever is left after the taxes, so net plus taxes is always exactly the price.
- A sale freezes its tax when it is charged, tax by tax on its lines whenever there is more than one tax or a line not taxed at every one, and an invoice freezes each tax on its row. The tax report, the CSV exports and the daily QuickBooks journal entry all read tax by tax, so the GST figure and the QST figure each stand on their own.
Bookatu started with one tax rate per business, which is all most of the world needs. A salon in New Zealand charges GST at 15%, and one number with one label covers it. Canada does not fit in one number. Quebec charges GST at 5% and QST at 9.975%, British Columbia charges GST and a 7% PST, Ontario folds both into a 13% HST, and some services are taxed at only part of that.
Tax lines and treatments
A business's tax position is a list of lines, called rates in the code, with at most four of them. Each line has a stable id, a label, a rate, the business's registration number for that tax, and a compound flag. A service or product carries a treatment that says which lines apply to it.
/** One tax the business charges. */
export interface TaxRate {
/** Stable id, named by a service or product taxed at only some lines. Kept
* when the line is renamed. */
id: string;
/** What the receipt, invoice and report call it: "GST", "QST", "HST". */
label: string;
/** As a fraction (0.09975), to three decimals of a percent. */
rate: number;
/** The business's registration number for this tax ("123456789 RT0001"). */
number: string;
/** Charged on the amount plus the taxes listed above it. Never the first. */
compound: boolean;
}
/** How a service or product is taxed: null every line, [] none, ids only those. */
export type TaxTreatment = string[] | null;Null means standard, which is what every service and product that existed before treatments reads as. An empty list means exempt. A list of ids means only those lines, which is how a haircut in British Columbia carries GST and not PST. The Tax page offers presets for each province and territory, filled in as charged in 2026, but a preset is only a starting point the business edits and saves. Its accountant confirms the rates, and it adds its own registration numbers.
One rule in the treatment code is there purely to stop a settings change from quietly removing tax. If a service is set to "GST only" and the business later deletes its GST line, the list now names nothing that exists. We could read that as exempt. We read it as standard instead, because a tax silently dropped is far worse than one the owner has to correct.
export function appliedRates(regime: TaxRegime, treatment: TaxTreatment | undefined): TaxRate[] {
if (treatment == null) return regime.rates;
if (treatment.length === 0) return [];
const picked = regime.rates.filter((r) => treatment.includes(r.id));
return picked.length > 0 ? picked : regime.rates;
}Every existing business had at most one saved rate when this landed, and their figures could not move by a cent. A business that never saved a list of lines reads as one line with exactly its old rate, label and number. A unit test then runs the new engine against splitTax, the single-rate function every existing figure came from, at every three-decimal rate from 0% to 99%, on a spread of amounts from nothing to $12,345.67, with prices both including and excluding tax, and fails on the first cent of difference.
Round each tax by itself
The interesting decision was where to round. The tempting approach is to add the rates up (5% plus 9.975% is 14.975%), work out one tax figure, and then split it between GST and QST for display. That is wrong for Canada. GST and QST are two taxes, each reported on its own, and each one's figure has to be its own rate applied to the sale. Rounding once on the combined rate and splitting afterwards produces numbers that neither rate, applied honestly, would give.
So the engine works each tax out separately, on each line, and rounds each one to the cent, half a cent up. The test file pins the case that made the point for us.
it("$100.00 before tax: GST $5.00, QST $9.98 (9.975 rounds up), $114.98", () => {
expect(one(10_000, qc)).toEqual({
netCents: 10_000, taxCents: 1_498, grossCents: 11_498,
taxes: [
{ rateId: "gst", label: "GST", rateMilli: 5_000, taxCents: 500 },
{ rateId: "qst", label: "QST", rateMilli: 9_975, taxCents: 998 },
],
});
});
it("each tax is rounded by itself, not once on 14.975%: 10 cents carries 1 + 1 cents, not 1", () => {
expect(taxesOf(10, qc)).toEqual([["gst", 1], ["qst", 1]]);
expect(splitTax(10, 0.14975, false).taxCents).toBe(1);
});Ten cents at 5% is half a cent, which rounds up to one. Ten cents at 9.975% is just under a cent, which also rounds to one. Ten cents at 14.975% is about one and a half cents, which rounds to one. Two correct taxes add up to two cents, and the combined shortcut says one. On a single sale nobody notices. Across a quarter of small add-ons it is a discrepancy an accountant has to explain.
Whole numbers all the way down
Our earlier tax arithmetic ran in floating point, and it had a bug that made us stop trusting floats for money. The rate 9.975%, worked out as 9.975 divided by 100, is held as 0.09974999999999999. Multiply $20.00 by that and you get 199.49999999999997 cents, which rounds to 199. The real QST on $20.00 is exactly 199.5 cents, which rounds to 200. The same thing happened at 7.25% and at 4.712%.
Now every rate is turned into a whole number of thousandths of a percent before any arithmetic happens (9.975% becomes 9975, out of 100,000), amounts are whole cents, and the division with rounding is exact.
/** A stored fractional rate (0.09975) as whole thousandths of a percent (9975).
* The same number lib/tax.ts taxRateMilli gives. */
export function rateMilli(rate: number): number {
if (!Number.isFinite(rate) || rate <= 0) return 0;
return Math.round(rate * UNIT);
}
/** n ÷ d rounded half up, exactly, for whole numbers n ≥ 0 and d > 0. */
function divRoundHalfUp(n: bigint, d: bigint): bigint {
return (2n * n + d) / (2n * d);
}The rounding function is the classic trick: doubling the numerator and adding the denominator before integer division gives half-up rounding with no fractions anywhere. It only works for non-negative numbers, so a refund is worked on its size and then given its sign back. Half a cent rounds away from zero in both directions, and a refund carries back exactly the tax its sale carried.
Inside the price, the net takes the remainder
Prices that include tax are the default for a new business, as is common for salons. Taking two taxes out of an inclusive price is where cents usually go missing. Each tax is the price times its rate divided by one plus every rate that applies. Round GST and QST separately and then also round the net separately, and the three numbers can disagree with the price by a cent.
We do not round the net at all. The taxes are rounded, and the net is the price minus the taxes.
function taxAmount(amountCents: number, rates: TaxRate[], inclusive: boolean): TaxedLine {
const active = rates.filter((r) => rateMilli(r.rate) > 0);
if (active.length === 0 || !Number.isFinite(amountCents)) {
return { netCents: amountCents, taxCents: 0, grossCents: amountCents, taxes: [] };
}
const size = BigInt(Math.round(Math.abs(amountCents)));
const each: bigint[] = [];
if (inclusive) {
const { t, d } = factors(active);
const whole = t.reduce((a, b) => a + b, d);
for (const f of t) each.push(divRoundHalfUp(size * f, whole));
} else {
let above = 0n;
for (const r of active) {
const base = r.compound ? size + above : size;
const tax = divRoundHalfUp(base * BigInt(rateMilli(r.rate)), UNIT_N);
each.push(tax);
above += tax;
}
}
const signed = (n: bigint) => (amountCents < 0 ? -Number(n) : Number(n));
const taxCents = signed(each.reduce((a, b) => a + b, 0n));
const taxes = active.map((r, i) => ({ rateId: r.id, label: r.label, rateMilli: rateMilli(r.rate), taxCents: signed(each[i]) }));
if (inclusive) return { netCents: amountCents - taxCents, taxCents, grossCents: amountCents, taxes };
return { netCents: amountCents, taxCents, grossCents: amountCents + taxCents, taxes };
}A test walks every inclusive price from zero to $200.00, cent by cent, in Quebec, and checks that net plus each tax is the price. Before tax the same holds the other way round: the gross is the amount plus the rounded taxes, so nothing is left over to reconcile.
The compound flag exists for history rather than for today. Quebec charged QST on the price plus GST until 2013, and none of the current presets compound. The factors helper puts every rate over one common denominator, a power of 100,000, so a compound rate's share of an inclusive price is still a whole number and still exact. The first line can never compound, because there is nothing above it to compound on.
How a sale stores its tax
The engine returns each line taxed, the totals, and a per-tax total that is simply the sum of that tax across the lines. Totals are never rounded again, so the GST on a receipt is always the sum of the GST on its lines.
At the till, one function reads the business's position once and decides what is recorded. One tax inside the price with every line taxed at it is recorded the way the till always did, on the sale's total. More than one tax, or any line that is exempt or partly taxed, freezes each line's taxes onto the receipt's own copy of the line. Prices that exclude tax with "Add tax at checkout" switched on freeze the taxes the same way, plus a flag that says they were added on top. If no tax was charged, nothing is recorded, and a receipt never shows a tax line nobody paid.
export function saleTaxRows(lines: PosSaleLine[]): Array<{ rateId: string; label: string; rateMilli: number; taxCents: number }> {
const rows = new Map<string, { rateId: string; label: string; rateMilli: number; taxCents: number }>();
for (const l of lines) {
for (const t of l.taxes ?? []) {
const row = rows.get(t.rateId) ?? { rateId: t.rateId, label: t.label, rateMilli: t.rateMilli, taxCents: 0 };
row.taxCents += t.taxCents;
rows.set(t.rateId, row);
}
}
return [...rows.values()].filter((r) => r.taxCents !== 0);
}The sale row itself still carries one tax figure, one label and one rate, because plenty of older screens read exactly those. With several lines, the figure is their sum, the label is the lines' names joined ("GST + QST"), and the rate is the combined rate. The till's receipt reads the frozen line taxes instead, through the function above, and prints one row per tax. The client's visit receipt does the same and prints each tax's registration number, which a test pins for a Quebec business.
Invoices freeze their tax on the row when they are raised, since the tax on an invoice is what was quoted and agreed. A business with one tax line keeps the old columns. With more than one, each tax goes into a tax_lines jsonb column.
/** One tax on an invoice, frozen when it was raised (drizzle/0109). */
export interface InvoiceTaxLine {
/** The tax line's id in the business's settings ("gst"). */
id: string;
label: string;
/** The rate, in thousandths of a percent (9975 = 9.975%). */
rateMilli: number;
/** The registration number it was raised under. */
number: string;
taxCents: number;
}When tax is added at checkout and the client pays through Stripe Checkout, each tax becomes a line item of its own, so the payment page and Stripe's receipt show GST and QST separately. The total tax goes into the session's metadata for the webhook to read back.
Reading it back: the report and the exports
The tax report covers everything a business sold in a period: visits completed, invoices paid, products sold at the till, class places, and deposits kept on no-shows and late cancellations. A kept deposit is treated as a sale with the tax inside it, because Canada deems a forfeited deposit to include tax. This is one place where we got it wrong first. The report used to count appointments alone, so a salon that invoiced a company and sold shampoo filed a return missing both. That was not a display bug. The export goes to an accountant.
Invoices raised with one combined tax before a business split its tax into lines needed a home. Each frozen tax is filed under one of today's lines by id, then by name, then under the only line if there is one, and otherwise under a separate "Other tax" column, rather than being guessed into GST or QST.
const currentLine = (id: string | null, label: string | null): string => {
if (id && totals.has(id)) return id;
const byName = regime.rates.find((r) => r.label === (label ?? ""));
if (byName) return byName.id;
return regime.rates.length === 1 ? regime.rates[0].id : OTHER_TAX;
};The exports split by tax only when there is more than one tax line. A single-rate business still gets exactly the columns it always had (Date, Reference, Description, Net, Tax, Gross), so nobody's import mapping breaks. A Quebec business gets a column per tax, headed "GST 5%" and "QST 9.975%", between Net and the total Tax. The Xero file names each line's tax type ("GST + QST on Income", or "Tax Exempt") and includes each line's tax amount, so Xero takes our figure instead of working out its own. The QuickBooks CSV marks each line taxable or not and gives a column per tax.
QuickBooks: one journal entry a day, tax by tax
For businesses that connect QuickBooks Online, we post each day of each location as one journal entry. The day's sales come from the same function as the Tax page, so the day's tax in QuickBooks is the day's tax in Bookatu.
QuickBooks Canada handles sales tax through tax codes, and a code is a set of rates. So we map by the combination a line was taxed at rather than by individual tax. The combination key is the line ids in the business's own order.
export function comboKey(rateIds: readonly string[], regime: TaxRegime): string {
const known = regime.rates.filter((r) => rateIds.includes(r.id)).map((r) => r.id);
const unknown = [...new Set(rateIds.filter((id) => !regime.rates.some((r) => r.id === id)))].sort();
const ids = [...known, ...unknown];
return ids.length ? ids.join("+") : EXEMPT_KEY;
}A Quebec business maps "gst+qst", plus "gst" if it has GST-only services, plus "exempt". Once a code is chosen, each of our lines is matched to the rate inside that code with the same percentage, and if two rates share a percentage, to the one whose name contains our label. If the code has no matching rate, the day is refused with a sentence the owner can act on, not posted with the tax in the wrong place. Suggested mappings are left empty when nothing fits. A company whose HST code still says 15% gets no suggestion for Nova Scotia's 14%.
The entry is whole cents throughout and only becomes dollars in the payload. Each line's tax rides on the line, on the same side, so debits equal credits by construction, and a clearing line absorbs money and sales that fall on different days.
What we would tell ourselves at the start
- Decide where rounding happens before writing any arithmetic. Per tax, per line, half up, with the net taking the remainder, was the only rule we found that every downstream consumer could agree with.
- Do not do money in floating point, even when the rate is "just a percentage". Converting to integer units at the boundary costs one function and removes a whole class of one-cent bugs.
- Freeze what was quoted. An invoice's tax was agreed when it was raised, and working it out again at today's rate would change what a past quarter owed.
- Keep the old shape for the businesses that do not need the new one. Single-rate exports have the same columns they had before, and a test proves the figures did not move.
Building on Bookatu?
Bookatu has a public REST API and webhooks. Have a look at the developer docs.
Developer docs