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 },
|
|
|
|
|
): 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
|
|
|
|
|
? Number(patch.markupPercent) || 0
|
|
|
|
|
: line.markupPercent;
|
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
|
|
|
// Floor applies HERE, at reprice, so every caller inherits it. The
|
|
|
|
|
// "apply markup to all lines" bulk action and the delivery-note
|
|
|
|
|
// review modal both land in this function, and both previously wrote
|
|
|
|
|
// whatever markup they were handed straight through.
|
|
|
|
|
const markupPercent = applyMarkupFloor(requestedMarkup, costPrice);
|
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 } : {}),
|
|
|
|
|
});
|
|
|
|
|
}
|