mega-plant/lib/purchase-invoice-amount.ts
Dave 696c157e2e 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>
2026-11-04 18:06:02 +02:00

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 };
}