// src/pages/DashboardPage/BillingPage/invoiceLineRow.ts // Phase B review fix (C1): the InvoiceEditor <-> InvoiceLineDraft conversion, extracted out of // InvoiceEditor.tsx so it's unit-testable without dragging in the services barrel (InvoiceEditor.tsx // imports it, no behavior change there). // // `fromRow` used to reconstruct the InvoiceLineDraft field-by-field, which silently dropped // `appointmentId` - a Time line's link back to the Appointment it billed. That broke // `billedAppointmentIds` (invoiceTransform.ts / unbilledAppointments.ts read `line.appointmentId`), // silently defeating Phase B's whole double-bill-prevention feature: a saved invoice line looked // exactly like one with no appointment link, so the same appointment could be billed again on the // next invoice. Rewritten to destructure out ONLY the three UI-only fields and spread the rest, so // every current AND future InvoiceLineDraft field (description, unitPriceCents, quantity, taxable, // sourceType, sourceRef, coding, appointmentId,...) survives the round trip without this file having // to be touched again when InvoiceLineDraft grows a new field. import { v4 as uuidv4 } from 'uuid'; import { lineAmountCents, type InvoiceLineDraft } from '../../../utils/invoiceTransform'; import { computeTax, sumCents } from '../../../utils/money'; // Row-local state adds `key` (a stable React key across add/remove - the array index would misattribute // focus/typed text to the wrong row once a line above it is removed), `amountText` (the raw unit-price // dollar string as typed), and `quantityText` (the raw quantity string as typed). Both `unitPriceCents` // and `quantity` are only ever refreshed from *parseable* text, so a half-typed "12." or a momentarily // empty quantity box is never clobbered back to a canonical value mid-keystroke - canonicalization only // happens on blur. `taxable` and `sourceRef`/`coding` (Service lines) round-trip untouched - this editor // only ever writes description/unitPriceCents/quantity. export type LineRow = InvoiceLineDraft & { key: string; amountText: string; quantityText: string }; export const toRow = (line: InvoiceLineDraft): LineRow => ({ ...line, key: uuidv4(), amountText: (line.unitPriceCents / 100).toFixed(2), quantityText: String(line.quantity > 0 ? line.quantity : 1), }); // Destructure out exactly the UI-only fields LineRow adds on top of InvoiceLineDraft (`key`, // `amountText`, `quantityText`) and spread everything else back out untouched - rather than listing // InvoiceLineDraft's fields one by one, which is exactly how `appointmentId` got silently dropped // before. If `toRow` ever adds another UI-only field, it must be destructured here too (the round-trip // test below would catch a miss: the extra field would leak into the saved draft). export const fromRow = (row: LineRow): InvoiceLineDraft => { const { key, amountText, quantityText, ...draft } = row; return draft; }; export type LineTotals = { subtotalCents: number; taxCents: number; totalCents: number }; // Task 7: the editor's live Subtotal/Tax/Total breakdown for the in-progress draft - mirrors // invoiceTransform.ts's buildInvoiceResource computation exactly (tax computed ONLY on taxable lines, // via the same shared computeTax helper - never hand-rolled a second way) so what the biller sees while // composing a draft always matches what gets frozen onto the invoice on save. Pure/testable independent // of the InvoiceEditor component; accepts any InvoiceLineDraft[] (LineRow satisfies this) so it works // directly against the editor's row state without a fromRow conversion first. export const computeLineTotals = (lines: InvoiceLineDraft[], taxRatePercent: number): LineTotals => { const subtotalCents = sumCents(lines.map(lineAmountCents)); const taxableSubtotalCents = sumCents(lines.filter((line) => line.taxable).map(lineAmountCents)); const taxCents = computeTax(taxableSubtotalCents, taxRatePercent); return { subtotalCents, taxCents, totalCents: subtotalCents + taxCents }; };