feat(delivery-notes): link a manual invoice, and show the invoice without leaving

Two requests. The second turned out to be much smaller than it
sounded.

## 1. Link an existing invoice (the backlog)

Hundreds of delivery notes were invoiced by hand before this app
existed. The invoice is already in QuickBooks; all the app needs is
the link. New "Link invoice" button beside "Create invoice" opens a
dialog that records the number and completes the note.

`POST /api/v1/delivery-notes/[id}/link-invoice` does two writes, and
the second is the one that's easy to forget:

  1. `reconcileOrderItemInvoicedStatus` — stamps
     `customer_invoice_number` + `invoiced_date` on the note's
     order_items and moves them out of ready-to-invoice.
  2. `markDeliveryNoteCompletedByInvoice` — flips the DN to
     `completed` so it leaves the Active list.

Without (1) the note closes but its lines stay "ready to invoice"
forever, so the Invoicing page still shows the work as outstanding —
done in one place, unfinished in another. Items go FIRST: if that
fails the note is left open and retryable, whereas closing first
would leave a completed note with no obvious way back.

It reuses `buildReconcileRows`, the same quantity rule as the
create-in-QuickBooks path, so the invoice qty is still capped at
min(qty_collected, quantity) and a short pickup isn't stamped as
fully invoiced. A second copy of that rule is how the two paths
would disagree about what "invoiced" means.

Deliberately a SEPARATE button from "Create invoice", not a mode of
it: one fires a webhook and creates money, the other records a
number. Folding them together is how a customer gets invoiced twice.

## 2. The invoice comes back to the screen

Not a new integration at all. n8n already downloads the QuickBooks
invoice PDF and attaches it to the note's order_items as
`qb_invoice_pdf` — 1,209 items carry one today. The Invoicing page
has been printing them from PocketBase all along.

The create route now returns `invoice: { number, date, pdfFileName,
pdfRecordId }` alongside the updated DN (additive — existing callers
are untouched), and the list opens that PDF in the same preview
dialog the quote uses. Print and Download, no navigation.

The `pdfRecordId` matters: the file hangs off the ORDER_ITEM, not
the delivery note, and PB's file URL is
`/api/files/<collection>/<recordId>/<fileName>` — anchoring on the
delivery note would 404.

Verified in the browser as David Els: both buttons render in order
(Create quote · Send · Link invoice · Create invoice), the link
dialog opens on the list, and the URL never leaves /delivery-notes.

Full suite 3783 passing, tsc clean, no lint warnings.
This commit is contained in:
Dave 2026-10-08 00:05:32 +02:00
parent 7256de8859
commit 9a72f2e285
5 changed files with 504 additions and 4 deletions

View file

