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

Pre-Auth Approvals Lifecycle

This is the heart of the feature. A pre-authorization (pre-auth, or just "approval") is the clinic asking the payer to commit, in advance, to paying for a specific set of treatments for a specific patient.

Business view

1. Submitting

The dentist plans treatment on the patient chart, ticks the operations to be covered, and presses GET Approval (n). A six-step wizard opens (seven when the operations carry attachments):

StepWhat it asks for
Patient InfoDOB, marital status, gender, occupation, identifier type & number, membership number, policy number, policy holder, policy class — mostly pre-filled and locked
Request InfoVisit reason, transaction type, request type, priority
Encounter InfoEncounter class/status, service event type, admission & discharge dates, and the treating practitioner (name, SCFHS licence, speciality)
DiagnosisOne or more ICD diagnoses plus the clinical narrative: chief complaint, treatment plan, patient history, physical examination, history of present illness
ServicesThe chosen procedures with tooth number, quantity, unit price, discount — VAT at 15% is computed automatically and cannot be edited
Attachments(only if the operations have files) which X-rays / reports to include
ReviewEverything, collapsible, before submit

Before the wizard even opens, Dentolize refuses several situations outright, each with a detailed notification listing the offending operations:

  • Nothing selected.
  • Grouped teeth — an operation covering more than one tooth. NPHIES services are

per-tooth.

  • Quantity greater than 1.
  • Mixed branches — the operations belong to different sites. The claim is filed under

one branch's NPHIES code, so a mixed-branch approval could never be polled or cancelled.

  • Operations already flagged not subject to insurance are silently excluded from the

count.

On success the payer returns an approval number, the operations are stamped with it, and the approval goes to PENDING.

2. Waiting

A background job runs every minute, picks up pending approvals, and asks the payer for an answer. When one arrives it updates the approval status and — for approved and partially approved claims — writes the approved amount onto each operation.

Statuses the clinic will see:

StatusMeaning
DRAFTCreated locally, never sent
PENDINGWith the payer, awaiting a decision
APPROVEDPayer will cover it
PARTIALLY_APPROVEDSome services approved, some not
REJECTEDPayer declined
DENIEDPayer declined (a distinct payer code)
CANCELEDWithdrawn by the clinic
ERRORThe exchange returned an error, or an unrecognised status

3. Acting on it

The Approvals tab on the patient chart lists every approval with its number, status, response date, whether it was manually updated, and timestamps. Hovering the status tag opens an action popover whose contents depend on the status:

StatusActions offered
PENDING / DRAFTCheck Status (poll now) · Cancel Approval
APPROVED / PARTIALLY_APPROVEDCancel Approval
CANCELEDRead-only: who cancelled it and when
ERRORRetry
REJECTED / DENIEDNothing — tag only

Each action is additionally gated by permission.

4. When the payer answers outside the system

Sometimes the payer calls, or the answer arrives by email, and the exchange never updates. Manual Update lets an authorised user set the approved flag and the insurance value per operation by hand. Once used, the approval is permanently marked Manually Updated with the user's name, so nobody mistakes a hand-entered figure for a payer-confirmed one.

Manual update is blocked once an approval is finalised (APPROVED, REJECTED or CANCELED).


Technical view

Submission

dhsApprovalSubmission (packages/server/src/resolvers/mutations/DHS/dhsApprovalSubmission.js:21-356)

Filing-branch derivation is the security keystone. The SDL accepts branchId, but the resolver ignores it entirely (documented at :62-70). Instead it loads the linked operations' branches, companyId-scoped, and runs resolveApprovalBranch (:71-84):

// packages/server/src/utils/dhsApprovalBranch.js:19-39
no branches               → 'No linked operations; cannot determine the filing branch'
more than one distinct id → 'Operations span multiple branches; cannot determine the filing branch'
branch without nphiesCode → 'Branch NPHIES code is not configured'
otherwise                 → { branch }

Because this runs before anything else, an empty operationIds always fails, even though the field is optional in the schema.

Other guards, in order:

