// src/pages/DashboardPage/BillingPage/invoiceDisplay.ts // Pure invoice-display helpers shared by InvoiceDetail, InvoiceList, PatientInvoiceList, and the print // model (invoicePrintModel.ts). Kept React-free so the print-model unit test doesn't drag the component // graph in. InvoiceDetail re-exports these, so existing `from './InvoiceDetail'` imports are unchanged. import type { Invoice } from 'fhir/r4'; import { formatMoney } from '../../../utils/money'; import { readInvoiceDueDate, readInvoiceTotalCents } from '../../../utils/invoiceTransform'; export const UNREADABLE_BILL_FIELD = 'Could not be read'; // The server allocates the invoice number onto `Invoice.identifier` under a fixed system // (aws/sam/services/api/src/utils/constants.ts -> FHIR.IDENTIFIER_SYSTEMS.INVOICE_NUMBER: // 'https://akashichealthrecords.com/fhir/identifier/invoice-number'). No client-side constant for it // exists yet, so this matches by substring instead of hardcoding that URL a second time - the two // copies would otherwise be free to silently drift apart. export const invoiceNumberOf = (invoice: Invoice): string | undefined => invoice.identifier?.find((id) => id.system?.includes('invoice-number'))?.value; // Status labels - one source of truth for the list rows, the detail badge, and the printout. These are // friendlier than the raw FHIR statuses (v2 "billing-workspace" pass): "Sent"/"Void"/"Voided" read better // to a biller than issued/cancelled/entered-in-error. Display-only - the FHIR statuses are unchanged. export const STATUS_LABEL: Record = { draft: 'Draft', issued: 'Sent', balanced: 'Paid', cancelled: 'Void', 'entered-in-error': 'Voided', }; // Safe accessor - prefer this over indexing STATUS_LABEL directly. An invoice whose `status` is missing or // unexpected (e.g. a partial resource delivered over a WebSocket broadcast) degrades to the raw string. export const statusLabel = (status?: Invoice['status']): string => (status ? STATUS_LABEL[status] : undefined) ?? status ?? 'Unknown'; // Today as a local YYYY-MM-DD - the biller's calendar day, the anchor "past due" is measured against. // Local (not UTC) so the boundary flips at the biller's midnight, matching how due dates are computed in // InvoiceEditor. Injectable into pastDue below so the derivation stays a pure, deterministic unit. export const todayLocalDate = (): string => { const now = new Date(); return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; }; // Past due = an unpaid, ISSUED invoice whose due date is set and strictly before today. It is a DERIVED // state (a red badge + a filter value), never a stored FHIR status - see the v2 design ยง2c. Compared as // YYYY-MM-DD strings, which order correctly for zero-padded ISO dates. `today` is injectable for tests. export const pastDue = (invoice: Invoice, today: string = todayLocalDate()): boolean => { if (invoice.status !== 'issued') return false; const dueDate = readInvoiceDueDate(invoice); return dueDate !== undefined && dueDate < today; }; // Past-due-on-REMAINING (Task 8): a fully-paid invoice that happened to clear late is not "past due" - // there's nothing left to chase. A partially-paid overdue invoice IS still past due, on what's left. Takes // `remainingCents` as a plain number (not the ledger) so this stays dependency-free of paymentModel.ts - // the caller (InvoiceDetail) already computes remaining via Task 7's model. export const pastDueWithRemaining = ( invoice: Invoice, remainingCents: number, today: string = todayLocalDate(), ): boolean => pastDue(invoice, today) && remainingCents > 0; /** * WHOSE INVOICE THIS IS, as a human would say it - and NEVER a raw FHIR reference. * * Found in a browser on 2026-08-18: the billing overview listed * INV-2026-0004 Patient/b0538301-9432-42d2-83e3-e7df903de878 $123.45 * INV-2026-0002 Patient One $60.00 * INV-2026-0001 Patient/f0e77739-b029-46a1-856a-709a52618e47 $185.00 * `f0e77739` IS Patient One's id - so the SAME patient rendered as a name on one row and a raw uuid on * the row below. Not "a patient with no name": three call sites each wrote their own * `display ?? reference ?? 'Unknown patient'` chain, and only the one with a cache ever resolved anything. * * THE `?? reference` TAIL IS THE DEFECT AND IT IS GONE. An internal identifier is not a fallback * rendering of a person - it tells a biller nothing, and via `invoicePrintModel` it reached a PRINTED * invoice, which is a document a patient can be handed. Do not put it back "for debugging": the id is in * the resource, the console and the URL already. * * `resolvedName` is passed by callers that hold PatientService's cache (a plain array lookup, no fetch). * This module is deliberately React-free - see the header - so it cannot resolve names itself; callers * without a cache simply fall through to the name frozen onto `subject.display` at creation. */ export const invoicePatientLabel = (invoice: Invoice, resolvedName?: string): string => { const cached = resolvedName?.trim(); const frozen = invoice.subject?.display?.trim(); if (cached && cached !== UNREADABLE_BILL_FIELD) return cached; if (frozen) return frozen; if (cached) return cached; return 'Unknown patient'; }; export const invoiceAmountText = (invoice: Invoice): string => { const cents = readInvoiceTotalCents(invoice); return cents === null ? UNREADABLE_BILL_FIELD : formatMoney(cents); }; /** * WHAT A BILL ROW SAYS OUT LOUD, and the reason it exists is the practice name. * * A patient is billed by every practice involved in their care, so a bill that does not name * one is a bill they cannot place. The issuer is on the resource and the patient's own home page * already prints it on each payments-due row - so today the two screens disagree about the same * invoice. * * THE ROW STILL DOES NOT SHOW IT AS A VISIBLE LINE, and that is deliberate rather than * unfinished. Number, date, total and badge already fill the row at phone width, so showing it means * restacking the row - a design change that has not been ruled on. The mockup says it in as many * words: build the data, hold the stack. This is the half that needs no layout at all. * * Built by hand rather than left to the accessible name the browser computes, because that * computed name IS the visible text - which is exactly what is missing the practice. */ export const invoiceRowLabel = (parts: { number: string; issuedBy?: string; date?: string; total: string; status: string; }): string => [ `Invoice ${parts.number}`, parts.issuedBy?.trim() ? `from ${parts.issuedBy.trim()}` : undefined, parts.date, parts.total, parts.status, ] .filter((part): part is string => !!part && part.length > 0) .join(', '); /** * THE ONE STATE A BILL IS IN, chosen once so no two surfaces can disagree about it. * * PAST DUE OUTRANKS THE STORED STATUS, because it is the only one of the two the patient has to * act on. A bill that is `issued` and overdue is not "Sent" - it is late, and every other surface in * the product already says so. * * `entered-in-error` and `cancelled` both land on Void. A patient does not need our two words for * a bill that is off the books, and "Voided" beside "Void" is two labels for one fact. */ export type InvoiceState = 'past-due' | 'sent' | 'paid' | 'part-paid' | 'void' | 'draft'; export const invoiceState = (invoice: Invoice, overdue: boolean, partPaid = false): InvoiceState => { if (invoice.status === 'cancelled' || invoice.status === 'entered-in-error') return 'void'; if (invoice.status === 'balanced') return 'paid'; if (invoice.status === 'draft') return 'draft'; if (overdue) return 'past-due'; // Only from a ledger that was read; an unread one never claims part paid. if (partPaid) return 'part-paid'; return 'sent'; }; /** The date a bill's row shows: its issue date, or its last update when it carries none. */ export const invoiceShownDate = (invoice: Invoice): string | undefined => invoice.date ?? invoice.meta?.lastUpdated; /** * Newest shown date first. The server can only order by last update, and a bill paid today with an * older issue date would lead the list; ties keep the server's order. */ export const newestShownFirst = (invoices: Invoice[]): Invoice[] => [...invoices].sort((left, right) => (Date.parse(invoiceShownDate(right) ?? '') || 0) - (Date.parse(invoiceShownDate(left) ?? '') || 0));