From 90586d6279dab45d161a36ecda4f04c0c0bf4eab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 1 Jun 2026 23:23:27 +0200 Subject: [PATCH] feat(export): emit a FHIR Coverage resource for the insurer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The health-record FHIR bundle now carries a Coverage resource right after the Patient. Coverage.status is active, beneficiary references the Patient, and subscriberId carries the KVNR when present. The payor is a contained Organization referenced by a local #-ref: its name is the insurer name and its identifier carries the IKNR under the http://fhir.de/sid/arge-ik/iknr system. When the IKNR is absent the Organization keeps its name only; when neither insurer name nor IKNR is known the Coverage is omitted rather than emitting an empty payor. The KVNR continues to ride on Patient.identifier unchanged — Coverage is purely additive. --- src/lib/doctor-report-data.ts | 6 + src/lib/fhir/__tests__/build-bundle.test.ts | 135 ++++++++++++++++++++ src/lib/fhir/build-bundle.ts | 42 ++++++ src/lib/fhir/types.ts | 22 ++++ 4 files changed, 205 insertions(+) diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 3b95a29d9..a40206ade 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -67,6 +67,10 @@ export interface DoctorReportData { // renderer treats absent and null identically. fullName?: string | null; insurerName?: string | null; + // v1.8.6 — German insurer institution number (IKNR), plaintext. Carried + // here for the FHIR `Coverage` payor Organization identifier. Optional + // so pre-v1.8.6 fixtures still typecheck. + insurerIkNumber?: string | null; }; /** * Free-text practice / clinic name printed on the cover. `null` omits the @@ -334,6 +338,7 @@ export async function collectDoctorReportData( // decrypts it and hands it to the builders. fullName: true, insurerName: true, + insurerIkNumber: true, }, }), ]); @@ -575,6 +580,7 @@ export async function collectDoctorReportData( heightCm: userProfile?.heightCm ?? null, fullName: userProfile?.fullName ?? null, insurerName: userProfile?.insurerName ?? null, + insurerIkNumber: userProfile?.insurerIkNumber ?? null, }, practiceName, measurements: filteredByType, diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index 985b7a263..3fb55e4a1 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -5,9 +5,18 @@ import type { FhirObservation, FhirMedicationStatement, FhirPatient, + FhirCoverage, FhirDiagnosticReport, } from "../types"; +function coverageOf( + bundle: ReturnType, +): FhirCoverage | undefined { + return bundle.entry + .map((e) => e.resource) + .find((r): r is FhirCoverage => r.resourceType === "Coverage"); +} + function observationsOf(bundle: ReturnType) { return bundle.entry .map((e) => e.resource) @@ -117,6 +126,132 @@ describe("buildFhirDocumentBundle", () => { expect(patient.identifier).toBeUndefined(); }); + it("emits a Coverage with a contained Organization carrying the IKNR + subscriberId KVNR", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + patient: { + username: "sample-user", + dateOfBirth: "1985-06-15T00:00:00.000Z", + gender: "MALE", + heightCm: 182, + fullName: "Sample Patient", + insurerName: "Example Insurer", + insurerIkNumber: "101234567", + }, + }), + { insuranceNumber: "A123456780" }, + FIXED_NOW, + ); + const coverage = coverageOf(bundle); + expect(coverage).toBeDefined(); + expect(coverage?.status).toBe("active"); + expect(coverage?.beneficiary.reference).toBe("Patient/patient-1"); + expect(coverage?.subscriberId).toBe("A123456780"); + // Payor references the contained Organization via a local #-ref. + const payorRef = coverage?.payor?.[0].reference; + expect(payorRef?.startsWith("#")).toBe(true); + const org = coverage?.contained?.[0]; + expect(org?.resourceType).toBe("Organization"); + expect(`#${org?.id}`).toBe(payorRef); + expect(org?.name).toBe("Example Insurer"); + expect(org?.identifier?.[0].system).toBe( + "http://fhir.de/sid/arge-ik/iknr", + ); + expect(org?.identifier?.[0].value).toBe("101234567"); + }); + + it("places the Coverage right after the Patient in the bundle", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + patient: { + username: "sample-user", + dateOfBirth: null, + gender: null, + heightCm: null, + fullName: "Sample Patient", + insurerName: "Example Insurer", + insurerIkNumber: "101234567", + }, + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const types = bundle.entry.map((e) => e.resource.resourceType); + const patientIdx = types.indexOf("Patient"); + const coverageIdx = types.indexOf("Coverage"); + expect(patientIdx).toBeGreaterThanOrEqual(0); + expect(coverageIdx).toBe(patientIdx + 1); + }); + + it("emits a name-only payor Organization when the IKNR is absent", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + patient: { + username: "sample-user", + dateOfBirth: null, + gender: null, + heightCm: null, + fullName: "Sample Patient", + insurerName: "Example Insurer", + // no insurerIkNumber + }, + }), + { insuranceNumber: "A123456780" }, + FIXED_NOW, + ); + const coverage = coverageOf(bundle); + expect(coverage).toBeDefined(); + expect(coverage?.subscriberId).toBe("A123456780"); + const org = coverage?.contained?.[0]; + expect(org?.name).toBe("Example Insurer"); + expect(org?.identifier).toBeUndefined(); + }); + + it("emits a Coverage with no subscriberId when the KVNR is absent", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + patient: { + username: "sample-user", + dateOfBirth: null, + gender: null, + heightCm: null, + fullName: "Sample Patient", + insurerName: "Example Insurer", + insurerIkNumber: "101234567", + }, + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const coverage = coverageOf(bundle); + expect(coverage).toBeDefined(); + expect(coverage?.subscriberId).toBeUndefined(); + expect(coverage?.contained?.[0].identifier?.[0].value).toBe("101234567"); + }); + + it("omits the Coverage entirely when neither insurer name nor IKNR is present", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + patient: { + username: "sample-user", + dateOfBirth: null, + gender: null, + heightCm: null, + fullName: "Sample Patient", + // neither insurerName nor insurerIkNumber + }, + }), + { insuranceNumber: "A123456780" }, + FIXED_NOW, + ); + expect(coverageOf(bundle)).toBeUndefined(); + // KVNR still rides on Patient.identifier. + const patient = bundle.entry.find( + (e) => e.resource.resourceType === "Patient", + )?.resource as FhirPatient; + expect(patient.identifier?.[0].value).toBe("A123456780"); + }); + it("maps weight to LOINC 29463-7 / UCUM kg with the latest reading", () => { const bundle = buildFhirDocumentBundle( makeData(), diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index f816843de..34a85eb26 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -40,6 +40,8 @@ import type { FhirObservation, FhirMedicationStatement, FhirPatient, + FhirCoverage, + FhirOrganization, FhirComposition, FhirDiagnosticReport, FhirReference, @@ -55,6 +57,9 @@ export interface FhirPatientIdentity { /** KVNR identifier namespace per the gematik SID. */ const KVNR_SYSTEM = "http://fhir.de/sid/gkv/kvid-10"; +/** German insurer institution-number (IKNR) identifier namespace. */ +const IKNR_SYSTEM = "http://fhir.de/sid/arge-ik/iknr"; + /** Escape the five XML-significant characters for the xhtml narrative. */ function escapeXml(value: string): string { return value @@ -140,6 +145,43 @@ export function buildFhirDocumentBundle( } entries.push({ fullUrl: `urn:uuid:${patientId}`, resource: patient }); + // --- Coverage (insurer; sits right after the Patient) ------------------ + // Emitted whenever ANY payor info is present (insurer name and/or IKNR). + // The payor is a CONTAINED Organization referenced by a local `#`-ref. + // The KVNR stays on `Patient.identifier`; here it doubles as the + // Coverage `subscriberId` (the member id) when present. When neither an + // insurer name nor an IKNR is known, omit Coverage entirely rather than + // emit an empty payor. + const insurerName = data.patient.insurerName ?? null; + const insurerIkNumber = data.patient.insurerIkNumber ?? null; + if (insurerName || insurerIkNumber) { + const orgId = "insurer-org-1"; + const payorOrg: FhirOrganization = { + resourceType: "Organization", + id: orgId, + }; + if (insurerIkNumber) { + payorOrg.identifier = [{ system: IKNR_SYSTEM, value: insurerIkNumber }]; + } + if (insurerName) payorOrg.name = insurerName; + + const coverage: FhirCoverage = { + resourceType: "Coverage", + id: "coverage-1", + status: "active", + contained: [payorOrg], + beneficiary: patientRef, + payor: [{ reference: `#${orgId}` }], + }; + if (identity.insuranceNumber) { + coverage.subscriberId = identity.insuranceNumber; + } + entries.push({ + fullUrl: `urn:uuid:${coverage.id}`, + resource: coverage, + }); + } + let obsSeq = 0; const pushObservation = (obs: FhirObservation) => { entries.push({ fullUrl: `urn:uuid:${obs.id}`, resource: obs }); diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index bb84d26b8..7b7484f66 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -106,6 +106,27 @@ export interface FhirDiagnosticReport { result?: FhirReference[]; } +export interface FhirOrganization { + resourceType: "Organization"; + id: string; + identifier?: FhirIdentifier[]; + name?: string; +} + +export interface FhirCoverage { + resourceType: "Coverage"; + id: string; + status: "active" | "cancelled" | "draft" | "entered-in-error"; + /** Inline payor Organization, referenced from `payor` via a local `#`-ref. */ + contained?: FhirOrganization[]; + /** The covered party — Reference(Patient). */ + beneficiary: FhirReference; + /** German KVNR (the subscriber's member id), when present. */ + subscriberId?: string; + /** Insurer(s) — Reference to the contained Organization. */ + payor?: FhirReference[]; +} + export interface FhirCompositionSection { title: string; code?: FhirCodeableConcept; @@ -128,6 +149,7 @@ export interface FhirComposition { export type FhirResource = | FhirComposition | FhirPatient + | FhirCoverage | FhirObservation | FhirMedicationStatement | FhirDiagnosticReport;