GuardLine
Session company present:23-31
No operation may have insuranceDiscount: false ("not subject to insurance"):47-60
Filing branch resolvable:71-84
Inline dhsAuthentication:86-94

Attachment handling (:198-236) — when a URL is supplied instead of inline binary:

  1. validateAttachmentUrl(url) — HTTPS, single S3 host, no private IPs post-DNS.
  2. axios.get with timeout: 10_000, maxContentLength: 10 MiB, maxRedirects: 0.
  3. Base64-encode, then two size checks: the base64 string against MAX_ATTACHMENT_BASE64

(~13.3 MB, :221-223) and the decoded bytes against 10 MiB (:225-228).

This is the only DHS call on the branch with an HTTP timeout, and it is on the attachment fetch, not on the DHS API call.

Redaction before storage (:257-265): the payload is cloned with every AttachmentBinary replaced by '[redacted]' before being written to DHSApproval.requestPayload. Patient images never land in the database twice.

Persistence (:281-331), only when the payer returned succeeded === true:

prisma.$transaction([
  dHSApproval.create({ companyId, patientId, status: 'PENDING', approvalNumber,
                       requestPayload, operations: { connect: [...] } }),
  operation.updateMany({ where: { id: { in: operationIds }, companyId },
                         data: { preAuth: approvalNumber } })
])
if (updateResult.count !== operationIds.length) throw  // rolls the whole thing back

The operations.connect is keyed on { id } with no company filter; the count assertion at :305-312 is what enforces tenancy, throwing Operation mismatch: Attempted to update N operations, but found M. Cross-tenant or invalid IDs detected.

Consequence worth knowing: if that rollback fires, the approval exists at the payer but not locally, and the user sees success:false. This is a real (if narrow) reconciliation hazard.

The polling cron

packages/server/src/cronJobs/dhsApprovals/dhsApprovalsCron.js

AspectValueLine
Schedule*/1 * * * * — every minutecronJobs.js:117-129
Redis lockSET <name> {...} EX 600 NX:52-58
Lock releasefinally { DEL }, error rethrown after release:343-346
Feature-flag bailGlobal active check, then per-company rule evaluation:68-75, :124-145
Batch captake: 100, orderBy: { updatedAt: 'asc' }:77-107
Selectionstatus: 'PENDING' and approvalNumber: { not: null }:80-83
GroupingBy patient.companyId:113-118
ConcurrencypLimit(5) across the whole tick:147
Token cacheRedis key dhs:token:${companyId}, TTL 3300 s (55 min):16, :39
Upstream callPOST ${DHS_PREAUTH_URL}/api/v1/Preauth/GetApprovalResponse, timeout: 10000, maxContentLength: 10 MiB:179-191
401 handlingDelete cached token, re-authenticate, retry once:192-217

Companies whose feature flag evaluates false are dropped from the batch and their approvals stay PENDING, untouched (:136-145).

Two behaviours to note:

  • The lock is released with an unconditional DEL, not a value-compare. A tick that

overran the 600 s TTL would delete a successor's lock.

  • DHS is the only job in cronJobs.js not wrapped in wrapCronJob (:121-123), so it

gets no jobContext observability wrapper.

Status mapping

packages/server/src/utils/dhsStatusMapping.js:12-36. Input is lower-cased and trimmed; falsy input returns ERROR at :13.

Payer stringLocal enum
queued, pended, pendingPENDING
approvedAPPROVED
partially approved, partially_approvedPARTIALLY_APPROVED
rejectedREJECTED
deniedDENIED
canceled, cancelledCANCELED
anything else, null, '', 0ERROR

Unit tests cover 18 cases (dhsStatusMapping.test.js), including coercion — an object with toString() → 'approved' maps to APPROVED, and the number 123 maps to ERROR. 'queued' is mapped in source but not covered by a test.

Operation reconciliation

Runs only for APPROVED and PARTIALLY_APPROVED (dhsApprovalsCron.js:241; dhsGetApproval.js:153). Both readers tolerate four envelope shapes for the service items and match on providerServiceCode === operation.code (first match wins, so duplicate codes collapse).

