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.