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):
| Step | What it asks for |
|---|---|
| Patient Info | DOB, marital status, gender, occupation, identifier type & number, membership number, policy number, policy holder, policy class — mostly pre-filled and locked |
| Request Info | Visit reason, transaction type, request type, priority |
| Encounter Info | Encounter class/status, service event type, admission & discharge dates, and the treating practitioner (name, SCFHS licence, speciality) |
| Diagnosis | One or more ICD diagnoses plus the clinical narrative: chief complaint, treatment plan, patient history, physical examination, history of present illness |
| Services | The 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 |
| Review | Everything, 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:
| Status | Meaning |
|---|---|
| DRAFT | Created locally, never sent |
| PENDING | With the payer, awaiting a decision |
| APPROVED | Payer will cover it |
| PARTIALLY_APPROVED | Some services approved, some not |
| REJECTED | Payer declined |
| DENIED | Payer declined (a distinct payer code) |
| CANCELED | Withdrawn by the clinic |
| ERROR | The 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:
| Status | Actions offered |
|---|---|
| PENDING / DRAFT | Check Status (poll now) · Cancel Approval |
| APPROVED / PARTIALLY_APPROVED | Cancel Approval |
| CANCELED | Read-only: who cancelled it and when |
| ERROR | Retry |
| REJECTED / DENIED | Nothing — 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:
| Guard | Line |
|---|---|
| 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:
validateAttachmentUrl(url)— HTTPS, single S3 host, no private IPs post-DNS.axios.getwithtimeout: 10_000,maxContentLength: 10 MiB,maxRedirects: 0.- 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
| Aspect | Value | Line |
|---|---|---|
| Schedule | */1 * * * * — every minute | cronJobs.js:117-129 |
| Redis lock | SET <name> {...} EX 600 NX | :52-58 |
| Lock release | finally { DEL }, error rethrown after release | :343-346 |
| Feature-flag bail | Global active check, then per-company rule evaluation | :68-75, :124-145 |
| Batch cap | take: 100, orderBy: { updatedAt: 'asc' } | :77-107 |
| Selection | status: 'PENDING' and approvalNumber: { not: null } | :80-83 |
| Grouping | By patient.companyId | :113-118 |
| Concurrency | pLimit(5) across the whole tick | :147 |
| Token cache | Redis key dhs:token:${companyId}, TTL 3300 s (55 min) | :16, :39 |
| Upstream call | POST ${DHS_PREAUTH_URL}/api/v1/Preauth/GetApprovalResponse, timeout: 10000, maxContentLength: 10 MiB | :179-191 |
| 401 handling | Delete 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.jsnot wrapped inwrapCronJob(: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 string | Local enum |
|---|---|
queued, pended, pending | PENDING |
approved | APPROVED |
partially approved, partially_approved | PARTIALLY_APPROVED |
rejected | REJECTED |
denied | DENIED |
canceled, cancelled | CANCELED |
anything else, null, '', 0 | ERROR |
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
!dhsApprovalIdguard (: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): allapproved: true→APPROVED;
all false → REJECTED; a mix → PARTIALLY_APPROVED.
- Stamps
manualUpdateById, which perschema.prisma:5591-5593is 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.