- /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>
52 lines
3.4 KiB
TypeScript
52 lines
3.4 KiB
TypeScript
import { revalidatePath } from 'next/cache';
|
|
import { clockAccess, clockFailure, clockJson } from '@/lib/clock-api';
|
|
import { clockPB } from '@/lib/clock-store';
|
|
import { cashBalance, hasOpening, parseAmount } from '@/lib/cash-float';
|
|
import { assetIdForCostCode } from '@/lib/cash-float-server';
|
|
import { resolveSupplier } from '@/lib/supplier-server';
|
|
|
|
// POST /api/clock/slip (multipart: slip_photo, supplier, amount, cost_code?, comment?)
|
|
// A photographed slip becomes one "expense" on the cash-float card and takes its amount off the balance.
|
|
export async function POST(request: Request) {
|
|
try {
|
|
const user = await clockAccess(request, { multipart: true });
|
|
if (user instanceof Response) return user;
|
|
if (user.access !== 'office') return Response.json({ error: 'Only office users can add slips here.' }, { status: 403 });
|
|
let form: FormData;
|
|
try { form = await request.formData(); } catch { return Response.json({ error: 'Send the slip photo and its details.' }, { status: 400 }); }
|
|
|
|
const photo = form.get('slip_photo');
|
|
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'));
|
|
if (!(photo instanceof File) || photo.size === 0) return Response.json({ error: 'Take a photo of the slip first.' }, { status: 400 });
|
|
if (!photo.type.startsWith('image/')) return Response.json({ error: 'The slip photo must be an image.' }, { status: 400 });
|
|
if (photo.size > 10 * 1024 * 1024) return Response.json({ error: 'The photo is too large (10 MB max).' }, { status: 413 });
|
|
if (!supplier) return Response.json({ error: 'Enter the supplier.' }, { status: 400 });
|
|
if (supplier.length > 200 || costCode.length > 100 || comment.length > 1000) return Response.json({ error: 'One of the fields is too long.' }, { status: 400 });
|
|
if (amount === null) return Response.json({ error: 'Enter the amount on the slip, more than 0.' }, { status: 400 });
|
|
|
|
const pb = await clockPB();
|
|
const entries = await pb.collection('cash_float_entries').getFullList({ fields: 'kind,amount' });
|
|
if (!hasOpening(entries)) return Response.json({ error: 'The card has no opening balance yet. Set it first.' }, { status: 409 });
|
|
|
|
// A cost code that names a vehicle in the register links to it; anything else is kept as typed.
|
|
const assetId = await assetIdForCostCode(pb, costCode);
|
|
|
|
const data = new FormData();
|
|
data.set('kind', 'expense'); data.set('amount', String(amount)); data.set('occurred_on', new Date().toISOString());
|
|
data.set('supplier', supplier); data.set('cost_code', costCode); data.set('comment', comment);
|
|
if (assetId) data.set('asset', assetId);
|
|
// A petty-cash supplier joins the one supplier list (as a cash supplier if it is new).
|
|
const supplierId = await resolveSupplier(pb, { name: supplier, terms: 'cash', source: 'Cash float' });
|
|
if (supplierId) data.set('supplier_ref', supplierId);
|
|
data.set('recorded_by', String(user.user.name || user.user.email || user.user.id));
|
|
data.set('slip_photo', photo, photo.name || 'slip.jpg');
|
|
const entry = await pb.collection('cash_float_entries').create(data);
|
|
|
|
revalidatePath('/expenses/petty-cash');
|
|
revalidatePath('/expenses/suppliers');
|
|
return clockJson({ id: entry.id, amount, balance: cashBalance([...entries, { kind: 'expense', amount }]) });
|
|
} catch (e) { return clockFailure(e); }
|
|
}
|