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

Insurance Discovery

Business view

A patient arrives with a national ID card or an Iqama. They believe they are insured with "Bupa, I think — my company arranged it." The receptionist has no reliable way to turn that into the three facts the billing system needs: which insurer, which policy, which class of cover.

Check Insurance asks DHS that question directly. The receptionist enters (or the patient record already holds) the national ID and the identifier type; Dentolize asks the exchange "what coverage exists for this person?" and gets back a list — sometimes one policy, sometimes several if the person is covered by more than one employer.

The receptionist picks the right one, and then the wizard does something genuinely useful: it checks whether Dentolize already knows that insurer, policy and class, and offers to create the missing pieces on the spot. Previously someone had to leave the patient screen, go to Associates → Insurance Companies, create the insurer, create the policy, create the class, then come back. Now it happens inline, pre-filled with the payer's own data.

The five steps

  1. Select Coverage — the coverage cards DHS returned. Each shows insurer, policy

number, class, expiry, issue date, maximum limit, deductible rate, beneficiary details.

  1. Insurance Company — either "we already have this insurer" (green) or "we don't"

(amber) with an Add New Company form pre-filled from the payer data.

  1. Insurance Policy — same pattern, with three ways forward: auto-create the policy

and its class in one go, fill the policy form manually, or skip (which also skips the class and jumps to Review).

  1. Policy Class — same pattern again.
  2. Review — a summary showing, for each of the three entities, whether it was

existing, auto created, manually created, or skipped. An eligibility check is embedded here so the receptionist can confirm cover without leaving the flow.

The wizard is smart about skipping: if all three entities already exist, it jumps straight to Review. If only the insurer exists, it starts at the policy step.

Where to find it

  • Patient profile → Check Insurance button (next to Check Eligibility).
  • Patient add/edit form, as a link beside the National ID field. It is disabled while

the national ID has a validation error.

Both are hidden unless the DHS feature is on, the branch has an NPHIES code, and the user holds Check DHS Insurance.


Technical view

Step A — ask the payer

checkInsuranceEligibility (packages/server/src/resolvers/mutations/DHS/checkInsurance.js:14-136)

  • Input: { patientKey: String!, systemTypeId: Int!, branchId: String! }

(inputs.graphql:895-899). patientKey is the national ID; systemTypeId classifies the identifier.

  • Branch lookup is tenant-scoped: branch.findFirst({ where: { id: branchId, companyId } })

(:28-45). A missing branch and a branch without an nphiesCode both produce the same message, "Branch NPHIES code is not configured" — a cross-tenant probe is therefore indistinguishable from a misconfiguration, which is the intent.

  • Calls dhsAuthentication inline for a token (:47), then

POST ${DHS_ELIGIBILITY_URL}/api/v1/Eligibility/CheckInsurance with headers Authorization: Bearer … and OrganizationCode: <branch.nphiesCode> (:66-76).

  • Payload: { PatientKey, SystemTypeID, Provider: { NphiesCode }, UserName } (:57-64).
  • Purely read-only. Nothing is persisted by this mutation.
  • Response parsing tolerates two envelope shapes (data.insurance[] or top-level

insurance[]) and normalizes all 22 coverage fields across camelCase / PascalCase spellings with ?? chains (:89-117).

Note that maxLimit and deductibleRate come back as strings, not numbers (types.graphql:5508-5530).

Step B — do we already have it?

resolveInsuranceCoverages (packages/server/src/resolvers/queries/DHS/resolveInsuranceCoverages.js:7-119)

A strict three-level cascade, each level conditional on the previous:

LevelLookupUnique keyLine
Insurance companyinsuranceCompany.findUniquecompanyId_nphiesInsuranceCompanyId:51-63
Insurance policyinsurancePolicy.findUniqueinsuranceCompanyId_policyNumber:74-86
Policy classpolicyClass.findUniqueinsurancePolicyId_name:96-107

The composite unique on the insurer (schema.prisma:1310) is the tenant scope — no separate companyId filter is needed.

Two consequences worth documenting:

  • A perfectly valid policyNumber reports exists: false whenever the insurer did not

resolve, because level 2 never ran. The response cannot distinguish "not found" from "not looked up".

  • This query has no try/catch (:7-119), so a Prisma error propagates as a raw GraphQL

error rather than the success:false envelope the rest of the module uses.

If all three inputs are empty it returns the all-exists:false skeleton without querying anything (:39-45).

Step C — create the missing entities

The wizard fires three ordinary mutations, defined in packages/clinic-web-canary/src/features/DHSCheckInsurance/DHSCheckInsurance.mutations.tsx:

MutationLineNotes
AddNewInsuranceCompanyDHS:37-70Hardcodes insuranceLimit: 0, insuranceUnlimited: true, approvalRequired: true, priceList: null, disabled: false, forcePolicySelect: false, taxPercent: 0
AddNewInsurancePolicyDHS:72-101
AddNewPolicyClassDHS:103-134

createAutoPolicy (CheckInsuranceModal.tsx:433-477) creates the policy and the class in one action and jumps straight to Review.

Pre-fill logic lives in DHSCheckInsurance.utils.ts:

  • getInsurancePercentageFromDeductible (:24-33) — strips %, returns 100 - value,

clamped 0–100, defaulting to 100 for null/NaN. A 20% deductible becomes 80% cover.

  • getInsuranceStartMonthFromIssueDate (:35-42) — dayjs(issueDate).month(), 0 on invalid.

Identifier type mapping

packages/clinic-web-canary/src/features/DHS/shared/DHSIdentifierMappings.ts is the single source of truth. For Check Insurance, mapIdentifierTypeToCheckInsurance (:76-96) produces the systemTypeId:

CodeMeaningIdentifier types
1Nationals and Residentsnational_id, iqama, residency
2Visitorsvisa, visitor_id
3Touristspassport
5Hajj and Umrahdriver_license, border_number, displaced_person, other, OTHER

There is no code 4. An unknown input logs to console and falls back to 1 — it never throws.

SYSTEM_TYPE_MAP in DHSCheckInsurance.utils.ts:5-17 is a local duplicate of this table. It is exported but unused — getSystemTypeId (:19-22) delegates to the shared mapping.

Step machine

CheckInsuranceModal.tsx:184-207 — once resolvedCoverage arrives, the modal auto-advances past any level that already exists, and jumps straight to Review when all three do.

CheckInsuranceModal.tsx:693-708 computes the footer state. Next is blocked when:

  • Step 0: no coverage selected.
  • Step 2: the policy doesn't exist and none of auto-create / show form / skip is chosen

(:696-697); clicking anyway sets error_choosePolicyAction (:525-528).

Selecting a different coverage radio at step 0 resets all resolution state (:209-216).

Reading the hierarchy back

getInsuranceCompanyDhsInsuranceHierarchy (packages/server/src/resolvers/queries/DHS/getInsuranceCompanyDhsInsuranceHierarchy.js:6-92) returns the full company → policies → classes tree in one query, used by the patient insurance form to populate its dropdowns.

Its tenancy check is after the factfindUnique is not companyId-scoped, and the comparison at :75-77 dereferences insuranceCompany before the null check at :79-85. A non-existent id therefore throws a TypeError instead of the intended "Insurance company not found", making that branch dead code. See Known Gaps.

What the sandbox showed

On the branch sandbox, with no valid DHS client secret stored, pressing Check Insurance produces a toast reading exactly:

DHS Integration not configured or secret missing

That message originates in dhsAuthentication.js:31-33 and is relayed through checkInsurance.js:49-55. The button correctly stays enabled and no local records are created.