Skip to main content

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.

TicketDeliversStatus
M18-001Maven module, /api/v1/insurance namespace, module descriptor, Feign context propagation, boundary testsdone
M18-002Payer/plan registry, benefit schedule, member coveragedone
M18-003Eligibility check with coded exclusion reasonsdone
M18-004Claims intake, adjudication worklist, per-line decisionsdone
M18-005Remittance advice and provider-side reconciliationdone
M18-006Milestone gate — Milestone18InsuranceIntegrationTest + core/MILESTONE18.mddone

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.

AreaMigrationPurpose
Schema001CREATE SCHEMA IF NOT EXISTS insurance
Payer registry002payer — global-capable definition (tenant + optional facility; GLOBAL visibility)
Plan registry003insurance_plan + relational plan_benefit schedule (concept-backed categories)
Member coverage004member — SENSITIVE (session tenant and facility; overlap rejected)
Claims intake005claim + claim_line — payer copy of submitted claim (SENSITIVE)
Denial reasons006denial_reason — TypeScope CRUD coded reasons
Adjudication007adjudication + adjudication_line — append-only with supersedes_adjudication_id
Remittance008remittance + 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)

MethodPathPermissionPurpose
POST/api/v1/insurance/payersinsurance.payer.writeCreate payer (?facilityLocal=true for facility scope)
GET/api/v1/insurance/payersinsurance.payer.readList visible payers
GET/api/v1/insurance/payers/{id}insurance.payer.readGet payer
PUT/api/v1/insurance/payers/{id}insurance.payer.writeUpdate payer
DELETE/api/v1/insurance/payers/{id}insurance.payer.writeVoid payer
POST/api/v1/insurance/plansinsurance.plan.writeCreate plan + benefit schedule
GET/api/v1/insurance/plansinsurance.plan.readList plans (?benefitCategoryConceptId= filter)
GET/api/v1/insurance/plans/{id}insurance.plan.readGet plan with benefits
PUT/api/v1/insurance/plans/{id}insurance.plan.writeUpdate plan; replaces benefits
DELETE/api/v1/insurance/plans/{id}insurance.plan.writeVoid plan
POST/api/v1/insurance/membersinsurance.member.writeCreate member coverage (requires session facility)
GET/api/v1/insurance/members?personId=insurance.member.readList coverage for person
GET/api/v1/insurance/members/active?personId=&serviceDate=insurance.member.readResolve active coverage (empty when none)
GET/api/v1/insurance/members/{id}insurance.member.readGet member row
PUT/api/v1/insurance/members/{id}insurance.member.writeUpdate member coverage
DELETE/api/v1/insurance/members/{id}insurance.member.writeVoid member coverage

API (M18-003)

MethodPathPermissionPurpose
POST/api/v1/insurance/eligibilityinsurance.eligibility.readCheck 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)

MethodPathPermissionPurpose
POST/api/v1/insurance/claims?autoAdjudicate=trueinsurance.claim.writeSubmit claim; auto-adjudicate by default
GET/api/v1/insurance/claims?status=insurance.claim.readAdjudication worklist
GET/api/v1/insurance/claims/{id}insurance.claim.readClaim with lines and latest adjudication
POST/api/v1/insurance/claims/{id}/adjudicateinsurance.adjudication.writeAuto- or re-adjudicate (append-only)
GET/api/v1/insurance/claims/{id}/adjudicationsinsurance.adjudication.readAdjudication history
GET/api/v1/insurance/claims/{id}/adjudications/{adjudicationId}insurance.adjudication.readOne adjudication snapshot
POST/api/v1/insurance/denial-reasonsinsurance.adjudication.writeCreate coded denial reason
GET/api/v1/insurance/denial-reasonsinsurance.adjudication.readList denial reasons (seeds standard codes)
PUT/api/v1/insurance/denial-reasons/{id}insurance.adjudication.writeUpdate display/description
DELETE/api/v1/insurance/denial-reasons/{id}insurance.adjudication.writeVoid 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 WorkflowInstanceClientworkflow_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)

MethodPathPermissionPurpose
POST/api/v1/insurance/remittancesinsurance.remittance.writeCreate remittance run from adjudicated claims (postImmediately default true)
GET/api/v1/insurance/remittancesinsurance.remittance.readList remittance runs
GET/api/v1/insurance/remittances/{id}insurance.remittance.readRemittance detail with lines
POST/api/v1/insurance/remittances/{id}/postinsurance.remittance.writeRetry pending/failed billing post-back (idempotent for posted lines)
GET/api/v1/insurance/remittances/exceptionsinsurance.remittance.readFailed post-back lines for retry
GET/api/v1/billing/insurance-claims/{id}billing.claim.readProvider claim read (Feign: BillingClient)
POST/api/v1/billing/insurance-claims/{id}/decisionbilling.claim.writeNarrow payer decision verb (Feign: BillingCommandClient)

Remittance posting maps payer AdjudicationOutcome to provider ClaimStatus (APPROVEDACCEPTED, PARTIALLY_APPROVEDPARTIALLY_APPROVED, DENIEDREJECTED). 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 core or another domain module. InsuranceArchitectureBoundaryTest fails the build on violation.
  • Delegated context: FeignContextPropagationConfig forwards Authorization, X-Tenant-Id, X-Facility-Id, X-Department-Id and X-Request-Id on every outbound Feign call. Without it, insurance → platform calls 403.
  • Remote errors: PlatformFeignErrorDecoder maps platform 4xx responses to local domain exceptions so callers see the platform's message, not a generic 500.
  • Permissions: InsuranceModuleDescriptor declares 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

SeedRungRows
Claim denial reasons (SEED-105)Global6
Nigerian HMO directory (DICT-005)Country, on FMOH-NG56
Insurance taxonomy (DICT-005B)Global9
Nigerian insurance schemes (DICT-005B)Country, on FMOH-NG2

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:

rungrows
Globalthe 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):

VariablePurpose
INSURANCE_APP_PORTHTTP port when running InsuranceApplication alone (default 8091)
INSURANCE_SERVICE_URLLoopback base when other modules call insurance over Feign (defaults to UHP_PLATFORM_URL)
INSURANCE_ADJUDICATION_REVIEW_WORKFLOW_DEFINITION_IDOptional workflow definition for exception review
INSURANCE_ADJUDICATION_REVIEW_THRESHOLD_AMOUNTOptional 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.

  • billing — M10-006/007 provider-side claims; M18-005 adds BillingClient.getInsuranceClaim and BillingCommandClient.recordClaimDecision for 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.