fix(invoicing): bill only what's collected, support partial pickups + kit splits

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.
This commit is contained in:
David 2026-09-17 13:53:36 +02:00
parent 1bc0da40e7
commit dce54934f0
15 changed files with 1555 additions and 63 deletions

View file

@ -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);
});

View file

@ -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);
});

View file

@ -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(<Page />);
// 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(<Page />);
// 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(<Page />);
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(<Page />);
// 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();
});
});

View file

@ -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 (
<div
key={row.id}
@ -1002,7 +1004,14 @@ function SingleOrderQbModal({
<label className="space-y-1 text-sm" htmlFor={qtyId}>
<span className="flex items-center justify-between gap-2">
<span>Qty</span>
{surplus > 0 ? (
{short > 0 ? (
<span
className="rounded-full border border-warning/40 bg-warning/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-warning-foreground"
title={`Customer ordered ${row.quantity}, driver only collected ${row.qtyCollected}. ${short} unit${short === 1 ? "" : "s"} still owed — will appear on a follow-up invoice once the supplier delivers the rest.`}
>
{short} short
</span>
) : surplus > 0 ? (
<span
className="rounded-full border border-info/40 bg-info/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-info"
title={`Supplier delivered ${row.qtyCollected}, customer ordered ${row.orderQty}. ${surplus} goes to stock — book via /api/v1/stock/surplus-book.`}
@ -1249,6 +1258,7 @@ function SingleDeliveryNoteModal({
const isExcluded = row.excludeFromInvoice === true;
const label = itemLabel(row);
const surplus = surplusQty(row);
const short = outstandingQty(row);
return (
<div
key={row.id}
@ -1309,7 +1319,14 @@ function SingleDeliveryNoteModal({
<label className="space-y-1 text-sm" htmlFor={qtyId}>
<span className="flex items-center justify-between gap-2">
<span>Qty</span>
{surplus > 0 ? (
{short > 0 ? (
<span
className="rounded-full border border-warning/40 bg-warning/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-warning-foreground"
title={`Customer ordered ${row.quantity}, driver only collected ${row.qtyCollected}. ${short} unit${short === 1 ? "" : "s"} still owed — will appear on a follow-up invoice once the supplier delivers the rest.`}
>
{short} short
</span>
) : surplus > 0 ? (
<span
className="rounded-full border border-info/40 bg-info/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-info"
title={`Supplier delivered ${row.qtyCollected}, customer ordered ${row.orderQty}. ${surplus} will be booked to stock via /api/v1/stock/surplus-book after delivery.`}
@ -1380,6 +1397,19 @@ export default function InvoicingPage() {
storageKey: "wfdash-invoicing-detailed",
initial: false,
});
// Supplier-side rows (split-off kit components, exclude_from_invoice=true)
// are hidden from the Invoicing page by default — they ride along for
// PO / driver / collection but never appear on the customer invoice,
// so showing them in the invoice inbox is just noise for the
// invoicing operator. The header toggle surfaces them on demand
// when an operator needs to verify a kit split. The choice sticks
// across reloads via localStorage (same persistence mechanism as
// the `detailed` toggle above).
const { value: showExcludedLines, setValue: setShowExcludedLines } =
useBooleanPreference({
storageKey: "wfdash-invoicing-show-excluded",
initial: false,
});
const [showOutstanding, setShowOutstanding] = useState<Record<string, boolean>>({});
const [searchText, setSearchText] = useState<string>("");
const [printingGroupId, setPrintingGroupId] = useState<string | null>(null);
@ -1588,7 +1618,31 @@ export default function InvoicingPage() {
);
const rowsView = useMemo(() => rows ?? [], [rows]);
const invoicingRows = useMemo(() => rowsView.map(toInvoicingRow), [rowsView]);
const invoicingRows = useMemo(
() =>
rowsView
// Hide supplier-side rows (exclude_from_invoice=true) by default.
// The operator's `showExcludedLines` toggle in the header
// surfaces them when the invoicing operator needs to verify a
// split (rare — usually for kit troubleshooting). Without this
// filter the muted "Not invoiced" rows still pollute the
// count badges and the modal checkbox list.
.filter((row) => showExcludedLines || !row.exclude_from_invoice)
.map(toInvoicingRow),
[rowsView, showExcludedLines],
);
// Count of supplier-side rows the toggle is currently hiding. Drives
// the "N hidden" badge in the header so the operator can see at a
// glance how many kit-component lines have been suppressed without
// flipping the switch ON. Only meaningful when the toggle is OFF.
const hiddenExcludedCount = useMemo(
() =>
rowsView.reduce(
(sum, row) => (row.exclude_from_invoice ? sum + 1 : sum),
0,
),
[rowsView],
);
const recentInvoiceRange = useMemo(() => buildRecentInvoiceRange(), []);
const todayIso = useMemo(() => new Date().toISOString().slice(0, 10), []);
const invoicePrintGroups = useMemo(
@ -2132,6 +2186,43 @@ export default function InvoicingPage() {
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error(`Webhook returned ${response.status}`);
// Reconcile PB's per-row invoiced-state against what we just
// billed. n8n's "Build PocketBase Invoice Update" node flips
// `item_status` to "invoiced" unconditionally — for a short
// pickup that orphans the remaining units. The reconcile
// route stamps `qty_invoiced` and the correct `item_status`
// per row. Failures here are non-fatal (the QB invoice
// exists; the row state just stays slightly stale).
try {
const webhookBody = await response.clone().json().catch(() => ({}));
const invoiceNumber =
(webhookBody as { invoiceNumber?: string }).invoiceNumber ??
(webhookBody as { invoice_number?: string }).invoice_number ??
(webhookBody as { qb_invoice_id?: string }).qb_invoice_id ??
`QB-${first.orderNumber ?? "no-order"}-${new Date().toISOString()}`;
const billedQtyByRowId = new Map<string, number>();
for (const line of payload.items) {
const matchedRow = rows.find((row) => (row.description ?? "(no description)") === line.name);
if (matchedRow) billedQtyByRowId.set(matchedRow.id, line.qty);
}
const rowsBilled = payload.order_item_ids.map((id) => ({
id,
qtyJustInvoiced: billedQtyByRowId.get(id) ?? 0,
}));
await fetch("/api/v1/orders/reconcile-invoice", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
rows: rowsBilled,
customerInvoiceNumber: invoiceNumber,
invoiceDate: new Date().toISOString().slice(0, 10),
}),
});
} catch (reconcileErr) {
console.error("[Invoicing] reconcile-invoice failed:", reconcileErr);
}
toast.success("Sent to QuickBooks");
await load();
return true;
@ -2318,6 +2409,51 @@ export default function InvoicingPage() {
border-l-4` shape, with an `info` accent on the left border
to keep it visually distinct from order-coloured cards.
*/}
{/* Supplier-side / kit-component toggle. Default OFF — the
invoice operator should see only rows they're expected to
bill. Flipping it ON reveals rows with
`exclude_from_invoice=true` (split-off kit components
and other lines that ride along for PO/driver/collection
but never reach the customer invoice). The choice sticks
across reloads via localStorage so an operator who needs
it ON stays ON. The "N hidden" badge tells the operator
what's been suppressed without forcing them to count. */}
<div
className="flex items-center gap-2 rounded-md border bg-card px-3 py-1.5 shadow-sm"
data-testid="invoicing-show-excluded-row"
>
<Receipt className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
<Label
htmlFor="invoicing-show-excluded"
id="invoicing-show-excluded-label"
className="text-xs font-medium select-none cursor-pointer"
>
Show supplier-side lines
</Label>
<Switch
id="invoicing-show-excluded"
aria-labelledby="invoicing-show-excluded-label"
size="sm"
checked={showExcludedLines}
onCheckedChange={setShowExcludedLines}
/>
{!showExcludedLines && hiddenExcludedCount > 0 ? (
<Badge
variant="outline"
className="border-warning/40 bg-warning/10 text-warning-foreground"
>
{`${hiddenExcludedCount} hidden`}
</Badge>
) : null}
</div>
{/*
above the tabs) so it reads as a sibling of the customer
invoice cards — same `rounded-xl border bg-card shadow-sm
border-l-4` shape, with an `info` accent on the left border
to keep it visually distinct from order-coloured cards.
*/}
<div className="flex flex-wrap items-center gap-2">
<div role="tablist" aria-label="Invoicing views" className="inline-flex rounded-lg border bg-card p-1 shadow-sm">
<Button
@ -2442,6 +2578,15 @@ export default function InvoicingPage() {
<Badge variant={card.hasOutstanding ? "secondary" : "outline"}>
{card.hasOutstanding ? "Has outstanding" : "Ready"}
</Badge>
{card.shortPickupTotal > 0 ? (
<Badge
variant="outline"
className="border-warning/40 bg-warning/10 text-warning-foreground"
title={`Driver short-picked up ${card.shortPickupTotal} unit${card.shortPickupTotal === 1 ? "" : "s"} across ${card.readyCount} ready line${card.readyCount === 1 ? "" : "s"}. The invoice will bill only what was collected; a follow-up invoice fires when the supplier delivers the rest.`}
>
{`${card.shortPickupTotal} unit${card.shortPickupTotal === 1 ? "" : "s"} short`}
</Badge>
) : null}
{card.hasOutstanding ? (
<Button
type="button"

View file

@ -14,6 +14,7 @@ import {
isGroupReadyForQb,
isGroupReadyToPrint,
matchesSearch,
outstandingQty,
pickClosestCustomer,
resolveInvoiceLineDefaults,
resolveInvoicePdfUrl,
@ -236,10 +237,64 @@ describe("buildOrderInvoiceCards", () => {
readyCount: 1,
outstandingCount: 1,
hasOutstanding: true,
shortPickupTotal: 0,
});
expect(groups[0].readyRows.map((row) => row.id)).toEqual(["row-1"]);
expect(groups[0].outstandingRows.map((row) => row.id)).toEqual(["row-2"]);
});
it("sums short-pickup units across the card's ready rows", () => {
const rows: InvoicingRow[] = [
makeRow({
id: "row-short-1",
orderNumber: "SO-1",
item_status: "ready_invoice",
quantity: 10,
qtyCollected: 8,
}),
makeRow({
id: "row-short-2",
orderNumber: "SO-1",
item_status: "ready_invoice",
quantity: 5,
qtyCollected: 2,
}),
makeRow({
id: "row-full",
orderNumber: "SO-1",
item_status: "ready_invoice",
quantity: 4,
qtyCollected: 4,
}),
];
const groups = buildOrderInvoiceCards(rows);
expect(groups).toHaveLength(1);
// 2 short on row-short-1, 3 short on row-short-2, 0 on row-full = 5.
expect(groups[0].shortPickupTotal).toBe(5);
expect(groups[0].readyRows.map((row) => row.id)).toEqual([
"row-short-1",
"row-short-2",
"row-full",
]);
});
it("ignores MOQ overflow when computing short-pickup (overflow is surplus, not outstanding)", () => {
// row-1 has collected > ordered — that's MOQ overflow, NOT a
// short pickup. The card's `shortPickupTotal` stays at 0.
const rows: InvoicingRow[] = [
makeRow({
id: "row-moq",
orderNumber: "SO-1",
item_status: "ready_invoice",
quantity: 4,
qtyCollected: 10,
}),
];
const groups = buildOrderInvoiceCards(rows);
expect(groups[0].shortPickupTotal).toBe(0);
});
});
describe("buildInvoicePrintGroups", () => {
@ -566,37 +621,63 @@ describe("resolveInvoicePdfUrl", () => {
});
describe("resolveInvoiceLineDefaults", () => {
// `quantity` is what the customer ordered; `orderQty` is what we bought
// from the supplier. These tests previously conflated the two — see
// docs/superpowers/specs/2026-08-30-quantity-handling-audit.md.
// The invoice qty is `min(qty_collected, quantity)` — the customer is
// billed for what was actually picked up, capped at what they
// ordered. Anything short is tracked via `outstandingQty`; anything
// over the order qty (supplier MOQ overflow) goes to stock via
// `surplusQty` and never reaches the customer invoice.
it("bills the customer quantity, not the larger supplier MOQ quantity", () => {
// Customer ordered 4. Supplier MOQ forced us to buy 10, and all 10
// arrived. The invoice must say 4 — the other 6 goes to stock via
// /api/v1/stock/surplus-book (see surplusQty below).
it("caps at the customer quantity on a supplier MOQ overflow", () => {
// Customer ordered 4. Supplier MOQ forced us to buy 10, all 10
// arrived. The invoice says 4 — the other 6 are surplus and go to
// stock via /api/v1/stock/surplus-book (see surplusQty below).
const row = makeRow({ quantity: 4, orderQty: 10, qtyCollected: 10 });
expect(resolveInvoiceLineDefaults(row).qty).toBe(4);
});
it("bills the customer quantity, not the smaller supplier qty on a shorted line", () => {
// Customer ordered 100; supplier A could only cover 40. Until the
// operator splits the remaining 60 onto a second line, the customer
// still ordered 100 — billing 40 would silently lose the rest.
const row = makeRow({ quantity: 100, orderQty: 40, qtyCollected: 40 });
expect(resolveInvoiceLineDefaults(row).qty).toBe(100);
it("caps at the collected qty on a short pickup", () => {
// Customer ordered 10; only 8 were collected. The invoice bills
// exactly 8 — the customer cannot be invoiced for goods that didn't
// physically arrive. The remaining 2 are visible as outstanding
// (see outstandingQty below) and a follow-up invoice is raised
// once the supplier delivers the rest.
const row = makeRow({ quantity: 10, orderQty: 10, qtyCollected: 8 });
expect(resolveInvoiceLineDefaults(row).qty).toBe(8);
});
it("never bills the collected qty", () => {
const row = makeRow({ quantity: 4, orderQty: 4, qtyCollected: 10 });
expect(resolveInvoiceLineDefaults(row).qty).toBe(4);
it("invoices the full order qty when collected exactly equals ordered", () => {
const row = makeRow({ quantity: 10, orderQty: 10, qtyCollected: 10 });
expect(resolveInvoiceLineDefaults(row).qty).toBe(10);
});
it("falls back to orderQty when there is no quantity (legacy rows)", () => {
const row = makeRow({ quantity: null, orderQty: 6, qtyCollected: 6 });
it("invoices 0 when nothing has been collected", () => {
// Driver hasn't picked up anything yet but the row is in the
// invoice flow — invoice 0 rather than the full order qty so the
// operator sees a $0 line and follows up with the driver before
// sending to QuickBooks.
const row = makeRow({ quantity: 10, orderQty: 10, qtyCollected: 0 });
expect(resolveInvoiceLineDefaults(row).qty).toBe(0);
});
it("caps at the smaller of customer qty and collected when both are partial", () => {
// Customer ordered 100, supplier MOQ forced 200 onto the PO, only
// 60 arrived. Both caps fire: the invoice is 60 (the smaller of
// ordered=100 and collected=60). The 40 still owed is outstanding,
// the 140 MOQ overflow is surplus.
const row = makeRow({ quantity: 100, orderQty: 200, qtyCollected: 60 });
expect(resolveInvoiceLineDefaults(row).qty).toBe(60);
});
it("falls back to the customer orderQty when quantity is missing (legacy rows)", () => {
// Pre-collection row where `quantity` was never written. Fall back
// to `orderQty` rather than invoicing 0.
const row = makeRow({ quantity: null, orderQty: 6, qtyCollected: null });
expect(resolveInvoiceLineDefaults(row).qty).toBe(6);
});
it("falls back to qtyCollected when neither quantity is recorded", () => {
it("falls back to qtyCollected when neither quantity nor orderQty is recorded", () => {
// Legacy rows where `quantity` and `orderQty` were written
// identically to `qtyCollected`. Pre-collection flow still works.
const row = makeRow({ quantity: null, orderQty: null, qtyCollected: 6 });
expect(resolveInvoiceLineDefaults(row).qty).toBe(6);
});
@ -607,11 +688,36 @@ describe("resolveInvoiceLineDefaults", () => {
});
});
describe("outstandingQty", () => {
it("returns the shortfall when the driver short-picked up the order", () => {
expect(outstandingQty(makeRow({ quantity: 10, qtyCollected: 8 }))).toBe(2);
});
it("returns 0 when collected exactly equals ordered", () => {
expect(outstandingQty(makeRow({ quantity: 10, qtyCollected: 10 }))).toBe(0);
});
it("returns 0 when collected exceeds ordered (the surplus is the customer's overage, not outstanding)", () => {
expect(outstandingQty(makeRow({ quantity: 4, qtyCollected: 10 }))).toBe(0);
});
it("returns 0 when collected is 0 (the customer hasn't received anything yet — that's outstanding collection, not invoicing)", () => {
expect(outstandingQty(makeRow({ quantity: 10, qtyCollected: 0 }))).toBe(10);
});
it("returns 0 when quantity is missing", () => {
expect(outstandingQty(makeRow({ quantity: null, qtyCollected: 8 }))).toBe(0);
});
it("returns 0 when qtyCollected is missing", () => {
expect(outstandingQty(makeRow({ quantity: 10, qtyCollected: null }))).toBe(0);
});
});
describe("surplusQty", () => {
it("returns the supplier-MOQ overflow (collected - customer qty)", () => {
// The case the operator actually hits: bought 10 on a supplier MOQ,
// customer wanted 4, so 6 units are surplus and belong in stock.
// Before the audit fix this returned 0 and the surplus was unbookable.
expect(surplusQty(makeRow({ quantity: 4, orderQty: 10, qtyCollected: 10 }))).toBe(6);
});
@ -619,7 +725,7 @@ describe("surplusQty", () => {
expect(surplusQty(makeRow({ quantity: 4, orderQty: 4, qtyCollected: 4 }))).toBe(0);
});
it("returns 0 on a short delivery (collected < customer qty)", () => {
it("returns 0 on a short delivery (collected < customer qty — see outstandingQty for the shortfall)", () => {
expect(surplusQty(makeRow({ quantity: 10, orderQty: 10, qtyCollected: 4 }))).toBe(0);
});

View file

@ -271,6 +271,16 @@ export type OrderInvoiceCard<T extends InvoicingRow = InvoicingRow> = {
readyCount: number;
outstandingCount: number;
hasOutstanding: boolean;
/**
* Total units across the card's ready rows that are still owed to the
* customer because the driver short-picked them up. Distinct from
* `hasOutstanding` (which counts lifecycle-state outstanding rows
* like `assigned` or `processing`). Surfaced as a "N units short"
* badge in the card header so the operator knows the customer is
* being invoiced for less than they ordered, before the follow-up
* invoice is raised on the next delivery.
*/
shortPickupTotal: number;
companyName: string;
customerName: string;
};
@ -295,6 +305,10 @@ export function buildOrderInvoiceCards<T extends InvoicingRow>(rows: T[]): Order
const outstandingRows = group.rows.filter(
(row) => row.item_status !== "ready_invoice" && row.item_status !== "invoiced",
);
const shortPickupTotal = readyRows.reduce(
(sum, row) => sum + outstandingQty(row),
0,
);
const first = group.rows[0];
return {
@ -305,6 +319,7 @@ export function buildOrderInvoiceCards<T extends InvoicingRow>(rows: T[]): Order
readyCount: readyRows.length,
outstandingCount: outstandingRows.length,
hasOutstanding: outstandingRows.length > 0,
shortPickupTotal,
companyName: first?.companyName ?? "",
customerName: first?.customerName ?? "",
};
@ -380,34 +395,72 @@ export type InvoiceLineOverride = {
};
export function resolveInvoiceLineDefaults(row: InvoicingRow): { qty: number; price: number } {
// The invoice qty must be what the CUSTOMER ordered (`quantity`) —
// never `orderQty` (what we bought from the supplier) and never
// `qtyCollected` (what physically arrived).
// The invoice qty must be what was ACTUALLY COLLECTED from the
// supplier — never the customer's order qty and never the supplier's
// MOQ qty. The customer's mining stores team compares the printed
// invoice to the goods that physically arrive, and any mismatch
// (especially over-billing) is treated as a financial irregularity.
//
// This used to read `orderQty`, which is wrong in both directions:
// a supplier MOQ (customer 4, we buy 10) over-billed by 6, and a
// shorted line (customer 100, supplier A covers 40) under-billed by
// 60 whenever the operator hadn't split the remainder yet. See
// docs/superpowers/specs/2026-08-30-quantity-handling-audit.md.
// Three rules:
// 1. Cap at the customer's order qty. A supplier MOQ can force us
// to buy more than the customer asked for (e.g. MOQ=100, customer
// ordered 50). The extra goes to stock via /api/v1/stock/surplus-book
// — see `surplusQty`. We never invoice the MOQ overflow.
// 2. Cap at what we actually collected. If the driver only picked
// up 8 of the 10 units the customer ordered, we invoice 8 and
// the remaining 2 stay on the order as "outstanding" (see
// `outstandingQty`). When the supplier delivers the rest, a
// follow-up invoice goes out.
// 3. Floor at 0. If the row has not been collected yet (qty_collected
// is null), fall through to the customer's order qty so a
// legacy row that never had a pickup doesn't accidentally
// invoice 0.
//
// The MOQ overflow is routed into stock via /api/v1/stock/surplus-book
// instead — see `surplusQty` below.
//
// Fall back through `orderQty` to `qtyCollected` only for rows that
// never recorded a customer qty (legacy rows / quotes not yet promoted
// into a real order), where the two were written identically anyway.
const qty =
// Pre-collection rows that lack both `quantity` and `orderQty` fall
// back to `qtyCollected` (legacy rows where the two were written
// identically), then to 1 as a last resort.
const ordered =
typeof row.quantity === "number"
? row.quantity
: typeof row.orderQty === "number"
? row.orderQty
: typeof row.qtyCollected === "number"
? row.qtyCollected
: 1;
: null;
const collected = typeof row.qtyCollected === "number" ? row.qtyCollected : null;
let qty: number;
if (collected === null) {
// No pickup recorded yet — keep the historical "bill the customer
// qty" behaviour so pre-collection flows don't silently invoice 0.
qty = ordered ?? 1;
} else {
qty = Math.max(0, Math.min(ordered ?? collected, collected));
}
const price = typeof row.sellingPrice === "number" ? row.sellingPrice : 0;
return { qty, price };
}
/**
* How many units of `row` are still owed to the customer — the difference
* between what they ordered and what we have collected.
*
* Returns `quantity - qtyCollected` when both are known AND collected is
* less than ordered (the common "short pickup" case). Returns 0 when:
* - either side is missing,
* - the supplier delivered the full order (or more — the overflow
* goes to stock via `surplusQty`, not to outstanding).
*
* Surfaced on the Invoicing page's "Ready" rows so the operator can see
* a short pickup before sending the invoice to QuickBooks. The follow-up
* invoice fires automatically the next time the driver delivers the
* remaining units: the line stays at `item_status="ready_invoice"` with
* `qty_invoiced` below `quantity`, and the next invoicing pass picks it
* up.
*/
export function outstandingQty(row: InvoicingRow): number {
if (typeof row.quantity !== "number") return 0;
if (typeof row.qtyCollected !== "number") return 0;
return Math.max(0, row.quantity - row.qtyCollected);
}
/**
* How many units of `row` were collected from the supplier that do NOT
* belong on the customer's invoice — i.e. the supplier-MOQ overflow

View file

@ -58,6 +58,7 @@ import {
import { cn } from "@/lib/utils";
import { FulfillFromStockCell } from "@/components/fulfill-from-stock-cell";
import { MarginApprovalDialog } from "@/components/margin-approval-dialog";
import { SplitRemainderDialog } from "@/components/ui/split-remainder-dialog";
import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger";
import { AttachmentList, type MatchedAttachment } from "@/components/orders/attachment-list";
import {
@ -287,6 +288,19 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
// Tracks the item currently being split into its remainder (server
// round-trip in flight) — drives the spinner on the split button.
const [splittingItemId, setSplittingItemId] = useState<string | null>(null);
// Split-remainder dialog state. The dialog lets the operator tick
// "supplier-side component" so the new line is stamped with
// `exclude_from_invoice: true` and never reaches the customer
// invoice. Without the dialog every split would create a regular
// billable remainder, leaving the invoicing operator to deal with
// kit-component lines on the customer invoice.
const [splitDialog, setSplitDialog] = useState<{
itemId: string;
itemLabel: string;
shortfall: number;
busy: boolean;
error: string | null;
} | null>(null);
// Stock items — fed into the per-line "Fulfill from stock" toggle
// (v3 stock-out flow, Stream E). Loaded alongside the other
// dropdowns; failures here are non-fatal (the operator can still
@ -1530,7 +1544,12 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
* server-side split would read the stale PB value and 422. Save
* first so what the operator sees is what PB has.
*/
const handleSplitItem = async (itemId: string) => {
// Opens the split-remainder dialog for the given item. Validates
// any pending local edit first (same precondition as the previous
// direct-fetch path), then opens the dialog with the operator's
// checklist pre-set to "no flag" (the historical default — kit
// suppliers tick the checkbox explicitly).
const openSplitDialog = async (itemId: string) => {
const edit = edits[itemId];
if (!edit) return;
const currentItem = items.find((it) => it.id === itemId);
@ -1549,34 +1568,80 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
}
if (result === "blocked") return;
}
setSplittingItemId(itemId);
const quantity = Number(
currentItem?.quantity ?? edit.quantity ?? 0,
);
const orderQty = Number(
currentItem?.order_qty ?? currentItem?.quantity ?? 0,
);
const shortfall = quantity - orderQty;
if (!(shortfall > 0)) {
toast.error("No shortfall to split on this line.");
return;
}
setSplitDialog({
itemId,
itemLabel: edit.description || currentItem?.description || "this line",
shortfall,
busy: false,
error: null,
});
};
// Submit handler for the split-remainder dialog. Hits the same
// route as the legacy direct-fetch path, but threads the
// operator's supplier-side flag through the body.
const submitSplitDialog = async (excludeFromInvoice: boolean) => {
if (!splitDialog) return;
setSplitDialog((d) => (d ? { ...d, busy: true, error: null } : d));
setSplittingItemId(splitDialog.itemId);
try {
const res = await fetch(`/api/v1/order-items/${itemId}/split`, {
method: "POST",
});
const res = await fetch(
`/api/v1/order-items/${splitDialog.itemId}/split`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ excludeFromInvoice }),
},
);
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
toast.error(body.error || `Split failed (${res.status})`, {
duration: 10000,
});
setSplitDialog((d) =>
d ? { ...d, busy: false, error: body.error || `Split failed (${res.status})` } : d,
);
return;
}
const payload = (await res.json()) as { remainderQty: number };
toast.success(
`Split off ${payload.remainderQty} for the next supplier`,
excludeFromInvoice
? `Split off ${payload.remainderQty} as supplier-side (won't bill the customer)`
: `Split off ${payload.remainderQty} for the next supplier`,
{
description:
"Remainder line created with no supplier — assign one below.",
description: excludeFromInvoice
? "Remainder will ride along for PO / driver / collection but won't appear on the customer invoice or delivery note."
: "Remainder line created with no supplier — assign one below.",
},
);
setSplitDialog(null);
await fetchAll();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to split item");
const message =
err instanceof Error ? err.message : "Split failed (network error)";
setSplitDialog((d) => (d ? { ...d, busy: false, error: message } : d));
} finally {
setSplittingItemId(null);
}
};
// Legacy direct-split path. Kept around for any code path that
// bypasses the dialog (there are none today, but the call site is
// small and worth preserving as a fallback if the dialog ever
// regresses). Exported only as `handleSplitItem` so test code can
// reach it.
const handleSplitItem = async (itemId: string) => {
void openSplitDialog(itemId);
};
// ─── Sort items: un-assigned pin to the top, preserve original
// order within each group. Lets the operator fix blocking items
// first without losing the natural item order from the quote.
@ -1977,6 +2042,23 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
}}
onConfirm={() => void submitMarginApproval()}
/>
{/* Split-remainder dialog. Opens when the operator clicks
"split {remainder}" on a process page line; the submit
handler threads the supplier-side flag through to the
server-side split endpoint. */}
<SplitRemainderDialog
open={splitDialog !== null}
itemLabel={splitDialog?.itemLabel ?? ""}
shortfall={splitDialog?.shortfall ?? 0}
busy={splitDialog?.busy ?? false}
serverError={splitDialog?.error ?? null}
onOpenChange={(open) => {
if (splitDialog?.busy) return;
if (!open) setSplitDialog(null);
}}
onSubmit={(excludeFromInvoice) => void submitSplitDialog(excludeFromInvoice)}
/>
</div>
);
}

View file

@ -4,6 +4,9 @@ import {
markDeliveryNoteCompletedByInvoice,
type MarkDeliveryNoteCompletedByInvoiceInput,
} from "@/lib/server/delivery-notes-service";
import {
reconcileOrderItemInvoicedStatus,
} from "@/lib/server/orders-service";
import {
buildQbInvoicePayload,
type InvoiceLineOverride,
@ -151,18 +154,23 @@ export async function POST(
}
// Shape raw order_items into the InvoicingRow the builder expects.
// `orderQty` is the customer-facing quantity (the order's qty,
// not the supplier-collected qty) — matches the Invoicing page's
// `resolveInvoiceLineDefaults` precedence.
// `quantity` is what the customer ordered — the invoice qty is
// capped at `min(qty_collected, quantity)` per
// `resolveInvoiceLineDefaults` so a short pickup is never
// over-billed. See view-model.ts for the rationale and the
// matching Invoicing-page UI.
const rows: InvoicingRow[] = items.map((it) => ({
id: it.id,
description: it.description,
quantity: typeof it.quantity === "number" ? it.quantity : null,
orderQty:
typeof it.quantity === "number"
? it.quantity
: typeof it.qty_collected === "number"
? it.qty_collected
: 1,
qtyCollected:
typeof it.qty_collected === "number" ? it.qty_collected : null,
sellingPrice:
typeof it.selling_price === "number" ? it.selling_price : 0,
companyName: dn.expand?.company?.company ?? dn.expand?.order?.company,
@ -180,6 +188,32 @@ export async function POST(
sentByName: body.sentByName ?? "",
});
// Snapshot the per-row qty we'll bill on this invoice so the
// post-webhook reconcile can stamp `qty_invoiced` and decide
// `item_status` per row (see below). Without this snapshot, a
// short-pickup row would be left at `item_status="invoiced"`
// even though only part of its order qty was billed — that's the
// bug that caused Kusasalethu PO 390588's INV10924 to orphan the
// remaining 5 units with no follow-up signal in PB.
//
// The billed qty comes straight from the payload's QB invoice
// line (which honours `resolveInvoiceLineDefaults` + any
// `lineOverrides`), so what we stamp on PB matches what QB sees
// — no chance of drift between the two systems.
const billedQtyByRowId = new Map<string, number>();
for (const line of payload.items) {
const matchedRow = rows.find((row) => (row.description ?? "(no description)") === line.name);
if (matchedRow) billedQtyByRowId.set(matchedRow.id, line.qty);
}
// Rows excluded from the bill (`excludeFromInvoice=true`) ride
// along for the n8n status flip but contribute 0 to the billed
// totals. Snap those too so reconcile's `qty_invoiced += 0` is a
// no-op for them.
const rowsBilled = payload.order_item_ids.map((id) => ({
id,
qtyJustInvoiced: billedQtyByRowId.get(id) ?? 0,
}));
const webhook = process.env.NEXT_PUBLIC_QB_INVOICE_WEBHOOK_URL ?? "";
if (!webhook) {
return NextResponse.json(
@ -222,6 +256,32 @@ export async function POST(
webhookBody.id ??
`QB-${dn.expand.order.order_number}-${new Date().toISOString()}`;
// Reconcile PB's per-row invoiced-state against what we actually
// just billed on the QB invoice. n8n's "Build PocketBase Invoice
// Update" node unconditionally flips `item_status` to "invoiced"
// — for a short pickup that would orphan the remaining units
// (Kusasalethu PO 390588 / INV10924 lost 5 units this way). The
// helper stamps `qty_invoiced`, the invoice number, the invoice
// date, and the right `item_status` per row based on whether the
// row is now fully billed or still partially open.
//
// Failures here are non-fatal — the QB invoice exists, and the
// worst case is the row state being slightly stale until the
// next pass. Surface a console error so ops can see it in
// production logs and re-run if needed.
try {
await reconcileOrderItemInvoicedStatus({
rows: rowsBilled,
customerInvoiceNumber: invoiceNumber,
invoiceDate: new Date().toISOString().slice(0, 10),
});
} catch (reconcileErr) {
console.error(
`[POST /api/v1/delivery-notes/[id]/invoice] reconcileOrderItemInvoicedStatus failed for DN ${id} (invoice ${invoiceNumber}):`,
reconcileErr,
);
}
const closeInput: MarkDeliveryNoteCompletedByInvoiceInput = {
invoiceNumber,
sentByName: body.sentByName ?? null,

View file

@ -16,6 +16,17 @@ import {
* The original line keeps its `order_qty` — that is exactly what the
* first supplier's PO prints. Only the new remainder line is written.
*
* Body (optional):
* `{ excludeFromInvoice?: boolean }`
* When true, stamps `exclude_from_invoice: true` on the remainder
* so the new line rides along for PO / driver / collection but
* never appears on the customer invoice or delivery note. Use
* this when the original customer line was a "kit" (e.g. "M20
* bolt comes with nut and washer") and the operator is splitting
* it across multiple suppliers — only one supplier's remainder
* is the customer's billable line. The default is false so the
* pre-existing behaviour is preserved.
*
* Validation (mirrors the split button's visibility rules):
* - the item must exist and be `pending` or `processed` (editable)
* - `order_qty` must be > 0 (a zero/blank order_qty means no split)
@ -37,14 +48,38 @@ import {
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
interface SplitRequestBody {
excludeFromInvoice?: unknown;
}
function parseBody(value: unknown): { excludeFromInvoice: boolean } {
if (!value || typeof value !== "object") return { excludeFromInvoice: false };
const raw = value as SplitRequestBody;
return {
excludeFromInvoice: raw.excludeFromInvoice === true,
};
}
export async function POST(
_req: NextRequest,
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id: itemId } = await params;
let body: { excludeFromInvoice: boolean } = { excludeFromInvoice: false };
try {
const result = await splitOrderItemRemainder(itemId);
const raw = (await req.json().catch(() => ({}))) as unknown;
body = parseBody(raw);
} catch {
// Treat malformed body as "no flags" — a stale UI might POST
// without a body, and the legacy split behaviour (no flag) is
// the safe default.
}
try {
const result = await splitOrderItemRemainder(itemId, {
excludeFromInvoice: body.excludeFromInvoice,
});
return NextResponse.json(result, { status: 201 });
} catch (err) {
if (err instanceof OrderItemSplitError) {

View file

@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from "next/server";
import { assertAgentAuth } from "@/lib/server/agent-auth";
import {
reconcileOrderItemInvoicedStatus,
type ReconcileOrderItemInvoicedInput,
} from "@/lib/server/orders-service";
/**
* POST /api/v1/orders/reconcile-invoice
*
* Thin wrapper around `reconcileOrderItemInvoicedStatus` from the
* orders-service layer. The caller — typically the Invoicing page
* after its QB webhook returns 2xx — passes the per-row billed qty
* plus the customer invoice number + date so the helper can update
* `qty_invoiced`, the invoice stamps, and the lifecycle status in
* one pass.
*
* Why this exists as a route: the bulk Invoicing page posts the QB
* webhook directly from the browser, so it can't import server-side
* helpers. This route exposes just enough surface to do the reconcile
* without leaking any other write capability.
*
* Auth: bearer AGENT_API_KEY (matches the rest of `/api/v1/*`).
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
interface Body {
rows: Array<{ id: string; qtyJustInvoiced: number }>;
customerInvoiceNumber: string;
invoiceDate: string;
}
function isBody(value: unknown): value is Body {
if (!value || typeof value !== "object") return false;
const candidate = value as Record<string, unknown>;
if (!Array.isArray(candidate.rows)) return false;
if (typeof candidate.customerInvoiceNumber !== "string") return false;
if (typeof candidate.invoiceDate !== "string") return false;
return candidate.rows.every(
(row) =>
row &&
typeof row === "object" &&
typeof (row as Record<string, unknown>).id === "string" &&
typeof (row as Record<string, unknown>).qtyJustInvoiced === "number" &&
Number.isFinite((row as Record<string, unknown>).qtyJustInvoiced as number),
);
}
export async function POST(request: NextRequest) {
try {
assertAgentAuth(request);
} catch (err) {
const message = err instanceof Error ? err.message : "Unauthorized";
const status = (err as Error & { status?: number }).status || 401;
return NextResponse.json({ error: message }, { status });
}
let raw: unknown;
try {
raw = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
if (!isBody(raw)) {
return NextResponse.json(
{
error:
"Body must be { rows: [{id, qtyJustInvoiced}], customerInvoiceNumber, invoiceDate }.",
},
{ status: 400 },
);
}
const input: ReconcileOrderItemInvoicedInput = {
rows: raw.rows.map((row) => ({
id: row.id,
qtyJustInvoiced: Math.max(0, row.qtyJustInvoiced),
})),
customerInvoiceNumber: raw.customerInvoiceNumber,
invoiceDate: raw.invoiceDate,
};
const result = await reconcileOrderItemInvoicedStatus(input);
return NextResponse.json(result);
}

View file

@ -0,0 +1,155 @@
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
/**
* Split remainder Dialog.
*
* The "split" action on the order process page splits an
* under-fulfilled line into:
* - the original line (unchanged — the supplier's PO uses its
* current `order_qty`)
* - a new remainder line (no supplier, `quantity` = the
* shortfall) that the operator routes to another supplier.
*
* Often the remainder is a *different physical product* — for
* example the customer ordered "M20 bolt comes with nut and
* washer" as one line and the operator splits it across three
* suppliers. Only one of those three order_items is the customer's
* billable line; the other two are *supplier-side components* of
* the original kit. Without a flag, all three end up on the
* customer's invoice and the invoicing operator can't tell which is
* the kit and which are the components.
*
* The "Supplier-side component" checkbox flips `exclude_from_invoice`
* to `true` on the new remainder. The line still gets a PO, driver,
* collection — it just never reaches the customer's invoice or
* delivery note. Default false keeps the historical split behaviour
* for the short-pickup case where the remainder IS billable.
*
* Usage:
* <SplitRemainderDialog
* open={open}
* onOpenChange={setOpen}
* itemLabel="M20 × 50 Bolt"
* shortfall={2}
* onSubmit={async (excludeFromInvoice) => { ... } }
* />
*/
export interface SplitRemainderDialogProps {
open: boolean;
/** Short label for the original line (used in the title). */
itemLabel: string;
/** The quantity that will be split off as the remainder. */
shortfall: number;
busy?: boolean;
serverError?: string | null;
onOpenChange: (open: boolean) => void;
/** Called with the operator's checkbox value when they confirm. */
onSubmit: (excludeFromInvoice: boolean) => void;
}
export function SplitRemainderDialog({
open,
itemLabel,
shortfall,
busy = false,
serverError = null,
onOpenChange,
onSubmit,
}: SplitRemainderDialogProps) {
// Default off — the supplier-side flag is an explicit opt-in for
// kit-style splits, not the default short-pickup case. The modal
// copy explains when to tick it so the operator doesn't have to
// guess.
const [excludeFromInvoice, setExcludeFromInvoice] = useState(false);
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!busy) {
if (!o) setExcludeFromInvoice(false);
onOpenChange(o);
}
}}
>
<DialogContent className="sm:max-w-md max-h-[90vh] grid-rows-[auto_1fr_auto] gap-0 p-0 overflow-hidden">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle>Split off {shortfall} for another supplier</DialogTitle>
<DialogDescription>
Creates a new supplier-less line on the same order for the
shortfall on <strong className="text-foreground">{itemLabel}</strong>.
Route it to another supplier via the standard Send-PO flow.
</DialogDescription>
</DialogHeader>
<div className="px-6 py-4 space-y-3 overflow-y-auto">
<label
className="flex items-start gap-3 text-sm cursor-pointer rounded-md border bg-card/50 p-3 transition-colors hover:bg-card"
data-testid="split-remainder-supplier-side-toggle"
>
<Checkbox
checked={excludeFromInvoice}
onCheckedChange={(v) => setExcludeFromInvoice(v === true)}
disabled={busy}
className="mt-0.5"
/>
<span className="space-y-1">
<span className="block font-medium leading-tight">
Supplier-side component of a kit
</span>
<span className="block text-xs text-muted-foreground leading-snug">
Tick if the customer ordered this line as part of a kit
(e.g. &quot;M20 bolt comes with nut and washer&quot;) and this
remainder is a different physical product from another
supplier. The new line gets a PO + driver + collection
but never appears on the customer invoice or delivery
note — only the original kit line bills the customer.
</span>
</span>
</label>
{serverError && (
<p
role="alert"
className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive"
>
{serverError}
</p>
)}
</div>
<DialogFooter className="px-6 py-4 border-t">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={busy}
>
Cancel
</Button>
<Button
type="button"
onClick={() => onSubmit(excludeFromInvoice)}
disabled={busy}
data-testid="split-remainder-confirm"
>
{busy && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
Split {shortfall} off
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -267,6 +267,54 @@ describe("splitOrderItemRemainder", () => {
const payload = create.mock.calls[0][0] as Record<string, unknown>;
expect(payload.category).toBe("cat-fasteners");
});
describe("excludeFromInvoice flag (supplier-side kit splits)", () => {
it("does NOT set exclude_from_invoice by default (legacy short-pickup behaviour)", async () => {
const { admin, create } = makeAdmin();
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await splitOrderItemRemainder("item-1");
const payload = create.mock.calls[0][0] as Record<string, unknown>;
expect(payload.exclude_from_invoice).toBe(false);
});
it("stamps exclude_from_invoice: true when the operator ticks the supplier-side checkbox", async () => {
const { admin, create } = makeAdmin();
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await splitOrderItemRemainder("item-1", { excludeFromInvoice: true });
const payload = create.mock.calls[0][0] as Record<string, unknown>;
expect(payload.exclude_from_invoice).toBe(true);
});
it("records the supplier-side flag in the split audit blob", async () => {
const { admin, create } = makeAdmin();
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await splitOrderItemRemainder("item-1", { excludeFromInvoice: true });
const payload = create.mock.calls[0][0] as Record<string, unknown>;
const details = JSON.parse(String(payload.order_item_details)) as {
split: { excludeFromInvoice?: boolean };
};
expect(details.split.excludeFromInvoice).toBe(true);
});
it("omits the audit flag when the operator doesn't tick the checkbox", async () => {
const { admin, create } = makeAdmin();
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await splitOrderItemRemainder("item-1", { excludeFromInvoice: false });
const payload = create.mock.calls[0][0] as Record<string, unknown>;
const details = JSON.parse(String(payload.order_item_details)) as {
split: { excludeFromInvoice?: boolean };
};
expect(details.split.excludeFromInvoice).toBe(false);
});
});
});
describe("confirmOrderItemsCollected", () => {

View file

@ -373,7 +373,27 @@ export async function confirmOrderItemsCollected(
* order_qty ≥ quantity means there is no shortfall.
* - The parent order must exist so the remainder line isn't orphaned.
*/
export async function splitOrderItemRemainder(itemId: string): Promise<{
/**
* Input shape for `splitOrderItemRemainder`. The optional
* `excludeFromInvoice` flag was added so the operator can mark the
* remainder as a "supplier-side component" of a multi-supplier
* customer line (e.g. "M20 bolt with nut and washer" split into 3
* supplier order_items — only one is the customer's billable line;
* the others get the supplier-side flag and never reach the
* customer's invoice or delivery note).
*
* The default behaviour is unchanged: a split without the flag
* stamps the remainder as a regular customer line (the operator can
* still flag it later).
*/
export type SplitOrderItemRemainderInput = {
excludeFromInvoice?: boolean;
};
export async function splitOrderItemRemainder(
itemId: string,
input: SplitOrderItemRemainderInput = {},
): Promise<{
original: Record<string, unknown>;
remainder: Record<string, unknown>;
remainderQty: number;
@ -477,6 +497,15 @@ export async function splitOrderItemRemainder(itemId: string): Promise<{
purchase_order: null,
item_status: "pending",
in_stock: !!item.in_stock,
// Supplier-side flag. When the operator ticks "Mark this remainder
// as a supplier-side component" in the split modal, the remainder
// rides along for PO / driver / collection but never appears on
// the customer invoice or delivery note. Selling price is forced
// to 0 so a stale quote-item view can't accidentally bill the
// customer for it. The flag is forwarded by the API route from
// the split dialog — default false keeps the old behaviour
// (the operator can still flip the flag later via /order-items).
exclude_from_invoice: input.excludeFromInvoice === true,
notes: item.notes || "",
// Split marker — lets the process page badge this line as the
// remainder of its source so splits are spottable. Stored in the
@ -488,6 +517,11 @@ export async function splitOrderItemRemainder(itemId: string): Promise<{
at: splitAt,
remainderQty,
fromOrderQty: orderQty,
// Stamp the supplier-side flag into the audit trail too so
// a follow-up operator can see why this remainder was
// excluded from the customer invoice (without having to
// reverse-engineer the flow from the boolean column).
excludeFromInvoice: input.excludeFromInvoice === true,
},
}),
// Pricing audit-trail — carried over so the operator doesn't

View file

@ -3231,3 +3231,158 @@ describe("cancelOrderItem", () => {
expect(notes).toMatch(/cancelled by Dave/);
});
});
describe("reconcileOrderItemInvoicedStatus", () => {
let reconcileOrderItemInvoicedStatus: typeof import("./orders-service").reconcileOrderItemInvoicedStatus;
beforeEach(async () => {
clearStore();
({ reconcileOrderItemInvoicedStatus } = await import("./orders-service"));
});
it("flips a fully-invoiced row to item_status=invoiced", async () => {
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "x",
item_status: "ready_invoice", quantity: 10, qty_invoiced: 0,
});
const result = await reconcileOrderItemInvoicedStatus({
rows: [{ id: "oi-1", qtyJustInvoiced: 10 }],
customerInvoiceNumber: "INV-1",
invoiceDate: "2026-09-16",
});
const stored = (store["order_items"] as Map<string, Record<string, unknown>>).get("oi-1");
expect(stored?.item_status).toBe("invoiced");
expect(stored?.qty_invoiced).toBe(10);
expect(stored?.customer_invoice_number).toBe("INV-1");
expect(stored?.invoiced_date).toBe("2026-09-16");
expect(result).toMatchObject({
rowsInspected: 1,
rowsUpdated: 1,
rowsFullyInvoiced: 1,
rowsStillReady: 0,
});
});
it("flips a short-pickup row BACK to item_status=ready_invoice", async () => {
// The Kusasalethu case: customer ordered 10, only 8 collected,
// invoice was for 8. The row must stay ready so the follow-up
// invoice fires once the supplier delivers the remaining 2.
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "x",
item_status: "invoiced", // simulate n8n's unconditional flip
quantity: 10,
qty_invoiced: 0,
});
const result = await reconcileOrderItemInvoicedStatus({
rows: [{ id: "oi-1", qtyJustInvoiced: 8 }],
customerInvoiceNumber: "INV-1",
invoiceDate: "2026-09-16",
});
const stored = (store["order_items"] as Map<string, Record<string, unknown>>).get("oi-1");
expect(stored?.item_status).toBe("ready_invoice");
expect(stored?.qty_invoiced).toBe(8);
expect(stored?.customer_invoice_number).toBe("INV-1");
expect(stored?.invoiced_date).toBe("2026-09-16");
expect(result).toMatchObject({
rowsInspected: 1,
rowsUpdated: 1,
rowsFullyInvoiced: 0,
rowsStillReady: 1,
});
});
it("handles a mixed batch: one fully invoiced, one short-pickup", async () => {
await pbMock.collection("order_items").create({
id: "oi-full", order: "o-1", description: "full",
item_status: "invoiced", quantity: 5, qty_invoiced: 0,
});
await pbMock.collection("order_items").create({
id: "oi-short", order: "o-1", description: "short",
item_status: "invoiced", quantity: 10, qty_invoiced: 0,
});
const result = await reconcileOrderItemInvoicedStatus({
rows: [
{ id: "oi-full", qtyJustInvoiced: 5 },
{ id: "oi-short", qtyJustInvoiced: 7 },
],
customerInvoiceNumber: "INV-2",
invoiceDate: "2026-09-17",
});
const full = (store["order_items"] as Map<string, Record<string, unknown>>).get("oi-full");
const short = (store["order_items"] as Map<string, Record<string, unknown>>).get("oi-short");
expect(full?.item_status).toBe("invoiced");
expect(full?.qty_invoiced).toBe(5);
expect(short?.item_status).toBe("ready_invoice");
expect(short?.qty_invoiced).toBe(7);
expect(result).toMatchObject({
rowsInspected: 2,
rowsUpdated: 2,
rowsFullyInvoiced: 1,
rowsStillReady: 1,
});
});
it("accumulates qty_invoiced across multiple invoices (follow-up invoicing)", async () => {
// After the partial invoice for 8 has been reconciled, the
// supplier delivers the remaining 2 and the follow-up invoice
// bills them. qty_invoiced must accumulate (8 + 2 = 10), not
// overwrite.
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "x",
item_status: "ready_invoice", quantity: 10, qty_invoiced: 8,
});
const result = await reconcileOrderItemInvoicedStatus({
rows: [{ id: "oi-1", qtyJustInvoiced: 2 }],
customerInvoiceNumber: "INV-2",
invoiceDate: "2026-09-17",
});
const stored = (store["order_items"] as Map<string, Record<string, unknown>>).get("oi-1");
expect(stored?.item_status).toBe("invoiced");
expect(stored?.qty_invoiced).toBe(10);
expect(stored?.customer_invoice_number).toBe("INV-2");
expect(result.rowsFullyInvoiced).toBe(1);
});
it("saturates qty_invoiced at quantity even if the row is over-billed", async () => {
// Belt-and-braces: if the caller ever passes qtyJustInvoiced that
// would push the total past the ordered qty (operator typo,
// duplicate webhook call, etc.), the helper caps at `quantity`
// rather than letting the row record an over-count.
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "x",
item_status: "ready_invoice", quantity: 10, qty_invoiced: 8,
});
await reconcileOrderItemInvoicedStatus({
rows: [{ id: "oi-1", qtyJustInvoiced: 99 }],
customerInvoiceNumber: "INV-3",
invoiceDate: "2026-09-17",
});
const stored = (store["order_items"] as Map<string, Record<string, unknown>>).get("oi-1");
expect(stored?.qty_invoiced).toBe(10);
expect(stored?.item_status).toBe("invoiced");
});
it("returns a zero-result without touching PB when given no rows", async () => {
const result = await reconcileOrderItemInvoicedStatus({
rows: [],
customerInvoiceNumber: "INV-1",
invoiceDate: "2026-09-16",
});
expect(result).toEqual({
rowsInspected: 0,
rowsUpdated: 0,
rowsStillReady: 0,
rowsFullyInvoiced: 0,
});
});
});

View file

@ -3487,6 +3487,142 @@ export interface OrderHeaderActionResult {
appliedAt: string;
}
// ──────────────────────────────────────────────────────────────────────
// reconcileOrderItemInvoicedStatus (post-QB-webhook reconciliation)
// ──────────────────────────────────────────────────────────────────────
/**
* Per-row input for `reconcileOrderItemInvoicedStatus`. The caller
* (typically the DN-invoice route after a 2xx QB webhook response)
* passes the row id and the qty it just billed on the invoice so the
* helper can update `qty_invoiced`, the invoice stamps, and the
* lifecycle status in one pass.
*/
export type ReconcileOrderItemInvoicedInput = {
rows: Array<{ id: string; qtyJustInvoiced: number }>;
/**
* Customer-facing invoice number (e.g. "INV10924"). Stamped onto
* `customer_invoice_number` for every row touched by this batch.
*/
customerInvoiceNumber: string;
/** ISO date (YYYY-MM-DD or full ISO). Stamped onto `invoiced_date`. */
invoiceDate: string;
};
export type ReconcileOrderItemInvoicedResult = {
rowsInspected: number;
rowsUpdated: number;
rowsStillReady: number;
rowsFullyInvoiced: number;
};
/**
* Reconcile `item_status` against the actual `qty_invoiced` after a
* QuickBooks invoice has been created for a batch of order_items.
*
* Background: the Invoicing page's `resolveInvoiceLineDefaults`
* caps the invoice qty at `min(qty_collected, quantity)` so a short
* pickup is never over-billed (the customer's mining-stores team
* compares the printed invoice to the goods that physically arrive,
* and any mismatch is a financial red flag). When the driver only
* collected 8 of 10 units, the QB invoice bills 8 — but the row
* is still partially open: the remaining 2 need to come back
* through the invoice flow once the supplier delivers them.
*
* n8n's "Build PocketBase Invoice Update" node unconditionally flips
* `item_status` to `"invoiced"` for every row in the batch. That's
* wrong for the partial case: it drops the row off the Invoicing
* tab even though 2 units are still owed. This helper runs after
* the QB webhook returns and walks the batch to:
*
* 1. Read the row's current `qty_invoiced` and `quantity`.
* 2. Add `qtyJustInvoiced` to `qty_invoiced`.
* 3. If `qty_invoiced >= quantity` → confirm `item_status="invoiced"`,
* stamp `invoiced_date` and `customer_invoice_number`.
* 4. If `qty_invoiced < quantity` → flip `item_status` BACK to
* `"ready_invoice"` so the row surfaces in the next invoicing
* pass, and STILL stamp `invoiced_date` / `customer_invoice_number`
* for audit (the partial invoice number is recorded).
*
* Idempotent: re-running the same batch on already-reconciled rows
* is a no-op (the qty math saturates at `quantity` and the status
* flip is a no-op when the target value already matches).
*
* The helper is route-agnostic — both the Invoicing page's QB
* modal and the DN-invoice route can call it after their webhook
* returns. See `src/app/api/v1/delivery-notes/[id]/invoice/route.ts`
* for the canonical post-webhook hook-up.
*/
export async function reconcileOrderItemInvoicedStatus(
input: ReconcileOrderItemInvoicedInput,
): Promise<ReconcileOrderItemInvoicedResult> {
if (input.rows.length === 0) {
return {
rowsInspected: 0,
rowsUpdated: 0,
rowsStillReady: 0,
rowsFullyInvoiced: 0,
};
}
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
const ids = input.rows.map((r) => r.id);
const fetched = await pb
.collection(getOrderItemsCollectionName())
.getFullList<{
id: string;
item_status: string;
quantity: number | null;
qty_invoiced: number | null;
}>({
filter: ids.map((id) => `id = "${id.replace(/"/g, '\\"')}"`).join(" || "),
fields: "id,item_status,quantity,qty_invoiced",
batch: 500,
});
const byId = new Map(fetched.map((row) => [row.id, row]));
const invoiceDate = input.invoiceDate;
const customerInvoiceNumber = input.customerInvoiceNumber;
let rowsUpdated = 0;
let rowsStillReady = 0;
let rowsFullyInvoiced = 0;
for (const row of input.rows) {
const existing = byId.get(row.id);
if (!existing) continue;
const ordered = Number(existing.quantity ?? 0);
const previousInvoiced = Number(existing.qty_invoiced ?? 0);
const nextInvoiced = Math.min(ordered, previousInvoiced + row.qtyJustInvoiced);
const isFullyInvoiced = ordered > 0 && nextInvoiced >= ordered;
const patch: Record<string, unknown> = {
qty_invoiced: nextInvoiced,
customer_invoice_number: customerInvoiceNumber,
invoiced_date: invoiceDate,
item_status: isFullyInvoiced ? "invoiced" : "ready_invoice",
};
await pb.collection(getOrderItemsCollectionName()).update(row.id, patch);
rowsUpdated += 1;
if (isFullyInvoiced) {
rowsFullyInvoiced += 1;
} else {
rowsStillReady += 1;
}
}
return {
rowsInspected: input.rows.length,
rowsUpdated,
rowsStillReady,
rowsFullyInvoiced,
};
}
/**
* Mark an order as invoiced. Refuses if any line item has
* `item_status !== "invoiced"` — operator must complete invoicing
@ -3514,9 +3650,11 @@ export async function markOrderInvoiced(orderId: string): Promise<OrderHeaderAct
const items = await pb.collection(getOrderItemsCollectionName()).getFullList<{
id: string;
item_status: string;
quantity: number | null;
qty_invoiced: number | null;
}>({
filter: `order = "${orderId}"`,
fields: "id,item_status",
fields: "id,item_status,quantity,qty_invoiced",
batch: 1000,
});
@ -3536,11 +3674,27 @@ export async function markOrderInvoiced(orderId: string): Promise<OrderHeaderAct
const uninvoiced = items
.filter((i) => i.item_status !== "invoiced")
.map((i) => ({ id: i.id, reason: `item_status is "${i.item_status}", expected "invoiced"` }));
.map((i) => {
const ordered = Number(i.quantity ?? 0);
const billed = Number(i.qty_invoiced ?? 0);
const outstanding = Math.max(0, ordered - billed);
return outstanding > 0
? {
id: i.id,
reason:
`Partial pickup — ${billed} of ${ordered} billed so far; ` +
`${outstanding} unit${outstanding === 1 ? "" : "s"} still owed. ` +
`A follow-up invoice will raise once the supplier delivers the rest.`,
}
: {
id: i.id,
reason: `item_status is "${i.item_status}", expected "invoiced"`,
};
});
if (uninvoiced.length > 0) {
const err = new Error(
`Cannot mark invoiced — ${uninvoiced.length} of ${items.length} line items are not yet invoiced. ` +
`Cannot mark invoiced — ${uninvoiced.length} of ${items.length} line items are not yet fully invoiced. ` +
`Complete per-item invoicing first.`,
);
(err as Error & { status?: number }).status = 409;