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.
296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
|
import {
|
|
markDeliveryNoteCompletedByInvoice,
|
|
type MarkDeliveryNoteCompletedByInvoiceInput,
|
|
} from "@/lib/server/delivery-notes-service";
|
|
import {
|
|
reconcileOrderItemInvoicedStatus,
|
|
} from "@/lib/server/orders-service";
|
|
import {
|
|
buildQbInvoicePayload,
|
|
type InvoiceLineOverride,
|
|
type InvoicingRow,
|
|
} from "@/app/(app)/invoicing/view-model";
|
|
|
|
export const runtime = "nodejs";
|
|
export const dynamic = "force-dynamic";
|
|
|
|
interface InvoiceRouteBody {
|
|
/** "CNL" | "ISHERWOOD" — which QuickBooks company to bill against. */
|
|
qbCompany: "CNL" | "ISHERWOOD";
|
|
/** PB id of the matched QuickBooks customer. */
|
|
customerId: string;
|
|
/** Display name of the matched QuickBooks customer. */
|
|
customerName: string;
|
|
/** PB id of the operator who clicked the button (for the banner). */
|
|
sentById?: string;
|
|
/** Operator display name (PB users.name or email). */
|
|
sentByName?: string;
|
|
/** Optional operator override for the order_number on the invoice. */
|
|
orderNumberOverride?: string;
|
|
/** Optional per-line qty/price overrides keyed by order_item.id. */
|
|
lineOverrides?: InvoiceLineOverride[];
|
|
}
|
|
|
|
interface QbWebhookResponse {
|
|
invoiceNumber?: string;
|
|
qb_invoice_id?: string;
|
|
id?: string;
|
|
invoice_number?: string;
|
|
}
|
|
|
|
/**
|
|
* POST /api/v1/delivery-notes/[id]/invoice
|
|
*
|
|
* One-shot "create the QuickBooks invoice AND auto-close the
|
|
* delivery note" endpoint. Driven by the "Invoice via QuickBooks"
|
|
* button on the delivery note detail page.
|
|
*
|
|
* Flow:
|
|
* 1. Read the delivery note (with expand.order).
|
|
* 2. Validate the gate: status === "po_received" AND order is set.
|
|
* Ad-hoc DNs (no order) cannot use this endpoint — the QB
|
|
* webhook needs an order_number.
|
|
* 3. Read the delivery note's order_items (joined by
|
|
* `delivery_note_number`).
|
|
* 4. Shape them into `InvoicingRow[]` and call `buildQbInvoicePayload`
|
|
* — the same pure builder the Invoicing page uses, so the
|
|
* payload is byte-identical to what the Invoicing page would
|
|
* have sent.
|
|
* 5. POST the payload to the configured QB webhook
|
|
* (`NEXT_PUBLIC_QB_INVOICE_WEBHOOK_URL`). Server-side fetch —
|
|
* no CORS, no client secret exposure, no `window.confirm`
|
|
* gymnastics.
|
|
* 6. On webhook 2xx, parse the response for an invoice number
|
|
* (accepts `invoiceNumber`, `qb_invoice_id`, `invoice_number`,
|
|
* or top-level `id`). Fall back to `QB-${orderNumber}-${ts}` so
|
|
* the audit banner is never blank.
|
|
* 7. Call `markDeliveryNoteCompletedByInvoice(id, { invoiceNumber,
|
|
* sentByName })` — flips status `invoiced → completed` and
|
|
* stamps a "QuickBooks invoice <num>" banner on the DN notes.
|
|
*
|
|
* Body:
|
|
* { qbCompany, customerId, customerName, sentById?, sentByName?,
|
|
* orderNumberOverride?, lineOverrides? }
|
|
*
|
|
* Returns: the updated `DeliveryNote` (now `status: "completed"`).
|
|
*
|
|
* Errors:
|
|
* - 400 if the body is malformed or required fields are missing.
|
|
* - 409 if the DN is not in "po_received" status (idempotency:
|
|
* clicking twice on an already-invoiced DN does nothing).
|
|
* - 422 if the DN has no parent order (ad-hoc DNs).
|
|
* - 502 if the QuickBooks webhook fails (non-2xx) or is not
|
|
* configured — the DN is NOT auto-closed in this case.
|
|
* - 500 on any other failure.
|
|
*/
|
|
export async function POST(
|
|
req: NextRequest,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
try {
|
|
const { id } = await params;
|
|
|
|
const body = (await req.json().catch(() => null)) as InvoiceRouteBody | null;
|
|
if (!body) {
|
|
return NextResponse.json(
|
|
{ error: "Request body must be JSON." },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
if (!body.qbCompany || (body.qbCompany !== "CNL" && body.qbCompany !== "ISHERWOOD")) {
|
|
return NextResponse.json(
|
|
{ error: "qbCompany must be 'CNL' or 'ISHERWOOD'." },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
if (!body.customerId || !body.customerName) {
|
|
return NextResponse.json(
|
|
{ error: "customerId and customerName are required." },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const pb = await getAdminPocketBase();
|
|
const dn = await pb
|
|
.collection("delivery_notes")
|
|
.getOne(id, { expand: "order" });
|
|
// Operator signal: invoice + close from ANY non-terminal stage
|
|
// (draft / sent / po_received / invoiced). The only statuses we
|
|
// refuse are terminal ones — `completed` (already done) and
|
|
// `cancelled` (don't resurrect a cancelled DN).
|
|
if (dn.status === "completed" || dn.status === "cancelled") {
|
|
return NextResponse.json(
|
|
{
|
|
error: `Delivery note in "${dn.status}" status cannot be invoiced via QuickBooks.`,
|
|
},
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
// Ad-hoc delivery notes (no parent order) still can't be invoiced
|
|
// — the QB webhook requires an order_number to attach the
|
|
// invoice to. This is the one structural gate that stays.
|
|
if (!dn.expand?.order?.id || !dn.expand.order.order_number) {
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
"Ad-hoc delivery notes (no parent order) cannot be invoiced through this endpoint.",
|
|
},
|
|
{ status: 422 },
|
|
);
|
|
}
|
|
|
|
// Read the order_items grouped under this delivery note by the
|
|
// canonical `delivery_note_number` join.
|
|
const items = await pb.collection("order_items").getFullList({
|
|
filter: `delivery_note_number = "${dn.delivery_note_number.replace(/"/g, '\"')}"`,
|
|
});
|
|
if (items.length === 0) {
|
|
return NextResponse.json(
|
|
{ error: "Delivery note has no order items." },
|
|
{ status: 422 },
|
|
);
|
|
}
|
|
|
|
// Shape raw order_items into the InvoicingRow the builder expects.
|
|
// `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,
|
|
}));
|
|
|
|
const payload = buildQbInvoicePayload({
|
|
orderNumber: dn.expand.order.order_number,
|
|
billingCompany: body.qbCompany,
|
|
invoiceCompany: body.customerName,
|
|
customer: { id: body.customerId, name: body.customerName },
|
|
rows,
|
|
orderNumberOverride: body.orderNumberOverride,
|
|
lineOverrides: body.lineOverrides,
|
|
sentById: body.sentById ?? "",
|
|
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(
|
|
{ error: "QuickBooks webhook URL is not configured on the server." },
|
|
{ status: 502 },
|
|
);
|
|
}
|
|
|
|
const webhookRes = await fetch(webhook, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!webhookRes.ok) {
|
|
const upstream = await webhookRes.text().catch(() => "");
|
|
return NextResponse.json(
|
|
{
|
|
error: `QuickBooks webhook returned ${webhookRes.status}.`,
|
|
upstream: upstream.slice(0, 500),
|
|
},
|
|
{ status: 502 },
|
|
);
|
|
}
|
|
|
|
// Pull the invoice number out of the webhook response so the
|
|
// banner on the DN notes can cite it. n8n's contract varies —
|
|
// accept several common field names, fall back to a synthetic
|
|
// marker so the banner is never blank.
|
|
let webhookBody: QbWebhookResponse = {};
|
|
try {
|
|
webhookBody = (await webhookRes.json()) as QbWebhookResponse;
|
|
} catch {
|
|
// Webhook returned 2xx with non-JSON body — still treat as
|
|
// success; we just can't cite a specific invoice number.
|
|
}
|
|
const invoiceNumber =
|
|
webhookBody.invoiceNumber ??
|
|
webhookBody.invoice_number ??
|
|
webhookBody.qb_invoice_id ??
|
|
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,
|
|
};
|
|
const updated = await markDeliveryNoteCompletedByInvoice(id, closeInput);
|
|
return NextResponse.json(updated);
|
|
} catch (err: unknown) {
|
|
const message =
|
|
err instanceof Error ? err.message : "Failed to invoice delivery note via QuickBooks";
|
|
return NextResponse.json({ error: message }, { status: 500 });
|
|
}
|
|
}
|