WFDASH-WEBAPP/src/lib/quote-pricing.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

368 lines
14 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;
}
/**
* True when the operator may type into this line's cost cell.
*
* The single source of truth for the "cost on file is fixed" rule, so
* the pricing grid and the review grid can't disagree about when a
* cost is locked. Paired with `repriceLine`, which enforces the same
* rule in the state — the UI hiding an input is a convenience, this
* is the guarantee.
*/
export function isCostEditable(line: Pick<LinePricing, "costLocked">): boolean {
return line.costLocked !== true;
}
/**
* 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;
/**
* True when the line ARRIVED carrying a cost price, so the cost
* cell is read-only and `repriceLine` refuses cost patches.
*
* Captured at load rather than derived from the live value, and
* that is the whole point: a cost the operator types in during
* this session must stay correctable until they save. Locking on
* the live value would mean one mis-typed digit has to be escaped
* by cancelling the dialog and starting over. After a save the row
* carries the cost, so the next load sets this true and the cell is
* fixed — which is the rule as stated: cost on file is fixed, a
* blank cost is fillable.
*/
costLocked: boolean;
}
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,
costLocked: hasCost,
};
}
const markupPercent = hasCost ? fallbackMarkup : 0;
return {
orderItemId: it.id,
description: it.description ?? "",
quantity,
costPrice,
markupPercent,
sellingPrice: hasCost
? sellingFromCostAndMarkup(costPrice, markupPercent)
: 0,
costLocked: hasCost,
};
});
}
/**
* 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.
*
* A `costPrice` patch on a LOCKED line is silently dropped rather
* than applied. The grids disable the cost cell, but a disabled
* input is a UI convention, not an invariant — a stray patch (a
* future bulk action, a keyboard shortcut, a half-merged rebase)
* would otherwise be able to rewrite what the business paid while
* the operator believes they only touched the markup. Dropping it
* here means the cost can only ever change on a line that had none.
*/
export function repriceLine(
line: LinePricing,
patch: { costPrice?: number; markupPercent?: number },
opts: { floor?: boolean } = {},
): LinePricing {
const costPrice =
patch.costPrice !== undefined && isCostEditable(line)
? 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 } : {}),
});
}