WFDASH-WEBAPP/src/lib/quote-pricing.test.ts
Dave c22e9d1bd6 feat(delivery-notes): lock the cost price, markup stays the only input
A delivery note's cost price is what the business paid. Editing it
while setting a margin rewrites that silently, so a stray keystroke
changes every downstream number on the note without ever looking like
a pricing decision.

The rule lives in quote-pricing.ts as a `costLocked` flag captured at
load time, and repriceLine DROPS a cost patch on a locked line. That
is the guarantee; the read-only cell in the UI is only the
convenience, so no caller can quietly rewrite the cost.

- "Edit note" and "Create quote" grids: cost is read-only with a lock
  icon once the line arrived costed; a blank cost stays fillable and
  still seeds the 25% default markup on first entry.
- readOnly, not disabled, so the number can still be selected and
  checked against a supplier invoice.
- Locked at load, not per keystroke: a cost typed in this session
  stays correctable until save (an operator choice), and locks on the
  next open because the row then carries the cost.
- The DN detail page's edit mode is untouched — it stays the
  deliberate "that cost is simply wrong" correction path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-15 15:44:24 +02:00

553 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from "vitest";
import {
applyMarkupFloor,
buildLinePricing,
DEFAULT_MARKUP_PERCENT,
hasCostPrice,
isCostEditable,
MIN_MARKUP_PERCENT,
markupFromCostAndSelling,
repriceLine,
repriceWithCostEntry,
round2,
round2Up,
sellingFromCostAndMarkup,
totalsForLines,
} from "./quote-pricing";
/**
* A wrong selling price here is invisible: it renders as a plausible
* number, the total still sums, and no screen says "error". These
* tests pin the arithmetic that the review modal's totals depend on.
*/
describe("round2", () => {
it("kills float drift rather than surfacing it", () => {
// 100 * 1.15 is 114.99999999999999 in IEEE-754. That has to reach
// the quote as 115, not as a 14-decimal number.
expect(sellingFromCostAndMarkup(100, 15)).toBe(115);
expect(round2(0.1 + 0.2)).toBe(0.3);
});
it("rounds half away from zero, not to even", () => {
expect(round2(1.005)).toBe(1.01);
expect(round2(2.675)).toBe(2.68);
});
it("coerces non-finite input to 0 rather than NaN", () => {
expect(round2(Number.NaN)).toBe(0);
expect(round2(Number.POSITIVE_INFINITY)).toBe(0);
});
});
describe("markup convention", () => {
it("treats markup as a PERCENTAGE, not a multiplier", () => {
// 25 means 25%, so cost 100 -> 125. Getting this backwards turns
// a 25% margin into a 25x price and nobody would notice until
// someone tried to pay it.
expect(sellingFromCostAndMarkup(100, 25)).toBe(125);
expect(sellingFromCostAndMarkup(100, 0)).toBe(100);
expect(sellingFromCostAndMarkup(100, 100)).toBe(200);
});
it("round-trips cost -> selling -> markup", () => {
for (const [cost, markup] of [
[100, 25],
[57.5, 33.3],
[1234.56, 18.75],
[1, 99],
] as const) {
const selling = sellingFromCostAndMarkup(cost, markup);
const back = markupFromCostAndSelling(cost, selling);
expect(Math.abs(back - markup)).toBeLessThan(0.02);
}
});
it("reports zero markup for a zero cost instead of dividing by zero", () => {
expect(markupFromCostAndSelling(0, 500)).toBe(0);
});
});
describe("hasCostPrice", () => {
it("requires a positive finite number", () => {
expect(hasCostPrice(0)).toBe(false);
expect(hasCostPrice(null)).toBe(false);
expect(hasCostPrice(undefined)).toBe(false);
expect(hasCostPrice(Number.NaN)).toBe(false);
expect(hasCostPrice(0.01)).toBe(true);
});
});
/**
* ── The minus-one-hundred regression ──────────────────────────────
*
* 40 live quote_items rows were found holding `markup = -100`, every
* one of them with a real cost (R0.04 … R0.38) and `selling_price = 0`.
* Nobody typed -100. The derivation `(selling - cost) / cost * 100`
* returns exactly -100 whenever selling is 0 and cost is positive, so
* any screen that derived markup from a line that had a cost but no
* selling price yet produced -100 and persisted it.
*
* These tests pin the fix at each of the three layers: derivation,
* the floor helper, and the round-up that stops selling collapsing
* to zero in the first place.
*/
describe("round2Up", () => {
it("never rounds DOWN", () => {
expect(round2Up(1.201)).toBe(1.21);
expect(round2Up(1.209)).toBe(1.21);
expect(round2Up(1.2)).toBe(1.2);
});
it("can never turn a positive number into zero — the structural guarantee", () => {
// This is the whole point. `round2` (to nearest) sends 0.07 × 1.30
// = 0.091 to 0.09, but on a 4-cent washer the same operation
// collapses the SELLING price to R0.00, and a selling price of 0
// is precisely what re-derives as -100%.
for (const cost of [0.04, 0.06, 0.07, 0.16, 0.38, 1.19]) {
for (const markup of [15, 20, 25, 30, 45]) {
const selling = round2Up(cost * (1 + markup / 100));
expect(selling).toBeGreaterThan(0);
}
}
});
it("keeps an exact 2dp boundary from being bumped a whole cent by float drift", () => {
// 0.056 × 1.25 computes as 0.07000000000000001, which a naive
// ceil takes to R0.08 — a whole cent overcharge on every such line.
// The EPSILON guard pulls it back to the R0.07 it actually is.
expect((0.056 * 1.25) * 100).toBeGreaterThan(Math.round(0.056 * 1.25 * 100)); // drift
expect(round2Up(0.056 * 1.25)).toBe(0.07);
expect(round2Up(0.112 * 1.25)).toBe(0.14);
expect(round2Up(0.168 * 1.25)).toBe(0.21);
});
it("coerces non-finite input to 0 rather than NaN", () => {
expect(round2Up(Number.NaN)).toBe(0);
expect(round2Up(Number.POSITIVE_INFINITY)).toBe(0);
});
});
describe("the -100 bug: markup derived from a zero selling price", () => {
it("used to return exactly -100, and must never do so again", () => {
// The raw formula, unguarded, for the real costs found in the DB.
for (const cost of [0.04, 0.06, 0.07, 0.16, 0.25, 0.38, 1.19]) {
expect(((0 - cost) / cost) * 100).toBe(-100);
// And the fix:
expect(markupFromCostAndSelling(cost, 0)).toBe(MIN_MARKUP_PERCENT);
}
});
it("floors a selling price set BELOW cost rather than reporting a loss", () => {
// "KEY STEEL, 18 X 11mm" sat at cost 321 / selling 182.95 = -43.01%.
expect(markupFromCostAndSelling(321, 182.95)).toBe(MIN_MARKUP_PERCENT);
// The 1.19 / 1.00 family that produced -15.97, -6.54, -4.76, -7.41.
expect(markupFromCostAndSelling(1.19, 1)).toBe(MIN_MARKUP_PERCENT);
expect(markupFromCostAndSelling(1.07, 1)).toBe(MIN_MARKUP_PERCENT);
});
it("still reports the truth above the floor", () => {
expect(markupFromCostAndSelling(100, 125)).toBe(25);
expect(markupFromCostAndSelling(100, 120)).toBe(20);
// Exactly on the floor passes through untouched.
expect(markupFromCostAndSelling(100, 118)).toBe(MIN_MARKUP_PERCENT);
});
it("does NOT round DOWN into the floor", () => {
// 17.4% is under the floor and must read as the floor, not 17.
expect(markupFromCostAndSelling(100, 117.4)).toBe(MIN_MARKUP_PERCENT);
});
});
describe("applyMarkupFloor", () => {
it("raises a sub-floor markup on a priced line", () => {
expect(applyMarkupFloor(0, 100)).toBe(MIN_MARKUP_PERCENT);
expect(applyMarkupFloor(8, 100)).toBe(MIN_MARKUP_PERCENT);
expect(applyMarkupFloor(-100, 0.07)).toBe(MIN_MARKUP_PERCENT);
});
it("leaves an above-floor markup exactly as chosen", () => {
expect(applyMarkupFloor(18, 100)).toBe(18);
expect(applyMarkupFloor(22, 100)).toBe(22);
expect(applyMarkupFloor(50, 100)).toBe(50);
});
it("does NOT floor an unpriced draft — 1,181 live rows depend on this", () => {
// cost 0 + markup 0 means "nothing decided yet", not "0% margin".
// Flooring these would block creating a quote line at all.
expect(applyMarkupFloor(0, 0)).toBe(0);
expect(applyMarkupFloor(0, null)).toBe(0);
expect(applyMarkupFloor(0, undefined)).toBe(0);
});
it("floors a NEGATIVE markup even on a cheap line", () => {
expect(applyMarkupFloor(-100, 0.07)).toBe(MIN_MARKUP_PERCENT);
expect(applyMarkupFloor(-43.01, 321)).toBe(MIN_MARKUP_PERCENT);
});
it("coerces garbage to the floor rather than propagating NaN", () => {
expect(applyMarkupFloor(Number.NaN, 100)).toBe(MIN_MARKUP_PERCENT);
});
});
describe("selling price cannot collapse to zero on a cheap line", () => {
it("keeps a 30% markup on the real washer/nut costs as a real price", () => {
// The approvals screen used `Math.round(cost * (1 + m/100))` with
// no precision argument, which is round-to-WHOLE-RAND: every one of
// these became R0.00 and then re-derived as -100%.
for (const cost of [0.04, 0.06, 0.07, 0.16, 0.38]) {
const selling = sellingFromCostAndMarkup(cost, 30);
expect(selling).toBeGreaterThan(0);
expect(selling).toBeGreaterThan(cost);
}
});
it("rounds UP so the quoted markup is never undercut", () => {
// 30% on R0.07 is R0.091. Ceiling → R0.10 (a real 42.9% margin, so
// the customer is never under-quoted). Rounding to nearest would
// give R0.09, which re-derives as 28.6% — the "my number changed to
// something random" complaint, and an UNDERQUOTE besides.
expect(sellingFromCostAndMarkup(0.07, 30)).toBe(0.1);
expect(sellingFromCostAndMarkup(0.06, 25)).toBe(0.08);
// Proof the old value was an underquote:
expect(Math.round(0.07 * 1.3 * 100) / 100).toBeLessThan(0.07 * 1.3);
});
it("leaves a price that is already clean alone", () => {
expect(sellingFromCostAndMarkup(100, 25)).toBe(125);
expect(sellingFromCostAndMarkup(12.5, 25)).toBe(15.63);
});
});
describe("repriceLine honours the floor", () => {
// `costLocked: false` = a costed line whose cost is still editable,
// i.e. a cost entered during the current session. These tests are
// about the floor, so the lock is deliberately left off here — the
// locked-line behaviour has its own describe block below.
const base = {
orderItemId: "i1",
description: "M10 FLAT WASHER BLK",
quantity: 1,
costPrice: 0.07,
markupPercent: 30,
sellingPrice: 0.1,
costLocked: false,
};
it("does NOT floor while the operator is typing", () => {
// This function runs on every keystroke in the pricing grids, so
// flooring here made the input fight the person typing in it:
// entering "20", the "2" was raised to 18 mid-keystroke and the
// box snapped. The operator's report was "it just reverts back
// to the old numbers" and "I start typing, automatically change
// it to 18". The floor belongs at COMMIT — see the tests below.
const r = repriceLine(base, { markupPercent: 0 });
expect(r.markupPercent).toBe(0);
const neg = repriceLine(base, { markupPercent: -100 });
expect(neg.markupPercent).toBe(-100);
});
it("floors when the caller asks for it (the commit path)", () => {
// blur / save pass `floor: true`. This is what keeps a
// sub-floor value out of the database, which is the -100-row
// hole the floor was introduced for.
const r = repriceLine(base, { markupPercent: 0 }, { floor: true });
expect(r.markupPercent).toBe(MIN_MARKUP_PERCENT);
expect(r.sellingPrice).toBeGreaterThan(0);
const bulk = repriceLine(base, { markupPercent: -100 }, { floor: true });
expect(bulk.markupPercent).toBe(MIN_MARKUP_PERCENT);
});
it("applyMarkupFloor is the commit-time rule both grids use", () => {
// Pinned directly, because it is now the ONLY thing standing
// between a typed "5" and a sub-floor row in the database.
expect(applyMarkupFloor(5, 100)).toBe(MIN_MARKUP_PERCENT);
expect(applyMarkupFloor(0, 100)).toBe(MIN_MARKUP_PERCENT);
expect(applyMarkupFloor(-100, 100)).toBe(MIN_MARKUP_PERCENT);
expect(applyMarkupFloor(25, 100)).toBe(25);
// An uncosted draft is not a 0% margin, it is an undecided line.
expect(applyMarkupFloor(0, 0)).toBe(0);
});
it("still allows the deliberate thin margins reps actually use", () => {
expect(repriceLine(base, { markupPercent: 18 }).markupPercent).toBe(18);
expect(repriceLine(base, { markupPercent: 20 }).markupPercent).toBe(20);
expect(repriceLine(base, { markupPercent: 22 }).markupPercent).toBe(22);
});
});
describe("buildLinePricing", () => {
it("applies the default markup to a cost-only line", () => {
const [line] = buildLinePricing([
{ id: "i1", description: "Bolt", quantity: 2, cost_price: 100 },
]);
expect(line).toMatchObject({
orderItemId: "i1",
quantity: 2,
costPrice: 100,
markupPercent: DEFAULT_MARKUP_PERCENT,
sellingPrice: 125,
});
});
it("respects an EXISTING selling price instead of re-marking it", () => {
// A human priced this before the delivery note existed. Silently
// replacing it with the default markup would overwrite a decision.
const [line] = buildLinePricing([
{ id: "i1", cost_price: 100, selling_price: 160 },
]);
expect(line.sellingPrice).toBe(160);
expect(line.markupPercent).toBe(60);
});
it("leaves a no-cost line at zero rather than quoting a fake R0.00", () => {
// cost 0 x (1 + 25%) is still 0 — and a selling price of 0 looks
// like a deliberate free-of-charge item. It has to read as
// "no cost on file" in the UI, which means a real 0 here.
const [line] = buildLinePricing([{ id: "i1", cost_price: 0 }], 25);
expect(line.costPrice).toBe(0);
expect(line.markupPercent).toBe(0);
expect(line.sellingPrice).toBe(0);
expect(hasCostPrice(line.costPrice)).toBe(false);
});
it("defaults a missing quantity to 1", () => {
const [line] = buildLinePricing([{ id: "i1", cost_price: 50 }]);
expect(line.quantity).toBe(1);
});
it("maps 1:1 in the order it was given", () => {
const lines = buildLinePricing([
{ id: "a", cost_price: 10 },
{ id: "b", cost_price: 20 },
{ id: "c", cost_price: 0 },
]);
expect(lines.map((l) => l.orderItemId)).toEqual(["a", "b", "c"]);
});
});
describe("repriceLine", () => {
const base = {
orderItemId: "i1",
description: "Bolt",
quantity: 1,
costPrice: 100,
markupPercent: 25,
sellingPrice: 125,
costLocked: false,
};
it("re-derives selling when the markup changes", () => {
expect(repriceLine(base, { markupPercent: 40 }).sellingPrice).toBe(140);
});
it("re-derives selling when the cost changes", () => {
const r = repriceLine(base, { costPrice: 200 });
expect(r.sellingPrice).toBe(250);
expect(r.markupPercent).toBe(25);
});
it("collapses to zero when the cost is zeroed out", () => {
// Editing cost to 0 must NOT leave the old selling price hanging
// on the line — that is a R125 charge on a free item.
const r = repriceLine(base, { costPrice: 0 });
expect(r.sellingPrice).toBe(0);
});
it("does not mutate the input line", () => {
repriceLine(base, { markupPercent: 99 });
expect(base.sellingPrice).toBe(125);
expect(base.markupPercent).toBe(25);
});
it("leaves untouched fields alone", () => {
const r = repriceLine(base, { markupPercent: 50 });
expect(r.orderItemId).toBe("i1");
expect(r.description).toBe("Bolt");
expect(r.quantity).toBe(1);
});
});
describe("totalsForLines", () => {
it("multiplies by quantity and sums both sides", () => {
// 100 @ 25% x3 = 375, and 50 @ 25% x2 = 125.
const totals = totalsForLines([
{ ...buildLinePricing([{ id: "a", cost_price: 100, quantity: 3 }])[0] },
{ ...buildLinePricing([{ id: "b", cost_price: 50, quantity: 2 }])[0] },
]);
expect(totals.costTotal).toBe(400);
expect(totals.sellingTotal).toBe(500);
expect(totals.marginValue).toBe(100);
});
it("reports margin as a share of REVENUE, not of cost", () => {
// cost 100 -> selling 125 is a 25% markup but only a 20% margin.
// Conflating the two is the classic pricing mistake.
const totals = totalsForLines([
buildLinePricing([{ id: "a", cost_price: 100, selling_price: 125 }])[0],
]);
expect(totals.marginPercent).toBe(20);
});
it("is zero-safe on an empty set and on no-revenue lines", () => {
expect(totalsForLines([])).toEqual({
costTotal: 0,
sellingTotal: 0,
marginValue: 0,
marginPercent: 0,
});
const noCost = totalsForLines([
buildLinePricing([{ id: "a", cost_price: 0 }])[0],
]);
expect(noCost.marginPercent).toBe(0);
});
it("keeps cost and selling totals in step after an edit", () => {
// The modal recomputes these live on every keystroke, so an
// inconsistency here shows up as footer numbers that don't match
// the visible rows.
let lines = buildLinePricing([{ id: "a", cost_price: 100, quantity: 4 }]);
lines = [repriceLine(lines[0], { markupPercent: 30 })];
const totals = totalsForLines(lines);
expect(totals.costTotal).toBe(400);
expect(totals.sellingTotal).toBe(520);
});
});
describe("repriceWithCostEntry", () => {
const noCost = {
orderItemId: "i1",
description: "1.6MM BRAZING ROD",
quantity: 10,
costPrice: 0,
markupPercent: 0,
sellingPrice: 0,
costLocked: false,
};
it("seeds the default markup when the FIRST cost is typed", () => {
// Found in the browser, not here. A counter note opens with no cost
// on file, so the line starts at markup 0; typing a cost used to
// leave it there and derive a selling price EQUAL to the cost —
// a line that looks priced and earns nothing.
const r = repriceWithCostEntry(noCost, { costPrice: 100 });
expect(r.markupPercent).toBe(DEFAULT_MARKUP_PERCENT);
expect(r.sellingPrice).toBe(125);
});
it("yields a margin, not a zero-margin line", () => {
const r = repriceWithCostEntry(noCost, { costPrice: 100 });
const totals = totalsForLines([r]);
expect(totals.sellingTotal).toBeGreaterThan(totals.costTotal);
expect(totals.marginValue).toBeGreaterThan(0);
});
it("does NOT reseed when the operator is editing an already-costed line", () => {
const costed = { ...noCost, costPrice: 100, markupPercent: 40, sellingPrice: 140 };
const r = repriceWithCostEntry(costed, { costPrice: 200 });
expect(r.markupPercent).toBe(40);
expect(r.sellingPrice).toBe(280);
});
it("floors an explicit 0 on a COSTED line once committed", () => {
// A zero-margin line is a line that can be rounded, re-derived or
// nudged into a negative one — the hole the -100 rows came
// through. So an explicit 0 is still raised at commit; it is just
// no longer raised WHILE TYPING, which is what broke the input.
// Uncosted drafts stay untouched — see applyMarkupFloor.
const typed = repriceWithCostEntry(noCost, {
costPrice: 100,
markupPercent: 0,
});
expect(typed.markupPercent).toBe(0);
const committed = repriceLine(typed, {}, { floor: true });
expect(committed.markupPercent).toBe(MIN_MARKUP_PERCENT);
expect(committed.sellingPrice).toBe(118);
});
it("seeds nothing when the cost is cleared back to zero", () => {
const costed = { ...noCost, costPrice: 100, markupPercent: 0, sellingPrice: 100 };
const r = repriceWithCostEntry(costed, { costPrice: 0 });
expect(r.sellingPrice).toBe(0);
});
it("is a pure no-op when the patch carries no cost", () => {
const costed = { ...noCost, costPrice: 100, markupPercent: 25, sellingPrice: 125 };
expect(repriceWithCostEntry(costed, { markupPercent: 30 })).toEqual({
...costed,
markupPercent: 30,
sellingPrice: 130,
});
});
});
/**
* A cost price that is already on file is fixed. The grids render it
* read-only, but the real guarantee is in `repriceLine`: a cost
* patch on a locked line is dropped, so no caller — a stray edit
* handler, a future bulk action, a keyboard shortcut — can quietly
* rewrite what was paid while the operator believes they only moved
* the markup.
*/
describe("a cost price already on file is fixed", () => {
const locked = buildLinePricing([
{ id: "i1", description: "Bolt", quantity: 2, cost_price: 100 },
])[0];
it("marks a costed line locked at load", () => {
expect(locked.costLocked).toBe(true);
expect(isCostEditable(locked)).toBe(false);
});
it("leaves an uncosted line open", () => {
const [open] = buildLinePricing([{ id: "i2", cost_price: 0 }]);
expect(open.costLocked).toBe(false);
expect(isCostEditable(open)).toBe(true);
});
it("still lets the markup move — that's the point of the screen", () => {
const r = repriceWithCostEntry(locked, { markupPercent: 40 });
expect(r.costPrice).toBe(100);
expect(r.markupPercent).toBe(40);
expect(r.sellingPrice).toBe(140);
});
it("drops a cost patch instead of applying it", () => {
const r = repriceWithCostEntry(locked, { costPrice: 1 });
expect(r.costPrice).toBe(100);
// And therefore the selling price still reflects the real cost —
// a rejected cost must not leave the margin quietly derived from
// the rejected number.
expect(r.sellingPrice).toBe(125);
});
it("drops a cost patch even when it arrives with a markup", () => {
const r = repriceWithCostEntry(locked, { costPrice: 999, markupPercent: 50 });
expect(r.costPrice).toBe(100);
expect(r.markupPercent).toBe(50);
expect(r.sellingPrice).toBe(150);
});
it("cannot be unlocked by re-running the cost-entry path", () => {
const r = repriceWithCostEntry(locked, { costPrice: 0 });
expect(r.costLocked).toBe(true);
expect(r.costPrice).toBe(100);
});
it("leaves the lock standing across a bulk re-markup", () => {
const bulk = locked;
const r = repriceWithCostEntry(bulk, { markupPercent: MIN_MARKUP_PERCENT }, 25);
expect(r.costLocked).toBe(true);
expect(r.sellingPrice).toBe(118);
});
it("keeps the lock across the commit-time floor pass", () => {
// `applyMarkupFloor` + a re-price is what save actually runs. If
// that path could drop the flag, the second open of the dialog
// would quietly unlock a cost.
const committed = repriceLine(locked, { markupPercent: 0 }, { floor: true });
expect(committed.costLocked).toBe(true);
expect(committed.markupPercent).toBe(MIN_MARKUP_PERCENT);
});
});