- /expenses/suppliers: terms (cash / 30 / 60 days), PO + petty cash totals per supplier, period filters, add/edit/delete, due dates - POs and slips resolve to a supplier by code then folded name; new PO suppliers start with terms unset, new slip suppliers as cash - Full-user guard on cash-float and job-card write routes Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
61 lines
3.7 KiB
TypeScript
61 lines
3.7 KiB
TypeScript
import { revalidatePath } from "next/cache";
|
|
import { serverAuth } from "@/lib/pb";
|
|
import { refuseUnlessFull } from "@/lib/require-full";
|
|
import { getSession } from "@/lib/auth";
|
|
import { hasOpening, parseAmount } from "@/lib/cash-float";
|
|
import { assetIdForCostCode } from "@/lib/cash-float-server";
|
|
import { resolveSupplier } from "@/lib/supplier-server";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
// POST /api/cash-float/entries (multipart: supplier, amount, occurred_on?, cost_code?, comment?, slip_photo?)
|
|
// A slip entered by hand in the main app. Same rules as the Slips tab in the clock app, except the
|
|
// photo is optional (a slip that was lost or never photographed) and the date can be set.
|
|
export async function POST(req: Request) {
|
|
const refused = await refuseUnlessFull();
|
|
if (refused) return refused;
|
|
let form: FormData;
|
|
try { form = await req.formData(); } catch { return Response.json({ ok: false, error: "Send the entry details." }, { status: 400 }); }
|
|
|
|
const supplier = String(form.get("supplier") || "").trim();
|
|
const costCode = String(form.get("cost_code") || "").trim();
|
|
const comment = String(form.get("comment") || "").trim();
|
|
const amount = parseAmount(form.get("amount"));
|
|
const photo = form.get("slip_photo");
|
|
if (!supplier) return Response.json({ ok: false, error: "Enter the supplier." }, { status: 400 });
|
|
if (supplier.length > 200 || costCode.length > 100 || comment.length > 1000) return Response.json({ ok: false, error: "One of the fields is too long." }, { status: 400 });
|
|
if (amount === null) return Response.json({ ok: false, error: "Enter the amount, more than 0." }, { status: 400 });
|
|
|
|
// The slip's own date. Defaults to now; it cannot be in the future.
|
|
const rawDate = String(form.get("occurred_on") || "").trim();
|
|
const when = rawDate ? new Date(rawDate) : new Date();
|
|
if (Number.isNaN(when.getTime())) return Response.json({ ok: false, error: "That date is not valid." }, { status: 400 });
|
|
if (when.getTime() > Date.now() + 60_000) return Response.json({ ok: false, error: "The date is in the future." }, { status: 400 });
|
|
|
|
const hasPhoto = photo instanceof File && photo.size > 0;
|
|
if (hasPhoto && !photo.type.startsWith("image/")) return Response.json({ ok: false, error: "The slip photo must be an image." }, { status: 400 });
|
|
if (hasPhoto && photo.size > 10 * 1024 * 1024) return Response.json({ ok: false, error: "The photo is too large (10 MB max)." }, { status: 413 });
|
|
|
|
try {
|
|
const pb = await serverAuth();
|
|
if (!hasOpening(await pb.collection("cash_float_entries").getFullList({ fields: "kind" }))) {
|
|
return Response.json({ ok: false, error: "Set the opening balance first." }, { status: 409 });
|
|
}
|
|
const session = await getSession();
|
|
const data = new FormData();
|
|
data.set("kind", "expense"); data.set("amount", String(amount)); data.set("occurred_on", when.toISOString());
|
|
data.set("supplier", supplier); data.set("cost_code", costCode); data.set("comment", comment);
|
|
const assetId = await assetIdForCostCode(pb, costCode);
|
|
if (assetId) data.set("asset", assetId);
|
|
const supplierId = await resolveSupplier(pb, { name: supplier, terms: "cash", source: "Cash float" });
|
|
if (supplierId) data.set("supplier_ref", supplierId);
|
|
data.set("recorded_by", `${String(session?.user.name || session?.user.email || "")} (manual)`.trim());
|
|
if (hasPhoto) data.set("slip_photo", photo as File, (photo as File).name || "slip.jpg");
|
|
await pb.collection("cash_float_entries").create(data);
|
|
revalidatePath("/expenses/petty-cash");
|
|
revalidatePath("/expenses/suppliers");
|
|
return Response.json({ ok: true });
|
|
} catch (e: any) {
|
|
return Response.json({ ok: false, error: e?.message || "Could not save" }, { status: 500 });
|
|
}
|
|
}
|