feat: POST /api/payments/create with validation and atomic invoice patching
This commit is contained in:
parent
5fec004a46
commit
44f17d2a6e
1 changed files with 145 additions and 0 deletions
145
app/api/payments/create/route.ts
Normal file
145
app/api/payments/create/route.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
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");
|
||||
|
||||
// 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
|
||||
if (!/^[A-Za-z0-9_-]{1,50}$/.test(batchRef)) {
|
||||
return NextResponse.json(
|
||||
{ error: "batch_ref must match ^[A-Za-z0-9_-]{1,50}$" },
|
||||
{ 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,
|
||||
});
|
||||
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}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
batchId,
|
||||
batchRef,
|
||||
invoiceCount: validIds.length,
|
||||
patchErrors: patchErrors.length > 0 ? patchErrors : undefined,
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue