- 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>
81 lines
3.4 KiB
TypeScript
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 });
|
|
}
|
|
}
|