Dentolize · ZATCA E-Invoice Rounding Fix Walkthrough
On this pageBusiness viewTechnical view

The Cent Problem & the Fix

Business view

What a patient and a clinic expect

An invoice has one total. The patient sees it, pays it, and the clinic books it. When that invoice is reported to Saudi Arabia's ZATCA e‑invoicing platform, the government's copy should carry the same total. Anything else means the receipt in the patient's hand and the record at the tax authority disagree.

What was actually happening

Dentolize produced that total twice, from two different code paths:

  • The app computed it once, when the invoice was created and saved.
  • The ZATCA package re‑computed it independently, from the raw line items, at the moment of reporting — it never reused the stored total.

Both used a 15% VAT rate and the same prices, but they rounded at different moments:

  • The app rounded VAT once, after adding up every line's taxable amount.
  • ZATCA rounded VAT on each line, then added the already‑rounded pieces together.

Adding up numbers that were each rounded first can land a cent away from rounding their sum once. On a multi‑line invoice, that cent appeared in the ZATCA total. Concretely, from the field report: an invoice paid as SAR 2999.99 was reported as SAR 3000.00.

A worked example (the PR's own reproduction)

Two lines, each with a taxable base of 100.10, VAT 15%:

TotalVATMatches the app total?
App — VAT on the aggregate base230.2330.03— (this is the source of truth)
Old ZATCA — VAT per line, then summed230.2230.02❌ off by a cent
New ZATCA — VAT on the aggregate base230.2330.03
  • App: round(0.15 × (100.10 + 100.10)) = round(0.15 × 200.20) = round(30.03) = 30.03.
  • Old ZATCA: round(0.15 × 100.10) per line, summed. Because these are floating‑point numbers, each line rounded to 15.01, and 15.01 + 15.01 = 30.02 — a cent low.
  • New ZATCA: computes VAT the same way the app does → 30.03. The two now agree.

The drift can go either way — a cent high (the 3000.00 case) or a cent low (this example) — depending on how the fractional cents fall. Either way, after the fix the numbers match.

What the fix delivers

  • The amount reported to ZATCA equals the invoice total the patient paid, to the cent.
  • The e‑invoice is internally consistent: its overall tax, its per‑category tax subtotals, and its payable amount all come from one figure.
  • No behaviour changes for anyone: no new setting, no new step, no re‑issuing of past invoices.

Honest boundaries

  • This PR fixes the dominant cause — the VAT rounding difference. The author notes a smaller, separate contributor still exists (a per‑unit discount rounding in the invoice calculator) that is out of scope here. See the follow‑ups section below and How Totals Reach ZATCA.
  • The underlying reason sub‑cent artifacts exist at all is that monetary fields are stored as floating‑point Float, not fixed‑point Decimal — a longer‑term change, not part of this PR.

Technical view

All references are to the branch head under /work/repo.

The two calculations, side by side

App / database — VAT once, on the aggregate base. packages/server/src/resolvers/mutations/actions/invoices/invoiceUtils.js, function calculateInvoiceValues (from line 96):

// invoiceUtils.js:155 — aggregate taxable base Σ(subtotal − discount)
const taxedLineItemsAfterDiscount =
  taxedLineItems.reduce((t, o) => t + (o.subtotal - o.discount), 0)

// invoiceUtils.js:163 — VAT computed ONCE on that aggregate base
tax = (firstTaxPercent * taxedLineItemsAfterDiscount) / 100

// invoiceUtils.js:205 — the persisted, patient-facing total
const total =
  Number(subtotal.toFixed(2)) - Number(discount.toFixed(2)) + Number(tax.toFixed(2))

This total is what is written to the Invoice row, shown in the UI, and posted to the ledger.

ZATCA package — the code this PR changes. packages/zatca/src/zatca/calc.ts, function constructTaxTotal (from line 203).

Before: each line's VAT was rounded and accumulated into a running taxes_total:

// OLD (removed): per-line rounding, then sum
let tax_amount = Number(
  roundingNumber(acceptWarning, line_item.VAT_percent * taxable_amount),
);
addTaxSubtotal(taxable_amount, tax_amount, line_item.VAT_percent);
taxes_total += parseFloat(new Decimal(tax_amount).toString());

After: the per‑line loop accumulates only the taxable base per VAT category (calc.ts:315‑342); the VAT for each category is computed once, afterward:

// calc.ts:335 — accumulate base only; note the VAT arg is 0 here
addTaxSubtotal(taxable_amount, 0, line_item.VAT_percent);
// calc.ts:344-364 — one rounding per VAT category, on the aggregated base
const fifteenTaxAmount = fifteenTaxSubTotal.exist
  ? Number(roundingNumber(acceptWarning, fifteenTaxSubTotal.taxable_amount * 0.15))
  : 0;
const fiveTaxAmount = fiveTaxSubTotal.exist
  ? Number(roundingNumber(acceptWarning, fiveTaxSubTotal.taxable_amount * 0.05))
  : 0;
let taxes_total = Number(
  roundingNumber(acceptWarning, fifteenTaxAmount + fiveTaxAmount + otherTaxesTotal),
);

roundingNumber (calc.ts:61) is new Decimal(number).toFixed(2) — a single fixed‑2‑decimal rounding. Computing it on Σ base × rate is precisely the app's round(rate × Σ base).

The per‑category tax subtotals now use the same figure

The cbc:TaxAmount written into each cac:TaxSubtotal was previously derived from a separately accumulated per‑line sum; it now uses the single category figure:

// calc.ts:377 — 15% subtotal tax amount
"#text": new Decimal(fifteenTaxAmount).toFixed(2),
// calc.ts:404 — 5% subtotal tax amount
"#text": new Decimal(fiveTaxAmount).toFixed(2),

The document total and payable amount are now consistent

Previously there was a latent split: the document TaxTotal used constructTaxTotal's value, while LegalMonetaryTotal (which drives TaxInclusiveAmount / PayableAmount) was fed a different, per‑line‑accumulated total_taxes. This PR removes that second accumulator and feeds the single category‑based total to both:

// calc.ts (Calc) — the per-line total_taxes accumulator is deleted;
// LegalMonetaryTotal now consumes the SAME number as TaxTotal
// calc.ts:556-564
invoice_xml.set(
  "Invoice/cac:LegalMonetaryTotal",
  true,
  constructLegalMonetaryTotal(
    total_extension_amount,
    taxTotalDetails.taxes_total,   // <- was `total_taxes` (per-line sum)
    acceptWarning,
  ),
);

constructLegalMonetaryTotal (calc.ts:479) builds TaxInclusiveAmount = TaxExclusiveAmount + total_tax and sets PayableAmount to it (calc.ts:495‑510). With total_tax now equal to the category‑based figure, the payable amount, the document tax total, and the per‑category subtotals are all the same number — and it matches the invoice total by construction.

Explicitly unchanged: per‑line cac:TaxTotal

Line‑level tax is still computed and rounded per line in constructLineItemTotals (calc.ts:126‑131):

let line_item_total_taxes = Number(
  roundingNumber(acceptWarning, line_extension_amount * line_item.VAT_percent),
);

ZATCA tolerates a difference between the sum of line‑level tax and the category‑level tax; only the document‑level aggregation changed. Zero‑rated / exempt lines (VAT_percent == 0) are handled by a separate path (modifiedZeroTaxSubTotal, calc.ts:211‑285) and are unaffected.

The compiled output is part of the change

The server imports the package via its built entry point (main: lib/index.js), so the PR also commits the rebuilt packages/zatca/lib/zatca/calc.js (the JavaScript mirror of the same edits) and the updated calc.d.ts.map. Without that, the fix would not take effect at runtime.

Verification (from the PR)

  • A standalone reproduction of both algorithms on the two‑line, 15% invoice above yields the table in the business view — old ZATCA 230.22, new ZATCA 230.23, app 230.23.
  • packages/zatca compiles cleanly with tsc, and lib/ is regenerated.

Follow‑ups the code still carries

  • Per‑unit discount rounding. packages/server/src/services/eInvoices/invoiceCalculator.js:313 divides a line's total discount by quantity and rounds per unit: const discountPerUnit = money(op.discount / op.amount || 0) (and the sibling insurance splits at :320 and :351). On a line whose discount doesn't divide evenly by quantity, this can nudge the taxable base by a cent — a smaller, separate contributor left for a future change.
  • Float vs Decimal. Invoice and Operation monetary fields are Float in packages/prisma/schema.prisma (e.g. Invoice.total, Invoice.tax, Invoice.subtotal, Invoice.discount). Fixed‑point Decimal would remove the sub‑cent artifacts at the root.