// src/pages/DashboardPage/BillingPage/InvoiceEditor.tsx // Phase 2 invoicing (Task 5), reworked by the charge-catalog epic's Task 3: the dialog a staff member // uses to compose a draft invoice. Adding a line is a typed picker - Service (from the catalog, prefilled // via invoiceLineBuilder.ts) or Custom (blank, free-text) - and every row exposes an override-able unit // price and a quantity, with a live total. The patient is locked (no picker) when launched from a patient // context (`patientId`) or when editing an existing draft (the patient comes from `invoice.subject` - an // invoice never changes whose patient it's for); otherwise a minimal search-select picker is shown. Not // yet wired into a page - later tasks mount this from BillingPage and the receptionist dialog. // // Task 8 (charge-catalog epic, Phase A's final task) adds "Fee" as a third line type, alongside Service // and Custom: a picker over the org's Fee ChargeItemDefinitions (Task 6/7), prefilled via // invoiceLineBuilder.ts's buildFeeLine the same way Service is prefilled via buildServiceLine. // // Task 5 (Phase B, the invoicing epic's final task) adds "Time" as a fourth line type: bill a patient // from one of their un-invoiced appointments at a timed HealthcareService's rate. Unlike the other three // types, Time needs a patient context to know which appointments to offer (Service/Fee/Custom don't - // the patient is only required at save time) - the type picker's Time option is disabled until a patient // is selected/locked, and the branch itself fetches (fetchInvoicesForPatient -> billedAppointmentIds -> // fetchAppointments -> unbilledAppointments, unbilledAppointments.ts's Task 4) as soon as one is. // // Task 3 (charge-catalog epic, Phase C) adds "Inventory" as a fifth line type, alongside Fee: a picker // over the org's Inventory ChargeItemDefinitions (Phase C Task 1/2), prefilled via invoiceLineBuilder.ts's // buildInventoryLine the same way Fee is prefilled via buildFeeLine - but Inventory also takes a // quantity picked at add-time (a priced good is usually billed more than one at a time; Fee/Service/ // Custom always start at quantity 1), reusing the same keystroke-parses/blur-canonicalizes quantity // pattern the per-row Qty input already uses below. import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { Trash2, TriangleAlert } from 'lucide-react'; import type { Appointment, ChargeItemDefinition, HealthcareService, Invoice, Patient } from 'fhir/r4'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '../../../components/ui/dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../../components/ui/select'; import { Input } from '../../../components/ui/input'; import { Checkbox } from '../../../components/ui/checkbox'; import SearchSelect from '../../../components/SearchSelect/SearchSelect'; import { useAppointmentServiceContext, useAuthServiceContext, useChargeItemDefinitionServiceContext, useFhirServiceContext, useHealthcareServiceServiceContext, useInvoiceServiceContext, } from '../../../services'; import type { InvoicePatientCandidate, InvoicePatientLookup } from '../../../services/InvoiceService'; import { useFhirHealthcareServices } from '../../../hooks'; import { isAdolescentPatient } from '../../../utils/adolescentFeatureGate'; import { buildInvoiceResource, computeDueDate, lineAmountCents, mergeEditorManagedExtensions, readInvoiceAllowPartialPay, readInvoiceDueDate, readInvoiceLines, INVOICE_ALLOW_PARTIAL_PAY_EXTENSION_URL, INVOICE_DUE_DATE_EXTENSION_URL, type InvoiceDraft, } from '../../../utils/invoiceTransform'; import { getServiceTimeRate, isTimedService, type TimeUnit } from '../../../config/serviceTimeRate'; import { isFeeCid, isInventoryCid, readFee, readInventory } from '../../../config/chargeItemDefinition'; import { formatMoney, parseDollarsToCents } from '../../../utils/money'; import { getOrganizationTaxRate } from '../../../utils/organizationTaxRate'; import { getDefaultNetDays, getOrgAllowPartialPayments } from '../../../utils/organizationInvoiceTerms'; import { formatFhirHumanName } from '../../../utils/fhirHumanName'; import { describeFetchFailure } from '../../../utils/fetchFailureMessage'; import { formatDate, formatDateTime } from '../../../utils/dateUtils'; import { billedAppointmentIds, unbilledAppointments } from './unbilledAppointments'; import { appointmentDurationMinutes, buildCustomLine, buildFeeLine, buildInventoryLine, buildServiceLine, buildTimedLine, describeServicePrice } from './invoiceLineBuilder'; import { computeLineTotals, fromRow, toRow, type LineRow } from './invoiceLineRow'; import { TellTheFrontDesk } from '../../../components/TellTheFrontDesk/TellTheFrontDesk'; // Mirrors FeeSchedule.tsx's own (unexported) label map - small presentational constant, not worth // sharing a module over. const TIME_UNIT_LABELS: Record = { hour: 'hour', '30min': '30 min', '15min': '15 min', session: 'session', }; // Payment-terms presets: net-days label. 0 = due on receipt; the rest are the common net terms. A // "Custom" option (handled separately in the Select) lets the biller pick any date; the org's default // (getDefaultNetDays) seeds a NEW invoice's terms/date. const TERM_OPTIONS: { value: number; label: string }[] = [ { value: 0, label: 'Due on receipt' }, { value: 15, label: 'Net 15' }, { value: 30, label: 'Net 30' }, { value: 60, label: 'Net 60' }, { value: 90, label: 'Net 90' }, ]; // Today as a local YYYY-MM-DD - the biller's calendar day is the issue anchor for computeDueDate. const todayDateString = (): string => { const now = new Date(); return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; }; // Which preset (if any) produces `dueDate` from today. Returns the matching net-days or 'custom'. For an // existing invoice whose due date was set on a prior day, no preset matches 'custom' (the concrete date // is what matters), which is exactly the intended seed for an already-dated draft. const termForDueDate = (dueDate: string): number | 'custom' => { const today = todayDateString(); const match = TERM_OPTIONS.find((option) => computeDueDate(today, option.value) === dueDate); return match ? match.value : 'custom'; }; // A compact label for the un-invoiced-appointment picker: when it was, what service it was booked for // (if any), and how long it ran - enough for a receptionist to tell two same-day appointments apart. const appointmentPickerLabel = (appointment: Appointment): string => { const when = appointment.start ? formatDateTime(appointment.start) : 'Unknown date'; const serviceTypeLabel = (appointment.serviceType ?? []) .map((concept) => concept.text || concept.coding?.[0]?.display || '') .filter(Boolean) .join(', '); const minutes = appointmentDurationMinutes(appointment); return [when, serviceTypeLabel, minutes > 0 ? `${minutes} min` : ''].filter(Boolean).join(' — '); }; export type InvoiceEditorProps = { open: boolean; onOpenChange: (open: boolean) => void; patientId?: string; invoice?: Invoice; onSaved: (invoice: Invoice) => void; }; // One page per type-to-search query - the biller narrows the catalog by typing, so a short capped // result list (like the patient picker, which shows one search page) is all a picker needs. Applies to // all three item pickers (Service / Fee / Inventory). const PICKER_SEARCH_PAGE_SIZE = 25; const patientIdFromReference = (reference?: string): string | undefined => { const [type, id] = reference?.split('/') ?? []; return type === 'Patient' && id ? id : undefined; }; const isRowAmountValid = (row: LineRow): boolean => { const cents = parseDollarsToCents(row.amountText); return cents !== undefined && cents >= 0; }; // Quantity is a positive number with up to 2 decimal places - "0", "-1", and "" all fail to parse, // leaving the previously-committed `quantity` in place (see updateQuantityText) rather than accepting a // bad value. Fractions are allowed (not just whole numbers) so a Time line's computed quantity - e.g. a // 90-minute appointment at an hourly rate = 1.5, or a 10-minute one = 0.17 - can be hand-corrected. const parseQuantity = (text: string): number | undefined => { const trimmed = text.trim(); if (!/^\d+(\.\d{1,2})?$/.test(trimmed)) return undefined; const parsed = Number(trimmed); return parsed > 0 ? parsed : undefined; }; /** * WHAT A PICKER SAYS WHEN IT HAS NOTHING TO SHOW - and it is not always "nothing matched". * * Measured 2026-09-08 as a user whose only hat is Billing. Typing *Patient One* into New Invoice: * * 403 GET /R4/Patient?name=Patient+One -> "No matching patients found." * 403 GET /R4/HealthcareService?... -> "No matching services found." * * The dialog told a biller the patient does not exist. That is a statement about the WORLD * standing in for a statement about PERMISSION, on the screen where somebody is trying to bill that * exact person - and the settled rule is that a refusal renders as a denial naming what is denied, * never as a failure or empty data. * * THE ERROR WAS CAUGHT, LOGGED TO THE CONSOLE AND DROPPED, which is why it read as an absence: the * empty-results state and the refused state were the same state. Holding the message is the whole * fix - the sentence comes from `describeFetchFailure`, which decides retryable-or-not from the * RESPONSE rather than from the words, so a permanent denial never invites a retry that cannot * succeed. * * ALL FOUR PICKERS, not the two that were refused. They are one mechanism with four instances in * one dialog, and fixing the two a Billing hat happens to trip would leave the identical sentence * lying on the other two for the next role that meets them. * * IT DOES NOT DECIDE WHETHER BILLING MAY READ PATIENTS. That is an authorization boundary and it is * Spencer's; this is true under either answer, because a refusal must announce itself either way. */ const pickerEmptyText = ( searching: boolean, failure: string | null, nothingMatched: string, ): string => { if (searching) return 'Searching…'; // The refusal outranks the count. Zero results BECAUSE we were refused is not zero results. return failure ?? nothingMatched; }; /** A lookup answer as the minimal Patient the picker, the adolescent notice and the saved name read. */ const candidatePatient = (candidate: InvoicePatientCandidate): Patient => ({ resourceType: 'Patient', id: candidate.id, ...(candidate.name ? { name: [{ text: candidate.name }] } : {}), ...(candidate.birthDate ? { birthDate: candidate.birthDate } : {}), }); /** A typed date of birth (year-month-day, or month/day/year) searches by date; anything else by name. */ const patientLookupOf = (query: string): InvoicePatientLookup => { if (/^\d{4}-\d{2}-\d{2}$/.test(query)) return { birthDate: query }; const us = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(query); if (us) return { birthDate: `${us[3]}-${us[1].padStart(2, '0')}-${us[2].padStart(2, '0')}` }; return { name: query }; }; const InvoiceEditor = ({ open, onOpenChange, patientId, invoice, onSaved }: InvoiceEditorProps) => { const { createInvoice, updateInvoiceDraft, setInvoiceStatus, fetchInvoicesForPatient, findInvoicePatients } = useInvoiceServiceContext(); const { fetchAppointments } = useAppointmentServiceContext(); const { unpackBundle } = useFhirServiceContext(); const { healthcareServicesList } = useFhirHealthcareServices(); const { searchHealthcareServices } = useHealthcareServiceServiceContext(); const { fetchChargeItemDefinitions } = useChargeItemDefinitionServiceContext(); const { currentOrganization } = useAuthServiceContext(); // The org's live sales-tax rate (Platform Manager's Tax Settings section) - read fresh on every // render rather than seeded once, so switching orgs or a same-session rate change (Settings -> // Tax) is reflected immediately in both the live breakdown below and the rate stamped on save. // Never used to re-derive an ALREADY-issued invoice's tax (invoiceTaxCents/invoiceTotalCents are // the frozen readers for that) - only for a still-editable draft's create/save path. const taxRatePercent = getOrganizationTaxRate(currentOrganization ?? undefined); const fieldId = useId(); const [lines, setLines] = useState([]); const [addLineType, setAddLineType] = useState<'service' | 'time' | 'fee' | 'inventory' | 'custom'>('service'); // Each item picker is a debounced type-to-search (mirrors the patient picker below): its own query // text, its last search-result set (looked up on select to build the line), and a searching flag. // Replaces the old eager fetch-whole-catalog-on-open loads, so the pickers work against an // arbitrarily large catalog instead of one capped page. const [serviceQuery, setServiceQuery] = useState(''); const [serviceResults, setServiceResults] = useState<(HealthcareService & { id: string })[]>([]); const [serviceSearching, setServiceSearching] = useState(false); const [feeQuery, setFeeQuery] = useState(''); const [feeResults, setFeeResults] = useState<(ChargeItemDefinition & { id: string })[]>([]); const [feeSearching, setFeeSearching] = useState(false); const [inventoryQuery, setInventoryQuery] = useState(''); const [inventoryResults, setInventoryResults] = useState<(ChargeItemDefinition & { id: string })[]>([]); const [inventorySearching, setInventorySearching] = useState(false); const [selectedPatient, setSelectedPatient] = useState(null); const [patientQuery, setPatientQuery] = useState(''); const [patientResults, setPatientResults] = useState([]); const [patientSearching, setPatientSearching] = useState(false); // WHY THE REFUSAL IS HELD RATHER THAN LOGGED AND DROPPED - see `pickerEmptyText` below. One // per picker, because they fail independently: on a Billing hat the patient search is refused // and the fee search is not, and one shared message would report the wrong field. const [patientFailure, setPatientFailure] = useState(null); const [serviceFailure, setServiceFailure] = useState(null); const [feeFailure, setFeeFailure] = useState(null); const [inventoryFailure, setInventoryFailure] = useState(null); const [submitting, setSubmitting] = useState<'save' | 'issue' | null>(null); const [error, setError] = useState(); // Invoice-level due date (YYYY-MM-DD) + the terms preset it was derived from ('custom' once the date // is hand-edited or an existing invoice's fixed date doesn't match a preset). Seeded on open below. const [dueDate, setDueDate] = useState(''); const [termDays, setTermDays] = useState(30); // Invoice-level allow-partial-pay policy toggle. Seeded on open below: a NEW invoice starts from the // org's house default (getOrgAllowPartialPayments); an existing draft keeps its own stored value // (readInvoiceAllowPartialPay). Persisted via InvoiceDraft.allowPartialPay -> buildInvoiceResource. const [allowPartialPay, setAllowPartialPay] = useState(false); // Time branch: the patient's un-invoiced appointments (Task 4's unbilledAppointments), and the two // in-progress picker selections that combine into one buildTimedLine call once both are made. const [unbilledAppts, setUnbilledAppts] = useState<(Appointment & { id: string })[]>([]); const [unbilledLoading, setUnbilledLoading] = useState(false); const [unbilledError, setUnbilledError] = useState(); const [timedApptId, setTimedApptId] = useState(''); const [timedServiceId, setTimedServiceId] = useState(''); // Guards against a slower, earlier fetch (e.g. for a since-changed patient) overwriting a later one's // result - same race a plain `active`-flag closure guards elsewhere in this file, but this fetch is // also re-triggered manually (the Retry button), which a closed-over `active` flag can't express. const unbilledRequestIdRef = useRef(0); // The patient is locked (no picker) when launched from a patient context, or when editing an existing // draft - an invoice never changes whose patient it's for. const lockedPatientId = patientId ?? patientIdFromReference(invoice?.subject?.reference); const isPatientLocked = Boolean(lockedPatientId); const effectivePatientId = lockedPatientId ?? selectedPatient?.id; // item 3 - drives the adolescent line-item notice below. // // The birthDate has to come from two different places: the picker path already holds the whole // Patient (`selectedPatient`), but the LOCKED path - launched from a patient context or editing an // existing draft, which is the common case - carries only an id, so it must be fetched. Without the // fetch the notice would be missing exactly where invoices are usually written. const [lockedPatientBirthDate, setLockedPatientBirthDate] = useState(); // Held alongside the birth date because it comes from the same fetch and is written at the same // moment; splitting them would invite one to be set without the other. const [lockedPatientDisplay, setLockedPatientDisplay] = useState(undefined); // Held in a ref rather than listed as a dependency. `fetchPatient` is a useCallback over // `[get, patientList]`, so its identity changes every time the shared patient list updates - // anywhere in the app. Depending on it directly would re-run this effect on unrelated list churn // and refetch for no reason. It does NOT setPatientList (it only reads the cache), so this is // wasted work rather than the provider feedback loop this codebase has been bitten by before, but // the ref removes the whole class. const findPatientsRef = useRef(findInvoicePatients); findPatientsRef.current = findInvoicePatients; useEffect(() => { if (!open || !lockedPatientId) { setLockedPatientBirthDate(undefined); return; } let active = true; findPatientsRef.current({ id: lockedPatientId }) .then((candidates) => { const patient = candidates[0] ? candidatePatient(candidates[0]) : undefined; if (!active) return; setLockedPatientBirthDate(patient?.birthDate); // THE NAME WAS ALREADY HERE AND WAS BEING THROWN AWAY, which is the whole of the // "Unknown patient" defect: this lookup ran for the adolescent notice and kept only // the birth date, so an invoice raised from a patient's chart froze no name onto // itself and the org-wide Billing list had nothing to show. It is the same fetch. setLockedPatientDisplay(formatFhirHumanName(patient?.name, { emptyText: '' }) || undefined); }) // Advisory only — a failed lookup silently shows no notice rather than blocking the editor // or surfacing an error. The server generalizes for a proxy regardless, so nothing is // disclosed by the banner's absence. .catch(() => { if (!active) return; setLockedPatientBirthDate(undefined); setLockedPatientDisplay(undefined); }); return () => { active = false; }; }, [open, lockedPatientId]); const subjectIsAdolescent = isAdolescentPatient( lockedPatientId ? { birthDate: lockedPatientBirthDate } : selectedPatient, ); // Reset all local state each time the dialog opens, seeded from `invoice` when editing an existing // draft. Callers are expected to hold `invoice` in stable state (fetched/selected once) rather than // recompute it inline on every render, since a changing reference while `open` stays true re-seeds // (and would discard in-progress edits). useEffect(() => { if (!open) return; setLines(invoice ? readInvoiceLines(invoice).map(toRow) : []); setAddLineType('service'); setServiceQuery(''); setServiceResults([]); setFeeQuery(''); setFeeResults([]); setInventoryQuery(''); setInventoryResults([]); setSelectedPatient(null); setPatientQuery(''); setPatientResults([]); setError(undefined); setSubmitting(null); setUnbilledAppts([]); setUnbilledError(undefined); setTimedApptId(''); setTimedServiceId(''); // Seed the due date: an existing draft keeps its stored due date (or, if it has none yet, the org // default from today); a new invoice starts at today + the org's default net-days. The terms // preset is derived from the seeded date (falls back to 'custom' for a fixed date that no longer // matches a preset). currentOrganization/getDefaultNetDays are intentionally not effect deps - the // org is already loaded by the time a biller opens the editor, and re-seeding on org churn would // discard in-progress edits (same pattern as the rest of this open-only reset). const netDaysDefault = getDefaultNetDays(currentOrganization ?? undefined); const seededDueDate = (invoice ? readInvoiceDueDate(invoice) : undefined) ?? computeDueDate(todayDateString(), netDaysDefault); setDueDate(seededDueDate); setTermDays(termForDueDate(seededDueDate)); // Seed allow-partial-pay the same way: an existing draft keeps its own stored flag; a new // invoice starts from the org's house default. Same currentOrganization-not-a-dep rationale as // netDaysDefault above. setAllowPartialPay(invoice ? readInvoiceAllowPartialPay(invoice) : getOrgAllowPartialPayments(currentOrganization ?? undefined)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, invoice?.id]); // Debounced patient search (name or date of birth, via the invoice patient lookup) - only runs while the // picker is showing, i.e. neither locked nor already resolved to a selection. useEffect(() => { if (isPatientLocked || selectedPatient) { return; } const trimmed = patientQuery.trim(); if (trimmed.length < 2) { setPatientResults([]); return; } let active = true; setPatientSearching(true); const timeout = window.setTimeout(() => { findInvoicePatients(patientLookupOf(trimmed)) .then((candidates) => { if (!active) return; setPatientFailure(null); setPatientResults(candidates.map(candidatePatient)); }) .catch((searchError) => { console.error('Patient search failed:', searchError); if (!active) return; setPatientResults([]); setPatientFailure(describeFetchFailure(searchError, 'Patient search failed')); }) .finally(() => { if (active) setPatientSearching(false); }); }, 250); return () => { active = false; window.clearTimeout(timeout); }; }, [patientQuery, isPatientLocked, selectedPatient, findInvoicePatients]); // Debounced Service search (name, via HealthcareServiceService.searchHealthcareServices) - mirrors // the patient block above: min 2 chars, 250ms debounce, `active` race guard. Org scope + name search // are forced server-side; we keep only active services client-side (a retired service shouldn't be // billable going forward - the same guard the old catalogServices derive applied). useEffect(() => { const trimmed = serviceQuery.trim(); if (trimmed.length < 2) { setServiceResults([]); return; } let active = true; setServiceSearching(true); const timeout = window.setTimeout(() => { searchHealthcareServices(PICKER_SEARCH_PAGE_SIZE, undefined, trimmed) .then((bundle) => { if (!active) return; setServiceFailure(null); setServiceResults( (unpackBundle(bundle) as HealthcareService[]).filter( (resource): resource is HealthcareService & { id: string } => Boolean(resource.id) && resource.active !== false, ), ); }) .catch((searchError) => { console.error('Service search failed:', searchError); if (!active) return; setServiceResults([]); setServiceFailure(describeFetchFailure(searchError, 'Service search failed')); }) .finally(() => { if (active) setServiceSearching(false); }); }, 250); return () => { active = false; window.clearTimeout(timeout); }; }, [serviceQuery, searchHealthcareServices, unpackBundle]); // Debounced Fee search - same shape as the Service block, over the server-side category='fee' query // (fetchChargeItemDefinitions). isFeeCid stays as a cheap defensive filter (and to narrow the type to // `& { id: string }` for buildFeeLine) in case a not-yet-backfilled item slips the category query. useEffect(() => { const trimmed = feeQuery.trim(); if (trimmed.length < 2) { setFeeResults([]); return; } let active = true; setFeeSearching(true); const timeout = window.setTimeout(() => { fetchChargeItemDefinitions(PICKER_SEARCH_PAGE_SIZE, undefined, trimmed, 'fee') .then((bundle) => { if (!active) return; setFeeFailure(null); setFeeResults( (unpackBundle(bundle) as ChargeItemDefinition[]).filter( (resource): resource is ChargeItemDefinition & { id: string } => Boolean(resource.id) && isFeeCid(resource), ), ); }) .catch((searchError) => { console.error('Fee search failed:', searchError); if (!active) return; setFeeResults([]); setFeeFailure(describeFetchFailure(searchError, 'Fee search failed')); }) .finally(() => { if (active) setFeeSearching(false); }); }, 250); return () => { active = false; window.clearTimeout(timeout); }; }, [feeQuery, fetchChargeItemDefinitions, unpackBundle]); // Debounced Inventory search - identical to the Fee block, over category='inventory', with // isInventoryCid as the defensive filter / type narrowing for buildInventoryLine. useEffect(() => { const trimmed = inventoryQuery.trim(); if (trimmed.length < 2) { setInventoryResults([]); return; } let active = true; setInventorySearching(true); const timeout = window.setTimeout(() => { fetchChargeItemDefinitions(PICKER_SEARCH_PAGE_SIZE, undefined, trimmed, 'inventory') .then((bundle) => { if (!active) return; setInventoryFailure(null); setInventoryResults( (unpackBundle(bundle) as ChargeItemDefinition[]).filter( (resource): resource is ChargeItemDefinition & { id: string } => Boolean(resource.id) && isInventoryCid(resource), ), ); }) .catch((searchError) => { console.error('Inventory search failed:', searchError); if (!active) return; setInventoryResults([]); setInventoryFailure(describeFetchFailure(searchError, 'Item search failed')); }) .finally(() => { if (active) setInventorySearching(false); }); }, 250); return () => { active = false; window.clearTimeout(timeout); }; }, [inventoryQuery, fetchChargeItemDefinitions, unpackBundle]); // Task 4's "Option C" un-invoiced-appointment pipeline: this patient's invoices -> the appointment // ids they've already billed -> this patient's appointments (capped at 100, most-recent-first per // AppointmentService - there's no pagination here, hence the helper text below the picker) -> the // ones still billable. Re-runs whenever the effective patient changes (including a locked patient on // dialog reopen, since a `subject`-scoped fetch built off a stale invoice list would miss anything // invoiced since this editor was last open) and can also be invoked directly by the Retry button. // Never throws into the render path - a failure here degrades to an inline error, not a crashed editor. const loadUnbilledAppointments = useCallback(async () => { if (!effectivePatientId) return; const requestId = ++unbilledRequestIdRef.current; setUnbilledLoading(true); setUnbilledError(undefined); try { const invoices = await fetchInvoicesForPatient(effectivePatientId); const billedIds = billedAppointmentIds(invoices); // fetchAppointments only ever reads `.id` off the patient argument - a full Patient fetch // isn't needed just to look up their appointments. const bundle = await fetchAppointments({ resourceType: 'Patient', id: effectivePatientId } as Patient); const appointments = unpackBundle(bundle) as Appointment[]; const unbilled = unbilledAppointments(appointments, billedIds) .filter((appointment): appointment is Appointment & { id: string } => Boolean(appointment.id)); if (unbilledRequestIdRef.current === requestId) { setUnbilledAppts(unbilled); } } catch (fetchError) { console.error('Failed to load un-invoiced appointments:', fetchError); if (unbilledRequestIdRef.current === requestId) { setUnbilledAppts([]); setUnbilledError('Could not load this patient’s appointments. Try again.'); } } finally { if (unbilledRequestIdRef.current === requestId) { setUnbilledLoading(false); } } }, [effectivePatientId, fetchInvoicesForPatient, fetchAppointments, unpackBundle]); useEffect(() => { if (!open || !effectivePatientId) { setUnbilledAppts([]); setUnbilledError(undefined); return; } void loadUnbilledAppointments(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, effectivePatientId]); // Active services only - a service the org has retired shouldn't be billable going forward. const catalogServices = useMemo( () => healthcareServicesList.filter( (service): service is HealthcareService & { id: string } => Boolean(service.id) && service.active !== false, ), [healthcareServicesList], ); // Picking a service from the search results prefills a fully-formed Service line (unit price from the // fee-schedule extension, the service's SNOMED coding if any, and a sourceRef audit trail) - see // invoiceLineBuilder.ts, the pure/tested half of this. The chosen id is looked up in the last // serviceResults set (what the SearchSelect was showing), then the query is cleared so the picker is // ready for the next add. const addServiceLine = useCallback((serviceId: string) => { const service = serviceResults.find((item) => item.id === serviceId); if (!service) return; setLines((current) => [...current, toRow(buildServiceLine(service))]); setServiceQuery(''); setServiceResults([]); }, [serviceResults]); // Picking a Fee from the search results prefills a Fee line (unit price + title from the // ChargeItemDefinition, plus a sourceRef audit trail) - see invoiceLineBuilder.ts's buildFeeLine, // mirroring addServiceLine. const addFeeLine = useCallback((cidId: string) => { const fee = feeResults.find((item) => item.id === cidId); if (!fee) return; setLines((current) => [...current, toRow(buildFeeLine(fee))]); setFeeQuery(''); setFeeResults([]); }, [feeResults]); // Picking an Inventory item from the search results prefills an Inventory line (unit price + title + // taxable flag from the ChargeItemDefinition, plus a sourceRef audit trail) - see // invoiceLineBuilder.ts's buildInventoryLine, mirroring addFeeLine. Quantity defaults to 1; the biller // adjusts it in the row's Qty column after the line is added. const addInventoryLine = useCallback((cidId: string) => { const item = inventoryResults.find((entry) => entry.id === cidId); if (!item) return; setLines((current) => [...current, toRow(buildInventoryLine(item, 1))]); setInventoryQuery(''); setInventoryResults([]); }, [inventoryResults]); // The Time branch's rate picker only ever offers services actually configured with a time-based // rate (serviceTimeRate.ts) - a flat-priced service has no per-minute rate to compute a timed // quantity from, so it belongs in the Service branch instead. const timedServices = useMemo( () => catalogServices.filter((service) => isTimedService(service)), [catalogServices], ); // unbilledAppts only reflects appointments not yet billed on a SAVED invoice - it has no idea about // a 'timed' line already added to this draft-in-progress editor. Without this filter, a user could // pick the same appointment twice in one session and double-charge it before ever saving. Excluding // an appointment the moment it's added (and un-excluding it if that line is later removed) closes // that window without needing a server round-trip. const availableUnbilledAppts = useMemo( () => unbilledAppts.filter((appointment) => !lines.some((line) => line.sourceType === 'timed' && line.appointmentId === appointment.id)), [unbilledAppts, lines], ); // Combines the Time branch's two independent picker selections (appointment + rate) into one // buildTimedLine call - mirrors addServiceLine/addFeeLine's "look the picked id up, prefill, append, // reset" shape, just over a pair of ids instead of one. const addTimedLine = useCallback((appointmentId: string, serviceId: string) => { const appointment = unbilledAppts.find((item) => item.id === appointmentId); const service = timedServices.find((item) => item.id === serviceId); if (!appointment || !service) return; setLines((current) => [...current, toRow(buildTimedLine(appointment, service))]); setAddLineType('time'); setTimedApptId(''); setTimedServiceId(''); }, [unbilledAppts, timedServices]); // Time needs BOTH a picked appointment and a picked rate before there's a line to add - rather than // duplicating "are both chosen yet?" in each Select's onValueChange (order-dependent either way), // this fires once whichever one is picked second lands. Resets both back to '' after adding, which // re-runs this effect a second time as a no-op (neither id is set). useEffect(() => { if (timedApptId && timedServiceId) { addTimedLine(timedApptId, timedServiceId); } }, [timedApptId, timedServiceId, addTimedLine]); const addCustomLine = useCallback(() => { setLines((current) => [...current, toRow(buildCustomLine())]); }, []); const removeLine = useCallback((row: LineRow) => { if (!window.confirm(`Remove "${row.description || 'this line'}"? This cannot be undone.`)) return; setLines((current) => current.filter((line) => line.key !== row.key)); }, []); const updateDescription = useCallback((key: string, description: string) => { setLines((current) => current.map((row) => (row.key === key ? { ...row, description } : row))); }, []); // Updates unitPriceCents on every keystroke IF the text currently parses (keeps the live total in sync // while typing a valid amount), but never rewrites amountText here - reformatting on every change is // exactly what would clobber a half-typed "12." into "12.00" mid-keystroke. const updateAmountText = useCallback((key: string, amountText: string) => { setLines((current) => current.map((row) => { if (row.key !== key) return row; const parsed = parseDollarsToCents(amountText); return { ...row, amountText, unitPriceCents: parsed ?? row.unitPriceCents }; })); }, []); // On blur, canonicalize the text to match the committed cents (e.g. "12" -> "12.00"). Leaves // unparseable/negative text as-is - the Save/Issue guard below catches it and the row is flagged red. const handleAmountBlur = useCallback((key: string) => { setLines((current) => current.map((row) => { if (row.key !== key) return row; const parsed = parseDollarsToCents(row.amountText); if (parsed === undefined || parsed < 0) return row; return { ...row, unitPriceCents: parsed, amountText: (parsed / 100).toFixed(2) }; })); }, []); // Same keystroke/blur split as the amount field: `quantity` only updates while `quantityText` parses // to a positive integer, so clearing the box to type a new number never gets clobbered mid-keystroke. const updateQuantityText = useCallback((key: string, quantityText: string) => { setLines((current) => current.map((row) => { if (row.key !== key) return row; const parsed = parseQuantity(quantityText); return { ...row, quantityText, quantity: parsed ?? row.quantity }; })); }, []); // On blur, canonicalize to the last valid quantity (falling back to 1 if the box was left empty or // invalid) so the row never displays text that doesn't match what will actually be saved. const handleQuantityBlur = useCallback((key: string) => { setLines((current) => current.map((row) => { if (row.key !== key) return row; const quantity = parseQuantity(row.quantityText) ?? (row.quantity > 0 ? row.quantity : 1); return { ...row, quantity, quantityText: String(quantity) }; })); }, []); // Per-line Taxable toggle - an inventory row arrives pre-checked (buildInventoryLine reads the // item's own taxable flag) but the biller can override any line either direction; every other line // type starts unchecked (buildServiceLine/buildFeeLine/buildCustomLine/buildTimedLine all default // false) since Phase A/B never charged tax. const updateTaxable = useCallback((key: string, taxable: boolean) => { setLines((current) => current.map((row) => (row.key === key ? { ...row, taxable } : row))); }, []); // Picking a terms preset recomputes the due date from TODAY + net-days; picking "Custom" keeps the // current date and just switches the label to custom (the date input then drives it). const handleTermChange = useCallback((value: string) => { if (value === 'custom') { setTermDays('custom'); return; } const days = Number(value); setTermDays(days); setDueDate(computeDueDate(todayDateString(), days)); }, []); // Hand-editing the date makes the terms "custom" - the date is now an explicit choice, not a preset. const handleDueDateChange = useCallback((value: string) => { setTermDays('custom'); setDueDate(value); }, []); // Live Subtotal / Tax / Total for the in-progress draft - see invoiceLineRow.ts's computeLineTotals // (the pure/tested half of this), which mirrors buildInvoiceResource's own tax computation exactly. const { subtotalCents, taxCents, totalCents } = useMemo( () => computeLineTotals(lines, taxRatePercent), [lines, taxRatePercent], ); const linesValid = lines.length > 0 && lines.every(isRowAmountValid); const canSubmit = linesValid && Boolean(effectivePatientId) && submitting === null; // Builds the FHIR-shaped lineItem/totals via the shared transform (never hand-rolled here - that's // the one place cents<->decimal Money conversion is allowed to happen, and the only place tax is // computed), stamped with the org's live tax rate, then either creates a new invoice or PUTs the // rebuilt totals onto the existing draft. // // Edit path: ALL of lineItem/totalNet/totalGross/totalPriceComponent come from the FRESH `built` // - never spread from the old `invoice` - so editing a taxed invoice recomputes tax against the // current lines/rate, and a line that's since become untaxed (or a rate that dropped to 0) drops the // stale totalNet/totalPriceComponent entirely (both come back `undefined` from buildInvoiceResource // when there's no tax, and JSON.stringify omits an undefined key, so the PUT clears them rather than // leaving the old taxed shape behind). Only id/status/subject/identifier/meta/etc. are preserved from // `invoice` via the spread. const persistDraft = async (): Promise => { if (!effectivePatientId) { throw new Error('A patient is required to save this invoice.'); } // Freeze the patient's name onto the invoice (subject.display) so the org-wide list can show a // name without a per-row Patient fetch. // // BOTH PATHS FREEZE IT NOW. The picker path always did; the locked path - an invoice raised // from a patient's chart, which is the front desk's ordinary route - left it unset, so those // invoices read "Unknown patient" in the Billing workspace forever. The patient was never // missing: their own portal and the chart both list the same invoice under their name, and only // the org-wide list, which has no patient context to fall back on, could not say who it was. const patientDisplay = selectedPatient ? formatFhirHumanName(selectedPatient.name, { emptyText: '' }) || undefined : lockedPatientDisplay; const draft: InvoiceDraft = { patientId: effectivePatientId, ...(patientDisplay ? { patientDisplay } : {}), ...(dueDate ? { dueDate } : {}), allowPartialPay, lines: lines.map(fromRow), }; let saved: Invoice; if (invoice) { const built = buildInvoiceResource(draft, taxRatePercent); // Merge the editor-managed extensions (due-date, allow-partial-pay) authoritatively via the // shared pure helper (invoiceTransform.ts) - preserves any OTHER invoice-level extension, // takes the freshly-built versions of these two (absent when e.g. the date was cleared), and // both COEXIST (neither write drops the other). Same freeze/clear pattern as totalNet/ // totalPriceComponent below: `undefined` (not `[]`) so JSON.stringify drops a now-empty key. const mergedExtensions = mergeEditorManagedExtensions( invoice.extension, built.extension, [INVOICE_DUE_DATE_EXTENSION_URL, INVOICE_ALLOW_PARTIAL_PAY_EXTENSION_URL], ); saved = await updateInvoiceDraft({ ...invoice, extension: mergedExtensions, lineItem: built.lineItem, totalNet: built.totalNet, totalGross: built.totalGross, totalPriceComponent: built.totalPriceComponent, }); } else { saved = await createInvoice(draft, taxRatePercent); } /* * THE INVALIDATION THAT DELETED THE THING IT WAS MEANT TO REVEAL. * * This used to call `invalidatePaginatorContext('invoices')` here. Measured at the wire on * 2026-09-13: POST /R4/Invoice 201, PUT /R4/Invoice/{id} 200, then GET /R4/Invoice?_count=10 * 200 - and the answer to that GET does not contain the invoice created eight hundred * milliseconds earlier, because the FHIR SEARCH INDEX LAGS A FRESH POST. The refetch then * replaced the list, wiping the row `onSaved` had just inserted. A biller issued an invoice * and watched it not appear. * * THE RACE IS UNWINNABLE, WHICH IS WHY THIS IS A REMOVAL RATHER THAN AN ORDERING FIX. * Invalidating after `onSaved` would only move the overwrite later; polling for the index to * catch up would make a save take seconds. The invoice we have in hand IS the answer - it came * back from the write - and every caller of `onSaved` already puts it into its own list. * * WHAT THIS DOES NOT CHANGE: another person's open list still learns about a new invoice on * their next fetch. `InvoiceList` deliberately does not handle a remote CREATE, and that is its * own decision recorded in its own header - not something this invalidation was delivering, * because it only ever invalidated the context of the person doing the saving. */ return saved; }; const handleSaveDraft = async () => { if (!canSubmit) return; setSubmitting('save'); setError(undefined); try { const saved = await persistDraft(); onSaved(saved); } catch (saveError) { console.error('Failed to save invoice draft:', saveError); setError('Failed to save the invoice. Please try again.'); } finally { setSubmitting(null); } }; const handleIssue = async () => { if (!canSubmit) return; setSubmitting('issue'); setError(undefined); try { const saved = await persistDraft(); const issued = await setInvoiceStatus(saved, 'issued'); onSaved(issued); } catch (issueError) { console.error('Failed to issue invoice:', issueError); setError('Failed to issue the invoice. Please try again.'); } finally { setSubmitting(null); } }; const patientSearchOptions = patientResults .filter((patient): patient is Patient & { id: string } => Boolean(patient.id)) .map((patient) => ({ value: patient.id, label: formatFhirHumanName(patient.name, { emptyText: 'Unnamed patient' }), })); // SearchSelect options for the three item pickers - same "name - price" labels the old dropdowns // rendered, now sourced from each picker's live search-result set instead of an eager catalog load. const serviceSearchOptions = serviceResults.map((service) => ({ value: service.id, label: (service.name ?? service.id) + ' — ' + describeServicePrice(service), })); const feeSearchOptions = feeResults.map((cid) => { const fee = readFee(cid); return { value: cid.id, label: `${fee.title || cid.id} — ${formatMoney(fee.priceCents)}` }; }); const inventorySearchOptions = inventoryResults.map((cid) => { const item = readInventory(cid); return { value: cid.id, label: `${item.title || cid.id} — ${formatMoney(item.priceCents)}` }; }); return ( {invoice ? 'Edit Invoice Draft' : 'New Invoice'} Add service, time, fee, inventory, or custom lines, then save as a draft or issue it.
{error && (
{error}
)}
{isPatientLocked ? (
Linked to the current patient.
) : selectedPatient ? (
{formatFhirHumanName(selectedPatient.name, { emptyText: 'Selected patient' })}
) : ( { const match = patientResults.find((patient) => patient.id === option.value); if (match) { setSelectedPatient(match); setPatientQuery(option.label); } }} /> )}
handleDueDateChange(event.target.value)} />
{dueDate && (

Due {formatDate(dueDate)}.

)}
setAllowPartialPay(checked === true)} className="mt-0.5" />
{/* * Card 042 item 3 — adolescent line-item notice (Spencer, 2026-07-31). * * Shared liability: whoever writes a line can type anything, and free text is the one * half no detection can catch (a coded service is derivable; "Contraceptive counseling" * typed by hand is not). The server generalizes every descriptor for a proxy of a 12-17 * patient regardless, so this is a BELT, not the control — it matters for the practice's * own records and for any non-proxy reader of the invoice. * * Advisory, never blocking: a required acknowledgement on every line would be clicked * through within a week. Placed directly above the line editor so it is read where the * text is actually typed, not buried in a header. */} {subjectIsAdolescent && (
)}
Add a line item:
{addLineType === 'service' ? (
addServiceLine(option.value)} />
) : addLineType === 'fee' ? (
addFeeLine(option.value)} />
) : addLineType === 'inventory' ? (
addInventoryLine(option.value)} />
) : addLineType === 'custom' ? ( ) : null}
{/* Time: needs a patient context (which appointments belong to?) and TWO combined picks (which appointment + which rate?), so unlike the other three types it gets its own full-width block instead of one inline Select next to the type picker. */} {addLineType === 'time' && (
{!effectivePatientId ? (

Select a patient above to bill time for one of their appointments.

) : unbilledLoading ? (

Loading un-invoiced appointments…

) : unbilledError ? (

{unbilledError}

) : unbilledAppts.length === 0 ? (

No un-invoiced appointments for this patient.

) : availableUnbilledAppts.length === 0 ? (

All of this patient's un-invoiced appointments have already been added to this invoice. Remove a Time line above to bill a different rate for the same appointment.

) : ( <>

Showing up to this patient's 100 most recent appointments — not necessarily all.

{timedServices.length === 0 && (

No timed-rate services configured — set one under Pricing options → Services & rates.

)} )}
)} {lines.length === 0 ? (

No lines yet — add a service, fee, or custom charge.

) : (
{lines.map((row) => { const rowValid = isRowAmountValid(row); return (
updateDescription(row.key, event.target.value)} />
Amount {formatMoney(lineAmountCents(row))}
); })}
)} {!linesValid && lines.length > 0 && (

Enter a valid, non-negative amount for every line before saving.

)}
{taxCents > 0 ? ( <>
Subtotal {formatMoney(subtotalCents)}
Tax {formatMoney(taxCents)}
Total {formatMoney(totalCents)}
) : (
Total {formatMoney(totalCents)}
)}
{/* THE DESK DOOR SITS WITH THE PATIENT, NOT WITH THE MONEY. F3 step 5: the biller has this patient in front of them, which is the one moment a pool message has a subject. Left of the save actions because it is not one of them - it neither saves nor issues. */}
{/* A Save is green wherever it appears - the standard's own words, and saving a draft is still saving. */}
); }; export default InvoiceEditor;