feat(fhir): emit Coverage on a bare KVNR + add Composition.text

Align the server's `Coverage` emission with the iOS exporter: a
Coverage is now emitted whenever ANY payor signal is present, including
a bare KVNR with no insurer name or IKNR. The KVNR-only case carries the
`subscriberId` with no contained payor Organization; the insurer-known
case is unchanged. A bundle with no payor signal at all still omits
Coverage entirely.

Also add a top-level `Composition.text` narrative (reusing the same
escaped summary the section narrative carries) so a strict
US-Core-style validator sees the document-level narrative it expects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 22c5ed7fbe02993307c3f04cd0498d199c79cf43)
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 16:45:23 +02:00
1 parent ca8781867b
commit 7fa2866154
3 files changed
+78 -22

No files matched your search

+43 -3
View File
@@ -241,7 +241,7 @@ describe("buildFhirDocumentBundle", () => {
expect(coverage?.contained?.[0].identifier?.[0].value).toBe("101234567");
});
it("omits the Coverage entirely when neither insurer name nor IKNR is present", () => {
it("omits the Coverage entirely when there is no payor signal at all", () => {
const bundle = buildFhirDocumentBundle(
makeData({
patient: {
@@ -253,17 +253,57 @@ describe("buildFhirDocumentBundle", () => {
// neither insurerName nor insurerIkNumber
},
}),
{ insuranceNumber: "A123456780" },
// ...and no KVNR.
{ insuranceNumber: null },
FIXED_NOW,
);
expect(coverageOf(bundle)).toBeUndefined();
// KVNR still rides on Patient.identifier.
});
it("emits a KVNR-only Coverage (no insurer) to align with the iOS exporter", () => {
const bundle = buildFhirDocumentBundle(
makeData({
patient: {
username: "sample-user",
dateOfBirth: null,
gender: null,
heightCm: null,
fullName: "Sample Patient",
// neither insurerName nor insurerIkNumber — just a bare KVNR.
},
}),
{ 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");
// No insurer known → no contained payor Organization, no payor[].
expect(coverage?.contained).toBeUndefined();
expect(coverage?.payor).toBeUndefined();
// KVNR still rides on Patient.identifier too.
const patient = bundle.entry.find(
(e) => e.resource.resourceType === "Patient",
)?.resource as FhirPatient;
expect(patient.identifier?.[0].value).toBe("A123456780");
});
it("emits a top-level Composition.text narrative", () => {
const bundle = buildFhirDocumentBundle(
makeData(),
{ insuranceNumber: null },
FIXED_NOW,
);
const composition = bundle.entry[0].resource;
expect(composition.resourceType).toBe("Composition");
if (composition.resourceType === "Composition") {
expect(composition.text?.status).toBe("generated");
expect(composition.text?.div).toContain("Health record for");
}
});
it("maps weight to LOINC 29463-7 / UCUM kg with the latest reading", () => {
const bundle = buildFhirDocumentBundle(
makeData(),
+29 -19
View File
@@ -226,33 +226,36 @@ 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.
// v1.9.0 — emitted whenever ANY payor signal is present: an insurer
// name, an IKNR, OR a bare KVNR. The KVNR-only case aligns the server
// with the iOS exporter (which emits a Coverage on a bare member id);
// it carries the `subscriberId` with no contained payor Organization.
// When an insurer name and/or IKNR is known, the payor is a CONTAINED
// Organization referenced by a local `#`-ref. The KVNR stays on
// `Patient.identifier` and doubles as the `subscriberId` (member id).
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 hasPayorOrg = Boolean(insurerName || insurerIkNumber);
if (hasPayorOrg || identity.insuranceNumber) {
const coverage: FhirCoverage = {
resourceType: "Coverage",
id: "coverage-1",
status: "active",
contained: [payorOrg],
beneficiary: patientRef,
payor: [{ reference: `#${orgId}` }],
};
if (hasPayorOrg) {
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;
coverage.contained = [payorOrg];
coverage.payor = [{ reference: `#${orgId}` }];
}
if (identity.insuranceNumber) {
coverage.subscriberId = identity.insuranceNumber;
}
@@ -555,6 +558,13 @@ export function buildFhirDocumentBundle(
date: now.toISOString(),
author: [{ reference: "Device/healthlog", display: "HealthLog" }],
title: "Health Record",
// v1.9.0 — top-level document narrative (Coverage nit 2). Reuses the
// same escaped plain-text summary the Vital-signs section carries, so
// a strict US-Core-style validator sees a `Composition.text`.
text: {
status: "generated",
div: `<div xmlns="http://www.w3.org/1999/xhtml">${escapeXml(narrativeText)}</div>`,
},
section: [
{
// Vital-signs section carries the narrative + every Observation ref,
+6
View File
@@ -175,6 +175,12 @@ export interface FhirComposition {
date: string;
author: FhirReference[];
title: string;
/**
* v1.9.0 — top-level document narrative. Section narrative is already
* present; a strict US-Core-style validator additionally expects a
* `Composition.text`. Reuses the same escaped plain-text summary.
*/
text?: FhirNarrative;
section?: FhirCompositionSection[];
}