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>
95 lines
3.4 KiB
TypeScript
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);
|
|
});
|