mega-plant/app/api/purchases/[id]/invoices/route.ts
David 5f86657849 Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 20:29:08 +02:00

81 lines
3.4 KiB
TypeScript

import { revalidatePath } from "next/cache";
import { serverAuth } from "@/lib/pb";
export const dynamic = "force-dynamic";
// POST /api/purchases/:id/invoices
// multipart/form-data: invoice_no (required), invoice_date, amount_incl, note, file (PDF, optional)
//
// Records a supplier invoice against a purchase order. A PO can carry several. These are
// typed in, not parsed, and they never touch the PO's own figures — the PO says what was
// ordered, the invoices say what the supplier has billed so far.
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
let form: FormData;
try {
form = await req.formData();
} catch {
return Response.json({ ok: false, error: "Expected a multipart upload" }, { status: 400 });
}
const invoiceNo = String(form.get("invoice_no") || "").trim();
if (!invoiceNo) return Response.json({ ok: false, error: "Enter the invoice number" }, { status: 400 });
const amountRaw = String(form.get("amount_incl") || "").trim().replace(/\s/g, "").replace(",", ".");
let amount: number | null = null;
if (amountRaw) {
amount = Number(amountRaw);
if (!Number.isFinite(amount) || amount < 0) {
return Response.json({ ok: false, error: "The amount must be a positive number" }, { status: 400 });
}
}
const file = form.get("file");
const hasFile = file instanceof File && file.size > 0;
if (hasFile) {
if (!file.name.toLowerCase().endsWith(".pdf")) {
return Response.json({ ok: false, error: "Invoices must be PDF files" }, { status: 400 });
}
if (file.size > 20 * 1024 * 1024) {
return Response.json({ ok: false, error: "That PDF is larger than the 20 MB limit" }, { status: 400 });
}
}
try {
const pb = await serverAuth();
const purchase = await pb.collection("purchases").getOne(id).catch(() => null);
if (!purchase) return Response.json({ ok: false, error: "Purchase order not found" }, { status: 404 });
// The same supplier invoice booked twice would double the "invoiced" total.
const existing = await pb.collection("purchase_invoices").getFullList({ filter: `purchase = "${id}"` });
if (existing.some((i) => (i.invoice_no || "").toUpperCase() === invoiceNo.toUpperCase())) {
return Response.json({ ok: false, error: `${invoiceNo} is already on ${purchase.po_no}` }, { status: 409 });
}
const row = await pb.collection("purchase_invoices").create({
purchase: id,
invoice_no: invoiceNo,
invoice_date: String(form.get("invoice_date") || "").trim(),
...(amount != null ? { amount_incl: amount } : {}),
note: String(form.get("note") || "").trim(),
source_file: hasFile ? file.name : "",
});
// PDF last, so a storage failure cannot lose the row (same rule as the PO intake).
let warning: string | undefined;
if (hasFile) {
try {
await pb.collection("purchase_invoices").update(row.id, { source_pdf: file });
} catch (e) {
console.error(`[purchase-invoices] ${invoiceNo}: PDF not stored — ${(e as Error).message}`);
warning = "The invoice was saved but its PDF could not be attached.";
}
}
revalidatePath("/running/purchases");
return Response.json({ ok: true, warning, 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 });
}
}