Operations & Invoicing Guards
Business view
The riskiest moment in insurance billing is the gap between "we asked the payer" and "the payer answered". If a treatment can be edited, re-priced, re-toothed or invoiced while a pre-auth is in flight, the clinic ends up billing something the payer never approved.
So a large, quiet part of this PR is not new screens at all — it is the app learning to say no.
What gets locked, and when
While an approval is PENDING:
- The operation cannot be added to an invoice. The Invoice button excludes it, and the
server refuses if you try anyway.
- A quotation containing it cannot be converted to an invoice; the button is disabled with
a "waiting for approval" tooltip.
- It cannot be re-submitted for another approval — the GET Approval count skips it.
Once any approval exists on an operation (any status except CANCELED):
- Price, amount, tooth, doctor, creation date and diagnosis become read-only.
- The pre-auth number itself is read-only permanently, in every status.
- Manual approve/deny on the operation is disabled — the approval status is now owned by
the payer, not by staff.
Once the approval is CANCELED:
- Price, tooth, doctor, diagnosis unlock again, so the clinic can correct the treatment and
re-submit.
- The pre-auth number stays locked, as an audit trail of what was originally asked for.
Operations flagged "not subject to insurance" are excluded from DHS submission entirely, both in the UI count and by a server-side guard.
Where the clinic sees approval status
| Surface | What appears |
|---|---|
| Patient chart, operations table | A coloured status tag with a hover action popover |
| Patient chart → Approvals tab | The full list, one row per approval |
| Logs → Operations | Two extra columns: Approval Status and Manual Updated |
| Operation detail | Approval status, read-only for NPHIES branches |
| Invoice expanded row | Approval status, read-only |
All of these are hidden unless the DHS feature is on for the company.
Technical view
Server-side guards
packages/server/src/resolvers/mutations/mutationUtils/dhsGuards.js exposes three helpers.
assertNoPendingDhsApproval (:40-53) throws 'app.waitingApproval[Translate Error]' when a PENDING approval exists and the feature flag resolves true for the company. The ordering is deliberate — the cheap existence check runs first, so the flag lookup is skipped on the common path, and a company with the flag off is never blocked from invoicing.
Called from:
resolvers/mutations/actions/addOperationsToInvoice.js:16resolvers/mutations/actions/invoices/createNewInvoice.js:35
assertDhsOwnedFieldsUnchanged re-exports the guard in packages/server/src/utils/dhsFieldGuards.js:33-48:
| Constant | Value | Line |
|---|---|---|
PREAUTH_PROTECTED_FIELDS | ['preAuth'] — locked in every status | :2 |
GENERAL_PROTECTED_FIELDS | ['tooth', 'price', 'amount', 'doctorId', 'createdAt', 'diagnosis'] | :3 |
The active set is PREAUTH alone when status === 'CANCELED', otherwise PREAUTH + GENERAL (:36-39). Only fields where incoming[field] !== undefined are compared (:42), so partial updates pass cleanly.
Comparison is careful (dhsFieldsEqual, :5-20): arrays are compared with order-insensitive multiset semantics — a diagnosis list reordered by the UI is not a change — and Date values are compared by getTime().
Called from:
actions/operations/editOperationWithoutInvoice.js:70patientMutations.js:947actions/patient/saveOperations.js:59
isDhsFeatureActiveForCompany (:12-28) — used by actions/operations/updateOperationsStatus.js:58.
Test coverage in dhsFieldGuards.test.js:12-112 includes the CANCELED unlock, preAuth staying locked when CANCELED, order-insensitive diagnosis comparison, and undefined-skipping.
Client-side selection logic
packages/clinic-web/src/components/dashboard/patients/Patient/components/PatientChart/ChartTable.js:264-280 computes which operations may be submitted:
const selectedForDHS = operations.filter(op =>
selected(op) &&
(!op.dhsApproval || !['PENDING', 'APPROVED'].includes(op.dhsApproval.status)) &&
!op.invoice &&
op.insuranceDiscount !== false
)
The comment above it cites ticket XLZ-474 and states the insuranceDiscount !== false clause exists to match the dhsApprovalSubmission backend guard.
Practical consequence, observed live: in the branch sandbox every seeded operation already had an invoice, so GET Approval was disabled on every patient until a fresh, un-invoiced procedure was added to the chart. !op.invoice is the clause most likely to confuse a tester who expects the button to be live.
The button label carries the count of submittable operations — selectedForDHS.filter(op => !op.dhsApproval).length (DHSApprovalSubmission.tsx:22-24) — and is disabled when that count is zero (:50).
Field locking in the UI
packages/clinic-web/src/components/dashboard/logs/operations/OperationForm.js:49-53:
isDhsPreAuthLocked = !!dhsApproval
isDhsGeneralLocked = !!dhsApproval && dhsApproval.status !== 'CANCELED'
Applied at :230, :286, :323, and to the pre-auth input at :405.
OperationApprovalStatus.js:25-26 widens the read-only condition beyond DHS-linked operations:
effectiveReadonly = readonly
|| !!operation?.dhsApproval
|| (hasDhsFeature && !!operation?.branch?.nphiesCode)
So on any NPHIES-mapped branch, manual approve/deny is disabled even for operations that have never been near DHS.
The mobile app carries equivalent guards without the feature flag: EditOperationScreen.js:42-45, AddInvoiceOperationsDrawer.js:82-87, NewInvoiceScreen.js:2193, OperationsMoreButton.js:101-141.
Invoicing surfaces
| File | Behaviour |
|---|---|
finances/invoices/AddOperationsButton.js:290,319,390 | PENDING operations unselectable |
finances/invoices/InvoiceExpendedRow.js:274 | Approval status read-only on NPHIES branches |
finances/quotations/ConvertInvoiceButton.js:15,43,50 | Convert disabled with an app.waitingApproval tooltip when any selected operation has a PENDING approval |
The status popover
packages/clinic-web/src/components/dashboard/common/DHSStatusPopover.js — status → appearance map at :10-33:
| Status | Rendering |
|---|---|
| PENDING | Spinning SyncOutlined, "processing" colour |
| DRAFT | Cyan |
| APPROVED / PARTIALLY_APPROVED | Success green |
| CANCELED | Default grey |
| ERROR / REJECTED / DENIED | Error red |
| Anything else | Plain <Tag>, no popover (:31, :113-115) |
Popover content by status and permission (:79-105): PENDING/DRAFT get Check Status (checkDhsInsurance) plus a Cancel Popconfirm (cancelDhsApproval); APPROVED/PARTIALLY_APPROVED get cancel only; CANCELED shows canceledAt (formatted lll) and canceledBy.name; ERROR gets Retry (getDhsApprovals); REJECTED/DENIED get nothing.
The hover trigger is only attached when hasNphies && content (:135), where hasNphies comes from branchData?.branchDetails?.nphiesCode (:47). On an unmapped branch the tag is inert.
The component is used in exactly one place —Approvals.js:11,153-162. The equivalent popover on the chart table (ChartTable.js:319-450) is hand-inlined rather than reusing it.
The Approvals tab
packages/clinic-web/src/components/dashboard/patients/Patient/components/Approvals/Approvals.js
Columns (:133-181): Approval Number (a link to /logs/dhsApproval/:id), Approval Status (the popover), Response Date (lll), Manual Updated (green tick with the updater's name in a tooltip, or red cross), Created, Last Updated.
Read-only by construction (:188-203): hasEdit={false}, newButton={null}, DrawerForm={null}. Only a date-range picker and a search-by-approval-number box.
Visibility condition (ChartTabs.js:130-134): hasDhsFeature && branchData?.branchDetails?.nphiesCode && user.permissions.viewDhsApprovals.
handleGetApprovalrefetches with hardcoded first-page variables (skip: 0, take: 15, orderBy: 'createdAt-desc',:53-72), so after polling from page 2 or under a filter the visible list will not reflect what the user was looking at.