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.
80 lines
3.4 KiB
TypeScript
80 lines
3.4 KiB
TypeScript
import { serverAuth } from "@/lib/pb";
|
|
import { revalidateInvoicePages } from "@/lib/invoice-revalidate";
|
|
import { canReplaceRetrySource, intakeRetryHeaders } from "@/lib/intake-retry";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
// POST /api/invoices/intake/:id/retry
|
|
// Re-attempts the import for a problem invoice.
|
|
//
|
|
// Retry is only meaningful once the REASON is addressed, so this never silently
|
|
// re-parses a broken PDF and calls it fixed: it re-reads the stored document and, if
|
|
// the parse still fails, updates the recorded reason instead of pretending it worked.
|
|
// Once it succeeds the inbox row is marked imported and linked to the new invoice.
|
|
export async function POST(
|
|
_req: Request,
|
|
{ params }: { params: Promise<{ id: string }> },
|
|
) {
|
|
const { id } = await params;
|
|
const pb = await serverAuth();
|
|
|
|
try {
|
|
const record = await pb.collection("invoice_intake").getOne(id);
|
|
if (!record.source_pdf) {
|
|
return Response.json({ ok: false, error: "The original document is no longer stored" }, { status: 400 });
|
|
}
|
|
|
|
// Pull the stored bytes back down. A bare /api/files/<name> 404s for a
|
|
// rule-locked collection even with an admin token — protected files resolve by
|
|
// the full /collectionId/recordId/filename path, which the SDK builds for us.
|
|
const token = record.source_pdf as string;
|
|
const url = pb.files.getURL(record, token);
|
|
const res = await fetch(url, {
|
|
headers: pb.authStore.token ? { Authorization: pb.authStore.token } : undefined,
|
|
});
|
|
if (!res.ok) {
|
|
const reason = `Could not read the stored document back from storage (HTTP ${res.status})`;
|
|
await pb.collection("invoice_intake").update(id, { status: "failed", error: reason });
|
|
return Response.json({ ok: false, error: reason }, { status: 502 });
|
|
}
|
|
|
|
const file = new File([await res.arrayBuffer()], record.file_name || token, {
|
|
type: "application/pdf",
|
|
});
|
|
const fd = new FormData();
|
|
fd.append("file", file);
|
|
|
|
// Re-run the full intake against the same bytes.
|
|
const intake = await fetch(new URL("/api/invoices/intake", _req.url), {
|
|
method: "POST",
|
|
headers: intakeRetryHeaders(_req),
|
|
body: fd,
|
|
redirect: "error",
|
|
});
|
|
const result = await intake.json().catch(() => ({ ok: false, error: "Unexpected response" }));
|
|
|
|
// Only discard this document once an import or a replacement inbox record
|
|
// exists. Auth/outage/duplicate failures must keep the original available.
|
|
if (!canReplaceRetrySource(intake.status, result)) {
|
|
return Response.json({ ok: false, error: result.error || "Could not retry this invoice. The original document has been kept." },
|
|
{ status: intake.ok ? 502 : intake.status });
|
|
}
|
|
|
|
if (result.ok) {
|
|
await pb.collection("invoice_intake").delete(id);
|
|
revalidateInvoicePages("/income");
|
|
return Response.json({ ok: true, retried: true, ...result });
|
|
}
|
|
|
|
// Still failing — keep it in the inbox with the newest reason, and drop the
|
|
// duplicate record the retry just created so the inbox stays one row per file.
|
|
await pb.collection("invoice_intake").delete(id).catch(() => {});
|
|
revalidateInvoicePages();
|
|
return Response.json(
|
|
{ ok: false, retried: true, stillFailing: true, error: result.error ?? "Import failed again" },
|
|
{ status: 422 },
|
|
);
|
|
} catch (e: any) {
|
|
return Response.json({ ok: false, error: e?.message || "Retry failed" }, { status: 500 });
|
|
}
|
|
}
|