Purchase invoices: price an invoice that has no amount

An invoice saved with no amount landed as amount_incl = 0 (PocketBase
stores a number field as zero, not null), so the PO read "R 0,00 invoiced"
and the money never booked. There were two ways to give it an amount and
neither worked: the Upload-invoice modal let a blank amount save silently,
while the double-click correction form refused to save without a value AND
a written reason, and sat behind Edit mode.

- lib/purchase-invoice-amount.ts holds the rules, shared by the screen and
  the API so they cannot disagree. R0 is not an amount: a blank amount and
  a figure of zero are both refused, so an invoice can never land as zero.
- The API now separates PRICING an invoice that never had an amount (no
  reason owed, still logged) from CORRECTING one that was booked (Full user
  + a reason, as before).
- Double-clicking an amount on a PO opens the ordinary invoice modal on
  that invoice, with the amount field waiting. The inline correction form is
  gone — one window, one rule. There is no pencil button: a booked figure
  must take a deliberate second gesture, so a single stray tap never opens
  a form that can rewrite money.
- The PO-lines table freezes its Description column with an opaque
  background and fades its right edge, so it passes the mobile checklist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dave 2026-11-04 18:06:02 +02:00
parent 475f754efa
commit 696c157e2e
6 changed files with 569 additions and 123 deletions

View file

@ -488,6 +488,15 @@ Three traps this file has already hit, all in AGENTS.md terms of "do not regress
Collections created by the collections API do **not** get `created`/`updated` for free —
declare them explicitly or a `sort: "-created"` fails with an opaque server error.
### The PO-lines table freezes its DESCRIPTION column, not the `#`
The lines table inside a PO is wider than a phone, so it scrolls in its own box with a fade on
the right edge. The column that sticks is **Description** — the `#` is a line number and tells
you nothing, while the description is what identifies the line when the money columns come into
view. Do not "fix" it by freezing the first column instead. The sticky cell carries an **opaque**
background (`bg-white` / `bg-gray-50` for the header) for the same reason the invoice register
does: a translucent sticky cell lets the columns under it bleed through on scroll.
### Running Costs means EVERYTHING linked to a vehicle
Client ruling, 2026-10-09: running cost is **insurance, finance, purchases, repairs —
@ -958,3 +967,47 @@ petty-cash slip (`cash_float_entries.supplier_ref`) is booked against a supplier
"Your Tyre Shop" and "Tyre Shop" are different suppliers.
- Due date = PO date + terms (`dueDate`). **Payments are not tracked**, so nothing is ever called "overdue".
- A supplier with anything booked against it cannot be deleted. Editing writes need a Full user.
## Purchase invoice amounts — PRICING is not CORRECTING
A supplier invoice against a PO carries an `amount_incl`. Two acts look alike in the data and
must not be treated alike, because only one of them changes money other screens have booked.
- **PRICING** — putting a first amount on an invoice that never had one. Nothing was booked, so
there is nothing to justify: **no reason is demanded** and no Full user is required. It is still
written to `purchase_invoice_edits` with the reason "Amount entered", so the trail shows when the
figure was entered.
- **CORRECTING** — changing a figure that was already booked, or the invoice's number or date. A
Full user does this, a reason of at least three characters is compulsory, and every changed field
is logged (old value, new value, reason, who).
`lib/purchase-invoice-amount.ts` holds both rules and the screen and the API read them from
there, so they cannot disagree.
### R0 is NOT an amount
`purchase_invoices.amount_incl` is a PocketBase **number** field, so an invoice saved with no
amount is stored as **0**, not null. That reads as a real invoice for nothing and quietly
swallows the money — the PO shows "R 0,00 invoiced" and the supplier's payable ignores it.
So an amount is **required** (`AMOUNT_REQUIRED_ON_NEW_INVOICE`): a blank amount and a figure of
zero are both refused, on the create route and on the screen. Never "fix" that by treating 0 as a
valid amount.
### There is ONE window for putting an amount on an invoice
`InvoiceUploadModal` does both jobs. With no `invoice` it ADDS one to a PO; with an `invoice` it
PRICES one that is already on file. Double-clicking the amount on a PO opens that same window on
that invoice, with the amount field focused.
- The amount opens on a **double-click and on nothing else** — there is **no pencil button**. A
booked figure must take a deliberate second gesture; a single stray tap must never open a form
that can rewrite money. A long press does the same on a phone, and Enter/Space from the keyboard.
- The old inline "correct this amount" form is **gone**. Two ways to set an amount is two ways to
drift apart; there is one window and one rule.
- A price that was never entered is not a correction, so that window asks for **no reason**. The
reason field appears only when a booked figure is being changed, or the number or date is being
touched.
`/running/purchases` shows an unpriced invoice as "no amount" in amber, and the PO header says how
many are unpriced, so a missing figure is visible rather than silent.

View file

