Eligibility Checks
Business view
Knowing who insures a patient is not the same as knowing their cover is live today. Policies lapse mid-year, employers switch insurers, dependants get dropped. An eligibility check is the clinic asking the payer, in real time: "is this person covered right now?"
In Dentolize this is the Check Eligibility button on the patient profile. The result becomes a small coloured tag next to the button — green Eligible or red Non-Eligible — with the timestamp of the last check on hover. The clinic can re-check at any time.
The "Fill Missing Fields" step
NPHIES will not answer an eligibility question unless it gets a complete picture of the patient: legal name, date of birth, gender, marital status, occupation, identifier type and number, plus the policy number and coverage class. Real patient records rarely have all twelve.
So the button does a pre-flight. If anything is missing, a Fill Missing Fields modal opens with everything the record already knows greyed out and read-only, and only the gaps editable. If nothing is missing, the check runs immediately with no modal at all.
This is a genuinely important behaviour to understand, and it surprises people:
The patient record is updated first, and the eligibility check runs second. If you fill in Marital Status and Occupation and press OK, those values are saved to the patient permanently — even if the eligibility call then fails.
This was confirmed live on the branch sandbox: filling in "Married" and "Doctor" persisted both to the patient profile, while the eligibility call itself failed because the sandbox has no DHS credential. That is by design (the clinic keeps the data-quality improvement), but support and training need to say it out loud.
Three visual states
| State | What the user sees |
|---|---|
| Patient has no insurance company | A grey Unavailable tag, tooltip explaining insurance is missing. No button. |
| Insured, never checked | The Check Eligibility button, primary blue. |
| Insured, checked | A green or red status tag, plus a Re-check Eligibility button in a muted style. |
When embedded in the Check Insurance review step with no saved patient yet, an info banner explains the check is preview-only.
The result
A modal shows payer name, provider name, policy number, policy holder, coverage type, inception / expiry / request dates, response date, payer reference number, and any remarks the payer returned.
Technical view
The mutation
dhsCheckEligibility (packages/server/src/resolvers/mutations/DHS/dhsCheckEligibility.js:15-183)
Input (inputs.graphql:971-978):
input DHSCheckEligibilityInput {
payer: PayerInput! # { nphiesCode }
patient: PatientInput! # name, DOB, gender, marital, occupation, identifier
coverage: CoverageInput! # membershipNumber, policyNumber, policyHolderName, className
branchId: String!
patientId: String # optional — controls whether anything is persisted
insuranceCompanyId: String
}
Sequence:
- Session company guard, then tenant-scoped branch +
nphiesCodecheck (:19-46) — same
shape as checkInsuranceEligibility.
- Inline
dhsAuthenticationfor a bearer token (:48-56). POST ${DHS_ELIGIBILITY_URL}/api/v2/Eligibility/Checkeligibility(note v2, and the
lower-case e in Checkeligibility) with Authorization and OrganizationCode headers (:92-102).
- If the body carries
succeeded === false, returnsuccess:falsewith **no database
write at all** (:107-114).
Status derivation
responseData?.data?.eligibilityCode?.code?.toLowerCase() === 'eligible'
? 'ELIGIBLE' : 'NON_ELIGIBLE'
(:116) — anything else, including a missing field, is NON_ELIGIBLE. The enum has only these two values (schema.prisma:5613-5616); there is no "unknown" state in the database.
The front end is slightly more permissive: parseEligibilityResponse (DHSCheckEligibility.utils.ts:199-201) treats either 'eligible' or 'active', read from either payerResponse.status or eligibilityCode.code, as eligible. So the badge and the stored row can in principle disagree for an 'active' response.
Persistence
Everything after step 4 is conditional on input.patientId being supplied (:120):
- Re-verify tenancy with
patient.findFirst({ where: { id: patientId, companyId } })
(:121-126). If the patient is not found, all writes are silently skipped (:128) and the mutation still returns success:true.
dHSEligibilityCheck.createwithcompanyId,patientId, resolved
insuranceCompanyId, status, the whole upstream body as responsePayload, and responseDate taken from data.requestHeader.submissionDateTime when present, else now (:129-145).
patient.updatesettingcurrentEligibilityIdto the new row, plusinsuranceNumber
when the payer returned a non-blank coverage.membershipNumber (:149-158).
Steps 2 and 3 are not wrapped in a$transaction. If the patient update fails, an orphanDHSEligibilityCheckrow is left behind and the mutation reportssuccess:falseeven though the check itself succeeded.
Patient.currentEligibilityId is @unique with a SET NULL delete rule (schema.prisma:965-966), so it points at exactly one check at a time — the "current" answer the UI renders.
Derived, not stored, UI state
useDHSCheckEligibility.ts:67-83 — activeEligibility is computed on every render, never kept in state:
- Prefer
localResult(this session's optimistic result) if itsinsuranceCompanyId
matches the patient's current insurer.
- Otherwise fall back to
patient.currentEligibilityunder the same match rule. - Otherwise
null.
The insurer-match rule is what makes the badge disappear when the patient's insurance is changed: a green Eligible from Bupa must not keep showing after the patient is moved to Tawuniya. The same guard appears in PatientProfile.js:91-93 and in the Check Insurance review step (ReviewStep.tsx:85-89).
On success the hook also writes a PatientEligibilityUpdate fragment into the Apollo cache (:127-145), minting a temporary id temp-<ts>-<rand> when the server did not return one.
The pre-flight gate
handleCheckClick (useDHSCheckEligibility.ts:187-232) checks twelve fields: insuranceCompany.id, insuranceCompany.nphiesCode, insurancePolicy.policyNumber, policyClass.name, nationalId, job, marital, gender, identifierType, birthDate, firstName, lastName. Any one missing opens the modal.
handleModalSubmit (:281-307) is where the "update first" behaviour lives:
validateFields()
→ if enablePatientUpdate: updatePatient() ← persists, aborts on failure
→ else if onPatientDataChange: push values to host without persisting
→ performCheck()
Field-level validation
Every field the patient already has is disabled — isFieldDisabled returns true for any non-empty trimmed value (DHSCheckEligibility.utils.ts:125-127). identifierType is the exception: it locks only when both the type and the national ID are already set (DHSCheckEligibility.tsx:179).
National-ID validation is country-aware, driven by user.company.country (DHSCheckEligibility.tsx:196-245):
| Country | Rule |
|---|---|
SA, types national_id / iqama / residency / border_number | Exactly 10 digits |
SA, national_id | Must start with 1 |
SA, iqama / residency | Must start with 2 |
| EG | Exactly 14 digits; first char 2 or 3; chars 3–5 a valid month; 5–7 a valid day |
The legacy patient form carries the matching Saudi rule at packages/clinic-web/src/components/common/formFields/NationalIdField.js:52.
Payload coding
buildEligibilityPayload (DHSCheckEligibility.utils.ts:143-181) throws 'Branch ID is missing' when branchId is falsy (:144) — caught by performCheck into a toast.
Coding tables, all with silent defaults:
| Field | Mapping | Line |
|---|---|---|
| Gender | male=0, female=1, default 0 | :94-97 |
| Marital status | single=1, married=2, divorced=3, widowed=4, separated=5, default 1 | :101-110 |
| Occupation | healthcare=7, military=8, unemployed=9, retired=10, student=11, everything else=14 | :112-123 |
| Identifier type | mapIdentifierTypeToEligibility, unknown → 7 | DHSIdentifierMappings.ts:22-43 |
Hardcoded values: subscriberRelated: 6 (Self), fallback NPHIES code 'INS-FHIRss', fallback birth date '2000-01-01', fallback names 'Unknown', fallback policy holder 'Holder'.
Writing back to the patient
updatePatientDetails (packages/server/src/resolvers/mutations/DHS/updatePatientDetails.js:12-98) is the mutation the modal calls first. It is the security-hardened rewrite mentioned in the PR description:
- Explicit allow-list via
lodash/pick(:21-37), not a rest-spread:gender,
nationality, birthDate, firstName, lastName, phoneNumber, email, nationalId, identifierType, firstNameE, lastNameE, marital, job.
- Tenant-scoped write:
patient.updateMany({ where: { id, companyId } }), throwing when
count !== 1 (:72-79).
- Future birth dates rejected (
:39-45). - Identifier types are normalized, never rejected:
iqama → residency;
visa | border_number | displaced_person → OTHER; anything unrecognised → OTHER with a log.warn (:52-66).
Two things to know about it:
- Three fields declared on
UpdatePatientDetailsInput—policyNumber,coverageType,
nphiesCode — are not on the allow-list and are silently discarded (inputs.graphql:911-924 vs :21-37).
- It is the only DHS-namespaced mutation not gated by
FEATURE_DHS_INTEGRATION
(permissions.js:2782). It is guarded by EDIT_PATIENTS_DETAILS + isSameCompanyAsPatient instead, because it is really a general patient-edit mutation that happens to live in the DHS/ folder.
Every failure — including "no valid fields" and "not in your company" — collapses to "Update failed. Please try again." (:90-97); the real cause only reaches the log.