feat(invoicing): split delivery-note PDF lines for non-invoiceable rows

Task 7 of the non-invoiceable-order-extras plan.

The status flip + audit stamp + delivery_note header create all
run on EVERY row so the order closes normally — excluded rows are
real operational lines from the supplier's POV. The PDF line
list is built from billable.items only, so the customer never
sees excluded lines.

Added exclude_from_invoice to the getFullList projection (load-
bearing — omit it and the billable filter silently passes
undefined, every extra lands on the customer delivery note).

New 400 guard 'No billable lines for a delivery note' when every
row in the request is excluded. UI guard in Task 6 prevents the
operator from reaching this path, but the route has its own
defence so the picker doesn't get a guaranteed-to-fail submit
silently.

2 tests + 1 mutation-test prove the split:
- excluded rows still get status flip + DN number + PDF upload,
  but the renderer is called with ONLY billable items
- all-excluded returns 400 before any PATCH fires
- mutation (PDF built from items): the mixed test fails because
  the renderer sees 3 lines instead of 2

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-30 17:35:15 +02:00
parent 08f6005fd5
commit cb3f1618c4
2 changed files with 147 additions and 11 deletions

View file

@ -1,11 +1,29 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { getFullList, getList, updateMock, createMock } = vi.hoisted(() => {
const {
getFullList,
getList,
updateMock,
createMock,
renderDeliveryNotePdfMock,
} = vi.hoisted(() => {
const getFullList = vi.fn();
const getList = vi.fn();
const updateMock = vi.fn();
const createMock = vi.fn();
return { getFullList, getList, updateMock, createMock };
// Spy on the PDF renderer so the test can assert which lines the
// route passes in. Default returns a tiny PDF buffer so the upload
// step in the route has something to upload.
const renderDeliveryNotePdfMock = vi.fn(async () =>
Buffer.from("%PDF-1.4\n"),
);
return {
getFullList,
getList,
updateMock,
createMock,
renderDeliveryNotePdfMock,
};
});
vi.mock("@/lib/server/pocketbase-admin", () => ({
@ -19,6 +37,10 @@ vi.mock("@/lib/server/pocketbase-admin", () => ({
}),
}));
vi.mock("@/lib/pdf/render", () => ({
renderDeliveryNotePdf: renderDeliveryNotePdfMock,
}));
import { POST } from "@/app/api/invoicing/delivery-note/route";
function makeRequest(body: unknown): Request {
@ -31,6 +53,8 @@ function makeRequest(body: unknown): Request {
describe("POST /api/invoicing/delivery-note", () => {
beforeEach(() => {
renderDeliveryNotePdfMock.mockReset();
renderDeliveryNotePdfMock.mockResolvedValue(Buffer.from("%PDF-1.4\n"));
getFullList.mockReset();
getList.mockReset();
updateMock.mockReset();
@ -313,4 +337,81 @@ describe("POST /api/invoicing/delivery-note", () => {
expect(call.item_status).toBe("delivery_note");
expect(call.delivery_note_number).toBe("DN26071401");
});
// Task 7 of the non-invoiceable-order-extras plan. The status
// flip (item_status + DN number) lands on EVERY row so the order
// closes normally; the PDF line list drops excluded rows so the
// customer never sees them. The two together are the load-bearing
// split.
it("stamps status + DN number on excluded rows but drops them from the PDF", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-14T13:00:00.000Z"));
// Three rows, two billable + one excluded. Override the default
// 2-row fixture so we can also assert the excluded row got the
// status flip while the PDF line list is filtered down to 2.
getFullList.mockResolvedValueOnce([
{ id: "bill-1", description: "Bolt", order_item_details: {} },
{ id: "excl-1", description: "Nut (supplier-side)", order_item_details: {}, exclude_from_invoice: true },
{ id: "bill-2", description: "Washer", order_item_details: {} },
]);
const response = await POST(
makeRequest({ orderItemIds: ["bill-1", "excl-1", "bill-2"] }) as any,
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.updated).toBe(3);
expect(body.deliveryNoteNumber).toBe("DN26071401");
// ALL three rows got the JSON PATCH (status flip + DN number).
// Excluded rows must ride along — they're an operational line
// from the supplier's POV, the order can only close if every row
// is in delivery_note status.
const jsonCalls = updateMock.mock.calls.filter(
(call) => !(call[1] instanceof FormData),
);
expect(jsonCalls.length).toBe(3);
for (const call of jsonCalls) {
const patch = call[1] as Record<string, unknown>;
expect(patch.item_status).toBe("delivery_note");
expect(patch.delivery_note_number).toBe("DN26071401");
}
// ALL three rows also got the FormData PDF upload (picking-slip
// regression: see Task 8. The picker still pulls the washers.).
const formCalls = updateMock.mock.calls.filter(
(call) => call[1] instanceof FormData,
);
expect(formCalls.length).toBe(3);
// The PDF renderer was called with ONLY the billable rows.
// Excluded rows drop out at this stage so the customer never
// sees them. Capturing this directly via the render mock is
// what catches the load-bearing mutation in this split.
expect(renderDeliveryNotePdfMock).toHaveBeenCalledTimes(1);
const renderInput = renderDeliveryNotePdfMock.mock.calls[0][0] as {
lines: Array<{ description?: string }>;
};
expect(renderInput.lines).toHaveLength(2);
expect(renderInput.lines.map((l) => l.description)).toEqual(["Bolt", "Washer"]);
});
it("returns 400 when every row is excluded (no billable lines for a delivery note)", async () => {
getFullList.mockResolvedValueOnce([
{ id: "excl-1", order_item_details: {}, exclude_from_invoice: true },
{ id: "excl-2", order_item_details: {}, exclude_from_invoice: true },
]);
const response = await POST(
makeRequest({ orderItemIds: ["excl-1", "excl-2"] }) as any,
);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toMatch(/no billable lines/i);
// No status flips happened — the guard runs before the PATCH loop.
expect(updateMock).not.toHaveBeenCalled();
});
});

View file

@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { renderDeliveryNotePdf } from "@/lib/pdf/render";
import { resolveDeliveryNoteLineQty } from "@/lib/pdf/delivery-note-document";
import { buildDeliveryNoteLine } from "@/lib/pdf/delivery-note-document";
import {
deliveryNoteLetterheadCode,
fetchLetterhead,
@ -52,8 +52,19 @@ type OrderItemRecord = {
delivery_note_number?: string;
order_item_details?: unknown;
order_item_attachements?: unknown;
/** Customer-facing qty — what the delivery note must show. */
quantity?: number | null;
/** Supplier-facing qty — what we bought. Never printed to the customer. */
order_qty?: number | null;
qty_collected?: number | null;
/**
* Non-invoiceable extras feature (2026-08-30). When true the row
* still receives the status flip + DN number (the order closes
* normally, the picker sees the washers on the slip) but it
* drops out of the `lines:` array sent to the PDF renderer —
* see the billable filter below.
*/
exclude_from_invoice?: boolean;
expand?: {
order?: {
id: string;
@ -208,14 +219,41 @@ export async function POST(request: NextRequest) {
// Fetch the items with the expand we need for the PDF (supplier
// + order + company + customer). We re-fetch the same items
// after the status update below to get the persisted state.
//
// `exclude_from_invoice` is included in the projection so the
// billable filter below can drop excluded rows from the PDF
// line list. Without it the field comes back undefined and every
// extra silently lands on the customer delivery note — the same
// load-bearing-projection concern documented for INVOICING_FIELDS.
const items = await pb.collection("order_items").getFullList<OrderItemRecord>({
filter: orderItemIds.map((id) => `id = "${id}"`).join(" || "),
fields: "id,description,delivery_note_number,order_item_details,order_item_attachements,quantity,order_qty,qty_collected,exclude_from_invoice",
expand: "purchase_order.supplier,order.company,order.customer",
});
if (items.length === 0) {
return NextResponse.json({ error: "No matching order items found" }, { status: 404 });
}
// Billable subset — Task 7 of the non-invoiceable-order-extras
// plan. The status flip + audit stamp above run on EVERY item
// (excluded rows must close the order like their siblings), but
// the PDF line list drops excluded rows so the customer never
// sees them. Sharing `billableRows` with the QB invoice path
// (Task 5) keeps one definition of "billable" for the whole
// feature — three callers, one truth.
const billable = items.filter((it) => it.exclude_from_invoice !== true);
if (billable.length === 0) {
// Every row in this delivery note is excluded from invoicing.
// The status flip would still close the order correctly, but
// rendering a zero-line delivery note is noise — return 400 so
// the UI (Task 6) can disable submit on the empty subset
// case and the operator can re-include a billable row.
return NextResponse.json(
{ error: "No billable lines for a delivery note" },
{ status: 400 },
);
}
// 1. Flip the status + stamp the audit metadata. Done as a
// parallel fan-out so a slow item doesn't block the rest.
// PB returns generic 400 "Failed to update record." on any
@ -359,14 +397,11 @@ export async function POST(request: NextRequest) {
companyName,
customerName: order?.expand?.customer?.customer ?? "",
},
lines: items.map((it) => ({
id: it.id,
description: (it.description ?? "").trim() || "(no description)",
qty: resolveDeliveryNoteLineQty({
orderQty: it.order_qty,
qtyCollected: it.qty_collected,
}),
})),
// Billable rows only — excluded rows drop out here so they
// never appear on the customer delivery note. The status
// flip above has already stamped item_status and the DN
// number on every row, so the order closes normally.
lines: billable.map(buildDeliveryNoteLine),
letterhead,
logoDataUri,
});