Renames the existing /income/invoices page to "Internal Invoices" and adds a sibling /income/external-invoices page, splitting ONE `invoices` collection by the debtor printed on the invoice: a Mega group company is internal, anything else is external. Never two collections. An invoice must not be able to exist on neither page, and the halves must stay reconcilable against /income. Both routes render through the single components/InvoiceRegisterPage.tsx so they cannot drift apart. lib/invoice-party-policy.json holds the group entity list and lib/invoice-party.ts does the matching. Matching is on debtor name OR debtor_no, never name alone: all three external invoices (PHI 178/186/192) carry an empty debtor name and only a third-party number, so a name-only match would misfile every one of them. An invoice naming neither is never internal - it lands on External and is reported as unclassified rather than silently filed. The split is 16 internal / 3 external across the 19 invoices in the register. Also fixes three things the split would otherwise break: - Booking a line, importing a PDF and retrying an intake can all land on either side depending on the debtor, so the five write paths only revalidated /income/invoices and would have left the other page stale. They now call revalidateInvoicePages() from lib/invoice-revalidate.ts. Route paths live in lib/invoice-routes.ts, which imports no next/* so a client component may import it; the revalidate helper imports next/cache and is server-only. - A site's "Go and assign them" link hard-coded the internal path, so a site whose outstanding lines sit on external invoices would dead-end. SiteSummary now carries the outstanding lines split by party. - verify-auth-routes.mjs now covers the new route. The two routes are siblings by design: isGroupActive matches a child by path prefix, so a nested /income/invoices/external would highlight both leaves at once. Verified: typecheck clean, 41 tests pass (7 new), production build green, anonymous access to both routes 307s to /login, and the mobile checklist passes at 375x812 and 768x1024 - no horizontal page scroll, no tap target under 44px, no text under 12px. The nested line-item table still scrolls inside its own container without stretching the outer table. Note: lib/rows.ts, lib/server-data.ts and the running/insurance feature are someone else's in-flight work and were deliberately left uncommitted here.
268 lines
No EOL
10 KiB
TypeScript
268 lines
No EOL
10 KiB
TypeScript
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<string, unknown> = { 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<string, string>();
|
|
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,
|
|
});
|
|
} |