WFDASH-WEBAPP/src/lib/quote-pricing.test.ts
Dave 97c3cd4d7e feat(delivery-notes): review pricing before a quote is created
The Create quote button fired blind: the quote appeared with whatever
pricing happened to be on the order items, and a wrong price was only
discoverable by finding the quote afterwards. A new review modal puts
the check in front of the create.

Modal (components/delivery-notes/quote-review-dialog.tsx)
- Letterhead picker. DN-backed quotes never set company_letterhead, so
  the renderer defaulted every one of them to CNL.
- Cost and markup per line; selling price is DERIVED, never typed, so
  the two cannot disagree. See src/lib/quote-pricing.ts.
- A no-cost line shows "No cost on file" and stays at R0.00 rather
  than deriving a fake price off a zero cost, which reads as a
  deliberate free-of-charge item.
- Bulk "set all markups" field — typing 25% into eight lines one at a
  time is the tedium the modal exists to remove.
- Totals, with margin as a share of REVENUE not cost: 25% markup is a
  20% margin, and conflating them quietly under-prices a job.
- Nothing persists until Generate. Cancel discards the whole grid.

Backend
- create-quote now accepts { letterhead, items: [{ orderItemId,
  costPrice, markupPercent }] } and derives selling server-side.
- Overrides are keyed by order_item id and validated against THIS
  note's lines. An unknown id aborts the create rather than moving
  another order's goods into this customer's quote.
- Quote items now go out as one PocketBase batch instead of a loop of
  individual creates. The batch is transactional: the old loop could
  fail on line 5 of 8 and leave a real quote header attached to half
  its lines. The header still has to be created first (the batch API
  can't reference a record created in the same batch), so a failed
  batch deletes the orphaned header rather than leaving it visible.
- Malformed bodies are 400s the operator can read, not 500s. "A quote
  already exists" and "not in draft" are now 409s.

Tests
- 33 new: pricing maths, and the route's validation guards. Verified
  the guards bite by removing them (2 fail) and restored.
- The existing service test's PB mock gained createBatch, modelled as
  genuinely all-or-nothing — the compensation path depends on that
  guarantee being real.

Full suite: 3615 passing. tsc clean.
2026-10-04 21:17:31 +02:00

211 lines
6.9 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
buildLinePricing,
DEFAULT_MARKUP_PERCENT,
hasCostPrice,
markupFromCostAndSelling,
repriceLine,
round2,
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);
});
});
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,
};
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);
});
});