// src/pages/DashboardPage/BillingPage/invoiceLineBuilder.ts // Invoicing charge-catalog epic, Phase A / Task 3: pure "prefill a new line from a picked source" logic // for the invoice editor's typed line builder (InvoiceEditor.tsx). No React, no HTTP - this is the part // unit-tested directly. Building a Service line always carries BOTH the sourceRef (audit trail back to // the HealthcareService) and the service's clinical SNOMED coding, if it has one - invoiceTransform.ts's // coding array was fixed (Task 1) specifically so a line can roundtrip both independently. // // Task 8 adds buildFeeLine: a Fee (src/config/chargeItemDefinition.ts, Task 6) is a flat named charge // with no clinical coding, so unlike buildServiceLine there's no `coding` slot to carry - only the // sourceRef audit trail back to the ChargeItemDefinition. // // Task 5 (Phase B) adds buildTimedLine: bills a patient from an un-invoiced Appointment at a // HealthcareService's per-time-unit rate (serviceTimeRate.ts, Task 1 of Phase B) - quantity is the // appointment's elapsed time expressed in that service's billing unit (computeTimedQuantity), rather // than always 1 like every other line type. sourceRef is the SAME kind ('HealthcareService') a flat // 'service' line uses, which is exactly why invoiceTransform.ts carries an explicit sourceType coding // now - sourceRef.kind alone can't disambiguate 'service' from 'timed'. // // Phase C's Task 3 adds buildInventoryLine: a priced good (readInventory, Task 1 of Phase C) billed at // a caller-supplied quantity - like Time (and unlike Service/Fee/Custom's fixed 1), quantity varies per // line, but here it comes from the editor's quantity picker rather than being computed from an // appointment. sourceRef is the SAME kind ('ChargeItemDefinition') a flat 'fee' line uses, so, exactly // as with Service/Timed, invoiceTransform.ts's explicit sourceType coding is what disambiguates 'fee' // from 'inventory' on read - sourceRef.kind alone cannot. import type { Appointment, ChargeItemDefinition, Coding, HealthcareService } from 'fhir/r4'; import { getServicePriceCents } from '../../../config/servicePrice'; import { getServiceTimeRate, computeTimedQuantity, type TimeUnit } from '../../../config/serviceTimeRate'; import { readFee, readInventory } from '../../../config/chargeItemDefinition'; import { formatDateTime } from '../../../utils/dateUtils'; import { formatMoney } from '../../../utils/money'; import type { InvoiceLineDraft } from '../../../utils/invoiceTransform'; const TIME_UNIT_LABEL: Record = { hour: 'hour', '30min': '30 min', '15min': '15 min', session: 'session', }; // Prefills a new line from a picked catalog service. A service can be priced FLAT or by TIME (Phase B): // prefer the flat price, but when the service is timed (no flat price) bill at its per-unit rate so the // line pulls the configured Services & rates price instead of $0 - the biller sets the quantity of units, // and the unit is appended to the description (e.g. "Consultation (per 15 min)") so that quantity is // unambiguous. (The dedicated "Time" line type is the appointment-driven variant that auto-computes the // quantity from an appointment's duration; this Service path is the manual one.) Either way the amount is // never undefined - always a valid, override-able starting value. Carries both the source ref and any // clinical SNOMED coding the service has (AddServiceModal writes it into `type[0].coding[0]`). // What a picker row says about a service price, using the SAME precedence the line builder bills at: // flat price first, then the per-unit time rate. The picker used to read the flat price alone, so a // time-rated service showed no price at all - and two services sharing a name were then two identical // rows with nothing to choose between, while the line each produced was priced differently. // // An unpriced service says so rather than going quiet: a row with no price is a fact the biller needs // before they put it on an invoice, not an absence to leave them guessing about. export const describeServicePrice = (service: HealthcareService): string => { const flatCents = getServicePriceCents(service); if (flatCents !== undefined) return formatMoney(flatCents); const timeRate = getServiceTimeRate(service); if (timeRate) return `${formatMoney(timeRate.rateCents)} per ${TIME_UNIT_LABEL[timeRate.unit]}`; return 'no price set'; }; export const buildServiceLine = (service: HealthcareService & { id: string }): InvoiceLineDraft => { const coding: Coding | undefined = service.type?.[0]?.coding?.[0]; const flatCents = getServicePriceCents(service); const timeRate = flatCents === undefined ? getServiceTimeRate(service) : undefined; const name = service.name ?? 'Service'; return { description: timeRate ? `${name} (per ${TIME_UNIT_LABEL[timeRate.unit]})` : name, unitPriceCents: flatCents ?? timeRate?.rateCents ?? 0, quantity: 1, taxable: false, sourceType: 'service', sourceRef: { kind: 'HealthcareService', id: service.id }, ...(coding ? { coding } : {}), }; }; // Prefills a new line from a picked Fee (ChargeItemDefinition): unit price from the fee's base // priceComponent (readFee already normalizes an absent/invalid amount to 0 - never undefined, same // always-valid-starting-amount guarantee as buildServiceLine), description from the fee's title, and // the source ref. A Fee has no clinical coding to carry, unlike a Service. export const buildFeeLine = (cid: ChargeItemDefinition & { id: string }): InvoiceLineDraft => { const fee = readFee(cid); return { description: fee.title || 'Fee', unitPriceCents: fee.priceCents, quantity: 1, taxable: false, sourceType: 'fee', sourceRef: { kind: 'ChargeItemDefinition', id: cid.id }, }; }; // Prefills a new line from a picked Inventory item (ChargeItemDefinition): unit price and taxable flag // from the item's stored price/taxable (readInventory already normalizes an absent/invalid price to 0 // and defaults taxable to true - same always-valid-starting-values guarantee as buildFeeLine), // description from the item's title, and the source ref. Unlike Service/Fee/Custom's fixed quantity 1, // quantity comes from the caller (the editor's quantity picker) - floored to 1 if a non-positive value // somehow gets through, the same defensive floor `lineAmountCents` applies at the total-computation // layer, so a line built here is never garbage even if a future caller forgets to validate. A Fee has // no clinical coding to carry, and neither does Inventory. export const buildInventoryLine = ( cid: ChargeItemDefinition & { id: string }, quantity: number, ): InvoiceLineDraft => { const inventory = readInventory(cid); return { description: inventory.title || 'Item', unitPriceCents: inventory.priceCents, quantity: quantity > 0 ? quantity : 1, taxable: inventory.taxable, sourceType: 'inventory', sourceRef: { kind: 'ChargeItemDefinition', id: cid.id }, }; }; // A blank custom (free-text) line - the staff member fills in description and amount directly on the // row once it's added to the table. export const buildCustomLine = (): InvoiceLineDraft => ({ description: '', unitPriceCents: 0, quantity: 1, taxable: false, sourceType: 'custom', }); // The appointment's elapsed minutes: prefer the explicit `minutesDuration` FHIR field, falling back to // (end - start) when it's absent (rounded - FHIR instants carry sub-minute precision that a billing // quantity has no use for). Yields 0 (never negative/NaN) when neither is available, so a caller doing // arithmetic never has to guard against undefined itself. export const appointmentDurationMinutes = (appointment: Appointment): number => { if (typeof appointment.minutesDuration === 'number') { return appointment.minutesDuration; } if (appointment.start && appointment.end) { const minutes = Math.round((Date.parse(appointment.end) - Date.parse(appointment.start)) / 60000); return Number.isFinite(minutes) && minutes > 0 ? minutes : 0; } return 0; }; // Prefills a new line from a picked (un-invoiced appointment, timed service) pair: unit price and // billing unit from the service's time-rate extensions, quantity from the appointment's elapsed time // expressed in that unit (computeTimedQuantity - e.g. a 90-min appointment at an hourly rate is 1.5). // If the service has no timed rate configured (shouldn't happen through the UI, which only offers // timed services here, but this stays defensive rather than trusting the caller), unitPriceCents falls // back to 0 and quantity to 1 - the same "always valid, override-able starting amount" guarantee as // buildServiceLine/buildFeeLine, using 'session' (whole-appointment) as the sentinel unit so quantity is // never the misleading 0 that "no rate found" would otherwise compute. export const buildTimedLine = ( appointment: Appointment & { id: string }, service: HealthcareService & { id: string }, ): InvoiceLineDraft => { const rate = getServiceTimeRate(service); const minutes = appointmentDurationMinutes(appointment); const name = service.name ?? 'Service'; return { description: appointment.start ? `${name} (${formatDateTime(appointment.start)})` : name, unitPriceCents: rate?.rateCents ?? 0, quantity: computeTimedQuantity(minutes, rate?.unit ?? 'session'), taxable: false, sourceType: 'timed', sourceRef: { kind: 'HealthcareService', id: service.id }, appointmentId: appointment.id, }; };