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

How Totals Reach ZATCA

This page gives the context around the fix: the journey an invoice takes from "saved in Dentolize" to "reported to ZATCA," and where in that journey the corrected calculation sits.

Business view

Two kinds of e‑invoice

Saudi ZATCA reporting distinguishes:

  • Simplified invoices (B2C) — the patient‑facing sale. Reported to ZATCA and cleared with a QR code on the receipt.
  • Standard invoices (B2B) — insurance / business counterparties. Reported through the same platform with additional buyer details.

Dentolize builds both from the same clinic invoice. The fix in this PR applies to the shared tax‑total calculation, so it protects both kinds.

The onboarding a clinic does once

Before any invoice can be reported, a clinic completes a one‑time setup:

  1. Business Registration — CRN, VAT number, legal and commercial names, and a full national address. (Settings → Business Registration.)
  2. ZATCA Integration — activate the connection with a one‑time password (OTP), which issues the clinic its certificates, and set the invoice counter so each e‑invoice chains to the one before it. (Settings → Zatca Integration.)

Until this is done, invoices show "E‑Invoice Not Reported" — exactly the state in the sandbox used for this walkthrough. The fix doesn't change onboarding; it changes what gets sent after onboarding.

Where the cent used to slip in

Once an invoice is reported, Dentolize doesn't ship the stored total verbatim. It rebuilds the invoice into the government's required XML shape — line items, taxes, seller, buyer — and computes the tax totals fresh. That rebuild is where the old rounding lived, and where this PR makes the rebuilt total match the stored one.


Technical view

References are to /work/repo at the branch head.

The pipeline, end to end

  1. Invoice saved. calculateInvoiceValues (invoiceUtils.js:96) computes and persists Invoice.total, .tax, .subtotal, etc. — VAT once on the aggregate base (invoiceUtils.js:163, :205).
  1. Reporting is triggered. GraphQL mutations submitEInvoice / reportPatientEInvoice and submitInsuranceEInvoice are registered in packages/server/src/resolvers/mutations/patientMutations.js:2086‑2087 (permission gate at packages/server/src/permissions/permissions.js:4090). The resolver action .../actions/patient/submitEInvoice.js calls submitEInvoices (.../actions/fragments/submitEInvoices.js:182).
  1. A queue worker dispatches to the right reporter. packages/server/src/queue/eInvoices/workers/reportSubmissionJob.js routes to reportSimplifiedInvoice (:25) or reportStandardInvoice (:32).
  1. The invoice is rebuilt for ZATCA.
  • Simplified: packages/server/src/services/eInvoices/reportSimplifiedInvoice.js (function at :129) builds a new ZATCAInvoice({ props, acceptWarning: true }) at :185‑188.
  • Standard: packages/server/src/services/eInvoices/reportStandardInvoice.js (function at :145), new ZATCAInvoice(...) at :216.
  • The line items handed to the package are produced by the calculator in packages/server/src/services/eInvoices/invoiceCalculator.js — mapping each Operation into a ZATCAInvoiceLineItem (quantity: op.amount, tax_exclusive_price: money(op.price), per‑unit discounts, VAT category), lines 300‑364.
  1. Totals are computed — the fixed step. Inside the package, Calc (packages/zatca/src/zatca/calc.ts:514) calls constructTaxTotal (:203) and constructLegalMonetaryTotal (:479). This is the function this PR corrects — see The Cent Problem & the Fix.
  1. Signed and submitted. The EGS unit signs the XML (reportSimplifiedInvoice.js:195, gated on XLZ_ENV === 'production') and submits it (egs.reportInvoice(...), :238).

The line‑item shape the calculation consumes

Defined in packages/zatca/src/zatca/templates/simplified_tax_invoice_template.ts:

  • InvoiceLineItem base (:180‑187): id, name, quantity, tax_exclusive_price, optional other_taxes, optional discounts.
  • LineItem (:198‑200): standard‑rated, VAT_percent: 0.15 | 0.05.
  • ZeroTaxLineItem (:189‑196): VAT_percent: 0 with a vat_category code (O/Z/E) and exemption reason.
  • Union ZATCAInvoiceLineItem (:202).

constructTaxTotal buckets these by rate (15% / 5% / zero) and — after the fix — computes one VAT figure per non‑zero bucket from its summed taxable_amount.

Per‑company configuration

Both keyed 1:1 to Company in packages/prisma/schema.prisma:

  • EInvoiceSettings (:6993‑7000) — autoSubmit Boolean @default(false) controls automatic submission.
  • ZatcaCredentials (:7173‑7188) — isConfigured (the "onboarded" flag surfaced as ZATCA integration not configured), csr, privateKey, compliance (sandbox) and production certificate/secret pairs, lastSubmittedInvoiceHash, and invoiceCounterNumber (the counter and hash shown on the ZATCA Integration screen).
  • Company‑level eInvoiceProvider String? @default("ZATCA") (:6942).

Sandbox‑vs‑production is selected by the environment (process.env.XLZ_ENV via getEgsEnvironment), not per company; the company row supplies both compliance and production certs, and the env var picks which is used. Config is written by updateZatcaIntegration.js and updateCompanyBusinessRegistration.js, gated by VIEW_/EDIT_ZATCA_INTEGRATION permissions.

Why the sandbox shows "Not Reported"

In the walkthrough sandbox, ZatcaCredentials.isConfigured is false (onboarding not completed) and the environment isn't production, so invoices are never signed or transmitted. The fix is exercised the moment a clinic does complete onboarding — the rebuilt totals then match the stored ones without any further action.