/** * Customer invoice numbers for an order, derived from its line items. * * An order has no invoice column of its own — invoicing happens per * `order_items` row, and a partial pickup can put one order across * several invoices. The number lives in one of two places depending on * which flow raised the invoice: * * `order_item_details.invoice.number` — written by the Invoicing page * (QuickBooks webhook). Carries the * QB link, date and who set it. * `customer_invoice_number` — written by * `reconcileOrderItemInvoicedStatus`. * * `order_item_details` wins when both are set because it carries the QB * metadata. Safe to import from client components. * * Careful: collection-confirm ALSO writes `order_item_details.invoice`, * with the SUPPLIER's invoice number ("Confirmed for invoicing — IN-…"), * before the customer is billed. QuickBooks' write-back later replaces * it. Only an `invoice` blob carrying QuickBooks fields (`qbInvoiceId`, * `qbInvoiceLink` or `billingCompany`) is a customer invoice. */ export type InvoiceBearingItem = { id?: string; customer_invoice_number?: string | null; invoiced_date?: string | null; order_item_details?: string | Record | null; }; export type OrderInvoiceSummary = { number: string; /** YYYY-MM-DD (or full ISO) when known, else null. */ date: string | null; qbInvoiceLink: string | null; billingCompany: string | null; setByName: string | null; /** How many of the order's lines went out on this invoice. */ lineCount: number; }; type InvoiceDetails = { number?: unknown; date?: unknown; qbInvoiceId?: unknown; qbInvoiceLink?: unknown; billingCompany?: unknown; setByName?: unknown; }; function str(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } function readInvoiceDetails( details: InvoiceBearingItem["order_item_details"], ): InvoiceDetails | null { let parsed: unknown = details; if (typeof details === "string") { if (!details.trim()) return null; try { parsed = JSON.parse(details); } catch { return null; } } if (!parsed || typeof parsed !== "object") return null; const invoice = (parsed as { invoice?: unknown }).invoice; if (!invoice || typeof invoice !== "object") return null; const blob = invoice as InvoiceDetails; // Supplier invoice from collection-confirm — not a customer invoice. const fromQuickBooks = !!(str(blob.qbInvoiceId) || str(blob.qbInvoiceLink) || str(blob.billingCompany)); return fromQuickBooks ? blob : null; } /** The invoice number a single line went out on, or null. */ export function readItemInvoiceNumber(item: InvoiceBearingItem): string | null { return ( str(readInvoiceDetails(item.order_item_details)?.number) ?? str(item.customer_invoice_number) ); } /** * Distinct invoices across an order's lines, oldest first. Lines with no * invoice number are ignored, so an order that isn't invoiced yet returns * an empty array. */ export function summarizeOrderInvoices( items: readonly InvoiceBearingItem[], ): OrderInvoiceSummary[] { const byNumber = new Map(); for (const item of items) { const details = readInvoiceDetails(item.order_item_details); const number = str(details?.number) ?? str(item.customer_invoice_number); if (!number) continue; const existing = byNumber.get(number); if (existing) { existing.lineCount += 1; existing.date ??= str(details?.date) ?? str(item.invoiced_date); existing.qbInvoiceLink ??= str(details?.qbInvoiceLink); existing.billingCompany ??= str(details?.billingCompany); existing.setByName ??= str(details?.setByName); continue; } byNumber.set(number, { number, date: str(details?.date) ?? str(item.invoiced_date), qbInvoiceLink: str(details?.qbInvoiceLink), billingCompany: str(details?.billingCompany), setByName: str(details?.setByName), lineCount: 1, }); } return [...byNumber.values()].sort((a, b) => (a.date ?? "9999").localeCompare(b.date ?? "9999") || a.number.localeCompare(b.number), ); }