@ -2,6 +2,7 @@ import { revalidatePath } from "next/cache";
import { serverAuth } from "@/lib/pb";
import { getSession } from "@/lib/auth";
import { refuseUnlessFull } from "@/lib/require-full";
import { checkAmountValue, checkReason, isUnpriced } from "@/lib/purchase-invoice-amount";
export const dynamic = "force-dynamic";
@ -27,20 +28,25 @@ export async function DELETE(
}
// PATCH /api/purchases/:id/invoices/:invoiceId { amount_incl?, invoice_date?, invoice_no?, reason }
// Admin correction of a typed-in invoice. A REASON is mandatory, and every changed field is
// written to `purchase_invoice_edits` (old value, new value, reason, who) — append-only.
//
// Two kinds of edit, and they are NOT treated alike:
//
// - PRICING — putting a first amount on an invoice that never had one. Nothing was booked, so
// there is nothing to justify: no reason is demanded and no Full user is required. It is the
// same act as adding an invoice with its amount. It is still logged, so the trail shows when
// the figure was entered.
// - CORRECTING — changing a figure that was already booked, or changing the invoice's number
// or date. A Full user does this, a reason is compulsory, and every changed field is written
// to `purchase_invoice_edits` (old value, new value, reason, who) — append-only.
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string; invoiceId: string }> },
) {
const refused = await refuseUnlessFull();
if (refused) return refused;
const { id, invoiceId } = await params;
let body: { amount_incl?: unknown; invoice_date?: unknown; invoice_no?: unknown; reason?: unknown } = {};
try { body = await req.json(); } catch { return Response.json({ ok: false, error: "Invalid JSON body" }, { status: 400 }); }
const reason = String(body.reason ?? "").trim();
if (reason.length < 3) return Response.json({ ok: false, error: "Give a reason for the change." }, { status: 400 });
try {
const pb = await serverAuth();
@ -51,10 +57,11 @@ export async function PATCH(
const patch: Record<string, unknown> = {};
const changes: { field: string; old: string; next: string }[] = [];
if ("amount_incl" in body) {
const raw = String(body.amount_incl ?? "").trim().replace(/\s/g, "").replace(",", ".");
const n = Number(raw);
if (!raw || !Number.isFinite(n) || n < 0) return Response.json({ ok: false, error: "The amount must be a positive number." }, { status: 400 });
if (n !== (inv.amount_incl ?? 0)) { patch.amount_incl = n; changes.push({ field: "amount_incl", old: String(inv.amount_incl ?? ""), next: String(n) }); }
// The figure is checked on its own here — whether a REASON is owed depends on what else
// the edit touches, which is only known once every field has been compared.
const value = checkAmountValue(String(body.amount_incl ?? ""));
if (!value.ok) return Response.json({ ok: false, error: value.error }, { status: 400 });
if (value.amount !== (inv.amount_incl ?? 0)) { patch.amount_incl = value.amount; changes.push({ field: "amount_incl", old: String(inv.amount_incl ?? ""), next: String(value.amount) }); }
}
if ("invoice_date" in body) {
const d = String(body.invoice_date ?? "").trim().slice(0, 10);
@ -73,19 +80,29 @@ export async function PATCH(
}
if (!changes.length) return Response.json({ ok: false, error: "Nothing was changed." }, { status: 400 });
// PRICING vs CORRECTING. Pricing touches nothing any other screen has already used, so it
// needs neither a reason nor a Full user. Anything else is a correction of a booked figure.
const pricingOnly = changes.length === 1 && changes[0].field === "amount_incl" && isUnpriced(inv.amount_incl);
if (!pricingOnly) {
const refused = await refuseUnlessFull();
if (refused) return refused;
}
const stated = checkReason({ pricingOnly, reason });
if (!stated.ok) return Response.json({ ok: false, error: stated.error }, { status: 400 });
const session = await getSession();
const who = session ? `${session.user.name || session.user.email} <${session.user.email}>` : "unknown";
// Log FIRST: if the log cannot be written the edit must not happen unrecorded.
for (const c of changes) {
await pb.collection("purchase_invoice_edits").create({
invoice: invoiceId, po_no: po.po_no, invoice_no: inv.invoice_no, field: c.field,
old_value: c.old, new_value: c.next, reason: reason.slice(0, 500), edited_by: who,
old_value: c.old, new_value: c.next, reason: stated.reason.slice(0, 500), edited_by: who,
});
}
await pb.collection("purchase_invoices").update(invoiceId, patch);
revalidatePath("/running/purchases");
revalidatePath("/expenses/suppliers-2");
return Response.json({ ok: true });
return Response.json({ ok: true, pricing: pricingOnly });
} catch (e: any) {
return Response.json({ ok: false, error: e?.message || "Could not save the change" }, { status: 500 });
}

View file

@ -1,5 +1,6 @@
import { revalidatePath } from "next/cache";
import { serverAuth } from "@/lib/pb";
import { checkNewAmount } from "@/lib/purchase-invoice-amount";
export const dynamic = "force-dynamic";
@ -9,6 +10,10 @@ export const dynamic = "force-dynamic";
// Records a supplier invoice against a purchase order. A PO can carry several. These are
// typed in, not parsed, and they never touch the PO's own figures — the PO says what was
// ordered, the invoices say what the supplier has billed so far.
//
// The amount is read through `checkNewAmount`, so the screen and this route agree on what an
// invoice may be saved with. R0 is not an amount: a figure of zero would read as "never
// priced" and quietly swallow the money, so it is refused like a blank one.
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@ -22,14 +27,9 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
const invoiceNo = String(form.get("invoice_no") || "").trim();
if (!invoiceNo) return Response.json({ ok: false, error: "Enter the invoice number" }, { status: 400 });
const amountRaw = String(form.get("amount_incl") || "").trim().replace(/\s/g, "").replace(",", ".");
let amount: number | null = null;
if (amountRaw) {
amount = Number(amountRaw);
if (!Number.isFinite(amount) || amount < 0) {
return Response.json({ ok: false, error: "The amount must be a positive number" }, { status: 400 });
}
}
const amountCheck = checkNewAmount(String(form.get("amount_incl") || ""));
if (!amountCheck.ok) return Response.json({ ok: false, error: amountCheck.error }, { status: 400 });
const amount = amountCheck.amount;
const file = form.get("file");
const hasFile = file instanceof File && file.size > 0;

View file

@ -8,6 +8,7 @@ import {
} from "lucide-react";
import { ZAR, ZAR2 } from "@/lib/utils";
import { invoiceStatusOf, STATUS_STYLE } from "@/lib/purchase-invoice-status";
import { checkAmountValue, checkNewAmount, checkReason, isUnpriced, AMOUNT_REQUIRED_ON_NEW_INVOICE } from "@/lib/purchase-invoice-amount";
const ACCENT = "#30638E";
@ -61,6 +62,9 @@ export function PurchaseIntake({ purchases: allPurchases }: { purchases: Purchas
const [notice, setNotice] = useState<string | null>(null);
const [open, setOpen] = useState<string | null>(null);
const [invoiceModal, setInvoiceModal] = useState(false);
// An invoice already on a PO whose amount still has to be put in. It opens the SAME modal
// that adds an invoice — one place to type an amount, not two that can drift apart.
const [pricing, setPricing] = useState<{ purchaseId: string; invoice: PurchaseInvoiceView } | null>(null);
const [range, setRange] = useState<"all" | "week" | "month">("all");
// 0 = the current week/month, -1 = the one before, …
const [offset, setOffset] = useState(0);
@ -197,6 +201,14 @@ export function PurchaseIntake({ purchases: allPurchases }: { purchases: Purchas
</label>
</div>
{invoiceModal && <InvoiceUploadModal purchases={allPurchases} onClose={() => setInvoiceModal(false)} />}
{pricing && (
<InvoiceUploadModal
purchases={allPurchases}
purchaseId={pricing.purchaseId}
invoice={pricing.invoice}
onClose={() => setPricing(null)}
/>
)}
</div>
{error && (
@ -223,7 +235,7 @@ export function PurchaseIntake({ purchases: allPurchases }: { purchases: Purchas
<div className="flex items-center gap-1 p-0.5 rounded-lg bg-white dark:bg-white/5 border border-gray-200 dark:border-border self-start">
{([["all", "All"], ["week", "Week"], ["month", "Month"]] as const).map(([id, label]) => (
<button key={id} onClick={() => { setRange(id); setOffset(0); }} aria-pressed={range === id}
className={`min-h-[44px] px-3 rounded-md text-xs font-medium transition ${
className={`min-h-[44px] min-w-[44px] px-3 rounded-md text-xs font-medium transition ${
range === id ? "bg-[#003D5B] text-white dark:bg-[#00798C]" : "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
}`}>
{label}
@ -344,14 +356,22 @@ export function PurchaseIntake({ purchases: allPurchases }: { purchases: Purchas
<Meta label="VAT" value={ZAR2(p.vat)} />
</dl>
<SupplierInvoices purchase={p} editMode={editMode} />
<SupplierInvoices
purchase={p}
editMode={editMode}
onPrice={(invoice) => setPricing({ purchaseId: p.id, invoice })}
/>
<div className="overflow-x-auto rounded-lg border border-gray-100 dark:border-border bg-white dark:bg-surface">
{/* Wide table: it scrolls inside its own box, the description stays put
so a line is still identifiable when the money columns come into view,
and the right edge fades to show there is more to scroll to. */}
<div className="relative">
<div className="overflow-x-auto rounded-lg border border-gray-100 dark:border-border bg-white dark:bg-surface [-webkit-overflow-scrolling:touch]">
<table className="w-full text-xs">
<thead>
<tr className="text-gray-500 dark:text-gray-400 uppercase tracking-wider bg-gray-50 dark:bg-surface-muted">
<th className="text-left py-2 px-3 font-medium">#</th>
<th className="text-left py-2 px-3 font-medium">Description</th>
<th className="text-left py-2 px-3 font-medium sticky left-0 z-20 bg-gray-50 dark:bg-surface-muted shadow-[1px_0_0_0_rgba(0,0,0,0.07)] dark:shadow-[1px_0_0_0_rgba(255,255,255,0.07)]">Description</th>
<th className="text-left py-2 px-3 font-medium">Cost code</th>
<th className="text-left py-2 px-3 font-medium">Machine</th>
<th className="text-right py-2 px-3 font-medium">Amount</th>
@ -362,7 +382,10 @@ export function PurchaseIntake({ purchases: allPurchases }: { purchases: Purchas
{p.lines.map((l) => (
<tr key={l.id} className="border-t border-gray-100 dark:border-border">
<td className="py-2 px-3 text-gray-400">{l.line_no}</td>
<td className="py-2 px-3 text-[#003D5B] dark:text-gray-300">
<td
title={l.description || undefined}
className="py-2 px-3 text-[#003D5B] dark:text-gray-300 sticky left-0 z-10 bg-white dark:bg-surface max-w-[190px] truncate shadow-[1px_0_0_0_rgba(0,0,0,0.07)] dark:shadow-[1px_0_0_0_rgba(255,255,255,0.07)]"
>
{l.description || <span className="italic text-gray-400">no description</span>}
</td>
<td className="py-2 px-3 font-mono text-gray-500 dark:text-gray-400">{l.cost_code || "—"}</td>
@ -388,6 +411,8 @@ export function PurchaseIntake({ purchases: allPurchases }: { purchases: Purchas
))}
</tbody>
</table>
</div>
<div className="pointer-events-none absolute inset-y-0 right-0 w-6 rounded-r-lg bg-gradient-to-l from-gray-100/90 dark:from-border/70 to-transparent" />
</div>
</div>
)}
@ -419,16 +444,24 @@ const inputCls =
* Supplier invoices received against one PO. A PO can have several. Typed in by a person
* with the PDF attached; the "invoiced" figure sums only the amounts actually entered, and
* an invoice with no amount is listed but never counted as R0.
*
* An invoice with no amount is not a dead end: double-click its amount (or tap the pencil)
* and the ordinary invoice modal opens on that invoice, with the amount field waiting.
*/
function SupplierInvoices({ purchase: p, editMode }: { purchase: PurchaseView; editMode: boolean }) {
function SupplierInvoices({
purchase: p,
editMode,
onPrice,
}: {
purchase: PurchaseView;
editMode: boolean;
onPrice: (invoice: PurchaseInvoiceView) => void;
}) {
const router = useRouter();
const [adding, setAdding] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [amt, setAmt] = useState("");
const [editId, setEditId] = useState<string | null>(null);
const [editAmt, setEditAmt] = useState("");
const [editReason, setEditReason] = useState("");
const invoiced = p.invoices.reduce((s, i) => s + (i.amount_incl ?? 0), 0);
const unpriced = p.invoices.filter((i) => i.amount_incl == null).length;
@ -437,6 +470,13 @@ function SupplierInvoices({ purchase: p, editMode }: { purchase: PurchaseView; e
async function add(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget;
// The same rule the route applies, so the message lands on the field instead of coming
// back as a failed request.
const amount = checkNewAmount(amt);
if (!amount.ok) {
setError(amount.error);
return;
}
setBusy(true);
setError(null);
try {
@ -459,33 +499,6 @@ function SupplierInvoices({ purchase: p, editMode }: { purchase: PurchaseView; e
}
}
function startEdit(i: PurchaseInvoiceView) {
setEditId(i.id);
setEditAmt(i.amount_incl == null ? "" : String(i.amount_incl));
setEditReason("");
setError(null);
}
async function saveEdit(i: PurchaseInvoiceView) {
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/purchases/${p.id}/invoices/${i.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount_incl: editAmt, reason: editReason }),
});
const body = await res.json();
if (!body.ok) { setError(body.error); return; }
setEditId(null);
router.refresh();
} catch {
setError("Could not save — check your connection");
} finally {
setBusy(false);
}
}
async function remove(i: PurchaseInvoiceView) {
if (!confirm(`Remove invoice ${i.invoice_no} from ${p.po_no}?`)) return;
const res = await fetch(`/api/purchases/${p.id}/invoices/${i.id}`, { method: "DELETE" });
@ -502,7 +515,9 @@ function SupplierInvoices({ purchase: p, editMode }: { purchase: PurchaseView; e
{p.invoices.length > 0 && (
<span className="ml-2 font-normal text-gray-500 dark:text-gray-400">
{ZAR2(invoiced)} invoiced · {ZAR2(Math.abs(outstanding))} of PO {outstanding < 0 ? "over" : "not yet invoiced"}
{unpriced > 0 && ` · ${unpriced} with no amount`}
{unpriced > 0 && (
<span className="text-[#EDAE49]"> · {unpriced} with no amount — double-click it to set one</span>
)}
</span>
)}
</div>
@ -523,28 +538,7 @@ function SupplierInvoices({ purchase: p, editMode }: { purchase: PurchaseView; e
{p.invoices.length > 0 && (
<ul className="divide-y divide-gray-100 dark:divide-border">
{p.invoices.map((i) => editId === i.id ? (
<li key={i.id} className="py-2 space-y-2">
<div className="text-xs font-semibold text-[#003D5B] dark:text-white">Correct invoice {i.invoice_no}</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<label className="text-[12px] text-gray-500 dark:text-gray-400">
Amount incl VAT (PO is {ZAR2(p.total)})
<input value={editAmt} onChange={(e) => setEditAmt(e.target.value)} inputMode="decimal" className={inputCls} autoFocus />
</label>
<label className="text-[12px] text-gray-500 dark:text-gray-400 sm:col-span-2">
Reason for the change (kept in the log)
<input value={editReason} onChange={(e) => setEditReason(e.target.value)} placeholder="e.g. decimal point missed when captured" className={inputCls} />
</label>
</div>
<div className="flex gap-2">
<button onClick={() => saveEdit(i)} disabled={busy || editReason.trim().length < 3}
className="min-h-[44px] px-4 rounded-lg text-xs font-medium text-white inline-flex items-center gap-2 disabled:opacity-50" style={{ background: ACCENT }}>
{busy && <Loader2 className="w-4 h-4 animate-spin" />} Save correction
</button>
<button onClick={() => setEditId(null)} className="min-h-[44px] px-4 rounded-lg text-xs font-medium text-gray-500 hover:bg-gray-50 dark:hover:bg-white/5">Cancel</button>
</div>
</li>
) : (
{p.invoices.map((i) => (
<li key={i.id} className="flex items-center gap-3 py-1">
<div className="flex-1 min-w-0 text-xs">
<span className="font-mono font-semibold text-[#003D5B] dark:text-white">{i.invoice_no}</span>
@ -553,22 +547,7 @@ function SupplierInvoices({ purchase: p, editMode }: { purchase: PurchaseView; e
{i.note && ` · ${i.note}`}
</span>
</div>
<div
onDoubleClick={() => editMode && startEdit(i)}
title={editMode ? "Double-click to correct this amount" : "Switch on Edit mode to correct this amount"}
className="text-xs tabular-nums font-semibold text-[#003D5B] dark:text-white shrink-0 cursor-text select-none"
>
{i.amount_incl == null ? <span className="font-normal italic text-gray-400">no amount</span> : ZAR2(i.amount_incl)}
</div>
{editMode && (
<button
onClick={() => startEdit(i)}
aria-label={`Correct the amount of ${i.invoice_no}`}
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg text-gray-400 hover:text-[#30638E] hover:bg-gray-50 dark:hover:bg-white/5"
>
<Pencil className="w-4 h-4" />
</button>
)}
<AmountButton invoice={i} onPrice={() => onPrice(i)} />
{i.hasPdf ? (
<a
href={`/api/purchases/${p.id}/invoices/${i.id}/document`}
@ -609,7 +588,7 @@ function SupplierInvoices({ purchase: p, editMode }: { purchase: PurchaseView; e
<input name="invoice_date" type="date" className={inputCls} />
</label>
<label className="text-[12px] text-gray-500 dark:text-gray-400">
Amount incl VAT
Amount incl VAT {AMOUNT_REQUIRED_ON_NEW_INVOICE ? "*" : ""}
<input name="amount_incl" value={amt} onChange={(e) => setAmt(e.target.value)} inputMode="decimal" placeholder="0.00" className={inputCls} />
<OverWarning po={p} typed={amt} />
</label>
@ -645,6 +624,63 @@ function SupplierInvoices({ purchase: p, editMode }: { purchase: PurchaseView; e
}
/**
* The amount on one supplier invoice.
*
* ⚠️ **It opens on a DOUBLE-CLICK and on nothing else.** There is no pencil button beside it.
* A figure that has already been booked reaches the supplier's payables and the client's
* invoice status, so changing one must take a deliberate second gesture — a single stray tap
* must never open a form that can rewrite money.
*
* A long press does the same job on a phone, where a double tap is not reliably delivered,
* and it is just as hard to do by accident. Enter or Space does it from the keyboard.
*/
function AmountButton({ invoice, onPrice }: { invoice: PurchaseInvoiceView; onPrice: () => void }) {
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Set once the window has opened, so a long press that ends in a double tap cannot open it twice.
const opened = useRef(false);
function open() {
if (opened.current) return;
opened.current = true;
onPrice();
}
function cancel() {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
}
return (
<button
type="button"
onDoubleClick={open}
onPointerDown={() => {
cancel();
timer.current = setTimeout(open, 500);
}}
onPointerUp={cancel}
onPointerLeave={cancel}
onPointerCancel={cancel}
onContextMenu={(e) => e.preventDefault()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
open();
}
}}
title="Double-click to set the amount"
aria-label={`Amount for invoice ${invoice.invoice_no}. Double-click to set it.`}
style={{ touchAction: "manipulation" }}
className="min-h-[44px] px-2 -my-1 rounded text-xs tabular-nums font-semibold text-[#003D5B] dark:text-white shrink-0 select-none hover:bg-[#30638E]/10 focus-visible:ring-2 focus-visible:ring-[#30638E] underline decoration-dotted decoration-[#30638E]/60 underline-offset-4"
>
{invoice.amount_incl == null ? <span className="font-normal italic text-[#EDAE49]">no amount</span> : ZAR2(invoice.amount_incl)}
</button>
);
}
function invoiceStatus(p: PurchaseView) {
return invoiceStatusOf(p.total, p.invoices.map((i) => i.amount_incl));
}
@ -652,20 +688,44 @@ function invoiceStatus(p: PurchaseView) {
const BRAND_ORANGE = "#EDAE49";
/**
* Global "Upload invoice": find the PO by number, supplier, amount or line description,
* then give the invoice number, date, amount and PDF (drag and drop or browse).
* The invoice window — ONE place to put an amount on an invoice.
*
* - No `invoice`: it ADDS a new invoice to a PO (the global "Upload invoice").
* - With `invoice`: it PRICES an invoice already on a PO. Double-clicking that invoice's
* amount opens this same window on it, with the amount field waiting. One window, one rule,
* one place to type a figure — instead of a second form that refused to save.
*
* Pricing an invoice that never had an amount is not a correction: nothing was booked, so
* there is nothing to justify and no reason is demanded. Changing a figure that WAS booked,
* or the invoice's number or date, is a correction — a reason is compulsory and it is logged.
*/
function InvoiceUploadModal({ purchases, onClose }: { purchases: PurchaseView[]; onClose: () => void }) {
function InvoiceUploadModal({
purchases,
onClose,
purchaseId,
invoice,
}: {
purchases: PurchaseView[];
onClose: () => void;
/** The PO the invoice belongs to. Fixed when `invoice` is given. */
purchaseId?: string;
/** An invoice already on file: the modal prices it instead of creating a new one. */
invoice?: PurchaseInvoiceView;
}) {
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState("");
const [poId, setPoId] = useState("");
const [chosenId, setChosenId] = useState(purchaseId ?? "");
const [file, setFile] = useState<File | null>(null);
const [amt, setAmt] = useState("");
const [amt, setAmt] = useState(invoice ? (invoice.amount_incl == null ? "" : String(invoice.amount_incl)) : "");
const [invoiceNo, setInvoiceNo] = useState(invoice?.invoice_no ?? "");
const [invoiceDate, setInvoiceDate] = useState(invoice?.invoice_date ?? "");
const [reason, setReason] = useState("");
const [drag, setDrag] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const editing = !!invoice;
const q = query.trim().toLowerCase().replace(/[\s,]/g, "");
const matches = purchases
.filter((p) => {
@ -678,9 +738,15 @@ function InvoiceUploadModal({ purchases, onClose }: { purchases: PurchaseView[];
return hay.includes(q);
})
.slice(0, 8);
const po = purchases.find((p) => p.id === poId) || null;
const po = purchases.find((p) => p.id === (editing ? purchaseId : chosenId)) || null;
const st = po ? invoiceStatus(po) : null;
// The invoice already carries a figure → correcting it. Changing its number or date is
// also a correction. Neither is true of a figure that was never entered.
const wasPriced = invoice ? !isUnpriced(invoice.amount_incl) : false;
const identityChanged = !!invoice && (invoiceDate !== invoice.invoice_date || invoiceNo !== invoice.invoice_no);
const needsReason = wasPriced || identityChanged;
function pick(f: File | undefined) {
if (!f) return;
if (!f.name.toLowerCase().endsWith(".pdf")) {
@ -697,12 +763,39 @@ function InvoiceUploadModal({ purchases, onClose }: { purchases: PurchaseView[];
setError("Choose the purchase order first");
return;
}
const fd = new FormData(e.currentTarget);
if (file) fd.set("file", file);
else fd.delete("file");
setBusy(true);
setError(null);
try {
if (editing && invoice) {
// The same rules the route applies, checked here so the message lands on the field.
const value = checkAmountValue(amt);
if (!value.ok) { setError(value.error); setBusy(false); return; }
const stated = checkReason({ pricingOnly: !needsReason, reason });
if (!stated.ok) { setError(stated.error); setBusy(false); return; }
const res = await fetch(`/api/purchases/${po.id}/invoices/${invoice.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
amount_incl: amt,
invoice_date: invoiceDate,
invoice_no: invoiceNo,
reason: stated.reason,
}),
});
const body = await res.json();
if (!body.ok) { setError(body.error); return; }
router.refresh();
onClose();
return;
}
const amount = checkNewAmount(amt);
if (!amount.ok) { setError(amount.error); setBusy(false); return; }
const fd = new FormData();
fd.append("invoice_no", invoiceNo);
fd.append("invoice_date", invoiceDate);
fd.append("amount_incl", amount.amount == null ? "" : String(amount.amount));
if (file) fd.set("file", file);
const res = await fetch(`/api/purchases/${po.id}/invoices`, { method: "POST", body: fd });
const body = await res.json();
if (!body.ok) {
@ -718,30 +811,43 @@ function InvoiceUploadModal({ purchases, onClose }: { purchases: PurchaseView[];
}
}
const title = editing ? (wasPriced ? "Correct invoice" : "Set the amount") : "Upload invoice";
const otherInvoiced = po && editing
? po.invoices.reduce((t, i) => (i.id === invoice!.id ? t : t + (i.amount_incl ?? 0)), 0)
: (po ? po.invoices.reduce((t, i) => t + (i.amount_incl ?? 0), 0) : 0);
return (
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center p-4 overflow-y-auto" onClick={onClose}>
<form
onSubmit={submit}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="Upload invoice"
className="w-full max-w-lg bg-white dark:bg-surface rounded-2xl p-5 shadow-xl space-y-3"
aria-label={title}
className="w-full max-w-lg bg-white dark:bg-surface rounded-2xl p-5 shadow-xl space-y-3 my-auto"
>
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">Upload invoice</h2>
<div className="min-w-0">
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">{title}</h2>
{editing && invoice && (
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{invoice.invoice_no}{po ? ` · ${po.po_no}` : ""}
</div>
)}
</div>
<button type="button" onClick={onClose} aria-label="Close"
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg hover:bg-gray-50 dark:hover:bg-white/5">
<X className="w-4 h-4" />
</button>
</div>
{!editing && (
<div>
<label className="text-[12px] text-gray-500 dark:text-gray-400" htmlFor="inv-search">
Find the purchase order — number, supplier, amount or description
</label>
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input id="inv-search" value={query} onChange={(e) => { setQuery(e.target.value); setPoId(""); }}
<input id="inv-search" value={query} onChange={(e) => { setQuery(e.target.value); setChosenId(""); }}
placeholder="e.g. MEGPPO49676, tyres, 12500" autoComplete="off"
className={`${inputCls} pl-9`} />
</div>
@ -750,7 +856,7 @@ function InvoiceUploadModal({ purchases, onClose }: { purchases: PurchaseView[];
{matches.length === 0 && <li className="p-3 text-xs text-gray-400">No purchase order matches.</li>}
{matches.map((p) => (
<li key={p.id}>
<button type="button" onClick={() => setPoId(p.id)}
<button type="button" onClick={() => setChosenId(p.id)}
className="w-full min-h-[44px] flex items-center gap-2 px-3 py-2 text-left hover:bg-gray-50 dark:hover:bg-white/5">
<span className="w-1 self-stretch rounded" style={{ background: STATUS_STYLE[invoiceStatus(p).status].color }} />
<span className="flex-1 min-w-0 text-xs">
@ -771,35 +877,80 @@ function InvoiceUploadModal({ purchases, onClose }: { purchases: PurchaseView[];
<span className="font-mono font-semibold text-[#003D5B] dark:text-white">{po.po_no}</span>
<span className="text-gray-500 dark:text-gray-400"> · {po.supplier}</span>
</span>
<button type="button" onClick={() => setPoId("")}
<button type="button" onClick={() => setChosenId("")}
className="min-h-[44px] px-2 text-[#30638E] font-medium">Change</button>
</div>
)}
</div>
)}
{editing && po && (
<div className="flex items-center gap-2 rounded-lg bg-gray-50 dark:bg-white/5 p-2 text-xs">
<span className="w-1 self-stretch rounded" style={{ background: STATUS_STYLE[st!.status].color }} />
<span className="flex-1 min-w-0">
<span className="font-mono font-semibold text-[#003D5B] dark:text-white">{po.po_no}</span>
<span className="text-gray-500 dark:text-gray-400"> · {po.supplier}</span>
</span>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className="text-[12px] text-gray-500 dark:text-gray-400">
Invoice number *
<input name="invoice_no" required className={inputCls} />
<input
value={invoiceNo}
onChange={(e) => setInvoiceNo(e.target.value)}
required
className={inputCls}
// An invoice already on file keeps its number unless somebody corrects it.
readOnly={editing}
/>
</label>
<label className="text-[12px] text-gray-500 dark:text-gray-400">
Invoice date
<input name="invoice_date" type="date" className={inputCls} />
<input
value={invoiceDate}
onChange={(e) => setInvoiceDate(e.target.value)}
type="date"
className={inputCls}
/>
</label>
<label className="text-[12px] text-gray-500 dark:text-gray-400 sm:col-span-2">
Amount incl VAT
<input name="amount_incl" value={amt} onChange={(e) => setAmt(e.target.value)} inputMode="decimal" placeholder="0.00" className={inputCls} />
{po && <OverWarning po={po} typed={amt} />}
{po && st && (
Amount incl VAT {editing ? "" : "*"}
<input
name="amount_incl"
value={amt}
onChange={(e) => setAmt(e.target.value)}
inputMode="decimal"
placeholder="0.00"
autoFocus
className={inputCls}
/>
{po && <OverWarning po={po} typed={amt} excludeId={editing ? invoice?.id : undefined} />}
{po && (
<span className="block mt-1 text-xs text-gray-500 dark:text-gray-400">
PO value <span className="font-semibold text-[#003D5B] dark:text-white tabular-nums">{ZAR2(po.total)}</span>
{" · "}already invoiced {ZAR2(st.invoiced)}
{" · "}left <span className="font-semibold tabular-nums">{ZAR2(Math.max(po.total - st.invoiced, 0))}</span>
{/* On an invoice already on file the figure being edited is excluded, so
"already invoiced" would read as nothing and mislead. */}
{!editing && <>{" · "}already invoiced {ZAR2(otherInvoiced)}</>}
{" · "}left <span className="font-semibold tabular-nums">{ZAR2(Math.max(po.total - otherInvoiced - (Number(amt.replace(/\s/g, "").replace(",", ".")) || 0), 0))}</span>
</span>
)}
</label>
{needsReason && (
<label className="text-[12px] text-gray-500 dark:text-gray-400 sm:col-span-2">
Reason for the change (kept in the log) *
<input
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. decimal point missed when captured"
className={inputCls}
/>
</label>
)}
</div>
{!editing && (
<div
onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
onDragLeave={() => setDrag(false)}
@ -828,6 +979,7 @@ function InvoiceUploadModal({ purchases, onClose }: { purchases: PurchaseView[];
</>
)}
</div>
)}
{error && <div className="text-xs text-[#D1495B] dark:text-[#D94F60]">{error}</div>}
@ -836,10 +988,11 @@ function InvoiceUploadModal({ purchases, onClose }: { purchases: PurchaseView[];
className="min-h-[44px] px-4 rounded-lg text-xs font-medium text-gray-500 hover:bg-gray-50 dark:hover:bg-white/5">
Cancel
</button>
<button type="submit" disabled={busy || !po}
<button type="submit" disabled={busy || (!editing && !po)}
className="min-h-[44px] px-4 rounded-lg text-xs font-medium text-white inline-flex items-center gap-2 disabled:opacity-50"
style={{ background: BRAND_ORANGE }}>
{busy && <Loader2 className="w-4 h-4 animate-spin" />} Save invoice
{busy && <Loader2 className="w-4 h-4 animate-spin" />}
{editing ? (wasPriced ? "Save correction" : "Save amount") : "Save invoice"}
</button>
</div>
</form>
@ -865,11 +1018,18 @@ function rangeWindow(range: "week" | "month", offset: number): { from: string; t
}
/** Live check while typing an invoice amount: anything that takes the PO over its total is flagged before it is saved. */
function OverWarning({ po, typed }: { po: PurchaseView; typed: string }) {
/**
* Live check while typing an invoice amount: anything that takes the PO over its total is
* flagged before it is saved.
*
* `excludeId` leaves out the invoice being edited — its own figure is being replaced, so
* counting it as well would report a false over-spend.
*/
function OverWarning({ po, typed, excludeId }: { po: PurchaseView; typed: string; excludeId?: string }) {
const n = Number(typed.trim().replace(/\s/g, "").replace(",", "."));
if (!typed.trim() || !Number.isFinite(n)) return null;
const over = po.invoices.reduce((t, i) => t + (i.amount_incl ?? 0), 0) + n - po.total;
const others = po.invoices.reduce((t, i) => (i.id === excludeId ? t : t + (i.amount_incl ?? 0)), 0);
const over = others + n - po.total;
if (over <= 1) return null;
return (
<span className="mt-1 flex items-start gap-1.5 text-xs font-medium text-[#8B5CF6]">

View file

@ -0,0 +1,95 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
checkAmountEdit,
checkAmountValue,
checkNewAmount,
checkReason,
isUnpriced,
AMOUNT_REQUIRED_ON_NEW_INVOICE,
} from "./purchase-invoice-amount";
test("an invoice with no amount is unpriced; a booked one is not", () => {
assert.equal(isUnpriced(null), true);
assert.equal(isUnpriced(0), true);
assert.equal(isUnpriced(undefined), true);
assert.equal(isUnpriced(1234.56), false);
});
test("pricing an invoice that has no amount needs no reason", () => {
const r = checkAmountEdit({ current: 0, raw: "5764.70" });
assert.equal(r.ok, true);
assert.ok(r.ok && r.amount === 5764.7);
assert.ok(r.ok && r.correcting === false);
// Still logged, so the trail shows when the figure was entered.
assert.ok(r.ok && r.reason.length > 0);
});
test("correcting an amount that was already booked needs a stated reason", () => {
const no = checkAmountEdit({ current: 5764.7, raw: "5764.70" });
assert.equal(no.ok, false);
assert.ok(!no.ok && /reason/i.test(no.error));
const short = checkAmountEdit({ current: 5764.7, raw: "5764.70", reason: "x" });
assert.equal(short.ok, false);
const ok = checkAmountEdit({ current: 5764.7, raw: "5764.71", reason: "decimal point missed" });
assert.equal(ok.ok, true);
assert.ok(ok.ok && ok.correcting === true);
assert.ok(ok.ok && ok.reason === "decimal point missed");
});
test("a blank, zero or negative amount is refused — R0 is not an amount", () => {
const blank = checkAmountEdit({ current: 0, raw: " " });
assert.equal(blank.ok, false);
assert.ok(!blank.ok && /enter the amount/i.test(blank.error));
const zero = checkAmountEdit({ current: 0, raw: "0" });
assert.equal(zero.ok, false);
const negative = checkAmountEdit({ current: 0, raw: "-50" });
assert.equal(negative.ok, false);
const words = checkAmountEdit({ current: 0, raw: "abc" });
assert.equal(words.ok, false);
});
test("a comma decimal point is read as a decimal point", () => {
const r = checkAmountEdit({ current: 0, raw: " 5 764,70 " });
assert.equal(r.ok, true);
assert.ok(r.ok && r.amount === 5764.7);
});
test("a new invoice must carry an amount when the policy says so", () => {
assert.equal(AMOUNT_REQUIRED_ON_NEW_INVOICE, true);
const blank = checkNewAmount("");
assert.equal(blank.ok, false);
const zero = checkNewAmount("0");
assert.equal(zero.ok, false);
const ok = checkNewAmount("13731");
assert.equal(ok.ok, true);
assert.ok(ok.ok && ok.amount === 13731);
});
test("pricing needs no reason, anything else does", () => {
const priced = checkReason({ pricingOnly: true, reason: "" });
assert.equal(priced.ok, true);
assert.ok(priced.ok && priced.reason === "Amount entered");
const correcting = checkReason({ pricingOnly: false, reason: "" });
assert.equal(correcting.ok, false);
assert.ok(!correcting.ok && /reason/i.test(correcting.error));
const withReason = checkReason({ pricingOnly: false, reason: "decimal point missed" });
assert.equal(withReason.ok, true);
assert.ok(withReason.ok && withReason.reason === "decimal point missed");
});
test("the value rules are usable on their own", () => {
assert.equal(checkAmountValue("5764.70").ok, true);
assert.equal(checkAmountValue("0").ok, false);
assert.equal(checkAmountValue("").ok, false);
const comma = checkAmountValue("5 764,70");
assert.equal(comma.ok, true);
assert.ok(comma.ok && comma.amount === 5764.7);
});

View file

@ -0,0 +1,121 @@
/**
* The amount on a supplier invoice against a purchase order.
*
* Two acts look alike on screen but must not be treated alike:
*
* - **PRICING** an invoice that has no amount yet. Nothing was booked, so there is nothing to
* justify and no reason to demand one. This is the ordinary case: the invoice was uploaded
* before its amount was known, and the PO shows R0 invoiced until somebody prices it.
* - **CORRECTING** an amount that was already booked. That figure has already reached the
* supplier's payables, so it needs a stated reason and it goes into the audit log.
*
* The API and the screen both read these rules from here, so they cannot disagree about what
* "no amount" means or when a reason is owed.
*
* ⚠️ **An amount of zero is NOT an amount.** A priced invoice reads `> 0`; zero means "nobody
* has typed a figure yet". Storing R0 would look like a real invoice for nothing, and it would
* quietly swallow the money — the exact thing this module exists to prevent.
*/
/**
* Whether a NEW invoice must carry an amount.
*
* `true` — an invoice can never be saved without one, so nothing is ever silently left at R0.
* `false` — an invoice may be filed with the amount still unknown; it is reported as unpriced
* and can be priced later by double-clicking its amount.
*/
export const AMOUNT_REQUIRED_ON_NEW_INVOICE = true;
/**
* An invoice that has never been priced.
*
* `null` and `0` are the same thing here: nobody has typed a figure yet. The PO's "invoiced"
* total must never read an unpriced invoice as R0 — it is simply not counted.
*/
export function isUnpriced(amount: number | null | undefined): boolean {
return amount == null || !(amount > 0);
}
export type AmountEditResult =
| { ok: true; amount: number; reason: string; correcting: boolean }
| { ok: false; error: string };
export type ValueResult = { ok: true; amount: number } | { ok: false; error: string };
/** The number rules on their own: a figure must be present and must be more than zero. */
export function checkAmountValue(raw: string): ValueResult {
const clean = String(raw ?? "").trim().replace(/\s/g, "").replace(",", ".");
if (!clean) {
return { ok: false, error: "Enter the amount for this invoice." };
}
const amount = Number(clean);
if (!Number.isFinite(amount)) {
return { ok: false, error: "The amount must be a number." };
}
if (amount <= 0) {
return { ok: false, error: "The amount must be more than zero." };
}
return { ok: true, amount };
}
export type ReasonResult = { ok: true; reason: string } | { ok: false; error: string };
/**
* The reason a correction must carry.
*
* `pricingOnly` means the edit does nothing but put a first figure on an invoice that never
* had one. Nothing was booked, so there is nothing to justify — but it is still logged, so the
* trail shows when the figure was entered.
*/
export function checkReason(input: { pricingOnly: boolean; reason: string }): ReasonResult {
const reason = String(input.reason ?? "").trim();
if (input.pricingOnly) {
return { ok: true, reason: reason || "Amount entered" };
}
if (reason.length < 3) {
return { ok: false, error: "Give a reason for the change — it is kept in the log." };
}
return { ok: true, reason };
}
/**
* Check an amount typed against an invoice that is already on file.
*
* `current` is what the invoice carries now (0 / null = never priced).
* `reason` is the person's stated reason — required only when a booked figure is being changed.
*/
export function checkAmountEdit(input: {
current: number | null | undefined;
raw: string;
reason?: string;
}): AmountEditResult {
const value = checkAmountValue(input.raw);
if (!value.ok) return value;
// Correcting = the invoice already carries a booked figure.
const correcting = !isUnpriced(input.current);
const stated = checkReason({ pricingOnly: !correcting, reason: input.reason ?? "" });
if (!stated.ok) return stated;
return { ok: true, amount: value.amount, reason: stated.reason, correcting };
}
export type NewAmountResult =
| { ok: true; amount: number | null }
| { ok: false; error: string };
/**
* Check the amount on a NEW invoice.
*
* `null` in the result means "no amount given" — only possible when
* `AMOUNT_REQUIRED_ON_NEW_INVOICE` is false.
*/
export function checkNewAmount(raw: string): NewAmountResult {
const clean = String(raw ?? "").trim().replace(/\s/g, "").replace(",", ".");
if (!clean) {
return AMOUNT_REQUIRED_ON_NEW_INVOICE
? { ok: false, error: "Enter the amount for this invoice." }
: { ok: true, amount: null };
}
const value = checkAmountValue(clean);
if (!value.ok) return value;
return { ok: true, amount: value.amount };
}