Dentolize · DHS (NPHIES) Integration Walkthrough
On this pageBusiness viewTechnical view

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

SurfaceWhat appears
Patient chart, operations tableA coloured status tag with a hover action popover
Patient chart → Approvals tabThe full list, one row per approval
Logs → OperationsTwo extra columns: Approval Status and Manual Updated
Operation detailApproval status, read-only for NPHIES branches
Invoice expanded rowApproval 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:16
  • resolvers/mutations/actions/invoices/createNewInvoice.js:35

assertDhsOwnedFieldsUnchanged re-exports the guard in packages/server/src/utils/dhsFieldGuards.js:33-48:

ConstantValueLine
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:70
  • patientMutations.js:947
  • actions/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

FileBehaviour
finances/invoices/AddOperationsButton.js:290,319,390PENDING operations unselectable
finances/invoices/InvoiceExpendedRow.js:274Approval status read-only on NPHIES branches
finances/quotations/ConvertInvoiceButton.js:15,43,50Convert 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:

StatusRendering
PENDINGSpinning SyncOutlined, "processing" colour
DRAFTCyan
APPROVED / PARTIALLY_APPROVEDSuccess green
CANCELEDDefault grey
ERROR / REJECTED / DENIEDError red
Anything elsePlain <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.

handleGetApproval refetches 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.