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>
553 lines
21 KiB
TypeScript
553 lines
21 KiB
TypeScript
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);
|
||
});
|
||
});
|