Insurance / Payer — How it works
Overview
The insurance module is the payer-side insurance domain: payer and plan registry, eligibility
checking, claims adjudication, and remittance — serving insurance companies. It is deliberately
distinct from the provider-side claim submission/eligibility request built in M12-008: M12-008
asks, M18 answers.
It is a domain module (com.zhenus.uhp.api.insurance), so it imports only common and
exchange and reaches platform engines over Feign — the same call whether it is bundled in the
single jar or split into its own service later. Design: MILESTONE18_PLAN.md.
workforce is the structural reference. M18-001 is the skeleton only. The module is installed,
scanned, permission-gated and documented; it owns the insurance schema but no business tables yet.
| Ticket | Delivers | Status |
|---|---|---|
| M18-001 | Maven module, /api/v1/insurance namespace, module descriptor, Feign context propagation, boundary tests | done |
| M18-002 | Payer/plan registry, benefit schedule, member coverage | done |
| M18-003 | Eligibility check with coded exclusion reasons | done |
| M18-004 | Claims intake, adjudication worklist, per-line decisions | done |
| M18-005 | Remittance advice and provider-side reconciliation | done |
| M18-006 | Milestone gate — Milestone18InsuranceIntegrationTest + core/MILESTONE18.md | done |
Frontend counterpart: FE-301 (module scaffold, nav entry, permission gating), then FE-302…FE-306.
Data model & ownership
Owned tables will live under insurance/src/main/resources/db/changelog/insurance/. The master
changelog is deliberately named insurance.db.changelog-master.yaml, not
db.changelog-master.yaml — the latter collides with core's on the single-jar classpath.
| Area | Migration | Purpose |
|---|---|---|
| Schema | 001 | CREATE SCHEMA IF NOT EXISTS insurance |
| Payer registry | 002 | payer — global-capable definition (tenant + optional facility; GLOBAL visibility) |
| Plan registry | 003 | insurance_plan + relational plan_benefit schedule (concept-backed categories) |
| Member coverage | 004 | member — SENSITIVE (session tenant and facility; overlap rejected) |
| Claims intake | 005 | claim + claim_line — payer copy of submitted claim (SENSITIVE) |
| Denial reasons | 006 | denial_reason — TypeScope CRUD coded reasons |
| Adjudication | 007 | adjudication + adjudication_line — append-only with supersedes_adjudication_id |
| Remittance | 008 | remittance + remittance_line — payment advice and billing post-back status |
Scoping (governing). payer and insurance_plan are definitions — global-capable config
(one payer and its benefit schedule serve every facility in the tenant). member, claim,
adjudication and remittance are person-tied — SENSITIVE: tenant and facility from the
signed session or fail loudly. See the data-scoping rule at the top of MILESTONE18_PLAN.md.
Payer visibility has three rungs (DICT-005)
A payer is visible to a session when it sits on the session's own tenant, on the country government tenant of that tenant, or on the Global tenant, and its facility arm is either unset or the session's facility. Nearest rung first.
⚠⚠ The country rung was added by DICT-005 and the read is wrong without it. Before that the query was tenant OR global only, which leaves nowhere correct to publish a national directory: Global shows Nigeria's HMOs to a deployment in Ghana, and a tenant rung has to be seeded again for every tenant in the country. Seeding at the country rung against a tenant-or-global read is worse than either, because the rows insert cleanly, the migration is green, and the payer list at check-in is simply empty.
The country arm comes from core's /type-scopes/current, not from the session token: the token
carries the tenant, and only core knows which country that tenant belongs to. It is never empty; a
tenant with no country resolves to the all-zero sentinel, because an empty IN (...) is a SQL
syntax error rather than an empty result.
Benefit categories are concept_id values validated over Feign, not free text or a local
lookup table.
API (M18-002)
| Method | Path | Permission | Purpose |
|---|---|---|---|
| POST | /api/v1/insurance/payers | insurance.payer.write | Create payer (?facilityLocal=true for facility scope) |
| GET | /api/v1/insurance/payers | insurance.payer.read | List visible payers |
| GET | /api/v1/insurance/payers/{id} | insurance.payer.read | Get payer |
| PUT | /api/v1/insurance/payers/{id} | insurance.payer.write | Update payer |
| DELETE | /api/v1/insurance/payers/{id} | insurance.payer.write | Void payer |
| POST | /api/v1/insurance/plans | insurance.plan.write | Create plan + benefit schedule |
| GET | /api/v1/insurance/plans | insurance.plan.read | List plans (?benefitCategoryConceptId= filter) |
| GET | /api/v1/insurance/plans/{id} | insurance.plan.read | Get plan with benefits |
| PUT | /api/v1/insurance/plans/{id} | insurance.plan.write | Update plan; replaces benefits |
| DELETE | /api/v1/insurance/plans/{id} | insurance.plan.write | Void plan |
| POST | /api/v1/insurance/members | insurance.member.write | Create member coverage (requires session facility) |
| GET | /api/v1/insurance/members?personId= | insurance.member.read | List coverage for person |
| GET | /api/v1/insurance/members/active?personId=&serviceDate= | insurance.member.read | Resolve active coverage (empty when none) |
| GET | /api/v1/insurance/members/{id} | insurance.member.read | Get member row |
| PUT | /api/v1/insurance/members/{id} | insurance.member.write | Update member coverage |
| DELETE | /api/v1/insurance/members/{id} | insurance.member.write | Void member coverage |
API (M18-003)
| Method | Path | Permission | Purpose |
|---|---|---|---|
| POST | /api/v1/insurance/eligibility | insurance.eligibility.read | Check benefit eligibility for a person on a service date |
The eligibility answerer resolves active member coverage for the service date (not “now”), evaluates
each requested benefitCategoryConceptId against the plan’s relational plan_benefit schedule, and
returns per-benefit status (COVERED, LIMITED, EXCLUDED) with a coded reason, annual/per-visit
limits and accumulated usage. When no coverage applies, the response carries noCoverageReason and an empty
benefit list — never a 500.
Usage accumulation derives from approved adjudication lines in the service year (M18-004).
Deferred: FHIR CoverageEligibilityRequest/CoverageEligibilityResponse mappers and M12-008
PayerGateway adapter — M12 financial FHIR types and PayerGateway SPI are not on the branch yet; native
REST only for this ticket.
API (M18-004)
| Method | Path | Permission | Purpose |
|---|---|---|---|
| POST | /api/v1/insurance/claims?autoAdjudicate=true | insurance.claim.write | Submit claim; auto-adjudicate by default |
| GET | /api/v1/insurance/claims?status= | insurance.claim.read | Adjudication worklist |
| GET | /api/v1/insurance/claims/{id} | insurance.claim.read | Claim with lines and latest adjudication |
| POST | /api/v1/insurance/claims/{id}/adjudicate | insurance.adjudication.write | Auto- or re-adjudicate (append-only) |
| GET | /api/v1/insurance/claims/{id}/adjudications | insurance.adjudication.read | Adjudication history |
| GET | /api/v1/insurance/claims/{id}/adjudications/{adjudicationId} | insurance.adjudication.read | One adjudication snapshot |
| POST | /api/v1/insurance/denial-reasons | insurance.adjudication.write | Create coded denial reason |
| GET | /api/v1/insurance/denial-reasons | insurance.adjudication.read | List denial reasons (seeds standard codes) |
| PUT | /api/v1/insurance/denial-reasons/{id} | insurance.adjudication.write | Update display/description |
| DELETE | /api/v1/insurance/denial-reasons/{id} | insurance.adjudication.write | Void denial reason |
Adjudication evaluates each line against the member's active plan plan_benefit schedule. Exceptions
(no coverage, missing prior auth, duplicate paid claim, amount above review threshold) pend the claim —
they are never auto-denied. When insurance.adjudication.review.workflow-definition-id is configured,
exceptions also start a workflow instance (SubjectType.INSURANCE_CLAIM_REVIEW); when unset the claim
remains queryable in PENDED status.
M18-004B verification: InsuranceClaimReviewPersistenceIntegrationTest in platform-app boots the
bundled deployable and exercises adjudication → real loopback WorkflowInstanceClient → workflow_instance,
queue_item, and insurance.adjudication.workflow_instance_id. Requires WORKFLOW-SUBJECT-001 (!374).
Re-adjudication writes a new adjudication row with supersedesAdjudicationId; prior decisions stay
readable. Payer lifecycle uses PayerClaimStatus — not billing.ClaimStatus.
Deferred: FHIR Claim/ClaimResponse mappers (claim.fhir stub); billing Feign claim read is available on BillingClient.getInsuranceClaim for reconciliation.
API (M18-005)
| Method | Path | Permission | Purpose |
|---|---|---|---|
| POST | /api/v1/insurance/remittances | insurance.remittance.write | Create remittance run from adjudicated claims (postImmediately default true) |
| GET | /api/v1/insurance/remittances | insurance.remittance.read | List remittance runs |
| GET | /api/v1/insurance/remittances/{id} | insurance.remittance.read | Remittance detail with lines |
| POST | /api/v1/insurance/remittances/{id}/post | insurance.remittance.write | Retry pending/failed billing post-back (idempotent for posted lines) |
| GET | /api/v1/insurance/remittances/exceptions | insurance.remittance.read | Failed post-back lines for retry |
| GET | /api/v1/billing/insurance-claims/{id} | billing.claim.read | Provider claim read (Feign: BillingClient) |
| POST | /api/v1/billing/insurance-claims/{id}/decision | billing.claim.write | Narrow payer decision verb (Feign: BillingCommandClient) |
Remittance posting maps payer AdjudicationOutcome to provider ClaimStatus (APPROVED→ACCEPTED,
PARTIALLY_APPROVED→PARTIALLY_APPROVED, DENIED→REJECTED). Pended claims cannot be remitted.
Each line carries remittanceReference on both sides for reconciliation. Duplicate
remittanceReference per payer is rejected; already-posted claims are skipped on retry.
Key rules & invariants
- Feign-only boundary: the module must not import
coreor another domain module.InsuranceArchitectureBoundaryTestfails the build on violation. - Delegated context:
FeignContextPropagationConfigforwardsAuthorization,X-Tenant-Id,X-Facility-Id,X-Department-IdandX-Request-Idon every outbound Feign call. Without it, insurance → platform calls 403. - Remote errors:
PlatformFeignErrorDecodermaps platform 4xx responses to local domain exceptions so callers see the platform's message, not a generic 500. - Permissions:
InsuranceModuleDescriptordeclares every M18 permission family from the skeleton onward so the access-control catalog seeder registers them at startup:insurance.payer.*,insurance.plan.*,insurance.member.*,insurance.eligibility.*,insurance.claim.*,insurance.adjudication.*,insurance.remittance.*(read/write each).
API
REST surface under /api/v1/insurance. M18-001 registers no controllers yet; route constants live in
InsuranceConstants for upcoming tickets. Link to the generated API Reference (/api-reference)
once controllers land. Swagger group: insurance.
Seeded reference data
| Seed | Rung | Rows |
|---|---|---|
| Claim denial reasons (SEED-105) | Global | 6 |
| Nigerian HMO directory (DICT-005) | Country, on FMOH-NG | 56 |
| Insurance taxonomy (DICT-005B) | Global | 9 |
| Nigerian insurance schemes (DICT-005B) | Country, on FMOH-NG | 2 |
The HMO directory carries a head-office address for all 56 and a telephone number for 38. That gap is the source's: the NHIA listing the workbook transcribes gives no number for the other 18.
⚠ These contact details rot. They are a starting directory so nobody types 56 payers by hand, not a source of truth. Any facility can edit a seeded payer and can add payers the platform never listed. A tenant outside Nigeria sees none of them.
The insurance taxonomy is two deep, and spans two rungs
insurance_type is a user-managed categorical, not a Java enum: NHIA licenses schemes and states
launch their own, so recording a new one must not require a release. It is self-referencing, because
the taxonomy genuinely nests: a row with no parent is a category, a row with one is a sub-type.
The split across rungs is the interesting part:
| rung | rows |
|---|---|
| Global | the three categories, plus HMO, group, individual and family, micro and PPO |
Country (FMOH-NG) | NHIS / NHIA, and the state health insurance schemes |
⚠ The Nigerian sub-types hang off the Global PUBLIC category. That is the point of the rungs:
a country adds to a shared taxonomy rather than copying it. Anything that resolved parents within a
single rung would silently orphan them, so there is a test for exactly that.
⚠⚠ Retiring a category that still has sub-types is refused. The foreign key does not prevent it, because voiding sets a flag rather than deleting the row: the database stays happy while every sub-type points at a category that no longer resolves.
Managed from Insurance → Payer registry → Insurance types.
Configuration & feature flags
Standalone run (optional):
| Variable | Purpose |
|---|---|
INSURANCE_APP_PORT | HTTP port when running InsuranceApplication alone (default 8091) |
INSURANCE_SERVICE_URL | Loopback base when other modules call insurance over Feign (defaults to UHP_PLATFORM_URL) |
INSURANCE_ADJUDICATION_REVIEW_WORKFLOW_DEFINITION_ID | Optional workflow definition for exception review |
INSURANCE_ADJUDICATION_REVIEW_THRESHOLD_AMOUNT | Optional billed-amount threshold routing claims to review |
Bundled single-jar run uses the platform datasource and includes
insurance.db.changelog-master.yaml from platform.db.changelog-master.yaml.
Related features
- billing — M10-006/007 provider-side claims; M18-005 adds
BillingClient.getInsuranceClaimandBillingCommandClient.recordClaimDecisionfor remittance reconciliation. - fhir — M12-004 financial resources; remittance reconciliation extends billing clients on M18-005.
- clinical — member coverage ties to patient/person references validated over Feign (M18-002).
Why it is this way
Payer and plan registries are TypeScope CRUD, not free text. Eligibility and claims reference stable payer/plan ids; adjudication outcomes post back to billing through exchange clients rather than duplicating invoice state inside insurance.
Claims and remittance reconcile billing — they do not replace it. M18-005 extends
BillingCommandClient.recordClaimDecision; the billing engine remains the financial source of
truth for issued invoices and payments.
Member coverage validates person/patient over Feign. Insurance never imports demographic engines; cross-module patient identity stays at the exchange boundary.
Traps
⚠ Adjudication review workflow is optional but threshold-driven — when
INSURANCE_ADJUDICATION_REVIEW_THRESHOLD_AMOUNT is set, high billed amounts must route to review;
misconfiguration silently skips oversight.
⚠ Do not store raw card or policy secrets in insurance tables — store tokens/references only; PCI and policy-number handling belong in controlled integration layers.
⚠ Claim status is categorical (enum) — never a free-text column; downstream billing reconciliation keys off closed outcome values.
⚠ Remittance post-back must be idempotent — duplicate recordClaimDecision calls from payer
files must not double-apply adjustments.