import { serverAuth } from "@/lib/pb"; import { revalidateInvoicePages } from "@/lib/invoice-revalidate"; import { parseInvoicePdf, ParseError, registerCodeFrom } from "@/lib/invoice-parser"; import { syncSitesFromInvoices, findSiteContainer, resolveSiteName } from "@/lib/sites"; export const dynamic = "force-dynamic"; const MAX_BYTES = 20 * 1024 * 1024; // GET /api/invoices/intake // The problem-invoice inbox: every PDF offered to the app and what became of it. // `?status=failed` narrows it to the ones that need attention. export async function GET(req: Request) { const url = new URL(req.url); const status = url.searchParams.get("status"); try { const pb = await serverAuth(); // PocketBase rejects an empty filter string, so only send one when filtering. const opts: Record = { sort: "-created" }; if (status) opts.filter = `status = "${status}"`; const records = await pb.collection("invoice_intake").getFullList(opts); return Response.json({ ok: true, items: records.map((r: any) => ({ id: r.id, file_name: r.file_name, file_size: r.file_size, invoice_no: r.invoice_no, status: r.status, error: r.error, parsed: r.parsed, invoice: r.invoice, created: r.created, })), }); } catch (e: any) { return Response.json({ ok: false, error: e?.message || "Could not read the inbox" }, { status: 500 }); } } // POST /api/invoices/intake // Multipart: `file` (a PDF). The PDF is parsed deterministically, checked arithmetically, // and only then written. A PDF that does not reconcile is stored in the inbox as a // problem invoice with the reason attached — it is never partially imported. export async function POST(req: Request) { let form: FormData; try { form = await req.formData(); } catch { return Response.json({ ok: false, error: "Expected multipart/form-data" }, { status: 400 }); } const file = form.get("file"); if (!(file instanceof File) || file.size === 0) { return Response.json({ ok: false, error: "No file received" }, { status: 400 }); } if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) { return Response.json({ ok: false, error: "Only PDF invoices are accepted" }, { status: 400 }); } if (file.size > MAX_BYTES) { return Response.json( { ok: false, error: `File is ${(file.size / 1048576).toFixed(1)} MB — limit is 20 MB` }, { status: 400 }, ); } const bytes = new Uint8Array(await file.arrayBuffer()); const pb = await serverAuth(); // Always record the attempt, so a bad PDF is never lost. const recordFailure = async (reason: string, parsed?: unknown) => { const form2 = new FormData(); form2.append("file_name", file.name); form2.append("file_size", String(file.size)); form2.append("status", "failed"); form2.append("error", reason); if (parsed) form2.append("parsed", JSON.stringify(parsed)); form2.append( "source_pdf", new File([bytes], file.name.replace(/[^\w.\- ]+/g, "_"), { type: "application/pdf" }), ); const rec = await pb.collection("invoice_intake").create(form2); revalidateInvoicePages(); return Response.json({ ok: false, status: "failed", intakeId: rec.id, error: reason }, { status: 422 }); }; let parsed; try { parsed = await parseInvoicePdf(bytes); } catch (e: any) { if (e instanceof ParseError) return recordFailure(e.reason); return recordFailure(`Could not read the PDF: ${e?.message ?? "unknown error"}`); } // Match each line to a plant item by the code the invoice prints. No match means the // line is left unassigned for manual booking — never guessed. const assets = await pb.collection("assets").getFullList(); const byCode = new Map(); for (const a of assets) if (a.code) byCode.set(a.code, a.id); // A duplicate is NOT a problem invoice. Nothing failed, and re-offering the same file // can never succeed without first deleting the original — so it must not land in the // retry inbox, where it would sit looking like something to fix. It is reported with // its own status so the UI can say "you already have this" instead of "this failed". const allInvoices = (await pb.collection("invoices").getFullList()) as any[]; const existing = allInvoices.find((i: any) => i.invoice_no === parsed.invoice_no); if (existing) { return Response.json( { ok: false, status: "duplicate", invoice_no: parsed.invoice_no, error: `Invoice ${parsed.invoice_no} is already in the register — nothing was added.`, existing: { invoice_no: existing.invoice_no, site: existing.site, invoice_date: existing.invoice_date, total_incl: existing.total_incl, lines: (await pb.collection("invoice_lines").getFullList({ filter: `invoice = "${existing.id}"` })).length, invoiceId: existing.id, }, }, { status: 409 }, ); } // The supplier abbreviates the site on the invoice ("…Sep 2026 Cosmo" for the site the // register calls "Cosmo City"). Resolve it BEFORE writing, because sites are derived // from the invoice wording — saving the abbreviation would create a second "Cosmo" and // split this customer across two sites on /sites. parsed.site = resolveSiteName( parsed.site, [...new Set(allInvoices.map((i: any) => i.site).filter((s: unknown): s is string => typeof s === "string" && !!s.trim()))], ); const periodEnd = new Date(parsed.period_to); const bookedMonth = periodEnd.getUTCMonth() + 1; const bookedYear = periodEnd.getUTCFullYear(); let invoiceRec; try { invoiceRec = await pb.collection("invoices").create({ invoice_no: parsed.invoice_no, invoice_date: parsed.invoice_date, order_no: parsed.order_no, debtor: parsed.debtor, debtor_no: parsed.debtor_no, period_from: parsed.period_from, period_to: parsed.period_to, description: parsed.description, site: parsed.site, section: parsed.section, category: parsed.category, currency: parsed.currency, vat_reg_no: parsed.vat_reg_no, source_file: file.name, subtotal: parsed.subtotal, escalation: parsed.escalation, vat: parsed.vat, vat_pct: parsed.vat_pct, total_excl: parsed.total_excl, total_incl: parsed.total_incl, booked_month: bookedMonth, booked_year: bookedYear, is_booked: false, }); } catch (e: any) { return recordFailure( `Parsed correctly but could not save the invoice: ${e?.response?.data?.message || e?.message}`, parsed, ); } // The invoice is written, so this site is now real. Sites are derived, never typed. const sites = await syncSitesFromInvoices(pb, [...allInvoices, invoiceRec as any]); // A container line carries a generic code the plant register does not have. If the // site this invoice belongs to has a container hired under that code, the line is // booked to it automatically rather than waiting for a human to notice. const siteContainerFor = (machineCode: string) => { const site = findSiteContainer(sites, parsed.site, machineCode); return site?.container_asset || ""; }; const unassigned: string[] = []; for (let i = 0; i < parsed.lines.length; i++) { const l = parsed.lines[i]; const registerCode = registerCodeFrom(l.machine_code); // Plant register first, then this site's own container, then nothing. const assetId = byCode.get(registerCode) || siteContainerFor(l.machine_code) || ""; if (!assetId) unassigned.push(l.machine_code); try { await pb.collection("invoice_lines").create({ invoice: invoiceRec.id, line_no: i + 1, machine_code: l.machine_code, asset_code: registerCode, description: l.description, qty: l.qty, unit: l.unit, rate: l.rate, rate_type: l.rate_type, amount_excl: l.amount_excl, vat: l.vat, total_incl: l.total_incl, booked_asset: assetId, booked_month: bookedMonth, booked_year: bookedYear, }); } catch (e: any) { // A half-written invoice is worse than none — roll it back and report. const written = await pb .collection("invoice_lines") .getFullList({ filter: `invoice = "${invoiceRec.id}"` }); for (const w of written) await pb.collection("invoice_lines").delete(w.id); await pb.collection("invoices").delete(invoiceRec.id); return recordFailure( `Parsed correctly but a line could not be saved, so the import was rolled back: ${e?.response?.data?.message || e?.message}`, parsed, ); } } // Book whatever could be matched. Anything unassigned stays visibly unbooked. const lines = await pb.collection("invoice_lines").getFullList({ filter: `invoice = "${invoiceRec.id}"` }); for (const line of lines) { if (!line.booked_asset) continue; await pb.collection("monthly_income").create({ asset: line.booked_asset, year: bookedYear, month: bookedMonth, income: line.total_incl, fixed_expenses: 0, is_forecast: false, invoice_line: line.id, }); } const allBooked = lines.every((l: any) => l.booked_asset); await pb.collection("invoices").update(invoiceRec.id, { is_booked: allBooked }); const form2 = new FormData(); form2.append("file_name", file.name); form2.append("file_size", String(file.size)); form2.append("invoice_no", parsed.invoice_no); form2.append("status", "imported"); form2.append("invoice", invoiceRec.id); form2.append("parsed", JSON.stringify(parsed)); form2.append( "source_pdf", new File([bytes], file.name.replace(/[^\w.\- ]+/g, "_"), { type: "application/pdf" }), ); await pb.collection("invoice_intake").create(form2); revalidateInvoicePages("/income"); return Response.json({ ok: true, status: "imported", invoice_no: parsed.invoice_no, invoiceId: invoiceRec.id, site: parsed.site, total_incl: parsed.total_incl, lines: lines.length, booked: lines.filter((l: any) => l.booked_asset).length, unassigned, fullyBooked: allBooked, }); }