The previous rule ^[A-Za-z0-9_-]{1,50}$ rejected common bank/POP
references that include punctuation [e.g. 'Bank ref 26/09/18',
'INV/2026.09:18 #3', 'POP 12345']. New rule permits letters, digits,
space, underscore, hyphen, slash, dot, comma, colon, parens,
ampersand, hash, apostrophe, plus; length 1-100. Still rejects
characters that break filesystems/URLs [e.g. < > " ? * | \ ;].
Files updated:
- app/api/payments/create/route.ts [inline regex + new error msg]
- app/api/payments/[id]/edit/route.ts [BATCH_REF_PATTERN + new error msg]
- app/unpaid/UnpaidClient.tsx [HTML pattern attribute]
- app/remittance/RemittanceClient.tsx [HTML pattern attribute]
- docs/superpowers/plans/2026-09-18-jei-payments-and-remittance.md [plan doc]
160 lines
4.9 KiB
TypeScript
160 lines
4.9 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { revalidatePath } from "next/cache";
|
|
import { getPb } from "../../../lib/pb";
|
|
|
|
const BATCHES = "JEI_payment_batches";
|
|
const INVOICES = "JEI_invoices";
|
|
const ALLOWED_MIME = new Set(["application/pdf", "image/jpeg", "image/png"]);
|
|
const MAX_POP_BYTES = 8 * 1024 * 1024;
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
export async function POST(req: NextRequest) {
|
|
const pb = getPb();
|
|
let form: FormData;
|
|
try {
|
|
form = await req.formData();
|
|
} catch (e: any) {
|
|
return NextResponse.json({ error: `Invalid form-data: ${e?.message || e}` }, { status: 400 });
|
|
}
|
|
|
|
const batchRef = String(form.get("batch_ref") || "").trim();
|
|
const paymentDate = String(form.get("payment_date") || "").trim();
|
|
const invoiceIdsRaw = String(form.get("invoice_ids") || "");
|
|
const popFile = form.get("pop_file");
|
|
// Optional ZAR amount actually paid for this batch. Empty / missing -> 0.
|
|
// PB "number" field accepts numeric strings or numbers via the SDK.
|
|
const zarPaidRaw = String(form.get("zar_paid") || "").trim();
|
|
const zarPaid = zarPaidRaw === "" ? 0 : Number(zarPaidRaw);
|
|
|
|
// 1. invoice_ids
|
|
let invoiceIds: string[];
|
|
try {
|
|
const parsed = JSON.parse(invoiceIdsRaw);
|
|
if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every((x) => typeof x === "string")) {
|
|
throw new Error("invoice_ids must be a non-empty JSON array of strings");
|
|
}
|
|
invoiceIds = parsed;
|
|
} catch (e: any) {
|
|
return NextResponse.json({ error: e?.message || "Invalid invoice_ids" }, { status: 400 });
|
|
}
|
|
|
|
// 2. batch_ref
|
|
// Permissive: letters/digits/space/_-/,.,:()&#'+, length 1-100.
|
|
// Rejects only characters that break filesystems/URLs (< > " ? * | \ ; etc).
|
|
if (!/^[A-Za-z0-9 _\-\/.,:()&#'+]{1,100}$/.test(batchRef)) {
|
|
return NextResponse.json(
|
|
{ error: "batch_ref must be 1-100 chars: letters, digits, space, _ - / . , : ( ) & # ' +" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// 3. payment_date
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(paymentDate)) {
|
|
return NextResponse.json(
|
|
{ error: "payment_date must be YYYY-MM-DD" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// 4. pop_file
|
|
if (!(popFile instanceof File)) {
|
|
return NextResponse.json({ error: "pop_file is required" }, { status: 400 });
|
|
}
|
|
if (!ALLOWED_MIME.has(popFile.type)) {
|
|
return NextResponse.json(
|
|
{ error: `pop_file type must be PDF, JPG or PNG (got ${popFile.type})` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
if (popFile.size > MAX_POP_BYTES) {
|
|
return NextResponse.json(
|
|
{ error: `pop_file exceeds 8 MB (got ${popFile.size} bytes)` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// 5. batch_ref uniqueness
|
|
try {
|
|
const dupe = await pb.collection(BATCHES).getList(1, 1, {
|
|
filter: `batch_ref="${batchRef.replace(/"/g, '\\"')}"`,
|
|
});
|
|
if (dupe.totalItems > 0) {
|
|
return NextResponse.json(
|
|
{ error: `Batch ref "${batchRef}" already exists` },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
} catch (e: any) {
|
|
return NextResponse.json(
|
|
{ error: `batch_ref uniqueness check failed: ${e?.message || e}` },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
|
|
// 6. Re-validate each invoice is still unpaid
|
|
const conflicts: string[] = [];
|
|
const validIds: string[] = [];
|
|
for (const id of invoiceIds) {
|
|
try {
|
|
const inv = await pb.collection(INVOICES).getOne(id);
|
|
if (inv.payment_batch && inv.payment_batch !== "") {
|
|
conflicts.push(id);
|
|
} else {
|
|
validIds.push(id);
|
|
}
|
|
} catch (e: any) {
|
|
conflicts.push(id); // missing or 404 → treat as conflict
|
|
}
|
|
}
|
|
if (conflicts.length > 0) {
|
|
return NextResponse.json(
|
|
{ error: `These invoices are already paid or missing: ${conflicts.join(", ")}` },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
|
|
// 7. Create the batch
|
|
let batchId: string;
|
|
try {
|
|
const created = await pb.collection(BATCHES).create({
|
|
batch_ref: batchRef,
|
|
payment_date: paymentDate,
|
|
pop_file: popFile,
|
|
zar_paid: Number.isFinite(zarPaid) ? zarPaid : 0,
|
|
});
|
|
batchId = created.id;
|
|
} catch (e: any) {
|
|
return NextResponse.json(
|
|
{ error: `Failed to create batch: ${e?.message || e}` },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
|
|
// 8. PATCH each invoice (best-effort, parallel)
|
|
const patchErrors: string[] = [];
|
|
await Promise.all(
|
|
validIds.map(async (id) => {
|
|
try {
|
|
await pb.collection(INVOICES).update(id, { payment_batch: batchId });
|
|
} catch (e: any) {
|
|
patchErrors.push(`${id}: ${e?.message || e}`);
|
|
}
|
|
}),
|
|
);
|
|
|
|
// Invalidate Next.js router caches for the affected pages so the
|
|
// just-paid invoices disappear from /unpaid and the new paid rows +
|
|
// batch appear on /remittance and /invoices.
|
|
revalidatePath("/unpaid");
|
|
revalidatePath("/remittance");
|
|
revalidatePath("/invoices");
|
|
|
|
return NextResponse.json({
|
|
ok: true,
|
|
batchId,
|
|
batchRef,
|
|
invoiceCount: validIds.length,
|
|
patchErrors: patchErrors.length > 0 ? patchErrors : undefined,
|
|
});
|
|
}
|