import type { Coding, Extension, Invoice, InvoiceLineItem } from 'fhir/r4'; import { computeTax, fromFhirMoney, sumCents, toFhirMoney } from './money'; export const LINE_TAXABLE_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/invoice-line-taxable'; // Invoice-level due date (valueDate, YYYY-MM-DD). The plugin WRITES this exact URL on save; both repos // read it back - so the literal MUST match the infra util byte for byte. Frozen at issue by the existing // issued-invoice immutability guard (no separate freeze logic). export const INVOICE_DUE_DATE_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/invoice-due-date'; // Invoice-level allow-partial-pay policy flag (valueBoolean). Mirrors the server's // checkoutPayload.ts INVOICE_ALLOW_PARTIAL_PAY_EXTENSION_URL (Task 6) - the plugin WRITES this exact // URL (seeded here or flipped retroactively via PUT /R4/invoice-partial-pay/{id}), the server reads it // back at checkout time - so the literal MUST match byte for byte. Unlike due-date, this is payment // POLICY not frozen financial content, so it stays editable on an already-issued invoice via its own // narrow endpoint rather than the issued-invoice immutability guard. export const INVOICE_ALLOW_PARTIAL_PAY_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/extensions/invoice-allow-partial-pay'; const SOURCE_SYSTEM = 'https://akashichealthrecords.com/fhir/CodeSystem/invoice-line-source'; // Explicit sourceType coding: a 'timed' line's sourceRef is a HealthcareService - the SAME kind as a // flat 'service' line - so deriving sourceType from sourceRef.kind alone is ambiguous as of Phase B. // This coding disambiguates going forward; readInvoiceLines falls back to kind-derivation for lines // already shipped in Phase A that have no explicit sourceType coding. const LINE_SOURCE_TYPE_SYSTEM = 'https://akashichealthrecords.com/fhir/CodeSystem/invoice-line-source-type'; const APPOINTMENT_SOURCE_SYSTEM = 'https://akashichealthrecords.com/fhir/CodeSystem/invoice-line-appointment'; export type InvoiceLineSourceType = 'service' | 'fee' | 'custom' | 'timed' | 'inventory'; export type InvoiceLineSourceRef = { kind: 'HealthcareService' | 'ChargeItemDefinition'; id: string }; export type InvoiceLineDraft = { description: string; unitPriceCents: number; // per-unit quantity: number; // > 0 (default 1); fractional allowed (e.g. a Time line's 1.5 hours) taxable: boolean; // default false (Phase A never computes tax) sourceType: InvoiceLineSourceType; sourceRef?: InvoiceLineSourceRef; appointmentId?: string; // set for 'timed' lines booked off an Appointment coding?: Coding; // retained for backward-compat reads }; export type InvoiceDraft = { patientId: string; // Human name frozen onto subject.display at creation, so the invoice list can show a patient name // without a per-row Patient fetch or a session-cache hit (see InvoiceList.patientLabel). Optional: // the locked-patient path (launched from a patient page) leaves it unset and relies on that patient // already being cached. patientDisplay?: string; // Invoice-level due date (YYYY-MM-DD). Optional: when unset, buildInvoiceResource omits the extension // entirely so the output is byte-identical to the pre-due-date shape. dueDate?: string; // Invoice-level allow-partial-pay policy. Optional: when undefined, buildInvoiceResource omits the // extension entirely (same "omit when unset" behavior as dueDate); when true or false, the extension // is written explicitly. Seeded in the editor from the org default (new invoice) or the existing // invoice's own extension (editing a draft) - see InvoiceEditor.tsx. allowPartialPay?: boolean; lines: InvoiceLineDraft[]; }; // Rounded to the nearest integer cent: quantity can now be fractional (e.g. a Time line's 1.5 hours), // and unitPriceCents * quantity can land on a fractional cent (3333 * 1.5 = 4999.5) - money.ts's // integer-cents invariant (and toFhirMoney's Money precision) requires an integer here. This is a no-op // for the whole-number quantities that made up every line before Task 5. export const lineAmountCents = (line: InvoiceLineDraft): number => Math.round(line.unitPriceCents * (line.quantity > 0 ? line.quantity : 1)); const sourceCoding = (ref?: InvoiceLineSourceRef): Coding | undefined => ref ? { system: SOURCE_SYSTEM, code: `${ref.kind}:${ref.id}` } : undefined; const readSourceRef = (coding?: Coding): InvoiceLineSourceRef | undefined => { if (coding?.system !== SOURCE_SYSTEM || !coding.code) return undefined; const [kind, id] = coding.code.split(':'); if ((kind === 'HealthcareService' || kind === 'ChargeItemDefinition') && id) return { kind, id }; return undefined; }; const VALID_SOURCE_TYPES: InvoiceLineSourceType[] = ['service', 'fee', 'custom', 'timed', 'inventory']; const isInvoiceLineSourceType = (code?: string): code is InvoiceLineSourceType => VALID_SOURCE_TYPES.includes(code as InvoiceLineSourceType); // Codings that are our own bookkeeping (source ref, explicit sourceType, appointment link) rather than // a clinical/SNOMED coding - used to find the "leftover" clinical coding without false-matching these. const BOOKKEEPING_SYSTEMS = [SOURCE_SYSTEM, LINE_SOURCE_TYPE_SYSTEM, APPOINTMENT_SOURCE_SYSTEM]; // Build a CREATE payload. Deliberately omits `status` and `identifier`: the server forces status:'draft' // and allocates the invoice number (Phase 1 Invoice/post.ts). Cents decimal only via toFhirMoney. // `taxRatePercent` defaults to 0 (no tax) so every pre-Phase-C caller/test keeps building an invoice // with NO tax component and totalGross = the plain line sum - unchanged from before this rate existed. export const buildInvoiceResource = (draft: InvoiceDraft, taxRatePercent = 0): Invoice => { const lineItem: InvoiceLineItem[] = draft.lines.map((line, i) => { // An issued invoice line must freeze the billed code, not just a pointer: carry BOTH the // source ref (audit trail back to the HealthcareService/ChargeItemDefinition) and any // clinical/SNOMED coding on the line, as separate entries in the same coding array. const srcCoding = sourceCoding(line.sourceRef); const sourceTypeCoding: Coding = { system: LINE_SOURCE_TYPE_SYSTEM, code: line.sourceType }; const appointmentCoding: Coding | undefined = line.appointmentId ? { system: APPOINTMENT_SOURCE_SYSTEM, code: line.appointmentId } : undefined; const coding: Coding[] = [ ...(srcCoding ? [srcCoding] : []), ...(line.coding ? [line.coding] : []), sourceTypeCoding, ...(appointmentCoding ? [appointmentCoding] : []), ]; const ext: Extension[] = line.taxable ? [{ url: LINE_TAXABLE_EXTENSION_URL, valueBoolean: true }] : []; return { sequence: i + 1, ...(ext.length ? { extension: ext } : {}), chargeItemCodeableConcept: { ...(coding.length ? { coding } : {}), text: line.description }, priceComponent: [{ type: 'base', factor: line.quantity > 0 ? line.quantity : 1, amount: toFhirMoney(line.unitPriceCents) }], }; }); // Tax is computed ONLY on lines flagged taxable, then frozen onto the invoice below - it is never // re-derived from a current rate on read (see invoiceTaxCents/invoiceTotalCents). const subtotalCents = sumCents(draft.lines.map(lineAmountCents)); const taxableSubtotalCents = sumCents(draft.lines.filter((l) => l.taxable).map(lineAmountCents)); const taxCents = computeTax(taxableSubtotalCents, taxRatePercent); // Invoice-level extensions COEXIST in one array: due-date and allow-partial-pay are independent // slots, each added only when its draft field is present/defined, so an invoice with neither (or // just one) serializes identically to before that field existed (existing snapshot/roundtrip tests // unchanged) and an invoice with both keeps both. const invoiceExtensions: Extension[] = [ ...(draft.dueDate ? [{ url: INVOICE_DUE_DATE_EXTENSION_URL, valueDate: draft.dueDate }] : []), ...(draft.allowPartialPay !== undefined ? [{ url: INVOICE_ALLOW_PARTIAL_PAY_EXTENSION_URL, valueBoolean: draft.allowPartialPay }] : []), ]; return { resourceType: 'Invoice', subject: { reference: `Patient/${draft.patientId}`, ...(draft.patientDisplay ? { display: draft.patientDisplay } : {}), }, ...(invoiceExtensions.length ? { extension: invoiceExtensions } : {}), lineItem, totalGross: toFhirMoney(subtotalCents + taxCents), // Omitted entirely (not just zeroed) when there's no tax, so a 0-rate/no-taxable-line invoice // serializes identically to the pre-tax shape - no totalNet, no totalPriceComponent. ...(taxCents > 0 ? { totalNet: toFhirMoney(subtotalCents), totalPriceComponent: [ { type: 'tax', factor: taxRatePercent / 100, amount: toFhirMoney(taxCents) }, ], } : {}), } as Invoice; }; const readUnitAndQty = (item: InvoiceLineItem): { unitPriceCents: number; quantity: number } => { const pc = item.priceComponent?.find((c) => c.type === 'base'); return { unitPriceCents: fromFhirMoney(pc?.amount), quantity: pc?.factor && pc.factor > 0 ? pc.factor : 1 }; }; export const readInvoiceLines = (invoice: Invoice): InvoiceLineDraft[] => (invoice.lineItem ?? []).map((item) => { const cc = item.chargeItemCodeableConcept; const codings = cc?.coding ?? []; // Source ref, explicit sourceType, appointment link, and clinical coding are independent slots // in the array - recover each on its own rather than assuming position, so a line built with // several of these roundtrips all of them. const sourceRef = readSourceRef(codings.find((c) => c.system === SOURCE_SYSTEM)); const coding = codings.find((c) => !BOOKKEEPING_SYSTEMS.includes(c.system ?? '')); const explicitSourceType = codings.find((c) => c.system === LINE_SOURCE_TYPE_SYSTEM)?.code; const appointmentId = codings.find((c) => c.system === APPOINTMENT_SOURCE_SYSTEM)?.code; const taxable = (item.extension ?? []).some( (e) => e.url === LINE_TAXABLE_EXTENSION_URL && e.valueBoolean === true); const { unitPriceCents, quantity } = readUnitAndQty(item); // Prefer the explicit sourceType coding (disambiguates 'service' vs 'timed', which share a // HealthcareService sourceRef); fall back to Phase-A kind-derivation for lines shipped before // this coding existed, so already-issued invoices keep reading correctly. const sourceType: InvoiceLineSourceType = isInvoiceLineSourceType(explicitSourceType) ? explicitSourceType : sourceRef ? (sourceRef.kind === 'HealthcareService' ? 'service' : 'fee') : 'custom'; return { description: cc?.text ?? '', unitPriceCents, quantity, taxable, sourceType, ...(sourceRef ? { sourceRef } : {}), ...(appointmentId ? { appointmentId } : {}), ...(coding ? { coding } : {}), }; }); /** Line sum (net of tax) - always recomputed from the frozen line items, never a stored total. */ export const invoiceSubtotalCents = (invoice: Invoice): number => sumCents(readInvoiceLines(invoice).map(lineAmountCents)); // FREEZE: reads the tax amount the invoice was BUILT with (stored `totalPriceComponent` type:'tax' // entry), never recomputed from today's rate. An org's tax rate can change after an invoice is issued; // re-deriving here would silently reprice every past invoice whenever that happens. export const invoiceTaxCents = (invoice: Invoice): number => { const taxComponent = invoice.totalPriceComponent?.find((c) => c.type === 'tax'); return fromFhirMoney(taxComponent?.amount); }; // FREEZE: reads the stored `totalGross` when present (set by buildInvoiceResource, tax-inclusive). // Falls back to the line sum only for backward-compat with any invoice data that predates totalGross // being set. NOT `subtotal + invoiceTaxCents(invoice)` - the stored totalGross IS the frozen figure. export const invoiceTotalCents = (invoice: Invoice): number => invoice.totalGross !== undefined ? fromFhirMoney(invoice.totalGross) : sumCents(readInvoiceLines(invoice).map(lineAmountCents)); const storedLineAmountCents = (item: InvoiceLineItem): number | null => { const pc = item.priceComponent?.find((c) => c.type === 'base'); const value = pc?.amount?.value; if (!pc?.amount || typeof value !== 'number' || !Number.isFinite(value)) return null; const quantity = pc.factor && pc.factor > 0 ? pc.factor : 1; return Math.round(fromFhirMoney(pc.amount) * quantity); }; export const readInvoiceTotalCents = (invoice: Invoice): number | null => { if (invoice.totalGross !== undefined) { if (typeof invoice.totalGross.value !== 'number' || !Number.isFinite(invoice.totalGross.value)) return null; return fromFhirMoney(invoice.totalGross); } const items = invoice.lineItem ?? []; if (items.length === 0) return null; const amounts = items.map(storedLineAmountCents); if (amounts.some((amount) => amount === null)) return null; return sumCents(amounts as number[]); }; /** Reads the invoice-level due date (YYYY-MM-DD) written by buildInvoiceResource; undefined when unset. */ export const readInvoiceDueDate = (invoice: Invoice): string | undefined => invoice.extension?.find((e) => e.url === INVOICE_DUE_DATE_EXTENSION_URL)?.valueDate; // Reads the invoice-level allow-partial-pay policy flag. Mirrors the server's checkoutPayload.ts // readInvoiceAllowPartialPay exactly (`.some(... && valueBoolean === true)`) - absent extension, or any // value other than `true`, reads as false (fail closed: partial pay is opt-in per invoice, not the // default). export const readInvoiceAllowPartialPay = (invoice: Invoice): boolean => (invoice.extension ?? []).some( (e) => e.url === INVOICE_ALLOW_PARTIAL_PAY_EXTENSION_URL && e.valueBoolean === true); // Pure: merge a set of "editor-managed" invoice-level extensions (currently due-date + allow-partial-pay) // back onto an EXISTING invoice's extension array when saving an edit, from a freshly-BUILT resource's // extensions. `existing` is stripped of every url in `managedUrls` (so a stale managed value is never // left behind), then the fresh values for those same urls are appended FROM `built` - only the ones // `built` actually carries (a managed field that's since been cleared, e.g. due date removed, is a URL // buildInvoiceResource omits, so it's correctly dropped here too). Every OTHER (non-managed, third-party) // extension on `existing` survives completely untouched. Returns `undefined` (not `[]`) when the result // is empty, matching buildInvoiceResource's own "omit the extension key entirely when there's nothing to // carry" convention, so JSON.stringify drops the key on the PUT rather than sending an empty array. // // Extracted from InvoiceEditor.tsx's persistDraft after this exact spot needed generalizing twice (once // for due-date alone, again to add allow-partial-pay) - a third editor-managed extension should extend // `managedUrls` at the call site, never re-inline this merge. export const mergeEditorManagedExtensions = ( existing: Extension[] | undefined, built: Extension[] | undefined, managedUrls: string[], ): Extension[] | undefined => { const otherExtensions = (existing ?? []).filter((e) => !managedUrls.includes(e.url ?? '')); const freshManagedExtensions = (built ?? []).filter((e) => managedUrls.includes(e.url ?? '')); const merged = [...otherExtensions, ...freshManagedExtensions]; return merged.length ? merged : undefined; }; // Pure: add `netDays` days to a YYYY-MM-DD (only the date part of `issueISO` is used). Anchored at UTC // noon so adding whole days never crosses a DST/timezone boundary into the previous/next calendar day // (the classic `new Date('YYYY-MM-DD')`-at-UTC-midnight off-by-one). Returns a plain YYYY-MM-DD. export const computeDueDate = (issueISO: string, netDays: number): string => { const [year, month, day] = issueISO.slice(0, 10).split('-').map(Number); const anchored = new Date(Date.UTC(year, month - 1, day, 12, 0, 0)); anchored.setUTCDate(anchored.getUTCDate() + netDays); const yyyy = anchored.getUTCFullYear(); const mm = String(anchored.getUTCMonth() + 1).padStart(2, '0'); const dd = String(anchored.getUTCDate()).padStart(2, '0'); return `${yyyy}-${mm}-${dd}`; };