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:
parent
475f754efa
commit
696c157e2e
6 changed files with 569 additions and 123 deletions
53
AGENTS.md
53
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.
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]">
|
||||
|
|
|
|||
95
lib/purchase-invoice-amount.test.ts
Normal file
95
lib/purchase-invoice-amount.test.ts
Normal 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);
|
||||
});
|
||||
121
lib/purchase-invoice-amount.ts
Normal file
121
lib/purchase-invoice-amount.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue