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 owntaxfield if set, else the clinic'suser.company.tax.insuranceTaxPercent= the linkedinsuranceCompany.taxPercentif it's a number, else it falls back topatientTaxPercent(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
insuranceTaxandpatientTaxseparately 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.