feat(invoicing): cancel a dead order off the To Invoice list, with a reason
Lines were getting stuck on To Invoice cards because the page pulls every sibling line of an order that has one ready line, and outstanding is defined as "not ready_invoice and not invoiced". Nothing auto-advances checked_in or collected to ready_invoice, so a card can sit there for months with no way to express "this order is dead, take it off my screen". Live data: 24 checked_in, 6 with_driver, 5 collected, 3 po_generated across the 16 orders that currently have ready lines. Adds a destructive "Cancel order" button next to Invoice in QuickBooks and Delivery Note. Order-level rather than per-line, because line items aren't editable from this page — the buyer owns those. The reason is required and stamped into order_item_details.cancellation on every cancelled row. Lines already invoiced, on a delivery note, or cancelled are left alone; the dialog states that split before you commit. Also fixes the reason the button would otherwise be worthless: buildOrderInvoiceCards treated cancelled and removed as outstanding, so flipping item_status to cancelled would leave the line sitting right where the operator was trying to get rid of it. Both now drop out.
This commit is contained in:
parent
9a72f2e285
commit
3bc4633587
6 changed files with 806 additions and 2 deletions
|
|
@ -1811,3 +1811,106 @@ describe("supplier-side line toggle (exclude_from_invoice)", () => {
|
|||
expect(screen.queryByText(/hidden/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel whole order from the To Invoice queue", () => {
|
||||
beforeEach(() => {
|
||||
mockedRows.splice(0, mockedRows.length);
|
||||
// This block is a TOP-LEVEL describe, so it does not inherit the
|
||||
// fetch stub from describe("InvoicingPage"). Without its own the
|
||||
// component hits the real network and the click silently hangs.
|
||||
invoiceWebhookFetchMock.mockReset();
|
||||
invoiceWebhookFetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true, cancelled: 1, alreadyClosed: 0 }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", invoiceWebhookFetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("keeps the confirm disabled until a reason is typed, then posts it", async () => {
|
||||
mockedRows.push(makeRawRow({ description: "DISC, 115mm, FLAPPING P 60" }));
|
||||
invoiceWebhookFetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true, cancelled: 1, alreadyClosed: 0 }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
render(<Page />);
|
||||
|
||||
const cancelButton = await screen.findByRole("button", { name: /cancel order/i });
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
const confirm = await screen.findByRole("button", { name: /cancel 1 line/i });
|
||||
// Reason is mandatory — no accidental one-click kills.
|
||||
expect(confirm).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/why are you cancelling/i), {
|
||||
target: { value: "Customer cancelled the job" },
|
||||
});
|
||||
expect(confirm).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(confirm);
|
||||
|
||||
await waitFor(() => {
|
||||
const call = invoiceWebhookFetchMock.mock.calls.find(
|
||||
([url]) => url === "/api/invoicing/cancel-order",
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
const body = JSON.parse((call?.[1] as RequestInit).body as string);
|
||||
expect(body).toMatchObject({
|
||||
orderId: "ord-1",
|
||||
reason: "Customer cancelled the job",
|
||||
});
|
||||
});
|
||||
|
||||
// Dialog closes once the write lands.
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText(/why are you cancelling/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces the route's own message when the cancel is refused", async () => {
|
||||
mockedRows.push(makeRawRow({ description: "Dead order line" }));
|
||||
invoiceWebhookFetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error:
|
||||
"Every line on this order is already invoiced, on a delivery note, or cancelled — nothing to cancel.",
|
||||
}),
|
||||
{ status: 409 },
|
||||
),
|
||||
);
|
||||
|
||||
render(<Page />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /cancel order/i }));
|
||||
fireEvent.change(await screen.findByLabelText(/why are you cancelling/i), {
|
||||
target: { value: "too late" },
|
||||
});
|
||||
fireEvent.click(await screen.findByRole("button", { name: /cancel 1 line/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invoiceWebhookFetchMock).toHaveBeenCalled();
|
||||
});
|
||||
// Dialog stays open on failure so the reason isn't lost.
|
||||
expect(await screen.findByLabelText(/why are you cancelling/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables cancel when the card's lines carry no order id", async () => {
|
||||
mockedRows.push(
|
||||
makeRawRow({
|
||||
description: "Orphan line",
|
||||
expand: { purchase_order: { id: "po-1", po_number: "PO-1" } },
|
||||
}),
|
||||
);
|
||||
|
||||
render(<Page />);
|
||||
|
||||
const cancelButton = await screen.findByRole("button", { name: /cancel order/i });
|
||||
expect(cancelButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Search, Building2, UserRound, FileText, ExternalLink, RefreshCw, Printer, Zap, Loader2, Truck, Boxes, Check, Receipt } from "lucide-react";
|
||||
import { Search, Building2, UserRound, FileText, ExternalLink, RefreshCw, Printer, Zap, Loader2, Truck, Boxes, Check, Receipt, Ban } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -41,6 +41,7 @@ import {
|
|||
surplusQty,
|
||||
type InvoiceLineOverride,
|
||||
type InvoicingRow,
|
||||
type OrderInvoiceCard,
|
||||
} from "./view-model";
|
||||
|
||||
const POCKETBASE_FILE_BASE_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL ?? "http://127.0.0.1:8090";
|
||||
|
|
@ -1084,6 +1085,109 @@ type SubmitDeliveryNote = (
|
|||
},
|
||||
) => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Whole-order cancel confirmation.
|
||||
*
|
||||
* Deliberately NOT a `window.confirm()`: cancelling is irreversible
|
||||
* from this page and the operator needs to state *why* in their own
|
||||
* words. A native confirm box can only offer OK / Cancel, and a reason
|
||||
* typed into a prompt() is easy to fat-finger and impossible to review
|
||||
* before it commits.
|
||||
*
|
||||
* The dialog states the blast radius up front — which order, how many
|
||||
* lines go, and that invoiced lines are untouched — because "cancel"
|
||||
* on an invoiced order is the mistake that costs real money.
|
||||
*/
|
||||
function CancelOrderDialog({
|
||||
card,
|
||||
reason,
|
||||
onReasonChange,
|
||||
onClose,
|
||||
onConfirm,
|
||||
busy,
|
||||
}: {
|
||||
card: OrderInvoiceCard<InvoicingRow> | null;
|
||||
reason: string;
|
||||
onReasonChange: (next: string) => void;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const orderNumber = card?.orderNumber?.trim() || "this order (no order number)";
|
||||
const lineCount = card?.rows.length ?? 0;
|
||||
// Invoiced lines keep their status — the customer already holds that
|
||||
// document — so we tell the operator up front what is protected.
|
||||
const protectedCount = card
|
||||
? card.rows.filter((row) => row.item_status === "invoiced").length
|
||||
: 0;
|
||||
const willCancel = Math.max(lineCount - protectedCount, 0);
|
||||
const canConfirm = reason.trim().length > 0 && !busy;
|
||||
|
||||
return (
|
||||
<ModalShell.Dialog
|
||||
open={card !== null}
|
||||
onOpenChange={(state) => {
|
||||
if (!state && !busy) onClose();
|
||||
}}
|
||||
size={{ base: "md", md: "lg" }}
|
||||
title="Cancel this order"
|
||||
description={`Take order ${orderNumber} off the To Invoice list. This does not delete anything — it marks the lines as cancelled with your reason against them.`}
|
||||
footer={
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
Keep order
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={!canConfirm}
|
||||
onClick={onConfirm}
|
||||
className="border border-destructive/40 bg-destructive/15 text-destructive hover:bg-destructive/25"
|
||||
>
|
||||
{busy ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Ban className="mr-1 h-3.5 w-3.5" />}
|
||||
{busy
|
||||
? "Cancelling…"
|
||||
: `Cancel ${willCancel} line${willCancel === 1 ? "" : "s"}`}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-sm">
|
||||
<p className="font-medium text-destructive">
|
||||
{`${willCancel} line${willCancel === 1 ? "" : "s"} will be marked cancelled`}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{protectedCount > 0
|
||||
? `${protectedCount} line${protectedCount === 1 ? " has" : "s have"} already been invoiced and will be left alone — the customer holds that document.`
|
||||
: "No lines on this order have been invoiced yet."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-1 text-sm" htmlFor="cancel-reason">
|
||||
<span className="font-medium">
|
||||
Why are you cancelling? <span className="text-destructive">*</span>
|
||||
</span>
|
||||
<textarea
|
||||
id="cancel-reason"
|
||||
value={reason}
|
||||
onChange={(event) => onReasonChange(event.currentTarget.value)}
|
||||
rows={3}
|
||||
required
|
||||
autoFocus
|
||||
placeholder="e.g. Customer cancelled the job, duplicate of BVPO020349, supplier can no longer supply"
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm shadow-sm focus:border-destructive focus:outline-none focus:ring-2 focus:ring-destructive/30"
|
||||
/>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Required. Stored on every cancelled line so this order is still
|
||||
explainable months from now.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</ModalShell.Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery note modal (single order) — the no-QuickBooks sibling of
|
||||
* `SingleOrderQbModal`. Mirrors the QB modal's lines editor (include /
|
||||
|
|
@ -1429,6 +1533,13 @@ export default function InvoicingPage() {
|
|||
// but the DN flow never touches the QuickBooks webhook.
|
||||
const [dnModal, setDnModal] = useState<{ group: QbGroup } | null>(null);
|
||||
const [dnInFlight, setDnInFlight] = useState<string | null>(null);
|
||||
// Whole-order cancel off the To Invoice queue. Line items are NOT
|
||||
// editable from this page (the buyer owns those), so this is
|
||||
// deliberately order-level: one button, one stated reason, and the
|
||||
// card disappears. See `cancelOrder` below.
|
||||
const [cancelTarget, setCancelTarget] = useState<OrderInvoiceCard<InvoicingRow> | null>(null);
|
||||
const [cancelReason, setCancelReason] = useState<string>("");
|
||||
const [cancelInFlight, setCancelInFlight] = useState<string | null>(null);
|
||||
// QB company picked inside each modal (CNL or ISHERWOOD). Lives here so
|
||||
// the parent can compute the right cache key for (customerQuery, qbCompany)
|
||||
// and the modal just renders the toggle + invokes ensureCustomers.
|
||||
|
|
@ -2097,6 +2208,104 @@ export default function InvoicingPage() {
|
|||
setDnModal({ group });
|
||||
}, []);
|
||||
|
||||
const openCancel = useCallback((card: OrderInvoiceCard<InvoicingRow>) => {
|
||||
// Fresh reason every time — a stale reason carried over from a
|
||||
// previous cancel is how the wrong order gets killed.
|
||||
setCancelReason("");
|
||||
setCancelTarget(card);
|
||||
}, []);
|
||||
|
||||
const closeCancel = useCallback(() => {
|
||||
setCancelTarget(null);
|
||||
setCancelReason("");
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Cancel the whole order off the To Invoice queue.
|
||||
*
|
||||
* Posts to `/api/invoicing/cancel-order`, which flips every line on
|
||||
* the order that isn't already invoiced / on a delivery note /
|
||||
* already cancelled. Rows the operator already invoiced keep their
|
||||
* status — the customer holds that document, so local state must not
|
||||
* pretend otherwise. The operator's reason travels with the request
|
||||
* and is stamped onto every cancelled row.
|
||||
*
|
||||
* After a successful write we re-run `load()`, which is what makes
|
||||
* the card actually disappear: the ready rows stop matching
|
||||
* `item_status = "ready_invoice"`, so `readyCount` drops to 0 and
|
||||
* `toInvoiceCards` filters the card out entirely.
|
||||
*/
|
||||
const cancelOrder = useCallback(async () => {
|
||||
const card = cancelTarget;
|
||||
const reason = cancelReason.trim();
|
||||
if (!card || !reason) return;
|
||||
|
||||
const orderId = card.rows[0]?.orderId ?? "";
|
||||
if (!orderId) {
|
||||
toast.error("This order has no order id on its lines — cannot cancel it from here.");
|
||||
return;
|
||||
}
|
||||
|
||||
setCancelInFlight(card.orderNumber);
|
||||
try {
|
||||
const authStore = getPocketBase().authStore as unknown as {
|
||||
model?: { id?: string; name?: string; email?: string } | null;
|
||||
};
|
||||
const sender = authStore.model ?? null;
|
||||
const response = await fetch("/api/invoicing/cancel-order", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
orderId,
|
||||
reason,
|
||||
cancelledById: sender?.id ?? "",
|
||||
cancelledByName: sender?.name?.trim() || sender?.email?.trim() || "",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `Cancel failed (${response.status})`;
|
||||
try {
|
||||
const data: unknown = await response.json();
|
||||
if (
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
"error" in data &&
|
||||
typeof (data as { error?: unknown }).error === "string"
|
||||
) {
|
||||
message = (data as { error: string }).error;
|
||||
}
|
||||
} catch {
|
||||
// ignore non-json body
|
||||
}
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: unknown = await response.json().catch(() => null);
|
||||
const cancelled =
|
||||
data && typeof data === "object" && typeof (data as { cancelled?: unknown }).cancelled === "number"
|
||||
? (data as { cancelled: number }).cancelled
|
||||
: 0;
|
||||
const alreadyClosed =
|
||||
data && typeof data === "object" && typeof (data as { alreadyClosed?: unknown }).alreadyClosed === "number"
|
||||
? (data as { alreadyClosed: number }).alreadyClosed
|
||||
: 0;
|
||||
|
||||
closeCancel();
|
||||
await load();
|
||||
toast.success(
|
||||
alreadyClosed > 0
|
||||
? `Cancelled ${cancelled} line${cancelled === 1 ? "" : "s"}. ${alreadyClosed} already closed and left untouched.`
|
||||
: `Order cancelled — ${cancelled} line${cancelled === 1 ? "" : "s"} removed from To Invoice.`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(`Cancel failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setCancelInFlight(null);
|
||||
}
|
||||
}, [cancelTarget, cancelReason, closeCancel, load]);
|
||||
|
||||
const submitQbInvoice = useCallback(
|
||||
async (
|
||||
group: QbGroup,
|
||||
|
|
@ -2626,6 +2835,28 @@ export default function InvoicingPage() {
|
|||
<Truck className="mr-1 h-3 w-3" />
|
||||
{dnInFlight === readyGroup.orderNumber ? "Marking…" : "Delivery Note"}
|
||||
</Button>
|
||||
{/* Whole-order cancel. Line items stay with the
|
||||
buyer — this only clears the order off the To
|
||||
Invoice queue. Requires a stated reason, which
|
||||
the dialog collects. */}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="destructive"
|
||||
disabled={
|
||||
cancelInFlight === card.orderNumber ||
|
||||
!card.rows[0]?.orderId
|
||||
}
|
||||
title={
|
||||
card.rows[0]?.orderId
|
||||
? `Cancel all ${card.rows.length} lines on order ${card.orderNumber || "(no order number)"} and take it off the To Invoice list`
|
||||
: "This order has no order id on its lines, so it cannot be cancelled from here"
|
||||
}
|
||||
onClick={() => openCancel(card)}
|
||||
className="border border-destructive/40 bg-destructive/15 text-destructive hover:bg-destructive/25"
|
||||
>
|
||||
<Ban className="mr-1 h-3 w-3" />
|
||||
{cancelInFlight === card.orderNumber ? "Cancelling…" : "Cancel order"}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -2917,6 +3148,14 @@ export default function InvoicingPage() {
|
|||
onSubmit={submitDeliveryNote}
|
||||
busy={dnInFlight !== null}
|
||||
/>
|
||||
<CancelOrderDialog
|
||||
card={cancelTarget}
|
||||
reason={cancelReason}
|
||||
onReasonChange={setCancelReason}
|
||||
onClose={closeCancel}
|
||||
onConfirm={() => void cancelOrder()}
|
||||
busy={cancelInFlight !== null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -245,6 +245,67 @@ describe("buildOrderInvoiceCards", () => {
|
|||
expect(groups[0].outstandingRows.map((row) => row.id)).toEqual(["row-2"]);
|
||||
});
|
||||
|
||||
// The cancel button is worthless without this: flipping a line to
|
||||
// `cancelled` used to leave it sitting under "Show outstanding",
|
||||
// which is exactly where the operator was trying to clear it from.
|
||||
it("drops cancelled and removed lines from the outstanding list", () => {
|
||||
const rows: InvoicingRow[] = [
|
||||
makeRow({ id: "ready", orderNumber: "SO-1", item_status: "ready_invoice" }),
|
||||
makeRow({ id: "cancelled", orderNumber: "SO-1", item_status: "cancelled" }),
|
||||
makeRow({ id: "removed", orderNumber: "SO-1", item_status: "removed" }),
|
||||
makeRow({ id: "still-live", orderNumber: "SO-1", item_status: "checked_in" }),
|
||||
];
|
||||
|
||||
const [card] = buildOrderInvoiceCards(rows);
|
||||
|
||||
expect(card.outstandingRows.map((row) => row.id)).toEqual(["still-live"]);
|
||||
expect(card.outstandingCount).toBe(1);
|
||||
expect(card.hasOutstanding).toBe(true);
|
||||
});
|
||||
|
||||
it("clears hasOutstanding once every non-ready line is cancelled", () => {
|
||||
const rows: InvoicingRow[] = [
|
||||
makeRow({ id: "ready", orderNumber: "SO-1", item_status: "ready_invoice" }),
|
||||
makeRow({ id: "cancelled", orderNumber: "SO-1", item_status: "cancelled" }),
|
||||
];
|
||||
|
||||
const [card] = buildOrderInvoiceCards(rows);
|
||||
|
||||
expect(card.outstandingRows).toHaveLength(0);
|
||||
expect(card.outstandingCount).toBe(0);
|
||||
expect(card.hasOutstanding).toBe(false);
|
||||
});
|
||||
|
||||
// A delivery note records that goods physically moved; it does not
|
||||
// close the commercial obligation. Those lines still owe an invoice,
|
||||
// so they must keep nagging the operator.
|
||||
it("still reports delivery_note lines as outstanding", () => {
|
||||
const rows: InvoicingRow[] = [
|
||||
makeRow({ id: "ready", orderNumber: "SO-1", item_status: "ready_invoice" }),
|
||||
makeRow({ id: "dn", orderNumber: "SO-1", item_status: "delivery_note" }),
|
||||
];
|
||||
|
||||
const [card] = buildOrderInvoiceCards(rows);
|
||||
|
||||
expect(card.outstandingRows.map((row) => row.id)).toEqual(["dn"]);
|
||||
expect(card.hasOutstanding).toBe(true);
|
||||
});
|
||||
|
||||
// Once the cancel route flips every ready line, readyCount hits 0 and
|
||||
// `toInvoiceCards` filters the card out — that is what makes the
|
||||
// order disappear from /invoicing rather than linger.
|
||||
it("leaves a fully cancelled order with zero ready rows", () => {
|
||||
const rows: InvoicingRow[] = [
|
||||
makeRow({ id: "a", orderNumber: "SO-1", item_status: "cancelled" }),
|
||||
makeRow({ id: "b", orderNumber: "SO-1", item_status: "cancelled" }),
|
||||
];
|
||||
|
||||
const [card] = buildOrderInvoiceCards(rows);
|
||||
|
||||
expect(card.readyCount).toBe(0);
|
||||
expect(card.readyRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sums short-pickup units across the card's ready rows", () => {
|
||||
const rows: InvoicingRow[] = [
|
||||
makeRow({
|
||||
|
|
|
|||
|
|
@ -299,11 +299,32 @@ export function groupRowsByOrderNumber<T extends InvoicingRow>(rows: T[]): Order
|
|||
return order.map((orderNumber) => ({ orderNumber, rows: map.get(orderNumber)! }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Line states that must NOT surface as "outstanding work" on a To
|
||||
* Invoice card.
|
||||
*
|
||||
* `invoiced` was already excluded before this set existed. `cancelled`
|
||||
* and `removed` join it so a cancelled line genuinely disappears from
|
||||
* the card instead of sitting under "Show outstanding" forever — the
|
||||
* cancel button is worthless without this, because flipping
|
||||
* `item_status` to `cancelled` would otherwise leave the line right
|
||||
* where the operator was trying to get rid of it.
|
||||
*
|
||||
* `delivery_note` is deliberately NOT in here. A delivery note records
|
||||
* that goods physically moved; it does not close the commercial
|
||||
* obligation. Those lines still owe an invoice, so they stay visible
|
||||
* as outstanding until somebody bills them.
|
||||
*/
|
||||
const HIDDEN_FROM_OUTSTANDING = new Set(["cancelled", "removed"]);
|
||||
|
||||
export function buildOrderInvoiceCards<T extends InvoicingRow>(rows: T[]): OrderInvoiceCard<T>[] {
|
||||
return groupRowsByOrderNumber(rows).map((group) => {
|
||||
const readyRows = group.rows.filter((row) => row.item_status === "ready_invoice");
|
||||
const outstandingRows = group.rows.filter(
|
||||
(row) => row.item_status !== "ready_invoice" && row.item_status !== "invoiced",
|
||||
(row) =>
|
||||
row.item_status !== "ready_invoice" &&
|
||||
row.item_status !== "invoiced" &&
|
||||
!HIDDEN_FROM_OUTSTANDING.has(String(row.item_status ?? "")),
|
||||
);
|
||||
const shortPickupTotal = readyRows.reduce(
|
||||
(sum, row) => sum + outstandingQty(row),
|
||||
|
|
|
|||
180
src/app/api/invoicing/cancel-order/route.test.ts
Normal file
180
src/app/api/invoicing/cancel-order/route.test.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { getFullList, updateMock } = vi.hoisted(() => ({
|
||||
getFullList: vi.fn(),
|
||||
updateMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
||||
getAdminPocketBase: vi.fn().mockResolvedValue({
|
||||
collection: () => ({
|
||||
getFullList,
|
||||
update: updateMock,
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { POST } from "@/app/api/invoicing/cancel-order/route";
|
||||
|
||||
function makeRequest(body: unknown): Request {
|
||||
return new Request("https://test.local/api/invoicing/cancel-order", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const NOW = "2026-10-08T00:00:00.000Z";
|
||||
|
||||
function line(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "oi-1",
|
||||
item_status: "ready_invoice",
|
||||
order_item_details: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("POST /api/invoicing/cancel-order", () => {
|
||||
beforeEach(() => {
|
||||
getFullList.mockReset();
|
||||
updateMock.mockReset();
|
||||
updateMock.mockResolvedValue({});
|
||||
vi.spyOn(Date.prototype, "toISOString").mockReturnValue(NOW);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("rejects a request with no reason before touching PocketBase", async () => {
|
||||
const res = await POST(
|
||||
makeRequest({ orderId: "ord-1", reason: " " }) as never,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/reason is required/i);
|
||||
expect(getFullList).not.toHaveBeenCalled();
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a request with no orderId", async () => {
|
||||
const res = await POST(makeRequest({ reason: "customer cancelled" }) as never);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toMatch(/orderId is required/i);
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flips every open line to cancelled and stamps the reason", async () => {
|
||||
getFullList.mockResolvedValue([
|
||||
line({ id: "oi-1" }),
|
||||
line({ id: "oi-2", item_status: "checked_in" }),
|
||||
line({ id: "oi-3", item_status: "with_driver" }),
|
||||
]);
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
orderId: "ord-1",
|
||||
reason: "Customer cancelled the job",
|
||||
cancelledById: "user-9",
|
||||
cancelledByName: "Ysterbaard",
|
||||
}) as never,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toMatchObject({ ok: true, cancelled: 3, alreadyClosed: 0 });
|
||||
expect(updateMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
for (const [id, patch] of updateMock.mock.calls) {
|
||||
expect(id).toMatch(/^oi-/);
|
||||
expect(patch.item_status).toBe("cancelled");
|
||||
expect(patch.order_item_details.cancellation).toMatchObject({
|
||||
reason: "Customer cancelled the job",
|
||||
by: "user-9",
|
||||
byName: "Ysterbaard",
|
||||
at: NOW,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The customer already holds an invoice for these. Rewriting their
|
||||
// status would desync local state from a document they have.
|
||||
it("never overwrites invoiced, delivery_note or already-cancelled lines", async () => {
|
||||
getFullList.mockResolvedValue([
|
||||
line({ id: "oi-invoiced", item_status: "invoiced" }),
|
||||
line({ id: "oi-dn", item_status: "delivery_note" }),
|
||||
line({ id: "oi-done", item_status: "cancelled" }),
|
||||
line({ id: "oi-open", item_status: "collected" }),
|
||||
]);
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({ orderId: "ord-1", reason: "duplicate order" }) as never,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({ cancelled: 1, alreadyClosed: 3 });
|
||||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||||
expect(updateMock.mock.calls[0][0]).toBe("oi-open");
|
||||
});
|
||||
|
||||
// A DN line still owes an invoice, so it counts as closed work here
|
||||
// (not cancelled) but must remain visible as outstanding in the UI.
|
||||
it("409s when every line is already closed", async () => {
|
||||
getFullList.mockResolvedValue([
|
||||
line({ id: "oi-1", item_status: "invoiced" }),
|
||||
line({ id: "oi-2", item_status: "delivery_note" }),
|
||||
]);
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({ orderId: "ord-1", reason: "nothing to do" }) as never,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).error).toMatch(/nothing to cancel/i);
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("404s when the order has no lines", async () => {
|
||||
getFullList.mockResolvedValue([]);
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({ orderId: "ord-1", reason: "gone" }) as never,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves sibling audit blocks when merging the cancellation stamp", async () => {
|
||||
getFullList.mockResolvedValue([
|
||||
line({
|
||||
id: "oi-1",
|
||||
order_item_details: {
|
||||
deliveryNote: { number: "DN26100104", setAt: "2026-10-01T09:34:00.000Z" },
|
||||
print: { printed: true, printCount: 2 },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await POST(makeRequest({ orderId: "ord-1", reason: "dead order" }) as never);
|
||||
|
||||
const patch = updateMock.mock.calls[0][1];
|
||||
expect(patch.order_item_details.deliveryNote).toMatchObject({
|
||||
number: "DN26100104",
|
||||
});
|
||||
expect(patch.order_item_details.print).toMatchObject({ printed: true });
|
||||
expect(patch.order_item_details.cancellation.reason).toBe("dead order");
|
||||
});
|
||||
|
||||
it("500s when the PocketBase update fails", async () => {
|
||||
getFullList.mockResolvedValue([line({ id: "oi-1" })]);
|
||||
updateMock.mockRejectedValue(new Error("Failed to update record."));
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({ orderId: "ord-1", reason: "boom" }) as never,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect((await res.json()).error).toMatch(/Failed to update record/);
|
||||
});
|
||||
});
|
||||
200
src/app/api/invoicing/cancel-order/route.ts
Normal file
200
src/app/api/invoicing/cancel-order/route.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Cancel a WHOLE order off the /invoicing To Invoice queue.
|
||||
*
|
||||
* The operator-facing problem this solves: a card sits in To Invoice
|
||||
* with `item_status = "ready_invoice"` and no way to express "this
|
||||
* order is dead, take it off my screen". Line items are NOT editable
|
||||
* here — the buyer owns those — so this is deliberately order-level,
|
||||
* not per-line.
|
||||
*
|
||||
* What it does: flips every line on the order that is NOT already in a
|
||||
* closed state to `item_status = "cancelled"` and stamps the operator's
|
||||
* reason into `order_item_details.cancellation`.
|
||||
*
|
||||
* Closed states we never overwrite — each of these has a document or a
|
||||
* deliberate operator decision behind it:
|
||||
*
|
||||
* - `invoiced` — the customer already has a QuickBooks invoice.
|
||||
* Cancelling it here would desync local state
|
||||
* from a document the customer holds.
|
||||
* - `delivery_note` — a DN was printed against it. A DN records that
|
||||
* goods physically moved; it does NOT close the
|
||||
* commercial obligation, so these stay visible as
|
||||
* outstanding work (they still owe an invoice).
|
||||
* - `cancelled` — already done. Re-stamping the reason would
|
||||
* rewrite an earlier decision's audit trail.
|
||||
*
|
||||
* `removed` IS flipped to `cancelled`. It means the operator struck the
|
||||
* line during review without giving a reason; folding it in here means
|
||||
* every non-invoiced line on a cancelled order carries the same audit
|
||||
* story.
|
||||
*
|
||||
* The reason is REQUIRED. A cancel with no stated cause is an
|
||||
* unauditable dead order six weeks from now, which is exactly the
|
||||
* ambiguity that leaves these rows stuck in the first place.
|
||||
*/
|
||||
type CancelOrderInput = {
|
||||
orderId?: unknown;
|
||||
reason?: unknown;
|
||||
cancelledById?: unknown;
|
||||
cancelledByName?: unknown;
|
||||
};
|
||||
|
||||
type OrderItemRecord = {
|
||||
id: string;
|
||||
item_status?: string;
|
||||
order_item_details?: unknown;
|
||||
};
|
||||
|
||||
const CLOSED_STATUSES = new Set(["invoiced", "delivery_note", "cancelled"]);
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a `cancellation` block into the item's `order_item_details`
|
||||
* JSON, preserving every sibling block (`deliveryNote`, `print`,
|
||||
* `collection`, …). Mirrors `mergeDeliveryNoteDetails` in the sibling
|
||||
* delivery-note route — same feed, same shape, so a reader of the audit
|
||||
* trail finds both the same way.
|
||||
*/
|
||||
function mergeCancellationDetails(
|
||||
details: unknown,
|
||||
override: { reason: string; by: string; byName: string; at: string },
|
||||
): Record<string, unknown> {
|
||||
const base = asObject(details) ?? {};
|
||||
const previous = asObject(base.cancellation) ?? {};
|
||||
return {
|
||||
...base,
|
||||
cancellation: {
|
||||
...previous,
|
||||
reason: override.reason,
|
||||
by: override.by || undefined,
|
||||
byName: override.byName || undefined,
|
||||
at: override.at,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: CancelOrderInput;
|
||||
try {
|
||||
body = (await request.json()) as CancelOrderInput;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const orderId = readString(body.orderId);
|
||||
if (!orderId) {
|
||||
return NextResponse.json({ error: "orderId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Required — see the module docblock. Reject before touching PB so a
|
||||
// blank reason can never leave a half-cancelled order behind.
|
||||
const reason = readString(body.reason);
|
||||
if (!reason) {
|
||||
return NextResponse.json(
|
||||
{ error: "A reason is required to cancel an order." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const cancelledById = readString(body.cancelledById);
|
||||
const cancelledByName = readString(body.cancelledByName);
|
||||
const cancelledAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const pb = await getAdminPocketBase();
|
||||
|
||||
// Load the whole order — we need the closed lines too, so we can
|
||||
// report an honest "nothing to cancel" instead of silently
|
||||
// succeeding on an already-invoiced order.
|
||||
const items = await pb.collection("order_items").getFullList<OrderItemRecord>({
|
||||
filter: `order = "${orderId.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`,
|
||||
fields: "id,item_status,order_item_details",
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "No order items found for this order" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const toCancel = items.filter(
|
||||
(item) => !CLOSED_STATUSES.has(String(item.item_status ?? "")),
|
||||
);
|
||||
const alreadyClosed = items.length - toCancel.length;
|
||||
|
||||
if (toCancel.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Every line on this order is already invoiced, on a delivery note, or cancelled — nothing to cancel.",
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
toCancel.map((item) =>
|
||||
pb.collection("order_items").update(item.id, {
|
||||
item_status: "cancelled",
|
||||
order_item_details: mergeCancellationDetails(item.order_item_details, {
|
||||
reason,
|
||||
by: cancelledById,
|
||||
byName: cancelledByName,
|
||||
at: cancelledAt,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (updateErr) {
|
||||
// PB returns a generic 400 on any field-level validation failure
|
||||
// (a select value missing from the enum, a JSON blob that won't
|
||||
// fit the field). Log the ids so the operator can see WHICH line
|
||||
// failed rather than staring at a bare 500.
|
||||
console.error(
|
||||
"[cancel-order] order_items update failed:",
|
||||
JSON.stringify(
|
||||
{
|
||||
message:
|
||||
updateErr instanceof Error ? updateErr.message : String(updateErr),
|
||||
orderId,
|
||||
itemIds: toCancel.map((i) => i.id),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
throw updateErr;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
orderId,
|
||||
cancelled: toCancel.length,
|
||||
alreadyClosed,
|
||||
reason,
|
||||
cancelledAt,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue