WFDASH-WEBAPP/src/lib/quote-pricing.ts
Dave 6cfb413508 fix(pricing): the 18% floor was applied on every keystroke
Operator report: "If I double click on it and I put in 20 there, goes
to 18... if I take everything out, I start typing, automatically change
it to 18. I need to be able to add any value there and when I click
save and the value is less than 18, it should go to 18."

The floor (`2387a71`) was correct in principle and wrong in WHERE it
sat. It was applied inside `repriceLine`, which runs on every
keystroke in both pricing grids — so the moment a sub-floor digit was
typed the state was raised to 18 and the box snapped.

Typing "20" is the clearest demonstration: the "2" is sub-floor, so it
became 18 mid-keystroke; the next character then APPENDED to "18" and
produced 180. What the operator saw as "goes to 18" was the first
keystroke, and "it jumps around" was the second.

She asked for the floor on SAVE, not while typing. So:

  - `repriceLine` no longer floors. Markup is also rounded to 2dp on
    the way in, so the box holds `80.06` rather than the float noise
    a back-solved markup produces (`80.05999755859375`).
  - The floor applies on BLUR and on SAVE, in both the delivery-note
    editor and the quote review modal, via `applyMarkupFloor` — the
    same function, still the only thing between a typed value and the
    database, so the -100-row hole stays closed.
  - `step="0.5"` replaced with `step="any"`. A back-solved 80.06 is
    not a multiple of 0.5, which left those boxes `:invalid` — and a
    number input in an invalid state fights the caret. This is very
    likely a second contributor to "I can't do anything there".
  - The bulk "set all markups" field gets the same treatment.

Verified with real keystrokes in the browser, not synthetic events
(which had masked this — they set a value in one shot, so the
mid-keystroke floor never fired):

  type "20"  -> stays 20, selling R605,40 (504,50 x 1,20)
  type "15"  -> stays 15 while typing
  blur       -> 18, selling R595,31 (504,50 x 1,18)

Three existing tests asserted the per-keystroke floor. They are
repointed, not deleted: the commit path is now pinned directly, and
`applyMarkupFloor` has its own tests so the database protection is
still asserted.

No data changed — the editor work above was dialog-local state only.

Full suite 3833 passing, tsc clean.
2026-10-09 12:49:13 +02:00

329 lines
12 KiB
TypeScript

/**
* Quote pricing maths for the "create quote from a delivery note"
* review modal.
*
* Pulled into its own module because this is the one place in the
* create-quote flow where a wrong answer is invisible: a selling
* price that's out by a few cents still renders as a plausible
* number, the total still sums, and nothing anywhere says "error".
* The operator only finds out when the customer is invoiced.
*
* The house rule, used consistently across `quotes.create`,
* `bulk_update_quote_items` and `quote_items.create`:
*
* selling = cost x (1 + markup / 100)
*
* Markup is the INPUT; selling price is DERIVED. Letting both be
* typed independently is how a 25% markup and a 30% selling price
* end up disagreeing on the same line with nothing to flag it.
*/
/** Round to 2dp, avoiding the float drift that makes 100*1.15 come
* out as 114.99999999999999. */
export function round2(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.round((value + Number.EPSILON) * 100) / 100;
}
/**
* Round UP to 2dp — never down.
*
* This is the fix for the "R0.07 became R0.00" class of bug, and the
* reason is structural rather than clever: a ceiling can never turn a
* positive number into zero, so anything derived through this can never
* land on a selling price of 0. Round-to-nearest can: 0.07 * 1.30 is
* 0.091, and `Math.round` sends that to a clean R0.09 — but a 30%
* markup on a 4-cent washer rounds the *selling* price to zero, and
* every later re-read of that row (`(0 - cost) / cost * 100`) reports
* -100% margin on a line nobody ever priced below cost.
*
* The EPSILON subtraction keeps a value that is already exactly on a
* 2dp boundary from being pushed up a whole cent by float drift
* (0.07 * 1.30 can compute as 0.09100000000000001).
*/
export function round2Up(value: number): number {
if (!Number.isFinite(value)) return 0;
// `|| 0` normalises the -0 that ceil produces for tiny negatives
// (-0 is falsy). It serialises to "0" but fails `Object.is(-0, 0)`,
// which would make a future test mysteriously fail.
return Math.ceil((value - Number.EPSILON) * 100) / 100 || 0;
}
/**
* The floor for a markup an operator has actually chosen.
*
* Deliberately NOT zero. A quote that goes out at a loss — or at zero —
* is the failure mode this whole module exists to prevent, and the
* order-item side already carries `min=0` plus a `Math.max(0, …)`
* clamp for exactly this reason. Quotes had neither, which is why 51
* quote_items rows were sitting on a negative markup while not one
* order_items row ever was.
*
* Scoped to lines that HAVE a cost. An unpriced draft (cost 0, markup
* 0) is not a 0% margin — it is an undecided line — and must stay
* writable, or creating a quote line would require inventing a margin
* on a line with no cost on file. See `applyMarkupFloor`.
*/
export const MIN_MARKUP_PERCENT = 18;
/**
* Apply the markup floor to a value the operator chose.
*
* Only ever RAISES a sub-floor markup, and only for a line with a real
* cost — the two conditions that keep this from breaking the ~1,181
* unpriced draft rows and the ~503 deliberate zero-margin rows already
* in the database. A zero-cost line passes through untouched so a
* blank line can still be saved as a draft.
*
* A negative markup (the -100 rows) is always raised. There is no
* legitimate below-cost quote in this workflow, and the user's rule is
* explicit: never below MIN_MARKUP_PERCENT.
*/
export function applyMarkupFloor(
markupPercent: number,
cost: number | null | undefined,
): number {
const markup = Number(markupPercent);
if (!Number.isFinite(markup)) return MIN_MARKUP_PERCENT;
// No cost on file = nothing has been priced yet. Leave it alone.
if (!(Number(cost) > 0)) return 0;
return Math.max(MIN_MARKUP_PERCENT, markup);
}
/**
* Selling price for a cost + markup pair. Markup is a percentage, so
* 25 means 25% (i.e. cost * 1.25), NOT 25x — the same convention the
* rest of the codebase uses.
*
* Rounds UP (never to nearest, never down). Rounding to nearest is
* what let a 30% markup on a R0.07 washer produce a selling price of
* R0.09 and then a *re-derived* markup of 28.6% — the "it changed my
* number to something random" complaint. Rounding up guarantees the
* selling price is always at least the markup implies, so the price
* can only ever drift in the customer's favour, and never lands on
* the zero that produced the -100 rows.
*/
export function sellingFromCostAndMarkup(
cost: number,
markupPercent: number,
): number {
const costNum = Number(cost) || 0;
const markupNum = Number(markupPercent) || 0;
return round2Up(costNum * (1 + markupNum / 100));
}
/**
* The markup percentage implied by a cost + selling pair. The inverse
* of the above. Returns 0 for a zero cost rather than dividing by
* zero — a line with no cost price genuinely has no margin to quote.
*
* The floor is applied HERE, at derivation, not only at save. This is
* the function that produced the -100s: a line with a cost of R0.07
* and a selling price of R0 (nothing typed yet) yields
* `(0 - 0.07) / 0.07 * 100` = exactly -100, and that number was
* written to the database on save. Flooring at the point of
* derivation means the -100 can no longer be *computed*, let alone
* stored — the screens that showed it and the screen that persisted it
* are fixed by the same edit.
*/
export function markupFromCostAndSelling(
cost: number,
selling: number,
floorPercent: number = MIN_MARKUP_PERCENT,
): number {
const costNum = Number(cost) || 0;
if (costNum <= 0) return 0;
const sellingNum = Number(selling) || 0;
const raw = ((sellingNum - costNum) / costNum) * 100;
// Never round DOWN to the floor — a genuinely-negative derivation
// means "selling is below cost", which is exactly the state the
// floor exists to prevent. Round UP, so a 17.4% derivation reads as
// the 18% floor rather than being trimmed further toward the margin.
if (raw < floorPercent) return floorPercent;
return round2(raw);
}
/** True when a line has a usable, fixed cost price to work from. */
export function hasCostPrice(cost: number | null | undefined): boolean {
const n = Number(cost);
return Number.isFinite(n) && n > 0;
}
/**
* Default markup to seed a line with when the operator hasn't chosen
* one. Deliberately a constant rather than a per-customer or
* per-category lookup: there is no markup table in the schema today,
* and inventing a "smart" default would be guessing at money.
*/
export const DEFAULT_MARKUP_PERCENT = 25;
export interface LinePricing {
orderItemId: string;
description: string;
quantity: number;
/** Cost carried from the order item. 0 means "nothing on file". */
costPrice: number;
/** Operator-editable. */
markupPercent: number;
/** Derived from cost + markup; never typed independently. */
sellingPrice: number;
}
export interface LineTotals {
costTotal: number;
sellingTotal: number;
/** sellingTotal - costTotal */
marginValue: number;
/** margin as a percentage of selling total. 0 when there's no revenue. */
marginPercent: number;
}
/** Roll a set of lines up into the footer figures. */
export function totalsForLines(lines: LinePricing[]): LineTotals {
let costTotal = 0;
let sellingTotal = 0;
for (const line of lines) {
const qty = Number(line.quantity) || 0;
costTotal += (Number(line.costPrice) || 0) * qty;
sellingTotal += (Number(line.sellingPrice) || 0) * qty;
}
const marginValue = round2(sellingTotal - costTotal);
return {
costTotal: round2(costTotal),
sellingTotal: round2(sellingTotal),
marginValue,
marginPercent:
sellingTotal > 0 ? round2((marginValue / sellingTotal) * 100) : 0,
};
}
/**
* Build the opening price grid for a delivery note's lines.
*
* `fallbackMarkup` is applied only where a real cost exists — a line
* with no cost gets markup 0 and selling 0, because deriving a selling
* price from a zero cost produces a confidently wrong R0.00 that looks
* like a deliberate free-of-charge item. Those lines are surfaced as
* "no cost on file" in the UI instead.
*/
export function buildLinePricing(
items: Array<{
id: string;
description?: string | null;
quantity?: number | null;
cost_price?: number | null;
selling_price?: number | null;
}>,
fallbackMarkup: number = DEFAULT_MARKUP_PERCENT,
): LinePricing[] {
return items.map((it) => {
const costPrice = round2(Number(it.cost_price) || 0);
const quantity = Number(it.quantity) || 1;
// A line that already carries a selling price keeps it — the
// office may have priced it deliberately before the DN existed,
// and silently re-marking it to a default would override a human
// decision. Only cost-only lines get the default markup.
const existingSelling = round2(Number(it.selling_price) || 0);
const hasCost = hasCostPrice(costPrice);
if (existingSelling > 0) {
return {
orderItemId: it.id,
description: it.description ?? "",
quantity,
costPrice,
markupPercent: markupFromCostAndSelling(costPrice, existingSelling),
sellingPrice: existingSelling,
};
}
const markupPercent = hasCost ? fallbackMarkup : 0;
return {
orderItemId: it.id,
description: it.description ?? "",
quantity,
costPrice,
markupPercent,
sellingPrice: hasCost
? sellingFromCostAndMarkup(costPrice, markupPercent)
: 0,
};
});
}
/**
* Re-derive one line's selling price after its cost or markup changed.
* Kept as a pure function so the modal's edit handler is a single
* call and the derivation rule lives in exactly one place.
*/
export function repriceLine(
line: LinePricing,
patch: { costPrice?: number; markupPercent?: number },
opts: { floor?: boolean } = {},
): LinePricing {
const costPrice =
patch.costPrice !== undefined ? round2(Number(patch.costPrice) || 0) : line.costPrice;
const requestedMarkup =
patch.markupPercent !== undefined
? round2(Number(patch.markupPercent) || 0)
: line.markupPercent;
// The floor is NOT applied here by default, and that is deliberate.
//
// This function runs on every keystroke in the pricing grids, so
// flooring here made the input fight the person typing in it: enter
// "20", the "2" was raised to 18 mid-keystroke, and the box snapped
// to 18 — the operator's words were "it just reverts back" and
// "automatically change it to 18". Typing a further character then
// appended to "18" and produced 180.
//
// The floor belongs at COMMIT — on blur and on save — which is what
// the operator actually asked for ("add any value there, and when
// I click save and the value is less than 18, it should go to 18").
// Bulk "set all markups" still passes `floor: true`.
const markupPercent = opts.floor
? applyMarkupFloor(requestedMarkup, costPrice)
: requestedMarkup;
return {
...line,
costPrice,
markupPercent,
sellingPrice: hasCostPrice(costPrice)
? sellingFromCostAndMarkup(costPrice, markupPercent)
: 0,
};
}
/**
* Entering the FIRST cost on a line seeds the default markup.
*
* Found in the browser, not in a test: a counter note arrives with no
* cost on file, so the line opens at cost 0 / markup 0 / selling 0.
* Typing a cost used to leave the markup at 0, which derived a selling
* price exactly equal to the cost — a line that *looked* priced, with
* a margin of R0.00. That is the worst possible default: the operator
* sees a plausible number and has been told the selling price is set.
*
* Seeding only happens when the line had no cost AND no markup yet, so
* it can never overwrite a margin the operator chose, and never
* touches a line that already had a price.
*/
export function repriceWithCostEntry(
line: LinePricing,
patch: { costPrice?: number; markupPercent?: number },
fallbackMarkup: number = DEFAULT_MARKUP_PERCENT,
): LinePricing {
const wasUncosted = !hasCostPrice(line.costPrice);
const enteringFirstCost =
patch.costPrice !== undefined &&
wasUncosted &&
hasCostPrice(patch.costPrice);
// An explicit markup in the same patch always wins.
const seeding =
enteringFirstCost && patch.markupPercent === undefined && line.markupPercent === 0;
return repriceLine(line, {
...patch,
...(seeding ? { markupPercent: fallbackMarkup } : {}),
});
}