- 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 <noreply@anthropic.com>
92 lines
4.9 KiB
TypeScript
92 lines
4.9 KiB
TypeScript
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";
|
|
|
|
// DELETE /api/purchases/:id/invoices/:invoiceId — remove a supplier invoice from a PO.
|
|
export async function DELETE(
|
|
_req: Request,
|
|
{ params }: { params: Promise<{ id: string; invoiceId: string }> },
|
|
) {
|
|
const { id, invoiceId } = await params;
|
|
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 });
|
|
}
|
|
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<string, unknown> = {};
|
|
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 });
|
|
}
|
|
}
|