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>
368 lines
14 KiB
TypeScript
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 } : {}),
|
|
});
|
|
}
|