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
- Select Coverage — the coverage cards DHS returned. Each shows insurer, policy
number, class, expiry, issue date, maximum limit, deductible rate, beneficiary details.
- 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.
- 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).
- Policy Class — same pattern again.
- 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
dhsAuthenticationinline 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:
| Level | Lookup | Unique key | Line |
|---|---|---|---|
| Insurance company | insuranceCompany.findUnique | companyId_nphiesInsuranceCompanyId | :51-63 |
| Insurance policy | insurancePolicy.findUnique | insuranceCompanyId_policyNumber | :74-86 |
| Policy class | policyClass.findUnique | insurancePolicyId_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
policyNumberreportsexists: falsewhenever 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:
| Mutation | Line | Notes |
|---|---|---|
AddNewInsuranceCompanyDHS | :37-70 | Hardcodes 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%, returns100 - 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:
| Code | Meaning | Identifier types |
|---|---|---|
| 1 | Nationals and Residents | national_id, iqama, residency |
| 2 | Visitors | visa, visitor_id |
| 3 | Tourists | passport |
| 5 | Hajj and Umrah | driver_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_MAPinDHSCheckInsurance.utils.ts:5-17is 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 fact — findUnique 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.