/** * 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): 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 } : {}), }); }