The approved flag is tri-state:

isApproved = true   if approvedQuantity >= 1
                    || serviceStatus is 'approved' or 'partially approved'
           = false  if approvedQuantity === 0 && serviceStatus === 'rejected'
           = null   otherwise  →  field is NOT written

(dhsApprovalsCron.js:264-269). Likewise insuranceValue = Math.round(approvedAmount) is only written when an amount came back (:275-276) — so amounts are stored as integers.

A load-bearing comment at :273 records the ownership boundary: DHS must never write tax or initialDiscount — those belong to the Invoice module. dhsManualUpdateOperations honours the same rule: it accepts discount and tax in its input but never persists them (dhsManualUpdateOperations.js:82-93).

Everything — the operation updates and the approval update — runs in a single prisma.$transaction (:303).

Polling on demand

dhsGetApproval (packages/server/src/resolvers/mutations/DHS/dhsGetApproval.js:17-254) is what the Check Status / Retry buttons call. Same logic as the cron, plus:

  • An explicit !dhsApprovalId guard (:30-31) whose comment explains why: Prisma drops

undefined filters, so without it the lookup would degrade to "any approval in this company".

  • On upstream failure or an unmapped status, it writes status: 'ERROR' with the

response body (:102-118, :128-149).

  • The catch block does a best-effort ERROR write inside a nested try/catch (:232-245).
There is no status precondition on the update. Re-polling a CANCELED approval will overwrite its status with whatever the payer currently reports.

Cancelling

dhsCancelApproval (packages/server/src/resolvers/mutations/DHS/dhsCancelApproval.js:15-166)

State-machine guard at :58-64: only PENDING or APPROVED may be cancelled. PARTIALLY_APPROVED is deliberately not cancellable.

On a successful cancel, one transaction does two companyId-scoped updateManys (:125-147):

DHSApproval  → status = 'CANCELED', canceledAt = now(), canceledById = <user>
Operation    → insuranceValue = 0, approved = false   -- for every linked operation

preAuth is deliberately not cleared. The upstream flag is read tolerantly — data.isCancelled === true || data.isCanceled === true (:118-122).

Note success: true with isCanceled: false is possible when the payer neither reports failure nor confirms cancellation.

Manual update

dhsManualUpdateOperations (packages/server/src/resolvers/mutations/DHS/dhsManualUpdateOperations.js:10-121) makes no external HTTP call. It is the offline reconciliation path.

  • Tenant assertion: loads the operations companyId-scoped and refuses when the count does

not equal the input length (:46-51). Duplicate operation ids therefore always fail.

  • Finalisation guard: refuses when the approval status is APPROVED, REJECTED or CANCELED

(:55-67). PENDING, PARTIALLY_APPROVED, DENIED, ERROR and DRAFT stay editable.

  • New status is derived from the input flags (:69-80): all approved: trueAPPROVED;

all falseREJECTED; a mix → PARTIALLY_APPROVED.

  • Stamps manualUpdateById, which per schema.prisma:5591-5593 is the source of truth

for "was this manually updated?"

In the modal (ManualUpdateModal.tsx), turning the approved switch off force-resets insuranceValue to 0 (:123-129), the value field is disabled unless approved is on (:141), and the discount column is displayed but always disabled (:149).

Status flags were removed from the schema

Migration 20260508120000_drop_dhs_redundant_status_flags dropped isCanceled and isManualUpdate after backfilling:

UPDATE "DHSApproval" SET "status" = 'CANCELED'
  WHERE "isCanceled" = true AND "status" <> 'CANCELED';
ALTER TABLE "DHSApproval" DROP COLUMN "isCanceled";
ALTER TABLE "DHSApproval" DROP COLUMN "isManualUpdate";

The derivations are now status = 'CANCELED' and manualUpdateById IS NOT NULL. canceledAt / canceledById / manualUpdateById are kept deliberately as audit data.

The name isCanceled does survive in the GraphQL response type DHSCancelApprovalResponse (types.graphql:5618-5622), now derived rather than stored.