mega-plant/lib/purchase-invoice-amount.test.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

95 lines
3.4 KiB
TypeScript

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