diff --git a/AGENTS.md b/AGENTS.md index 36d9b4e..59e0a41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/app/api/purchases/[id]/invoices/[invoiceId]/route.ts b/app/api/purchases/[id]/invoices/[invoiceId]/route.ts index 3a87af2..6288671 100644 --- a/app/api/purchases/[id]/invoices/[invoiceId]/route.ts +++ b/app/api/purchases/[id]/invoices/[invoiceId]/route.ts @@ -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 = {}; 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 }); } diff --git a/app/api/purchases/[id]/invoices/route.ts b/app/api/purchases/[id]/invoices/route.ts index 5b392f6..5dae8f4 100644 --- a/app/api/purchases/[id]/invoices/route.ts +++ b/app/api/purchases/[id]/invoices/route.ts @@ -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; diff --git a/components/PurchaseIntake.tsx b/components/PurchaseIntake.tsx index 5c7e035..da7dc80 100644 --- a/components/PurchaseIntake.tsx +++ b/components/PurchaseIntake.tsx @@ -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(null); const [open, setOpen] = useState(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 {invoiceModal && setInvoiceModal(false)} />} + {pricing && ( + setPricing(null)} + /> + )} {error && ( @@ -223,7 +235,7 @@ export function PurchaseIntake({ purchases: allPurchases }: { purchases: Purchas
{([["all", "All"], ["week", "Week"], ["month", "Month"]] as const).map(([id, label]) => ( - -
- - ) : ( + {p.invoices.map((i) => (
  • {i.invoice_no} @@ -553,22 +547,7 @@ function SupplierInvoices({ purchase: p, editMode }: { purchase: PurchaseView; e {i.note && ` · ${i.note}`}
    -
    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 ? no amount : ZAR2(i.amount_incl)} -
    - {editMode && ( - - )} + onPrice(i)} /> {i.hasPdf ? ( @@ -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 | 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 ( + + ); +} + 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(null); const [query, setQuery] = useState(""); - const [poId, setPoId] = useState(""); + const [chosenId, setChosenId] = useState(purchaseId ?? ""); const [file, setFile] = useState(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(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 (
    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" >
    -

    Upload invoice

    +
    +

    {title}

    + {editing && invoice && ( +
    + {invoice.invoice_no}{po ? ` · ${po.po_no}` : ""} +
    + )} +
    + {!editing && (
    - { setQuery(e.target.value); setPoId(""); }} + { setQuery(e.target.value); setChosenId(""); }} placeholder="e.g. MEGPPO49676, tyres, 12500" autoComplete="off" className={`${inputCls} pl-9`} />
    @@ -750,7 +856,7 @@ function InvoiceUploadModal({ purchases, onClose }: { purchases: PurchaseView[]; {matches.length === 0 &&
  • No purchase order matches.
  • } {matches.map((p) => (
  • -
  • )}
    + )} + + {editing && po && ( +
    + + + {po.po_no} + · {po.supplier} + +
    + )}
    + {needsReason && ( + + )}
    + {!editing && (
    { e.preventDefault(); setDrag(true); }} onDragLeave={() => setDrag(false)} @@ -828,6 +979,7 @@ function InvoiceUploadModal({ purchases, onClose }: { purchases: PurchaseView[]; )}
    + )} {error &&
    {error}
    } @@ -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 - @@ -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 ( diff --git a/lib/purchase-invoice-amount.test.ts b/lib/purchase-invoice-amount.test.ts new file mode 100644 index 0000000..27f0547 --- /dev/null +++ b/lib/purchase-invoice-amount.test.ts @@ -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); +}); diff --git a/lib/purchase-invoice-amount.ts b/lib/purchase-invoice-amount.ts new file mode 100644 index 0000000..1001483 --- /dev/null +++ b/lib/purchase-invoice-amount.ts @@ -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 }; +}