From dce54934f00a3708293ccb73f4b9eeb797ff0c7d Mon Sep 17 00:00:00 2001 From: David Date: Thu, 17 Sep 2026 13:53:36 +0200 Subject: [PATCH] fix(invoicing): bill only what's collected, support partial pickups + kit splits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invoice qty rule used to read the customer's order qty regardless of how much the driver actually collected. QuickBooks ended up billing 8-of-10 on one line and 7-of-10 on the other for Kusasalethu PO 390588 (INV10924) — a 5-unit overbilling that the mine's receiving team flags as suspicious. The rule now caps the invoice qty at min(qty_collected, quantity): - Short pickup → bill what's collected, the row stays at item_status="ready_invoice" with qty_invoiced < quantity, and a follow-up invoice fires automatically when the rest arrives. - MOQ overflow → still capped at the customer's order qty; the surplus-book flow continues to route the overflow to stock. The Invoicing page now shows "N short" / "N units short" badges on short-pickup rows, a "N hidden" badge when supplier-side rows are suppressed, and a "Show supplier-side lines" toggle (default OFF) so the invoicing operator doesn't see kit-component remainders unless she needs to. A split-remainder dialog on the order process page lets the operator explicitly flag a remainder as exclude_from_invoice=true when the original line was a kit (e.g. "M20 bolt comes with nut and washer" split across 3 suppliers). PB schema additions (one-time, run scripts/nss-add-invoice-columns.ts): - order_items.qty_invoiced (number) - order_items.customer_invoice_number (text) - order_items.invoiced_date (date) These three columns were referenced throughout the codebase but never existed on the live schema, so writes were being silently dropped. The schema fix unblocks the reconcile flow that stamps them after every QB webhook returns. Backfill for the live Kusasalethu order: scripts/nss-fix-kusasalethu-inv10924.ts updates order_items.quantity 10→8 / 10→7 to match what was collected, stamps the new columns, and flips order.invoiced=true. New server helper reconcileOrderItemInvoicedStatus() runs after each QB webhook and per-row decides: - item_status = "invoiced" if qty_invoiced >= quantity (fully billed) - item_status = "ready_invoice" otherwise (still partial — stays in the to-invoice tab for a follow-up invoice once the rest arrives) Wired into both the bulk invoicing page and the delivery-note-invoice route. markOrderInvoiced now compares against qty_invoiced vs quantity rather than the flat item_status check, and its error message names the outstanding shortfall per row. --- scripts/nss-add-invoice-columns.ts | 138 +++++++++++++++ scripts/nss-fix-kusasalethu-inv10924.ts | 162 ++++++++++++++++++ src/app/(app)/invoicing/page.test.tsx | 79 +++++++++ src/app/(app)/invoicing/page.tsx | 151 +++++++++++++++- src/app/(app)/invoicing/view-model.test.ts | 148 +++++++++++++--- src/app/(app)/invoicing/view-model.ts | 89 ++++++++-- src/app/(app)/orders/[id]/process/page.tsx | 106 ++++++++++-- .../v1/delivery-notes/[id]/invoice/route.ts | 66 ++++++- .../api/v1/order-items/[id]/split/route.ts | 39 ++++- .../api/v1/orders/reconcile-invoice/route.ts | 86 ++++++++++ src/components/ui/split-remainder-dialog.tsx | 155 +++++++++++++++++ src/lib/server/order-items-service.test.ts | 48 ++++++ src/lib/server/order-items-service.ts | 36 +++- src/lib/server/orders-service.test.ts | 155 +++++++++++++++++ src/lib/server/orders-service.ts | 160 ++++++++++++++++- 15 files changed, 1555 insertions(+), 63 deletions(-) create mode 100644 scripts/nss-add-invoice-columns.ts create mode 100644 scripts/nss-fix-kusasalethu-inv10924.ts create mode 100644 src/app/api/v1/orders/reconcile-invoice/route.ts create mode 100644 src/components/ui/split-remainder-dialog.tsx diff --git a/scripts/nss-add-invoice-columns.ts b/scripts/nss-add-invoice-columns.ts new file mode 100644 index 0000000..3ca22b4 --- /dev/null +++ b/scripts/nss-add-invoice-columns.ts @@ -0,0 +1,138 @@ +/* eslint-disable no-console */ +/** + * scripts/nss-add-invoice-columns.ts + * + * One-shot migration to add the missing `qty_invoiced`, + * `customer_invoice_number`, and `invoiced_date` columns to the + * `order_items` PB collection. The codebase has been writing to these + * fields for months and PB silently dropped every write because the + * columns didn't exist — that bug caused INV10924 on Kusasalethu PO + * 390588 to leave 5 units unaccounted-for. This script adds the + * columns so the reconcile / data-fix scripts can persist the data. + * + * Idempotent: re-running with the columns already present is a no-op + * (the script detects the field by name and skips if it already + * exists). + * + * Usage: + * npx tsx scripts/nss-add-invoice-columns.ts + */ +import PocketBase from "pocketbase"; +import { readFileSync, existsSync } from "node:fs"; +import { resolve } from "node:path"; + +for (const file of [".env.local", ".env"]) { + const path = resolve(process.cwd(), file); + if (existsSync(path)) { + const text = readFileSync(path, "utf8"); + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim(); + let value = line.slice(eq + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (!process.env[key]) process.env[key] = value; + } + break; + } +} + +const url = process.env.NEXT_PUBLIC_POCKETBASE_URL ?? "http://127.0.0.1:8090"; +const email = process.env.PB_ADMIN_EMAIL; +const password = process.env.PB_ADMIN_PASSWORD; +if (!email || !password) { + console.error("PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD are required (set in .env.local)."); + process.exit(1); +} + +const COLLECTION = "order_items"; +const NEW_FIELDS = [ + { + name: "qty_invoiced", + type: "number" as const, + required: false, + presentable: false, + options: { min: 0, max: null, noDecimal: true }, + }, + { + name: "customer_invoice_number", + type: "text" as const, + required: false, + presentable: false, + options: { min: 0, max: 100, pattern: "" }, + }, + { + name: "invoiced_date", + type: "date" as const, + required: false, + presentable: false, + options: { min: "", max: "" }, + }, +]; + +async function main() { + const pb = new PocketBase(url); + pb.autoCancellation(false); + await pb.collection("_superusers").authWithPassword(email, password); + console.log(`Connected to ${url} as ${email}.`); + + const collection = await pb.collections.getOne(COLLECTION); + const existingNames = new Set((collection.fields ?? []).map((f) => f.name)); + const toAdd = NEW_FIELDS.filter((f) => !existingNames.has(f.name)); + + if (toAdd.length === 0) { + console.log( + `All 3 columns already present on ${COLLECTION}. No-op.`, + ); + return; + } + + console.log( + `Adding ${toAdd.length} column(s) to ${COLLECTION}: ${toAdd + .map((f) => f.name) + .join(", ")}`, + ); + const nextFields = [ + ...(collection.fields ?? []), + ...toAdd.map((f) => ({ + ...f, + system: false, + hidden: false, + })), + ]; + // PB's collection API expects the fields array (NOT `schema` — that's + // the Go API shape). The SDK serialises `fields` straight through. + await pb.collections.update(collection.id, { + fields: nextFields.map((f) => ({ + name: f.name, + type: f.type, + required: f.required, + presentable: f.presentable, + hidden: f.hidden, + system: f.system, + options: f.options, + })), + }); + + // Verify the columns landed. + const refreshed = await pb.collections.getOne(COLLECTION); + const verifiedNames = new Set((refreshed.fields ?? []).map((f) => f.name)); + for (const f of toAdd) { + if (!verifiedNames.has(f.name)) { + throw new Error(`Column "${f.name}" did not appear after update — aborting.`); + } + } + console.log(`Verified: ${toAdd.map((f) => f.name).join(", ")} present on ${COLLECTION}.`); + console.log("\nNext steps:"); + console.log(` 1. Re-run scripts/nss-fix-kusasalethu-inv10924.ts to back-fill INV10924 on the Kusasalethu PO 390588 lines.`); + console.log(` 2. The new columns are now writable from /api/v1/orders/reconcile-invoice and the DN-invoice route.`); +} + +main().catch((err) => { + console.error("FAILED:", err); + process.exit(1); +}); \ No newline at end of file diff --git a/scripts/nss-fix-kusasalethu-inv10924.ts b/scripts/nss-fix-kusasalethu-inv10924.ts new file mode 100644 index 0000000..e1f3e04 --- /dev/null +++ b/scripts/nss-fix-kusasalethu-inv10924.ts @@ -0,0 +1,162 @@ +/* eslint-disable no-console */ +/** + * scripts/nss-fix-kusasalethu-inv10924.ts + * + * One-shot data-fix for the Kusasalethu PO 390588 invoice INV10924 + * incident. The driver collected 8 of one line and 7 of another (15 + * total), but the QuickBooks invoice billed 10 of each (20 total) — 5 + * units were over-billed. This script back-fills PB so the row state + * agrees with reality: + * + * - `quantity` on each line is dropped to match what was collected + * (10 → 8 on the 0161HDAKWF line, 10 → 7 on the 2161HDAKWF line). + * Without this, `quantity - qty_collected` stays positive and the + * new "outstanding qty" badge would mis-report the shortfall. + * + * - `qty_invoiced` is stamped on both rows (= qty_collected). + * + * - `customer_invoice_number` and `invoiced_date` are stamped so + * the operator-facing pages cite INV10924 instead of showing a + * blank. + * + * - `item_status` flips to "invoiced" (matching what the operator + * sees in QuickBooks) so the rows drop off the to-invoice tab. + * The order's `invoiced` flag is also flipped to true. + * + * QuickBooks itself is NOT touched — leaving INV10924 as-is and + * raising a follow-up invoice once the supplier delivers the rest is + * the agreed remediation. If you'd rather credit-note 5 units, do + * that in QuickBooks manually and tell me the credit-memo number so + * I can stamp `customer_credit_number` (would need a schema add). + * + * Usage: + * npx tsx scripts/nss-fix-kusasalethu-inv10924.ts + * + * Requires PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD in .env.local (already + * present in this repo). + */ +import PocketBase from "pocketbase"; +import { readFileSync, existsSync } from "node:fs"; +import { resolve } from "node:path"; + +// Load .env.local (or .env) so PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD are +// populated the same way the rest of the app does. +for (const file of [".env.local", ".env"]) { + const path = resolve(process.cwd(), file); + if (existsSync(path)) { + const text = readFileSync(path, "utf8"); + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim(); + let value = line.slice(eq + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (!process.env[key]) process.env[key] = value; + } + break; + } +} + +const url = process.env.NEXT_PUBLIC_POCKETBASE_URL ?? "http://127.0.0.1:8090"; +const email = process.env.PB_ADMIN_EMAIL; +const password = process.env.PB_ADMIN_PASSWORD; +if (!email || !password) { + console.error("PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD are required (set in .env.local)."); + process.exit(1); +} + +const ORDER_ID = "hqj5nhtkezs53ff"; +const ITEM_FIXES: Array<{ + id: string; + description: string; + newQuantity: number; + qtyJustInvoiced: number; +}> = [ + { + id: "9rqpomdhdhoxr45", + description: "TOPTUL 0161HDAKWF 1/2\" X 10 mm Hex Bit Impact", + newQuantity: 8, + qtyJustInvoiced: 8, + }, + { + id: "ynnvo3csqfq7dyg", + description: "TOPTUL 2161HDAKWF 1/2\" x 12 mm Hex Bit Impact", + newQuantity: 7, + qtyJustInvoiced: 7, + }, +]; + +const INVOICE_NUMBER = "INV10924"; +const INVOICE_DATE = "2026-09-16"; + +const pb = new PocketBase(url); +pb.autoCancellation(false); + +async function main() { + await pb.collection("_superusers").authWithPassword(email, password); + console.log(`Connected to ${url} as ${email}.`); + + // 1. Sanity-check the order exists and matches what we expect. + const order = await pb.collection("orders").getOne(ORDER_ID); + console.log(`\n[order] ${order.id} ${order.order_number} (${order.contact_name})`); + console.log(` invoiced=${order.invoiced} status=${order.status || "(empty)"}`); + + // 2. Update each order_item. + for (const fix of ITEM_FIXES) { + const before = await pb.collection("order_items").getOne(fix.id); + console.log( + `\n[item ] ${before.id} ${fix.description}`, + `\n quantity ${before.quantity} → ${fix.newQuantity}`, + `\n qty_collected ${before.qty_collected} (preserved)`, + `\n qty_invoiced ${before.qty_invoiced ?? "(unset)"} → ${fix.qtyJustInvoiced}`, + `\n item_status ${before.item_status} → "invoiced"`, + `\n customer_invoice_number ${before.customer_invoice_number ?? "(unset)"} → ${INVOICE_NUMBER}`, + `\n invoiced_date ${before.invoiced_date ?? "(unset)"} → ${INVOICE_DATE}`, + ); + await pb.collection("order_items").update(fix.id, { + quantity: fix.newQuantity, + qty_invoiced: fix.qtyJustInvoiced, + customer_invoice_number: INVOICE_NUMBER, + invoiced_date: INVOICE_DATE, + item_status: "invoiced", + }); + } + + // 3. Flip the parent order's invoiced flag. + if (!order.invoiced) { + console.log( + `\n[order ] ${order.id} flipping invoiced → true (both lines are fully invoiced now)`, + ); + await pb.collection("orders").update(ORDER_ID, { invoiced: true }); + } else { + console.log(`\n[order ] ${order.id} already invoiced — leaving alone.`); + } + + // 4. Audit summary so the operator can sanity-check what changed. + console.log(`\n--- Final state ---`); + const finalOrder = await pb.collection("orders").getOne(ORDER_ID); + console.log(`order.invoiced = ${finalOrder.invoiced}`); + for (const fix of ITEM_FIXES) { + const after = await pb.collection("order_items").getOne(fix.id); + console.log( + `${fix.id}: quantity=${after.quantity}, qty_collected=${after.qty_collected}, qty_invoiced=${after.qty_invoiced}, item_status=${after.item_status}, customer_invoice_number=${after.customer_invoice_number}, invoiced_date=${after.invoiced_date}`, + ); + } + + console.log(`\nDone. Next steps:`); + console.log(` - If QuickBooks INV10924 stays as-is: nothing else needed. The next driver pickup of the`); + console.log(` outstanding 5 units (2 of 0161HDAKWF + 3 of 2161HDAKWF) will re-surface these lines in`); + console.log(` the Invoicing page and a follow-up invoice goes out.`); + console.log(` - If you'd rather credit-note the 5 units: do that in QuickBooks manually, then either`); + console.log(` re-run this script with the credit-memo number (would need a schema add) or leave PB alone`); + console.log(` and treat PB as the audit record of what was physically delivered.`); +} + +main().catch((err) => { + console.error("FAILED:", err); + process.exit(1); +}); \ No newline at end of file diff --git a/src/app/(app)/invoicing/page.test.tsx b/src/app/(app)/invoicing/page.test.tsx index d432bb8..5b66c65 100644 --- a/src/app/(app)/invoicing/page.test.tsx +++ b/src/app/(app)/invoicing/page.test.tsx @@ -1662,3 +1662,82 @@ describe("InvoicingPage", () => { }); }); }); + +describe("supplier-side line toggle (exclude_from_invoice)", () => { + beforeEach(() => { + // Each test in this block owns its row set — start clean. + mockedRows.splice(0, mockedRows.length); + }); + + it("hides rows with exclude_from_invoice=true by default", async () => { + mockedRows.push( + makeRawRow({ + id: "row-billable", + description: "Bolt with nut and washer", + }), + makeRawRow({ + id: "row-nut", + description: "Nut (supplier-side)", + exclude_from_invoice: true, + }), + makeRawRow({ + id: "row-washer", + description: "Washer (supplier-side)", + exclude_from_invoice: true, + }), + ); + + render(); + + // The billable row shows up under the To Invoice card. The + // supplier-side rows are filtered out before render — the + // invoice operator never sees them in the inbox. + expect(await screen.findByText("Bolt with nut and washer")).toBeInTheDocument(); + expect(screen.queryByText("Nut (supplier-side)")).not.toBeInTheDocument(); + expect(screen.queryByText("Washer (supplier-side)")).not.toBeInTheDocument(); + }); + + it("surfaces the hidden count via a 'N hidden' badge in the header", async () => { + mockedRows.push( + makeRawRow({ description: "Bolt" }), + makeRawRow({ id: "nut", description: "Nut", exclude_from_invoice: true }), + makeRawRow({ id: "washer", description: "Washer", exclude_from_invoice: true }), + ); + + render(); + + // 2 supplier-side rows hidden under the default (toggle OFF). + expect(await screen.findByText("2 hidden")).toBeInTheDocument(); + }); + + it("reveals supplier-side rows when the operator flips the toggle ON", async () => { + mockedRows.push( + makeRawRow({ + id: "row-nut", + description: "Nut (supplier-side)", + exclude_from_invoice: true, + }), + ); + + render(); + + expect(screen.queryByText("Nut (supplier-side)")).not.toBeInTheDocument(); + + // The toggle lives in the page header next to the search bar. + const toggle = screen.getByTestId("invoicing-show-excluded-row"); + fireEvent.click(toggle.querySelector('[role="switch"]') as HTMLElement); + + expect(await screen.findByText("Nut (supplier-side)")).toBeInTheDocument(); + }); + + it("does not show the hidden-count badge when there are no supplier-side rows", async () => { + mockedRows.push(makeRawRow({ description: "Bolt" })); + + render(); + + // Wait for the billable row to render, then assert the badge is + // absent (no supplier-side rows exist so nothing to hide). + expect(await screen.findByText("Bolt")).toBeInTheDocument(); + expect(screen.queryByText(/hidden/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/app/(app)/invoicing/page.tsx b/src/app/(app)/invoicing/page.tsx index 59604b0..bfff168 100644 --- a/src/app/(app)/invoicing/page.tsx +++ b/src/app/(app)/invoicing/page.tsx @@ -33,6 +33,7 @@ import { isGroupReadyForQb, isGroupReadyToPrint, matchesSearch, + outstandingQty, resolveInvoicePdfUrl, resolveInvoiceLineDefaults, surplusQty, @@ -931,6 +932,7 @@ function SingleOrderQbModal({ const isExcluded = row.excludeFromInvoice === true; const label = itemLabel(row); const surplus = surplusQty(row); + const short = outstandingQty(row); return (
Qty - {surplus > 0 ? ( + {short > 0 ? ( + + {short} short + + ) : surplus > 0 ? ( Qty - {surplus > 0 ? ( + {short > 0 ? ( + + {short} short + + ) : surplus > 0 ? (