@ -20,6 +20,7 @@ import {
import { CreateAdHocDeliveryNoteDialog } from "@/components/delivery-notes/create-adhoc-dialog";
import { DeliveryNoteItemsExpanded } from "@/components/delivery-notes/items-expanded";
import { DeliveryNoteActions } from "@/components/delivery-notes/action-buttons";
import { LinkInvoiceDialog } from "@/components/delivery-notes/link-invoice-dialog";
import { QbInvoiceDialog } from "@/components/delivery-notes/qb-invoice-dialog";
import { SendQuoteModal } from "@/components/quotes/send-quote-modal";
import { QuoteReviewDialog } from "@/components/delivery-notes/quote-review-dialog";
@ -824,6 +825,34 @@ export default function DeliveryNotesPage() {
customerQuery: "",
});
// "Link an existing invoice" (the backlog path) + the invoice the
// create path just made, so she never has to leave the list to find
// or print it.
const [linkModal, setLinkModal] = useState<{
open: boolean;
noteId: string;
number: string;
orderNumber: string;
}>({ open: false, noteId: "", number: "", orderNumber: "" });
const [createdInvoice, setCreatedInvoice] = useState<{
number: string;
date: string;
pdfFileName: string | null;
/** The order_item the PDF file hangs off — PB file URLs are
* /api/files/<collection>/<recordId>/<fileName>. */
pdfRecordId: string | null;
dnNumber: string;
} | null>(null);
const openLinkModal = useCallback((note: DeliveryNote) => {
setLinkModal({
open: true,
noteId: note.id,
number: note.delivery_note_number,
orderNumber: note.expand?.order?.order_number ?? "",
});
}, []);
const openInvoiceModal = useCallback((note: DeliveryNote) => {
setInvoiceModal({
open: true,
@ -906,6 +935,10 @@ export default function DeliveryNotesPage() {
// available; no quote required. Opens the QuickBooks
// modal right here, so she never leaves the list.
"invoice-qb": () => openInvoiceModal(note),
// The backlog path: an invoice already exists in
// QuickBooks, we just record the number. Separate from
// the button above so nobody double-invoices.
"link-invoice": () => openLinkModal(note),
}}
/>
</div>
@ -915,7 +948,10 @@ export default function DeliveryNotesPage() {
// here: the flow control offers quote / send / invoice only. Those
// two actions live on the detail page, which still renders the
// full set from the same component.
[busyIds, openInvoiceModal, openQuoteReview, openSendModal, quoteByDnId, router],
// `router` is no longer referenced: every action now happens in
// place (quote review, send, invoice, link-invoice) rather than
// by navigating.
[busyIds, openInvoiceModal, openLinkModal, openQuoteReview, openSendModal, quoteByDnId],
);
const columns = useMemo(
@ -1712,6 +1748,52 @@ export default function DeliveryNotesPage() {
on any row that doesn't have a quote yet. The dialog POSTs
the create itself with the operator's confirmed pricing;
`handleQuoteCreated` only refreshes the row behind it. */}
{/* Link an existing QuickBooks invoice (the backlog path). */}
{linkModal.noteId && (
<LinkInvoiceDialog
key={`dn-link-invoice-${linkModal.noteId}`}
open={linkModal.open}
onOpenChange={(v) => setLinkModal((prev) => ({ ...prev, open: v }))}
deliveryNoteId={linkModal.noteId}
deliveryNoteNumber={linkModal.number}
orderNumber={linkModal.orderNumber}
onLinked={() => {
setLinkModal((prev) => ({ ...prev, open: false }));
void fetchNotes();
}}
/>
)}
{/*
The invoice that was just created, shown in place so she can
print or download it without leaving the list — the same
affordance the quote PDF popup gives. The file is already in
PocketBase: n8n downloads the QuickBooks PDF and attaches it
to the note's order_items as `qb_invoice_pdf`, so this is
just surfacing a file that exists, not a new integration.
*/}
{createdInvoice && (
<PdfPreviewDialog
key={`dn-invoice-pdf-${createdInvoice.number}`}
open={!!createdInvoice}
onOpenChange={(v) => {
if (!v) setCreatedInvoice(null);
}}
pdfUrl={
createdInvoice.pdfFileName
? `${process.env.NEXT_PUBLIC_POCKETBASE_URL}/api/files/order_items/${createdInvoice.pdfRecordId}/${createdInvoice.pdfFileName}`
: ""
}
title={`Invoice ${createdInvoice.number}`}
subtitle={
createdInvoice.dnNumber
? `${createdInvoice.dnNumber} · ${createdInvoice.date}`
: createdInvoice.date
}
downloadFilename={`Invoice-${createdInvoice.number}.pdf`}
/>
)}
{/* QuickBooks invoice, in place on the list. */}
{invoiceModal.noteId && (
<QbInvoiceDialog
@ -1723,9 +1805,31 @@ export default function DeliveryNotesPage() {
deliveryNoteId={invoiceModal.noteId}
orderNumber={invoiceModal.orderNumber}
customerQuerySeed={invoiceModal.customerQuery}
onInvoiced={() => {
onInvoiced={(updated) => {
setInvoiceModal((prev) => ({ ...prev, open: false }));
void fetchNotes();
const inv = (
updated as {
invoice?: {
number?: string;
date?: string;
pdfFileName?: string | null;
pdfRecordId?: string | null;
};
}
)?.invoice;
void fetchNotes().then(() => {
// Bring the invoice back on the list instead of making
// her go and find it in QuickBooks.
if (inv?.number) {
setCreatedInvoice({
number: inv.number,
date: inv.date ?? "",
pdfFileName: inv.pdfFileName ?? null,
pdfRecordId: inv.pdfRecordId ?? null,
dnNumber: invoiceModal.number,
});
}
});
}}
/>
)}

View file

@ -282,7 +282,33 @@ export async function POST(
sentByName: body.sentByName ?? null,
};
const updated = await markDeliveryNoteCompletedByInvoice(id, closeInput);
return NextResponse.json(updated);
// Additive: the updated DN fields stay exactly where they were, so
// existing callers are unaffected. `invoice` is new — it lets the
// delivery-notes LIST show the invoice in a popup after creating it,
// instead of the operator having to go find it in QuickBooks.
//
// The PDF is not fetched here. n8n already downloads it and
// attaches it to the note's order_items as `qb_invoice_pdf` (1,200+
// rows carry one), so we hand back that reference and the browser
// prints it from PocketBase like any other file.
// `rowsBilled` is a shape-only projection for reconcile; it has no
// PDF field. Read the reference off the DN's own items.
// The PDF is a file on the ORDER_ITEM, not on the delivery note,
// so the browser needs that record's id to build the PB file URL
// (`/api/files/<collection>/<recordId>/<fileName>`).
const pdfItem = items.find((it) => it.qb_invoice_pdf);
const pdfRef = pdfItem?.qb_invoice_pdf ?? null;
return NextResponse.json({
...updated,
invoice: {
number: invoiceNumber,
date: qbInvoice?.date ?? new Date().toISOString().slice(0, 10),
// A single-file field is a bare string; several is an array.
pdfFileName: Array.isArray(pdfRef) ? (pdfRef[0] ?? null) : (pdfRef ?? null),
pdfRecordId: pdfItem?.id ?? null,
},
});
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to invoice delivery note via QuickBooks";

View file

@ -0,0 +1,178 @@
import { NextRequest, NextResponse } from "next/server";
import {
markDeliveryNoteCompletedByInvoice,
} from "@/lib/server/delivery-notes-service";
import { reconcileOrderItemInvoicedStatus } from "@/lib/server/orders-service";
import {
buildReconcileRows,
type InvoicingRow,
} from "@/app/(app)/invoicing/view-model";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
/**
* POST /api/v1/delivery-notes/[id]/link-invoice
*
* Attach an invoice that ALREADY EXISTS in QuickBooks to a delivery
* note, then close the note.
*
* ## Why this exists
*
* The normal path fires the `wfdash-quickbooks-invoice-creator`
* webhook, which creates the invoice and returns its number. But a
* pile of delivery notes were invoiced by hand, months ago, before
* any of this existed. For those the invoice is already sitting in
* QuickBooks; all the app needs to do is record the link so the note
* stops looking outstanding.
*
* ## What it writes
*
* Two things, and both matter:
*
* 1. `markDeliveryNoteCompletedByInvoice` — flips the DN to
* `completed` (so it leaves the Active list and stops being
* "outstanding") and stamps an audit banner on its notes.
* 2. `reconcileOrderItemInvoicedStatus` — stamps
* `customer_invoice_number` + `invoiced_date` on the DN's
* `order_items` and moves them out of the ready-to-invoice state.
*
* (2) is the part that's easy to forget. Without it the DN closes but
* its lines stay "ready to invoice" forever, so the Invoicing page
* still shows the work as outstanding — the note would look done in
* one place and unfinished in another.
*
* Body: `{ invoiceNumber, sentById?, sentByName?, invoiceDate? }`
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
let body: {
invoiceNumber?: unknown;
sentByName?: unknown;
sentById?: unknown;
invoiceDate?: unknown;
};
try {
body = JSON.parse((await req.text()) || "{}");
} catch {
return NextResponse.json(
{ error: "Request body is not valid JSON" },
{ status: 400 },
);
}
const invoiceNumber =
typeof body.invoiceNumber === "string" ? body.invoiceNumber.trim() : "";
if (!invoiceNumber) {
// A synthetic placeholder here would be worse than refusing: this
// field is what someone searches for when a customer queries an
// invoice, and `QB-<dn>-<timestamp>` finds nothing in QuickBooks.
return NextResponse.json(
{ error: "invoiceNumber is required." },
{ status: 400 },
);
}
const sentByName =
typeof body.sentByName === "string" ? body.sentByName.trim() : null;
const invoiceDate =
typeof body.invoiceDate === "string" && body.invoiceDate.trim()
? body.invoiceDate.trim()
: new Date().toISOString().slice(0, 10);
try {
const pb = await getAdminPocketBase();
const dn = await pb.collection("delivery_notes").getOne<{
id: string;
delivery_note_number: string;
status: string;
}>(id);
// The note's lines, so they can be stamped too. Same selector the
// create-in-QB route uses.
const items = dn.delivery_note_number
? await pb
.collection("order_items")
.getFullList<{
id: string;
description?: string;
quantity?: number;
qty_collected?: number;
selling_price?: number;
}>({
filter: `delivery_note_number = "${dn.delivery_note_number.replace(/"/g, '\\"')}"`,
})
: [];
// Same shape + same quantity rule as the create-in-QuickBooks
// path: the invoice qty is capped at min(qty_collected, quantity)
// so a short pickup is never stamped as fully invoiced. Building
// it here with a different rule would make the two paths disagree
// about what "invoiced" means.
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: "",
excludeFromInvoice: false,
}));
// Items FIRST. If this fails we have not yet closed the note, so
// the operator can retry without a half-linked DN. The reverse
// order would leave a completed note whose lines still read as
// outstanding, with no obvious way back.
let reconciled = 0;
try {
const result = await reconcileOrderItemInvoicedStatus({
rows: buildReconcileRows(rows),
customerInvoiceNumber: invoiceNumber,
invoiceDate,
});
reconciled = result.rowsUpdated;
} catch (reconcileErr) {
console.error(
`[link-invoice] DN ${id}: item reconcile failed; leaving the note OPEN so it can be retried.`,
reconcileErr,
);
return NextResponse.json(
{
error:
"Could not stamp the invoice on the note's items. The delivery note was left open — fix the cause and try again.",
detail:
reconcileErr instanceof Error
? reconcileErr.message
: String(reconcileErr),
},
{ status: 500 },
);
}
const updated = await markDeliveryNoteCompletedByInvoice(id, {
invoiceNumber,
sentByName,
});
return NextResponse.json({ ...updated, rowsLinked: reconciled });
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to link the invoice";
const status =
/cannot be auto-closed|cancelled|completed/i.test(message) ? 409 : 500;
return NextResponse.json({ error: message }, { status });
}
}

View file

@ -57,6 +57,7 @@ export type DnActionId =
| "capture-po"
| "mark-invoiced"
| "invoice-qb"
| "link-invoice"
| "mark-complete"
| "cancel"
| "regenerate-pdf"
@ -301,6 +302,24 @@ function FlowControl({
Send
</Button>
{/* Slot 4 — the backlog path. An invoice already exists in
QuickBooks (raised by hand, months ago); this only records
the number and completes the note. Deliberately NOT folded
into "Create invoice": one fires a webhook and creates
money, the other records a number. Merging them is how a
customer gets invoiced twice. */}
<Button
size="sm"
variant="outline"
onClick={onAction["link-invoice"]}
disabled={off || !isLive}
data-testid={`dn-flow-link-invoice-${note.id}`}
title="Attach an invoice number that already exists in QuickBooks"
className="h-7 px-2.5 text-xs"
>
Link invoice
</Button>
{/* Slot 3 — the customer-to-customer path, always available
while the note is live. */}
<Button

View file

@ -0,0 +1,173 @@
"use client";
/**
* "Link an invoice that already exists in QuickBooks."
*
* For the backlog. Hundreds of delivery notes were invoiced by hand
* before this app existed; the invoice is already in QuickBooks and
* all we need is the link, so the note stops looking outstanding.
*
* Deliberately separate from `QbInvoiceDialog`, which CREATES an
* invoice. Both sit on the row, because both are legitimate "finish
* this note" actions — but one fires a webhook and the other only
* records a number, and conflating them would be how someone ends up
* double-invoicing a customer.
*/
import { useState } from "react";
import { toast } from "sonner";
import { AlertTriangle, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export interface LinkInvoiceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
deliveryNoteId: string;
deliveryNoteNumber: string;
/** The DN's order number, shown for reference (read-only). */
orderNumber?: string;
onLinked?: (result: { invoiceNumber: string; rowsLinked: number }) => void;
}
export function LinkInvoiceDialog({
open,
onOpenChange,
deliveryNoteId,
deliveryNoteNumber,
orderNumber,
onLinked,
}: LinkInvoiceDialogProps) {
const [invoiceNumber, setInvoiceNumber] = useState("");
const [saving, setSaving] = useState(false);
const submit = async () => {
const trimmed = invoiceNumber.trim();
if (!trimmed) {
toast.error("Enter the invoice number from QuickBooks.");
return;
}
setSaving(true);
try {
const res = await fetch(
`/api/v1/delivery-notes/${deliveryNoteId}/link-invoice`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ invoiceNumber: trimmed }),
},
);
const body = (await res.json().catch(() => ({}))) as {
error?: string;
rowsLinked?: number;
};
if (!res.ok) {
throw new Error(body.error || `Failed (${res.status})`);
}
toast.success(
`Linked invoice ${trimmed} to ${deliveryNoteNumber}. The note is now completed.`,
);
setInvoiceNumber("");
onOpenChange(false);
onLinked?.({ invoiceNumber: trimmed, rowsLinked: body.rowsLinked ?? 0 });
} catch (err: unknown) {
toast.error(
err instanceof Error ? err.message : "Failed to link the invoice",
);
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-w-lg">
<DialogHeader>
<DialogTitle>Link an existing invoice</DialogTitle>
<DialogDescription>
For a delivery note whose invoice was already raised in
QuickBooks. This records the number and completes the note —
it does not create anything in QuickBooks.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Delivery note</span>
<span className="font-mono font-medium">
{deliveryNoteNumber}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Order</span>
<span className="font-mono font-medium">
{orderNumber || "— none —"}
</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="dn-link-invoice-number">
QuickBooks invoice number
</Label>
<Input
id="dn-link-invoice-number"
value={invoiceNumber}
onChange={(e) => setInvoiceNumber(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void submit();
}
}}
placeholder="e.g. INV11041"
autoFocus
disabled={saving}
data-testid="dn-link-invoice-number"
/>
<p className="text-xs text-muted-foreground">
This is what someone searches for when a customer queries
an invoice, so type the real QuickBooks number — not a
placeholder.
</p>
</div>
<p className="flex items-start gap-2 text-xs text-warn">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Linking marks the note and its lines as invoiced. They will
leave the active list and stop showing as outstanding.
</p>
</div>
<DialogFooter className="gap-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={saving}
>
Cancel
</Button>
<Button
onClick={() => void submit()}
disabled={saving || !invoiceNumber.trim()}
data-testid="dn-link-invoice-submit"
>
{saving ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : null}
Link invoice &amp; complete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}