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(