WFDASH-WEBAPP/src/lib/quote-pricing.ts

330 lines
12 KiB
TypeScript
Raw Normal View History

feat(delivery-notes): review pricing before a quote is created The Create quote button fired blind: the quote appeared with whatever pricing happened to be on the order items, and a wrong price was only discoverable by finding the quote afterwards. A new review modal puts the check in front of the create. Modal (components/delivery-notes/quote-review-dialog.tsx) - Letterhead picker. DN-backed quotes never set company_letterhead, so the renderer defaulted every one of them to CNL. - Cost and markup per line; selling price is DERIVED, never typed, so the two cannot disagree. See src/lib/quote-pricing.ts. - A no-cost line shows "No cost on file" and stays at R0.00 rather than deriving a fake price off a zero cost, which reads as a deliberate free-of-charge item. - Bulk "set all markups" field — typing 25% into eight lines one at a time is the tedium the modal exists to remove. - Totals, with margin as a share of REVENUE not cost: 25% markup is a 20% margin, and conflating them quietly under-prices a job. - Nothing persists until Generate. Cancel discards the whole grid. Backend - create-quote now accepts { letterhead, items: [{ orderItemId, costPrice, markupPercent }] } and derives selling server-side. - Overrides are keyed by order_item id and validated against THIS note's lines. An unknown id aborts the create rather than moving another order's goods into this customer's quote. - Quote items now go out as one PocketBase batch instead of a loop of individual creates. The batch is transactional: the old loop could fail on line 5 of 8 and leave a real quote header attached to half its lines. The header still has to be created first (the batch API can't reference a record created in the same batch), so a failed batch deletes the orphaned header rather than leaving it visible. - Malformed bodies are 400s the operator can read, not 500s. "A quote already exists" and "not in draft" are now 409s. Tests - 33 new: pricing maths, and the route's validation guards. Verified the guards bite by removing them (2 fail) and restored. - The existing service test's PB mock gained createBatch, modelled as genuinely all-or-nothing — the compensation path depends on that guarantee being real. Full suite: 3615 passing. tsc clean.
2026-10-04 19:17:31 +00:00
/**
* 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;
}
fix(quotes): stop quote_items markup going negative; round prices up Quoteers reported markup changing to a random number after saving or clicking away, and lines carrying markup = -100 that nobody had edited. 51 quote_items rows were affected (40 of them exactly -100), all with a real cost and selling_price = 0. All 51 reproduced exactly from ((selling - cost) / cost * 100) — none were typed by a human. Two independent defects, both price-rounding: 1. The approvals screen priced with Math.round(cost * (1 + m/100)) and no precision argument — round to WHOLE RAND. A 30% markup on a R0.07 washer became R0.00, and a selling price of 0 re-derives as exactly -100. Rounding is now a 2dp ceiling: R0.07 @ 30% -> R0.10. A ceiling can never collapse a positive price to zero, so that failure mode is structurally unreachable rather than merely patched. 2. Round-to-nearest also undercut cheap lines generally: 30% on R0.07 rounded to R0.09, which re-derives as 28.6% — the "random number". Rounding up means the price can only drift in the customer's favour. Markup now floors at MIN_MARKUP_PERCENT (18) once a line has a cost, applied in one place so every screen inherits it. Scoped to priced lines: an unpriced draft (no cost) is undecided, not 0% margin, and stays writable — 1,181 live rows depend on that. Applied at derivation (not just on save) so -100 can no longer be computed, plus server-side in review-approve as the authoritative gate. order_items already carried markup min=0 plus a Math.max(0, …) clamp, which is why no order row was ever affected. quote_items had neither. Verified: 3,817 tests pass, typecheck clean, lint delta zero, and all 16 of the real broken rows re-priced through the new logic resolve to a valid markup and a non-zero selling price (was 16/16 broken). Existing negative rows are left untouched — re-pricing live quotes is a separate, deliberate decision.
2026-10-08 05:13:07 +00:00
/**
* 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);
}
feat(delivery-notes): review pricing before a quote is created The Create quote button fired blind: the quote appeared with whatever pricing happened to be on the order items, and a wrong price was only discoverable by finding the quote afterwards. A new review modal puts the check in front of the create. Modal (components/delivery-notes/quote-review-dialog.tsx) - Letterhead picker. DN-backed quotes never set company_letterhead, so the renderer defaulted every one of them to CNL. - Cost and markup per line; selling price is DERIVED, never typed, so the two cannot disagree. See src/lib/quote-pricing.ts. - A no-cost line shows "No cost on file" and stays at R0.00 rather than deriving a fake price off a zero cost, which reads as a deliberate free-of-charge item. - Bulk "set all markups" field — typing 25% into eight lines one at a time is the tedium the modal exists to remove. - Totals, with margin as a share of REVENUE not cost: 25% markup is a 20% margin, and conflating them quietly under-prices a job. - Nothing persists until Generate. Cancel discards the whole grid. Backend - create-quote now accepts { letterhead, items: [{ orderItemId, costPrice, markupPercent }] } and derives selling server-side. - Overrides are keyed by order_item id and validated against THIS note's lines. An unknown id aborts the create rather than moving another order's goods into this customer's quote. - Quote items now go out as one PocketBase batch instead of a loop of individual creates. The batch is transactional: the old loop could fail on line 5 of 8 and leave a real quote header attached to half its lines. The header still has to be created first (the batch API can't reference a record created in the same batch), so a failed batch deletes the orphaned header rather than leaving it visible. - Malformed bodies are 400s the operator can read, not 500s. "A quote already exists" and "not in draft" are now 409s. Tests - 33 new: pricing maths, and the route's validation guards. Verified the guards bite by removing them (2 fail) and restored. - The existing service test's PB mock gained createBatch, modelled as genuinely all-or-nothing — the compensation path depends on that guarantee being real. Full suite: 3615 passing. tsc clean.
2026-10-04 19:17:31 +00:00
/**
* 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.
fix(quotes): stop quote_items markup going negative; round prices up Quoteers reported markup changing to a random number after saving or clicking away, and lines carrying markup = -100 that nobody had edited. 51 quote_items rows were affected (40 of them exactly -100), all with a real cost and selling_price = 0. All 51 reproduced exactly from ((selling - cost) / cost * 100) — none were typed by a human. Two independent defects, both price-rounding: 1. The approvals screen priced with Math.round(cost * (1 + m/100)) and no precision argument — round to WHOLE RAND. A 30% markup on a R0.07 washer became R0.00, and a selling price of 0 re-derives as exactly -100. Rounding is now a 2dp ceiling: R0.07 @ 30% -> R0.10. A ceiling can never collapse a positive price to zero, so that failure mode is structurally unreachable rather than merely patched. 2. Round-to-nearest also undercut cheap lines generally: 30% on R0.07 rounded to R0.09, which re-derives as 28.6% — the "random number". Rounding up means the price can only drift in the customer's favour. Markup now floors at MIN_MARKUP_PERCENT (18) once a line has a cost, applied in one place so every screen inherits it. Scoped to priced lines: an unpriced draft (no cost) is undecided, not 0% margin, and stays writable — 1,181 live rows depend on that. Applied at derivation (not just on save) so -100 can no longer be computed, plus server-side in review-approve as the authoritative gate. order_items already carried markup min=0 plus a Math.max(0, …) clamp, which is why no order row was ever affected. quote_items had neither. Verified: 3,817 tests pass, typecheck clean, lint delta zero, and all 16 of the real broken rows re-priced through the new logic resolve to a valid markup and a non-zero selling price (was 16/16 broken). Existing negative rows are left untouched — re-pricing live quotes is a separate, deliberate decision.
2026-10-08 05:13:07 +00:00
*
* 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.
feat(delivery-notes): review pricing before a quote is created The Create quote button fired blind: the quote appeared with whatever pricing happened to be on the order items, and a wrong price was only discoverable by finding the quote afterwards. A new review modal puts the check in front of the create. Modal (components/delivery-notes/quote-review-dialog.tsx) - Letterhead picker. DN-backed quotes never set company_letterhead, so the renderer defaulted every one of them to CNL. - Cost and markup per line; selling price is DERIVED, never typed, so the two cannot disagree. See src/lib/quote-pricing.ts. - A no-cost line shows "No cost on file" and stays at R0.00 rather than deriving a fake price off a zero cost, which reads as a deliberate free-of-charge item. - Bulk "set all markups" field — typing 25% into eight lines one at a time is the tedium the modal exists to remove. - Totals, with margin as a share of REVENUE not cost: 25% markup is a 20% margin, and conflating them quietly under-prices a job. - Nothing persists until Generate. Cancel discards the whole grid. Backend - create-quote now accepts { letterhead, items: [{ orderItemId, costPrice, markupPercent }] } and derives selling server-side. - Overrides are keyed by order_item id and validated against THIS note's lines. An unknown id aborts the create rather than moving another order's goods into this customer's quote. - Quote items now go out as one PocketBase batch instead of a loop of individual creates. The batch is transactional: the old loop could fail on line 5 of 8 and leave a real quote header attached to half its lines. The header still has to be created first (the batch API can't reference a record created in the same batch), so a failed batch deletes the orphaned header rather than leaving it visible. - Malformed bodies are 400s the operator can read, not 500s. "A quote already exists" and "not in draft" are now 409s. Tests - 33 new: pricing maths, and the route's validation guards. Verified the guards bite by removing them (2 fail) and restored. - The existing service test's PB mock gained createBatch, modelled as genuinely all-or-nothing — the compensation path depends on that guarantee being real. Full suite: 3615 passing. tsc clean.
2026-10-04 19:17:31 +00:00
*/
export function sellingFromCostAndMarkup(
cost: number,
markupPercent: number,
): number {
const costNum = Number(cost) || 0;
const markupNum = Number(markupPercent) || 0;
fix(quotes): stop quote_items markup going negative; round prices up Quoteers reported markup changing to a random number after saving or clicking away, and lines carrying markup = -100 that nobody had edited. 51 quote_items rows were affected (40 of them exactly -100), all with a real cost and selling_price = 0. All 51 reproduced exactly from ((selling - cost) / cost * 100) — none were typed by a human. Two independent defects, both price-rounding: 1. The approvals screen priced with Math.round(cost * (1 + m/100)) and no precision argument — round to WHOLE RAND. A 30% markup on a R0.07 washer became R0.00, and a selling price of 0 re-derives as exactly -100. Rounding is now a 2dp ceiling: R0.07 @ 30% -> R0.10. A ceiling can never collapse a positive price to zero, so that failure mode is structurally unreachable rather than merely patched. 2. Round-to-nearest also undercut cheap lines generally: 30% on R0.07 rounded to R0.09, which re-derives as 28.6% — the "random number". Rounding up means the price can only drift in the customer's favour. Markup now floors at MIN_MARKUP_PERCENT (18) once a line has a cost, applied in one place so every screen inherits it. Scoped to priced lines: an unpriced draft (no cost) is undecided, not 0% margin, and stays writable — 1,181 live rows depend on that. Applied at derivation (not just on save) so -100 can no longer be computed, plus server-side in review-approve as the authoritative gate. order_items already carried markup min=0 plus a Math.max(0, …) clamp, which is why no order row was ever affected. quote_items had neither. Verified: 3,817 tests pass, typecheck clean, lint delta zero, and all 16 of the real broken rows re-priced through the new logic resolve to a valid markup and a non-zero selling price (was 16/16 broken). Existing negative rows are left untouched — re-pricing live quotes is a separate, deliberate decision.
2026-10-08 05:13:07 +00:00
return round2Up(costNum * (1 + markupNum / 100));
feat(delivery-notes): review pricing before a quote is created The Create quote button fired blind: the quote appeared with whatever pricing happened to be on the order items, and a wrong price was only discoverable by finding the quote afterwards. A new review modal puts the check in front of the create. Modal (components/delivery-notes/quote-review-dialog.tsx) - Letterhead picker. DN-backed quotes never set company_letterhead, so the renderer defaulted every one of them to CNL. - Cost and markup per line; selling price is DERIVED, never typed, so the two cannot disagree. See src/lib/quote-pricing.ts. - A no-cost line shows "No cost on file" and stays at R0.00 rather than deriving a fake price off a zero cost, which reads as a deliberate free-of-charge item. - Bulk "set all markups" field — typing 25% into eight lines one at a time is the tedium the modal exists to remove. - Totals, with margin as a share of REVENUE not cost: 25% markup is a 20% margin, and conflating them quietly under-prices a job. - Nothing persists until Generate. Cancel discards the whole grid. Backend - create-quote now accepts { letterhead, items: [{ orderItemId, costPrice, markupPercent }] } and derives selling server-side. - Overrides are keyed by order_item id and validated against THIS note's lines. An unknown id aborts the create rather than moving another order's goods into this customer's quote. - Quote items now go out as one PocketBase batch instead of a loop of individual creates. The batch is transactional: the old loop could fail on line 5 of 8 and leave a real quote header attached to half its lines. The header still has to be created first (the batch API can't reference a record created in the same batch), so a failed batch deletes the orphaned header rather than leaving it visible. - Malformed bodies are 400s the operator can read, not 500s. "A quote already exists" and "not in draft" are now 409s. Tests - 33 new: pricing maths, and the route's validation guards. Verified the guards bite by removing them (2 fail) and restored. - The existing service test's PB mock gained createBatch, modelled as genuinely all-or-nothing — the compensation path depends on that guarantee being real. Full suite: 3615 passing. tsc clean.
2026-10-04 19:17:31 +00:00
}
/**
* 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.
fix(quotes): stop quote_items markup going negative; round prices up Quoteers reported markup changing to a random number after saving or clicking away, and lines carrying markup = -100 that nobody had edited. 51 quote_items rows were affected (40 of them exactly -100), all with a real cost and selling_price = 0. All 51 reproduced exactly from ((selling - cost) / cost * 100) — none were typed by a human. Two independent defects, both price-rounding: 1. The approvals screen priced with Math.round(cost * (1 + m/100)) and no precision argument — round to WHOLE RAND. A 30% markup on a R0.07 washer became R0.00, and a selling price of 0 re-derives as exactly -100. Rounding is now a 2dp ceiling: R0.07 @ 30% -> R0.10. A ceiling can never collapse a positive price to zero, so that failure mode is structurally unreachable rather than merely patched. 2. Round-to-nearest also undercut cheap lines generally: 30% on R0.07 rounded to R0.09, which re-derives as 28.6% — the "random number". Rounding up means the price can only drift in the customer's favour. Markup now floors at MIN_MARKUP_PERCENT (18) once a line has a cost, applied in one place so every screen inherits it. Scoped to priced lines: an unpriced draft (no cost) is undecided, not 0% margin, and stays writable — 1,181 live rows depend on that. Applied at derivation (not just on save) so -100 can no longer be computed, plus server-side in review-approve as the authoritative gate. order_items already carried markup min=0 plus a Math.max(0, …) clamp, which is why no order row was ever affected. quote_items had neither. Verified: 3,817 tests pass, typecheck clean, lint delta zero, and all 16 of the real broken rows re-priced through the new logic resolve to a valid markup and a non-zero selling price (was 16/16 broken). Existing negative rows are left untouched — re-pricing live quotes is a separate, deliberate decision.
2026-10-08 05:13:07 +00:00
*
* 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.
feat(delivery-notes): review pricing before a quote is created The Create quote button fired blind: the quote appeared with whatever pricing happened to be on the order items, and a wrong price was only discoverable by finding the quote afterwards. A new review modal puts the check in front of the create. Modal (components/delivery-notes/quote-review-dialog.tsx) - Letterhead picker. DN-backed quotes never set company_letterhead, so the renderer defaulted every one of them to CNL. - Cost and markup per line; selling price is DERIVED, never typed, so the two cannot disagree. See src/lib/quote-pricing.ts. - A no-cost line shows "No cost on file" and stays at R0.00 rather than deriving a fake price off a zero cost, which reads as a deliberate free-of-charge item. - Bulk "set all markups" field — typing 25% into eight lines one at a time is the tedium the modal exists to remove. - Totals, with margin as a share of REVENUE not cost: 25% markup is a 20% margin, and conflating them quietly under-prices a job. - Nothing persists until Generate. Cancel discards the whole grid. Backend - create-quote now accepts { letterhead, items: [{ orderItemId, costPrice, markupPercent }] } and derives selling server-side. - Overrides are keyed by order_item id and validated against THIS note's lines. An unknown id aborts the create rather than moving another order's goods into this customer's quote. - Quote items now go out as one PocketBase batch instead of a loop of individual creates. The batch is transactional: the old loop could fail on line 5 of 8 and leave a real quote header attached to half its lines. The header still has to be created first (the batch API can't reference a record created in the same batch), so a failed batch deletes the orphaned header rather than leaving it visible. - Malformed bodies are 400s the operator can read, not 500s. "A quote already exists" and "not in draft" are now 409s. Tests - 33 new: pricing maths, and the route's validation guards. Verified the guards bite by removing them (2 fail) and restored. - The existing service test's PB mock gained createBatch, modelled as genuinely all-or-nothing — the compensation path depends on that guarantee being real. Full suite: 3615 passing. tsc clean.
2026-10-04 19:17:31 +00:00
*/
export function markupFromCostAndSelling(
cost: number,
selling: number,
fix(quotes): stop quote_items markup going negative; round prices up Quoteers reported markup changing to a random number after saving or clicking away, and lines carrying markup = -100 that nobody had edited. 51 quote_items rows were affected (40 of them exactly -100), all with a real cost and selling_price = 0. All 51 reproduced exactly from ((selling - cost) / cost * 100) — none were typed by a human. Two independent defects, both price-rounding: 1. The approvals screen priced with Math.round(cost * (1 + m/100)) and no precision argument — round to WHOLE RAND. A 30% markup on a R0.07 washer became R0.00, and a selling price of 0 re-derives as exactly -100. Rounding is now a 2dp ceiling: R0.07 @ 30% -> R0.10. A ceiling can never collapse a positive price to zero, so that failure mode is structurally unreachable rather than merely patched. 2. Round-to-nearest also undercut cheap lines generally: 30% on R0.07 rounded to R0.09, which re-derives as 28.6% — the "random number". Rounding up means the price can only drift in the customer's favour. Markup now floors at MIN_MARKUP_PERCENT (18) once a line has a cost, applied in one place so every screen inherits it. Scoped to priced lines: an unpriced draft (no cost) is undecided, not 0% margin, and stays writable — 1,181 live rows depend on that. Applied at derivation (not just on save) so -100 can no longer be computed, plus server-side in review-approve as the authoritative gate. order_items already carried markup min=0 plus a Math.max(0, …) clamp, which is why no order row was ever affected. quote_items had neither. Verified: 3,817 tests pass, typecheck clean, lint delta zero, and all 16 of the real broken rows re-priced through the new logic resolve to a valid markup and a non-zero selling price (was 16/16 broken). Existing negative rows are left untouched — re-pricing live quotes is a separate, deliberate decision.
2026-10-08 05:13:07 +00:00
floorPercent: number = MIN_MARKUP_PERCENT,
feat(delivery-notes): review pricing before a quote is created The Create quote button fired blind: the quote appeared with whatever pricing happened to be on the order items, and a wrong price was only discoverable by finding the quote afterwards. A new review modal puts the check in front of the create. Modal (components/delivery-notes/quote-review-dialog.tsx) - Letterhead picker. DN-backed quotes never set company_letterhead, so the renderer defaulted every one of them to CNL. - Cost and markup per line; selling price is DERIVED, never typed, so the two cannot disagree. See src/lib/quote-pricing.ts. - A no-cost line shows "No cost on file" and stays at R0.00 rather than deriving a fake price off a zero cost, which reads as a deliberate free-of-charge item. - Bulk "set all markups" field — typing 25% into eight lines one at a time is the tedium the modal exists to remove. - Totals, with margin as a share of REVENUE not cost: 25% markup is a 20% margin, and conflating them quietly under-prices a job. - Nothing persists until Generate. Cancel discards the whole grid. Backend - create-quote now accepts { letterhead, items: [{ orderItemId, costPrice, markupPercent }] } and derives selling server-side. - Overrides are keyed by order_item id and validated against THIS note's lines. An unknown id aborts the create rather than moving another order's goods into this customer's quote. - Quote items now go out as one PocketBase batch instead of a loop of individual creates. The batch is transactional: the old loop could fail on line 5 of 8 and leave a real quote header attached to half its lines. The header still has to be created first (the batch API can't reference a record created in the same batch), so a failed batch deletes the orphaned header rather than leaving it visible. - Malformed bodies are 400s the operator can read, not 500s. "A quote already exists" and "not in draft" are now 409s. Tests - 33 new: pricing maths, and the route's validation guards. Verified the guards bite by removing them (2 fail) and restored. - The existing service test's PB mock gained createBatch, modelled as genuinely all-or-nothing — the compensation path depends on that guarantee being real. Full suite: 3615 passing. tsc clean.
2026-10-04 19:17:31 +00:00
): number {
const costNum = Number(cost) || 0;
if (costNum <= 0) return 0;
const sellingNum = Number(selling) || 0;
fix(quotes): stop quote_items markup going negative; round prices up Quoteers reported markup changing to a random number after saving or clicking away, and lines carrying markup = -100 that nobody had edited. 51 quote_items rows were affected (40 of them exactly -100), all with a real cost and selling_price = 0. All 51 reproduced exactly from ((selling - cost) / cost * 100) — none were typed by a human. Two independent defects, both price-rounding: 1. The approvals screen priced with Math.round(cost * (1 + m/100)) and no precision argument — round to WHOLE RAND. A 30% markup on a R0.07 washer became R0.00, and a selling price of 0 re-derives as exactly -100. Rounding is now a 2dp ceiling: R0.07 @ 30% -> R0.10. A ceiling can never collapse a positive price to zero, so that failure mode is structurally unreachable rather than merely patched. 2. Round-to-nearest also undercut cheap lines generally: 30% on R0.07 rounded to R0.09, which re-derives as 28.6% — the "random number". Rounding up means the price can only drift in the customer's favour. Markup now floors at MIN_MARKUP_PERCENT (18) once a line has a cost, applied in one place so every screen inherits it. Scoped to priced lines: an unpriced draft (no cost) is undecided, not 0% margin, and stays writable — 1,181 live rows depend on that. Applied at derivation (not just on save) so -100 can no longer be computed, plus server-side in review-approve as the authoritative gate. order_items already carried markup min=0 plus a Math.max(0, …) clamp, which is why no order row was ever affected. quote_items had neither. Verified: 3,817 tests pass, typecheck clean, lint delta zero, and all 16 of the real broken rows re-priced through the new logic resolve to a valid markup and a non-zero selling price (was 16/16 broken). Existing negative rows are left untouched — re-pricing live quotes is a separate, deliberate decision.
2026-10-08 05:13:07 +00:00
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);
feat(delivery-notes): review pricing before a quote is created The Create quote button fired blind: the quote appeared with whatever pricing happened to be on the order items, and a wrong price was only discoverable by finding the quote afterwards. A new review modal puts the check in front of the create. Modal (components/delivery-notes/quote-review-dialog.tsx) - Letterhead picker. DN-backed quotes never set company_letterhead, so the renderer defaulted every one of them to CNL. - Cost and markup per line; selling price is DERIVED, never typed, so the two cannot disagree. See src/lib/quote-pricing.ts. - A no-cost line shows "No cost on file" and stays at R0.00 rather than deriving a fake price off a zero cost, which reads as a deliberate free-of-charge item. - Bulk "set all markups" field — typing 25% into eight lines one at a time is the tedium the modal exists to remove. - Totals, with margin as a share of REVENUE not cost: 25% markup is a 20% margin, and conflating them quietly under-prices a job. - Nothing persists until Generate. Cancel discards the whole grid. Backend - create-quote now accepts { letterhead, items: [{ orderItemId, costPrice, markupPercent }] } and derives selling server-side. - Overrides are keyed by order_item id and validated against THIS note's lines. An unknown id aborts the create rather than moving another order's goods into this customer's quote. - Quote items now go out as one PocketBase batch instead of a loop of individual creates. The batch is transactional: the old loop could fail on line 5 of 8 and leave a real quote header attached to half its lines. The header still has to be created first (the batch API can't reference a record created in the same batch), so a failed batch deletes the orphaned header rather than leaving it visible. - Malformed bodies are 400s the operator can read, not 500s. "A quote already exists" and "not in draft" are now 409s. Tests - 33 new: pricing maths, and the route's validation guards. Verified the guards bite by removing them (2 fail) and restored. - The existing service test's PB mock gained createBatch, modelled as genuinely all-or-nothing — the compensation path depends on that guarantee being real. Full suite: 3615 passing. tsc clean.
2026-10-04 19:17:31 +00:00
}
/** 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 },
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 10:49:13 +00:00
opts: { floor?: boolean } = {},
feat(delivery-notes): review pricing before a quote is created The Create quote button fired blind: the quote appeared with whatever pricing happened to be on the order items, and a wrong price was only discoverable by finding the quote afterwards. A new review modal puts the check in front of the create. Modal (components/delivery-notes/quote-review-dialog.tsx) - Letterhead picker. DN-backed quotes never set company_letterhead, so the renderer defaulted every one of them to CNL. - Cost and markup per line; selling price is DERIVED, never typed, so the two cannot disagree. See src/lib/quote-pricing.ts. - A no-cost line shows "No cost on file" and stays at R0.00 rather than deriving a fake price off a zero cost, which reads as a deliberate free-of-charge item. - Bulk "set all markups" field — typing 25% into eight lines one at a time is the tedium the modal exists to remove. - Totals, with margin as a share of REVENUE not cost: 25% markup is a 20% margin, and conflating them quietly under-prices a job. - Nothing persists until Generate. Cancel discards the whole grid. Backend - create-quote now accepts { letterhead, items: [{ orderItemId, costPrice, markupPercent }] } and derives selling server-side. - Overrides are keyed by order_item id and validated against THIS note's lines. An unknown id aborts the create rather than moving another order's goods into this customer's quote. - Quote items now go out as one PocketBase batch instead of a loop of individual creates. The batch is transactional: the old loop could fail on line 5 of 8 and leave a real quote header attached to half its lines. The header still has to be created first (the batch API can't reference a record created in the same batch), so a failed batch deletes the orphaned header rather than leaving it visible. - Malformed bodies are 400s the operator can read, not 500s. "A quote already exists" and "not in draft" are now 409s. Tests - 33 new: pricing maths, and the route's validation guards. Verified the guards bite by removing them (2 fail) and restored. - The existing service test's PB mock gained createBatch, modelled as genuinely all-or-nothing — the compensation path depends on that guarantee being real. Full suite: 3615 passing. tsc clean.
2026-10-04 19:17:31 +00:00
): LinePricing {
const costPrice =
patch.costPrice !== undefined ? round2(Number(patch.costPrice) || 0) : line.costPrice;
fix(quotes): stop quote_items markup going negative; round prices up Quoteers reported markup changing to a random number after saving or clicking away, and lines carrying markup = -100 that nobody had edited. 51 quote_items rows were affected (40 of them exactly -100), all with a real cost and selling_price = 0. All 51 reproduced exactly from ((selling - cost) / cost * 100) — none were typed by a human. Two independent defects, both price-rounding: 1. The approvals screen priced with Math.round(cost * (1 + m/100)) and no precision argument — round to WHOLE RAND. A 30% markup on a R0.07 washer became R0.00, and a selling price of 0 re-derives as exactly -100. Rounding is now a 2dp ceiling: R0.07 @ 30% -> R0.10. A ceiling can never collapse a positive price to zero, so that failure mode is structurally unreachable rather than merely patched. 2. Round-to-nearest also undercut cheap lines generally: 30% on R0.07 rounded to R0.09, which re-derives as 28.6% — the "random number". Rounding up means the price can only drift in the customer's favour. Markup now floors at MIN_MARKUP_PERCENT (18) once a line has a cost, applied in one place so every screen inherits it. Scoped to priced lines: an unpriced draft (no cost) is undecided, not 0% margin, and stays writable — 1,181 live rows depend on that. Applied at derivation (not just on save) so -100 can no longer be computed, plus server-side in review-approve as the authoritative gate. order_items already carried markup min=0 plus a Math.max(0, …) clamp, which is why no order row was ever affected. quote_items had neither. Verified: 3,817 tests pass, typecheck clean, lint delta zero, and all 16 of the real broken rows re-priced through the new logic resolve to a valid markup and a non-zero selling price (was 16/16 broken). Existing negative rows are left untouched — re-pricing live quotes is a separate, deliberate decision.
2026-10-08 05:13:07 +00:00
const requestedMarkup =
feat(delivery-notes): review pricing before a quote is created The Create quote button fired blind: the quote appeared with whatever pricing happened to be on the order items, and a wrong price was only discoverable by finding the quote afterwards. A new review modal puts the check in front of the create. Modal (components/delivery-notes/quote-review-dialog.tsx) - Letterhead picker. DN-backed quotes never set company_letterhead, so the renderer defaulted every one of them to CNL. - Cost and markup per line; selling price is DERIVED, never typed, so the two cannot disagree. See src/lib/quote-pricing.ts. - A no-cost line shows "No cost on file" and stays at R0.00 rather than deriving a fake price off a zero cost, which reads as a deliberate free-of-charge item. - Bulk "set all markups" field — typing 25% into eight lines one at a time is the tedium the modal exists to remove. - Totals, with margin as a share of REVENUE not cost: 25% markup is a 20% margin, and conflating them quietly under-prices a job. - Nothing persists until Generate. Cancel discards the whole grid. Backend - create-quote now accepts { letterhead, items: [{ orderItemId, costPrice, markupPercent }] } and derives selling server-side. - Overrides are keyed by order_item id and validated against THIS note's lines. An unknown id aborts the create rather than moving another order's goods into this customer's quote. - Quote items now go out as one PocketBase batch instead of a loop of individual creates. The batch is transactional: the old loop could fail on line 5 of 8 and leave a real quote header attached to half its lines. The header still has to be created first (the batch API can't reference a record created in the same batch), so a failed batch deletes the orphaned header rather than leaving it visible. - Malformed bodies are 400s the operator can read, not 500s. "A quote already exists" and "not in draft" are now 409s. Tests - 33 new: pricing maths, and the route's validation guards. Verified the guards bite by removing them (2 fail) and restored. - The existing service test's PB mock gained createBatch, modelled as genuinely all-or-nothing — the compensation path depends on that guarantee being real. Full suite: 3615 passing. tsc clean.
2026-10-04 19:17:31 +00:00
patch.markupPercent !== undefined
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 10:49:13 +00:00
? round2(Number(patch.markupPercent) || 0)
feat(delivery-notes): review pricing before a quote is created The Create quote button fired blind: the quote appeared with whatever pricing happened to be on the order items, and a wrong price was only discoverable by finding the quote afterwards. A new review modal puts the check in front of the create. Modal (components/delivery-notes/quote-review-dialog.tsx) - Letterhead picker. DN-backed quotes never set company_letterhead, so the renderer defaulted every one of them to CNL. - Cost and markup per line; selling price is DERIVED, never typed, so the two cannot disagree. See src/lib/quote-pricing.ts. - A no-cost line shows "No cost on file" and stays at R0.00 rather than deriving a fake price off a zero cost, which reads as a deliberate free-of-charge item. - Bulk "set all markups" field — typing 25% into eight lines one at a time is the tedium the modal exists to remove. - Totals, with margin as a share of REVENUE not cost: 25% markup is a 20% margin, and conflating them quietly under-prices a job. - Nothing persists until Generate. Cancel discards the whole grid. Backend - create-quote now accepts { letterhead, items: [{ orderItemId, costPrice, markupPercent }] } and derives selling server-side. - Overrides are keyed by order_item id and validated against THIS note's lines. An unknown id aborts the create rather than moving another order's goods into this customer's quote. - Quote items now go out as one PocketBase batch instead of a loop of individual creates. The batch is transactional: the old loop could fail on line 5 of 8 and leave a real quote header attached to half its lines. The header still has to be created first (the batch API can't reference a record created in the same batch), so a failed batch deletes the orphaned header rather than leaving it visible. - Malformed bodies are 400s the operator can read, not 500s. "A quote already exists" and "not in draft" are now 409s. Tests - 33 new: pricing maths, and the route's validation guards. Verified the guards bite by removing them (2 fail) and restored. - The existing service test's PB mock gained createBatch, modelled as genuinely all-or-nothing — the compensation path depends on that guarantee being real. Full suite: 3615 passing. tsc clean.
2026-10-04 19:17:31 +00:00
: line.markupPercent;
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 10:49:13 +00:00
// 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;
feat(delivery-notes): review pricing before a quote is created The Create quote button fired blind: the quote appeared with whatever pricing happened to be on the order items, and a wrong price was only discoverable by finding the quote afterwards. A new review modal puts the check in front of the create. Modal (components/delivery-notes/quote-review-dialog.tsx) - Letterhead picker. DN-backed quotes never set company_letterhead, so the renderer defaulted every one of them to CNL. - Cost and markup per line; selling price is DERIVED, never typed, so the two cannot disagree. See src/lib/quote-pricing.ts. - A no-cost line shows "No cost on file" and stays at R0.00 rather than deriving a fake price off a zero cost, which reads as a deliberate free-of-charge item. - Bulk "set all markups" field — typing 25% into eight lines one at a time is the tedium the modal exists to remove. - Totals, with margin as a share of REVENUE not cost: 25% markup is a 20% margin, and conflating them quietly under-prices a job. - Nothing persists until Generate. Cancel discards the whole grid. Backend - create-quote now accepts { letterhead, items: [{ orderItemId, costPrice, markupPercent }] } and derives selling server-side. - Overrides are keyed by order_item id and validated against THIS note's lines. An unknown id aborts the create rather than moving another order's goods into this customer's quote. - Quote items now go out as one PocketBase batch instead of a loop of individual creates. The batch is transactional: the old loop could fail on line 5 of 8 and leave a real quote header attached to half its lines. The header still has to be created first (the batch API can't reference a record created in the same batch), so a failed batch deletes the orphaned header rather than leaving it visible. - Malformed bodies are 400s the operator can read, not 500s. "A quote already exists" and "not in draft" are now 409s. Tests - 33 new: pricing maths, and the route's validation guards. Verified the guards bite by removing them (2 fail) and restored. - The existing service test's PB mock gained createBatch, modelled as genuinely all-or-nothing — the compensation path depends on that guarantee being real. Full suite: 3615 passing. tsc clean.
2026-10-04 19:17:31 +00:00
return {
...line,
costPrice,
markupPercent,
sellingPrice: hasCostPrice(costPrice)
? sellingFromCostAndMarkup(costPrice, markupPercent)
: 0,
};
}
fix(delivery-notes): what the browser found — clipped actions, zero-margin lines Found by driving the real app in a browser as David Els, not by reading the code. Two of these were invisible to typecheck, build and 3670 unit tests. 1. The action icons were clipped off the right edge `preview-dn` and `download-pdf` were the SAME action — on the list both opened the DN PDF preview, on the detail both called `setPreviewOpen(true)`. A duplicate I had introduced in the canonical set. Even after removing it the row still overflowed: the Company column was ~294px, the widest thing in the table, and every row already sits inside a company group header spelling the same name out. Removing the column removed the redundancy AND the overflow. Table is now 1140 in a 1140 container, 0px clipped. 2. Entering a first cost produced a ZERO-MARGIN line A counter note opens with no cost on file, so the line starts at cost 0 / markup 0 / selling 0. Typing a cost left the markup at 0, which derived a selling price exactly equal to the cost — a line that looks priced and earns nothing, on the commonest path in this workflow. Entering a first cost now seeds the default markup, so the operator gets R125 selling on R100 cost and a visible 20% margin. Seeding only happens when the line had no cost AND no markup, so it can never overwrite a margin someone chose. Verified in the browser: cost 100 -> markup 25 -> selling R125,00 -> line total R1 250,00 -> margin R250,00 (20.0%). The 20% on a 25% markup is the distinction the whole pricing module exists for, and it renders correctly. 3. The actions column also gets `w-px whitespace-nowrap` so the cell sizes to its icons instead of being squeezed by its neighbours. Also pinned with 6 tests (repriceWithCostEntry): seeds on first cost, yields a margin, does NOT reseed on an already-costed line, does not override an explicit 0, collapses when the cost is cleared, and is a no-op without a cost. Verified they bite: removing the seed fails 2. Known limitation, reported rather than hidden: item DESCRIPTIONS are not searchable. The list endpoint returns headers only and never loads items, so "brazing" finds nothing while "kobus" (the customer on the same row) finds 9. Making descriptions searchable means loading items for every row. Full suite 3676 passing, tsc clean, build compiles.
2026-10-05 14:01:55 +00:00
/**
* 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 } : {}),
});
}