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>
121 lines
4.7 KiB
TypeScript
121 lines
4.7 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|