From 642d23dd24ba83e467df077644165dbf50067d68 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 29 Oct 2026 20:49:05 +0200 Subject: [PATCH] Suppliers: replace the menu entry with the new page, flag over-invoiced POs, audited invoice amount corrections - Menu: old Suppliers leaf hidden (route kept), Suppliers 2 renamed Suppliers - PO invoice status: new 'over' state (never closed), live over-PO warning when typing - Admin correction of invoice amount/date/number with mandatory reason, logged to purchase_invoice_edits - Payable now carries unpaid balance forward month to month Co-Authored-By: Claude Sonnet 5.5 --- app/(dashboard)/expenses/suppliers-2/page.tsx | 2 +- app/(dashboard)/layout.tsx | 5 +- .../[id]/invoices/[invoiceId]/route.ts | 68 ++++++++++ app/api/purchases/[id]/invoices/route.ts | 7 +- components/PurchaseIntake.tsx | 99 +++++++++++++-- components/Suppliers2View.tsx | 21 ++-- lib/purchase-invoice-status.ts | 7 +- lib/supplier-spend.ts | 12 +- scripts/setup-invoice-edits-collection.mjs | 118 ++++++++++++++++++ 9 files changed, 315 insertions(+), 24 deletions(-) create mode 100644 scripts/setup-invoice-edits-collection.mjs diff --git a/app/(dashboard)/expenses/suppliers-2/page.tsx b/app/(dashboard)/expenses/suppliers-2/page.tsx index 907efc1..71e8ac6 100644 --- a/app/(dashboard)/expenses/suppliers-2/page.tsx +++ b/app/(dashboard)/expenses/suppliers-2/page.tsx @@ -52,7 +52,7 @@ export default async function Suppliers2Page() {
-

Suppliers 2

+

Suppliers

Spend and payables per supplier

diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index da0f328..1da49b9 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -78,8 +78,9 @@ const navItems: NavItem[] = [ { href: "/expenses", label: "Overview" }, // Vehicle Costs and Finance (Bastion) are hidden: Running Costs (Insurance, Financing) already // covers them. The pages still exist at /expenses/vehicles and /expenses/finance. - { href: "/expenses/suppliers", label: "Suppliers" }, - { href: "/expenses/suppliers-2", label: "Suppliers 2" }, + // The original list at /expenses/suppliers still exists but is hidden from the menu; + // this page replaced it. + { href: "/expenses/suppliers-2", label: "Suppliers" }, { href: "/expenses/personnel", label: "Personnel" }, { href: "/expenses/subscriptions", label: "Subscriptions" }, { href: "/expenses/rent", label: "Rent & Utilities" }, diff --git a/app/api/purchases/[id]/invoices/[invoiceId]/route.ts b/app/api/purchases/[id]/invoices/[invoiceId]/route.ts index 92c4203..3a87af2 100644 --- a/app/api/purchases/[id]/invoices/[invoiceId]/route.ts +++ b/app/api/purchases/[id]/invoices/[invoiceId]/route.ts @@ -1,5 +1,7 @@ import { revalidatePath } from "next/cache"; import { serverAuth } from "@/lib/pb"; +import { getSession } from "@/lib/auth"; +import { refuseUnlessFull } from "@/lib/require-full"; export const dynamic = "force-dynamic"; @@ -17,8 +19,74 @@ export async function DELETE( } await pb.collection("purchase_invoices").delete(invoiceId); revalidatePath("/running/purchases"); + revalidatePath("/expenses/suppliers-2"); return Response.json({ ok: true }); } catch (e: any) { return Response.json({ ok: false, error: e?.message || "Could not delete the invoice" }, { status: 500 }); } } + +// PATCH /api/purchases/:id/invoices/:invoiceId { amount_incl?, invoice_date?, invoice_no?, reason } +// Admin correction of a typed-in invoice. A REASON is mandatory, and every changed field is +// written to `purchase_invoice_edits` (old value, new value, reason, who) — append-only. +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string; invoiceId: string }> }, +) { + const refused = await refuseUnlessFull(); + if (refused) return refused; + const { id, invoiceId } = await params; + let body: { amount_incl?: unknown; invoice_date?: unknown; invoice_no?: unknown; reason?: unknown } = {}; + try { body = await req.json(); } catch { return Response.json({ ok: false, error: "Invalid JSON body" }, { status: 400 }); } + + const reason = String(body.reason ?? "").trim(); + if (reason.length < 3) return Response.json({ ok: false, error: "Give a reason for the change." }, { status: 400 }); + + try { + const pb = await serverAuth(); + const inv = await pb.collection("purchase_invoices").getOne(invoiceId).catch(() => null); + if (!inv || inv.purchase !== id) return Response.json({ ok: false, error: "Invoice not found on that purchase order" }, { status: 404 }); + const po = await pb.collection("purchases").getOne(id); + + const patch: Record = {}; + const changes: { field: string; old: string; next: string }[] = []; + if ("amount_incl" in body) { + const raw = String(body.amount_incl ?? "").trim().replace(/\s/g, "").replace(",", "."); + const n = Number(raw); + if (!raw || !Number.isFinite(n) || n < 0) return Response.json({ ok: false, error: "The amount must be a positive number." }, { status: 400 }); + if (n !== (inv.amount_incl ?? 0)) { patch.amount_incl = n; changes.push({ field: "amount_incl", old: String(inv.amount_incl ?? ""), next: String(n) }); } + } + if ("invoice_date" in body) { + const d = String(body.invoice_date ?? "").trim().slice(0, 10); + if (d && !/^\d{4}-\d{2}-\d{2}$/.test(d)) return Response.json({ ok: false, error: "Enter a valid date." }, { status: 400 }); + if (d !== (inv.invoice_date || "")) { patch.invoice_date = d; changes.push({ field: "invoice_date", old: inv.invoice_date || "", next: d }); } + } + if ("invoice_no" in body) { + const no = String(body.invoice_no ?? "").trim(); + if (!no) return Response.json({ ok: false, error: "The invoice number cannot be empty." }, { status: 400 }); + if (no !== inv.invoice_no) { + const clash = (await pb.collection("purchase_invoices").getFullList({ filter: `purchase = "${id}"` })) + .find((i) => i.id !== invoiceId && (i.invoice_no || "").toUpperCase() === no.toUpperCase()); + if (clash) return Response.json({ ok: false, error: `${no} is already on ${po.po_no}` }, { status: 409 }); + patch.invoice_no = no; changes.push({ field: "invoice_no", old: inv.invoice_no || "", next: no }); + } + } + if (!changes.length) return Response.json({ ok: false, error: "Nothing was changed." }, { status: 400 }); + + const session = await getSession(); + const who = session ? `${session.user.name || session.user.email} <${session.user.email}>` : "unknown"; + // Log FIRST: if the log cannot be written the edit must not happen unrecorded. + for (const c of changes) { + await pb.collection("purchase_invoice_edits").create({ + invoice: invoiceId, po_no: po.po_no, invoice_no: inv.invoice_no, field: c.field, + old_value: c.old, new_value: c.next, reason: reason.slice(0, 500), edited_by: who, + }); + } + await pb.collection("purchase_invoices").update(invoiceId, patch); + revalidatePath("/running/purchases"); + revalidatePath("/expenses/suppliers-2"); + return Response.json({ ok: true }); + } catch (e: any) { + return Response.json({ ok: false, error: e?.message || "Could not save the change" }, { status: 500 }); + } +} diff --git a/app/api/purchases/[id]/invoices/route.ts b/app/api/purchases/[id]/invoices/route.ts index fc32d3f..5b392f6 100644 --- a/app/api/purchases/[id]/invoices/route.ts +++ b/app/api/purchases/[id]/invoices/route.ts @@ -74,7 +74,12 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str } revalidatePath("/running/purchases"); - return Response.json({ ok: true, warning, invoice: { id: row.id, invoice_no: invoiceNo } }); + revalidatePath("/expenses/suppliers-2"); + // Not blocked (an order can legitimately be amended), but never silent: an invoice above + // the PO total is how a missed decimal point (R2 210,83 → R221 083) looks. + const invoicedBefore = existing.reduce((t, i) => t + (i.amount_incl || 0), 0); + const over = amount != null ? invoicedBefore + amount - (purchase.total || 0) : 0; + return Response.json({ ok: true, warning, over: over > 1 ? over : 0, invoice: { id: row.id, invoice_no: invoiceNo } }); } catch (e: any) { return Response.json({ ok: false, error: e?.message || "Could not save the invoice" }, { status: 500 }); } diff --git a/components/PurchaseIntake.tsx b/components/PurchaseIntake.tsx index 4518736..6b143c2 100644 --- a/components/PurchaseIntake.tsx +++ b/components/PurchaseIntake.tsx @@ -4,7 +4,7 @@ import { useState, useRef, useTransition, useMemo } from "react"; import { useRouter } from "next/navigation"; import { Upload, FileText, ChevronDown, ChevronRight, Loader2, TriangleAlert, - Check, ShoppingCart, X, Plus, Trash2, Search, Receipt, ChevronLeft, + Check, ShoppingCart, X, Plus, Trash2, Search, Receipt, ChevronLeft, Pencil, } from "lucide-react"; import { ZAR, ZAR2 } from "@/lib/utils"; import { invoiceStatusOf, STATUS_STYLE } from "@/lib/purchase-invoice-status"; @@ -307,7 +307,7 @@ export function PurchaseIntake({ purchases: allPurchases }: { purchases: Purchas {p.po_date || "no date"} · {p.lines.length} line{p.lines.length !== 1 && "s"} ·{" "} {p.lines.filter((l) => l.assetCode).length} allocated ·{" "} - {inv.status === "complete" ? "fully invoiced — can be closed" : inv.status === "partial" ? `${ZAR2(inv.invoiced)} of ${ZAR2(p.total)} invoiced` : "no invoice yet"} + {inv.status === "over" ? `invoiced ${ZAR2(inv.invoiced - p.total)} OVER the PO — check the amount` : inv.status === "complete" ? "fully invoiced — can be closed" : inv.status === "partial" ? `${ZAR2(inv.invoiced)} of ${ZAR2(p.total)} invoiced` : "no invoice yet"} @@ -414,6 +414,10 @@ function SupplierInvoices({ purchase: p }: { purchase: PurchaseView }) { const [adding, setAdding] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [amt, setAmt] = useState(""); + const [editId, setEditId] = useState(null); + const [editAmt, setEditAmt] = useState(""); + const [editReason, setEditReason] = useState(""); const invoiced = p.invoices.reduce((s, i) => s + (i.amount_incl ?? 0), 0); const unpriced = p.invoices.filter((i) => i.amount_incl == null).length; @@ -431,8 +435,10 @@ function SupplierInvoices({ purchase: p }: { purchase: PurchaseView }) { setError(body.error); return; } - if (body.warning) setError(body.warning); + if (body.over > 0) setError(`Saved — but this takes the invoices ${ZAR2(body.over)} OVER the PO total. Check the amount.`); + else if (body.warning) setError(body.warning); form.reset(); + setAmt(""); setAdding(false); router.refresh(); } catch { @@ -442,6 +448,33 @@ function SupplierInvoices({ purchase: p }: { purchase: PurchaseView }) { } } + function startEdit(i: PurchaseInvoiceView) { + setEditId(i.id); + setEditAmt(i.amount_incl == null ? "" : String(i.amount_incl)); + setEditReason(""); + setError(null); + } + + async function saveEdit(i: PurchaseInvoiceView) { + setBusy(true); + setError(null); + try { + const res = await fetch(`/api/purchases/${p.id}/invoices/${i.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ amount_incl: editAmt, reason: editReason }), + }); + const body = await res.json(); + if (!body.ok) { setError(body.error); return; } + setEditId(null); + router.refresh(); + } catch { + setError("Could not save — check your connection"); + } finally { + setBusy(false); + } + } + async function remove(i: PurchaseInvoiceView) { if (!confirm(`Remove invoice ${i.invoice_no} from ${p.po_no}?`)) return; const res = await fetch(`/api/purchases/${p.id}/invoices/${i.id}`, { method: "DELETE" }); @@ -457,7 +490,7 @@ function SupplierInvoices({ purchase: p }: { purchase: PurchaseView }) { Supplier invoices {p.invoices.length > 0 && ( - {ZAR2(invoiced)} invoiced · {ZAR2(outstanding)} of PO {outstanding < 0 ? "over" : "not yet invoiced"} + {ZAR2(invoiced)} invoiced · {ZAR2(Math.abs(outstanding))} of PO {outstanding < 0 ? "over" : "not yet invoiced"} {unpriced > 0 && ` · ${unpriced} with no amount`} )} @@ -479,7 +512,28 @@ function SupplierInvoices({ purchase: p }: { purchase: PurchaseView }) { {p.invoices.length > 0 && (