Dentolize · Quotation Tax Fix & New Design Walkthrough
On this pageBusiness viewTechnical view

Patient vs. Insurance Tax Split

Business view

When a dental clinic quotes a patient who has insurance, part of the bill is usually paid by the insurance company and part by the patient. In many markets — Saudi Arabia in particular, where Dentolize's tax/VAT handling is most exercised — the tax rate charged to an insurance company can be different from the tax rate charged to the patient. Before this PR, Dentolize's quotation screen didn't know that: it applied one single tax percentage to the entire estimate, no matter how the bill was split between insurer and patient.

That meant a quotation for an insured patient could show the wrong total tax — sometimes overcharging the patient, sometimes undercharging, and in both cases producing a number that wouldn't match the invoice generated later from that quotation (invoices already did this split correctly).

Now, every quotation stores two tax rates:

  • Patient tax % — the rate applied to the portion of each procedure the patient pays out of pocket.
  • Insurance tax % — the rate applied to the portion covered by the patient's insurance company.

The clinic never has to type these in from scratch: the patient's own tax rate and the insurance company's tax rate are pulled automatically from the patient's profile and their linked insurance company when a quotation is built, and staff can override them per procedure if needed.

Technical view

Data model

Quotation gained two columns, both defaulting to 0:

patientTaxPercent     Float          @default(0)
insuranceTaxPercent   Float          @default(0)

packages/prisma/schema.prisma:2764-2765, added by packages/prisma/migrations/20260806203102/migration.sql.

The Invoice model already has an identical pair (packages/prisma/schema.prisma:2634-2635) — this PR brings Quotation to parity with Invoice rather than inventing a new pattern.

GraphQL exposes both as nullable Float fields on Quotation (packages/server/src/types.graphql:2071-2072) and as optional arguments on both mutations:

createNewQuotation(..., insurancePercent: Float, pendingPaymentPercent: Float,
                    patientTaxPercent: Float, insuranceTaxPercent: Float, ...): Quotation!
editQuotation(..., insurancePercent: Float, pendingPaymentPercent: Float,
               patientTaxPercent: Float, insuranceTaxPercent: Float, ...): Quotation!

packages/server/src/schema.graphql:1166-1167.

Where the two rates come from

Both the web drawer and the mobile screen derive the rates the same way, in buildLineItemsFromSelection (web, QuotationDrawer.js:189-194) / buildLineItemsFromOps (mobile, NewQuotationScreen.js:187-194):

  • patientTaxPercent = the patient's own tax field if set, else the clinic's user.company.tax.
  • insuranceTaxPercent = the linked insuranceCompany.taxPercent if it's a number, else it falls back to patientTaxPercent (i.e., no insurer-specific rate means "same as patient").

When editing an existing quotation, these are instead seeded straight from the stored quotation.patientTaxPercent / quotation.insuranceTaxPercent (mobile NewQuotationScreen.js:462-463, web equivalent on load) rather than recomputed from the patient's current profile — so changing a patient's tax rate or insurance company after a quotation exists does not retroactively change that quotation.

Per-procedure tax math

For each procedure line item, tax is computed as two pieces and summed:

const insuranceTax = taxApplied && insuranceTaxPercent.current
  ? (insurance * insuranceTaxPercent.current) / 100
  : 0
const patientPart = subtotal - discount - insurance
const patientPartTax = taxApplied && patientTaxPercent.current
  ? (patientPart * patientTaxPercent.current) / 100
  : 0

(web QuotationDrawer.js:221-224; the identical shape repeats at lines 309-313, 549-552, 586-589 and 703). taxApplied is a per-procedure boolean toggle; a procedure with tax turned off contributes 0 regardless of rate.

The quotation-level totals aggregator, updateValuesFromLineItems (QuotationDrawer.js:353-465), branches on whether the two rates are equal:

  • Equal rates (patientTaxPercent.current === insuranceTaxPercent.current, line 379): falls back to a single uniform-tax computation — the historical behavior, preserved as a simpler path when there's no actual split to make.
  • Different rates (lines 397-415): sums insuranceTax and patientTax separately across every taxed line item.

A small floating-point correction (subtracting 0.001 before rounding, around QuotationDrawer.js:440-445) exists because summing many per-line percentage calculations can drift by fractions of a cent — worth knowing if totals ever look off by a cent during testing.

Server-side recalculation (handleUpdateQuotations)

packages/server/src/resolvers/mutations/mutationUtils/quotationUtils.js:4-90 is the function the server runs to recompute a quotation's stored totals (for example after a linked invoice payment or procedure change) — it can't trust client-supplied totals, so it rebuilds them from the stored per-operation JSON (quotation.operationInsurance) and the patientTaxPercent/insuranceTaxPercent columns:

const taxApplied = stored.taxApplied != null ? !!stored.taxApplied : !!op.taxApplied
if (taxApplied) {
  const baseAfterDiscount = Math.max(opSubtotal - opDiscount, 0)
  if (stored.tax != null) {
    tax += Number(stored.tax) || 0
  } else if (hasPerOpData && patientTaxPercent !== insuranceTaxPercent) {
    const insurancePart = Math.min(opInsurance, baseAfterDiscount)
    const patientPart = Math.max(baseAfterDiscount - insurancePart, 0)
    tax += (insurancePart * insuranceTaxPercent) / 100 + (patientPart * patientTaxPercent) / 100
  } else {
    const rate = stored.taxPercent ? Number(stored.taxPercent) : topTaxPercent
    tax += (baseAfterDiscount * rate) / 100
  }
}

quotationUtils.js:63-79. It also carries explicit legacy fallbacks (storedByOp, hasPerOpData) so quotations created before this PR — which have no per-operation JSON — still recompute sensibly using the old flat-percentage path.

pendingPayment is now computed from a pendingPaymentPercent instead of always assuming 100%:

const pendingPaymentPercent = typeof args.pendingPaymentPercent === 'number' ? args.pendingPaymentPercent : 100
const pendingPayment = Number((((args.total - (args.insurance || 0)) * pendingPaymentPercent) / 100 || 0).toFixed(2))

packages/server/src/resolvers/mutations/patientMutations.js:1231-1232 (create quotation) and the equivalent block in editQuotation.

Note for QA: the GraphQL argument pendingPaymentPercent is declared in the schema and in both client mutations, but neither QuotationDrawer.js (web) nor NewQuotationScreen.js (mobile) currently sends it in their mutation variables — so in practice it always falls back to 100 today. This looks like schema plumbing added ahead of a UI control that doesn't exist yet; don't assume pending-payment percentage is actually editable end-to-end from either app.

The "editing the top-level field resets the split" behavior

handleUpdateTax / handleUpdateTaxPercent (web QuotationDrawer.js:613-681, mirrored on mobile) set both refs to the same manually typed value:

patientTaxPercent.current = taxPercent
insuranceTaxPercent.current = taxPercent

(QuotationDrawer.js:621-622 and 653-654). This is by design — typing a value into the single quotation-level "Tax %" field is how staff force one flat rate for the whole quotation — but it silently discards any patient/insurance split that was previously in effect. The only UI cue is that the top-level discount/tax/insurance inputs become disabled whenever the two rates currently differ (disabled={patientTaxPercent.current !== insuranceTaxPercent.current}, QuotationDrawer.js:1560,1578), forcing edits down to the per-procedure level in that case — there's no separate warning dialog explaining why.

See also Per-Procedure Line Items for how staff toggle tax and insurance per procedure in the UI.