Repository navigation
Future: coupon codes as the discount mechanism, instead of typed prices #730
Description
Activity
- addedsliceThin vertical work itemThin vertical work itemarea:domainDomain layerDomain layerarea:apiAPI/endpoint layerAPI/endpoint layerarea:frontendReact/Vite web clientReact/Vite web clientepic-719Discount visibility and control on sales (epic #719)Discount visibility and control on sales (epic #719)size:LSeveral days; wide blast radius or unresolved scopeSeveral days; wide blast radius or unresolved scopeneeds-scopeScope contradicts the code; decide before schedulingScope contradicts the code; decide before scheduling
on Sep 8, 2026 What #720 settles for this slice, and what it deliberately does not
#720 (PR #734) is landing the list-price foundation. Three things here matter to a future coupon slice.
1. This is a different axis from coupons — plan for your own field
#720 adds
SalesOrderItem.ListPriceBasis:Recorded | ProductUnpriced | NotComparable | PreDating. That records why a line has no comparable list price. It does not record where a discount came from, which is what this issue needs.On a coupon line the product will usually have a list price, so the basis reads
Recordedand tells you nothing about the coupon. Do not extendListPriceBasiswith coupon values — that would overload one column with two unrelated meanings. This issue's own "records the coupon id and the resolved amount" is the right shape and stays right.2. The spec sentence this issue flags is confirmed, and is now filed
This issue notes that
specs/product/specs.mdstates "Cluckwork does not derive or enforce a discount from another product" and needs deliberate amendment. Confirmed while building #720 — and #720 found two more inaccuracies in the same region, now filed together:- §10.4 already reserves
discount_centsonsales_ordersfor an entered, order-level amount. Sales: snapshot the list price on the order line (discount foundation) #720 introduces a derived, per-line discount under the same English word. The glossary disambiguates the two; the spec does not. - §10.5 lists
line_total_cents, which is not a column (LineTotalis computed and EF-ignored), and omitsegg_grade_identirely.
A coupon slice amending §4.3/§10 should pick these up in the same pass rather than adding to prose that is already wrong.
3. Terminology already exists — reuse it
#720 shipped, in en/es/tl with glossary entries: List price, Discount, Above list. The epic originally assigned the discount terminology to #723; it moved to #720. A coupon slice should reuse these rather than coining new ones, and should note that Discount on a line already means derived list − unit, so a coupon-sourced discount needs either the same word with a stated source or a distinct term chosen deliberately.
4. What #720 leaves you
ListUnitPriceMinorUnits(nullablelong), snapshotted at line creation, never re-resolved on edit.ListPriceBasis, so a pre-migration line is permanently distinguishable from one where no comparable list price existed.- A stale-price guard (
SalesOrder.ListPriceChanged) that refuses a line when the catalogue moved under the seller. A coupon apply path would need the equivalent — a coupon edited between display and submit is the same class of problem. UpdateItemdeliberately carries no such guard, because editing a line records no list price. If a coupon slice makes editing re-resolve anything, that reasoning stops holding and the guard becomes necessary.
Filed from #720's review rounds; the body above is unchanged.
- §10.4 already reserves
What #727 leaves this slice
#727 (PR #766) ships the per-farm discount ceiling. Four things matter here. Written plainly,
because the interesting part is a decision, not a mechanism.1. The ceiling does not know where a discount came from, and under option 3 it must
The ceiling measures one thing: how far below its list price a line sold. It has no idea why.
That is fine today, because there is only one way to discount: a seller types a lower price. It
stops being fine the moment a coupon exists. A coupon line still has a list price and still sells
below it, so the ceiling sees an ordinary discount and refuses it.Under this issue's recommended option 3 — a coupon is pre-authorised, so it needs no reason and
no approval — that is backwards. A 20%-off coupon on a farm with a 10% ceiling would be blocked,
even though the Owner authorised that exact discount by name.So option 3 is not free. It needs the ceiling taught the difference between a discount the seller
chose and one the farm pre-approved.2. The same gap is in #721, one slice earlier
HasBelowListLineis what makes #721 demand a discount reason. A coupon line is below list, so
#721 would ask the seller to justify, in free text, a discount the Owner already authorised by name.Two separate checks, the same missing idea. AGENTS.md's guard rule says two misses of one shape mean
the method is wrong. So the fix is not to patch both checks. GiveSalesOrderItemone notion of
discretionary versus pre-authorised and route both through it. Doing it twice by hand is the
version that rots.3. Measuring per line is what makes coupons possible at all
#727 measures the ceiling per line, never against the order total. That was chosen so an order
could not be gamed by padding it with at-list items, but it turns out to be what makes coupons work.A per-order ceiling would have broken outright: one coupon line would push the whole order's
discount over the cap and block an order containing no seller-chosen discount at all. Per line, a
mixed order resolves correctly — the coupon line is exempt, the typed line is still bound.Nothing here needs revisiting. It is load-bearing, so do not switch to an order-level ceiling later
without re-reading this.4. What to change, and where
Small, and all of it in the right place already:
Surface Change SalesOrderItem.AgainstCeilingOne arm. It sits on the entity that would own the coupon id. SalesOrder.HasBelowListLineThe same exemption, for #721. web/src/lib/discountCeiling.tslineExceedsCeilingonly. LeaveexceedsCeilingand its vector table alone — that is pure arithmetic and stays correct.i18n, Help, glossary, en/es/tl The hint reads "the largest discount a Sales or Worker user may put on one sale line". Under coupons it has to say a discount they set themselves, or it is wrong. Untouched by coupons:
DiscountCeiling(pure arithmetic over list, unit and basis points), the
Accounts.MaxDiscountBasisPointscolumn and its check constraint,ConfirmSaleHandler's gate (the
exemption belongs behindFindCeilingBreach, not in the gate), and the per-caller
yourMaxDiscountPercentonGET /account, which is about the actor and not the line.One trap worth knowing before you touch it
FindCeilingBreachswitches onLineCeilingStatusand handlesUnmeasurableandExceeds.
Withinfalls through by omission. Add a fourth member likeCouponAuthorisedand it routes to
allowed silently — which is the behaviour you want, and exactly why it is dangerous. It would
compile, pass every test, and never state the decision anywhere a reviewer would see it.If a coupon status is added, make that arm explicit.
On the "two ways to express one policy" worry in the body
The body worries that a ceiling and coupons are two answers to one question. Having built the
ceiling, I think option 3 resolves that cleanly rather than papering over it, and the shipped code
is already the right half of it.A ceiling governs discounts a seller invents at the keyboard. A coupon is a discount the farm
decided in advance. Those are genuinely different things and a farm wants both: the coupon for the
planned promotion, the ceiling to bound the ad-hoc deal nobody planned. #727 is already the
"seller invents it" half. It simply has no word yet for the other kind.Two corrections to the body while I am here. It says #727 and coupons "coexist coherently" under
option 3 — true as a design, but not for free, per section 1 above. And #721 shipped with a
five-value picklist rather than the free text this issue suggested cutting it down to; that is done
and is not worth revisiting, but a coupon slice supersedes more of it than the body assumes.Not decided here
Whether a coupon may stack with a typed discount on the same line, and if so what the ceiling
measures. That is this slice's call, not #727's, and it is the question I would settle first,
because it decides whether "discretionary versus pre-authorised" is a per-line flag or a split of
the line's discount into two amounts.- addedpriority:tier4Deferred or speculativeDeferred or speculativeand removedepic-719Discount visibility and control on sales (epic #719)Discount visibility and control on sales (epic #719)
on Sep 13, 2026 Moved off epic #719 on 2026-09-13 — #719 is being closed at its value line, and this is a future pricing idea rather than a slice of it.
Now standalone at
priority:tier4, keepingneeds-scopeandsize:L. No new parent: it is not alert work (#803/#728) and it is not revamp work (#674); it is a proposal to replace typed prices with coupon codes as the discount mechanism, which is a domain-model decision of its own.What #719 leaves behind for it, so the scoping does not start cold:
- The list-price snapshot (Sales: snapshot the list price on the order line (discount foundation) #720) and
ListPriceBasisare in place, so "was this discounted" is a stored fact rather than a live comparison against today's catalogue. - Sales: require a discount reason when confirming a below-list order #721's
DiscountReasonCodeis the existing vocabulary for why. A coupon code is arguably a structured reason, and the two designs must not end up as rival answers to the same question. - Sales: per-farm discount ceiling, with Owner/Manager approval above it #727's per-farm ceiling is stored in basis points and measured per line. Any coupon mechanism has to state how it interacts with that ceiling — exempt, bounded by it, or replacing it.
- The coupon-compatibility analysis recorded on this issue during Sales: per-farm discount ceiling, with Owner/Manager approval above it #727 still stands.
- specs: §10.2/§10.4/§10.5 drift found while building #720 #737 flags that
specs/product/specs.md§10.2 ("Cluckwork does not derive or enforce a discount from another product") and §10.4's reserveddiscount_centsboth need deliberate amendment, and says that should happen alongside this issue rather than before it.
Nothing here is scheduled.
needs-scopeis the accurate state.- The list-price snapshot (Sales: snapshot the list price on the order line (discount foundation) #720) and
#737's third item lands here. Recorded 2026-09-13 during the issue cleanup.
#737 raised three pieces of spec drift found while building #720. Parts 1 and 2 were plain corrections to §10.5 and are fixed in PR #812. Part 3 is a vocabulary decision, not a correction, and #737 itself says to handle it alongside this issue — because this slice must touch the same prose and would otherwise write over a fix made before it.
So it is written down here rather than left on a closed issue:
§10.4 reserves
discount_centsonsales_ordersfor an entered, ORDER-LEVEL amount. #720 introduced a derived, PER-LINE discount under the same English word. #720's glossary disambiguates the two; the spec does not. Whatever this issue decides about coupons has to settle which of the twodiscountmeans in §10.4 — or rename one of them.§10.2 states: "Cluckwork does not derive or enforce a discount from another product." #720 already derives a discount from the same product's own list price, so the sentence reads as though the system derives no discounts at all, which is no longer true. It is not strictly contradicted — "from another product" is doing real work in that sentence — but it misleads, and a coupon mechanism makes it worse rather than better.
Amend both as part of this slice's design, not before it. A correction written now would be written against a vocabulary this issue is about to change.
Also relevant from the #719 close-out: the existing vocabulary a coupon design has to fit beside is
ListPriceBasis(#720),DiscountReasonCode(#721) and the per-farm ceiling in basis points (#727). A coupon code is arguably a structured discount reason, and the two must not become rival answers to the same question.- added a commit that references this issue
on Sep 13, 2026
Future slice on #719. Not scheduled — this needs a scoping decision first, and it
is probably epic-sized rather than slice-sized.
What
Discounts become named, pre-authorised coupon codes the seller applies, instead
of a price the seller types into the unit-price box.
Why this is the strategically different answer
Everything else on #719 makes an individually-decided discount visible after the
fact. A coupon changes who decides. The Owner defines "HOLIDAY10, 10% off, expires
31 Dec, egg products only"; the seller picks it. The discount stops being a
judgement call made at the keyboard and becomes a farm policy artifact with an
author, a validity window and a name.
That is a better fix for the situation that opened #719 than any amount of
reporting, because it is preventive by construction rather than by threshold.
Nothing like this exists today
grep -rniE "coupon|voucher|promo|promotion" src/ web/src specs/returns onlyuser-role promote/demote. No aggregate, no spec section, no glossary entry, no
i18n keys.
specs/product/specs.md:1376currently states outright that "Cluckworkdoes not derive or enforce a discount from another product", so §4.3 and §10 need a
deliberate amendment, not just an addition.
Rough shape
Couponaggregate: code, kind (percent or fixed amount), value, validitywindow, optional product or grade restriction, optional per-customer
restriction, usage limit, active flag,
Version.AccountIdas a plain non-nullableGuid, per AccountId must be a non-nullable Guid: the write guard and the #562 token walk are both fail-open for any other shape #673 — the model walk then givesit the tenant concurrency token automatically. No
FlockId, so Add discovery guard for flock-scoped EF query filters #613'sflock-scope walk does not apply.
SalesOrderItem(orSalesOrder) recordsthe coupon id and the resolved amount, snapshotted like every other line
fact per §10.5. Editing the coupon later must never rewrite a past order.
customer not eligible, and whether two coupons may stack.
The decision that has to come first
Does manual price entry survive? Three coherent answers, and picking none of
them ships two overlapping controls:
breaks legitimate ad-hoc pricing (damaged stock, a one-off bulk deal).
build and it changes almost nothing about the original problem.
without approval. A typed price still needs Sales: require a discount reason when confirming a below-list order #721's reason, and above Sales: per-farm discount ceiling, with Owner/Manager approval above it #727's
ceiling still needs Owner or Manager approval. This is my recommendation —
it makes the coupon the path of least resistance without removing the escape
hatch a working farm needs.
Overlap with slices already on #719 — read before building either
structured reason. If coupons are close, consider shipping Sales: require a discount reason when confirming a below-list order #721 as free text
rather than building the five-value picklist that coupons would supersede.
A ceiling says "you may discount up to 10%"; coupons say "you may apply these
three". Under option 3 above they coexist coherently. Shipping both without
deciding gives the farm two ways to express one policy.
more actionable than discount by reason string.
Dependencies
Needs #720 — a coupon still has to record what the price would have been, so the
list-price snapshot is the base it discounts from. Otherwise "10% off" has no
referent.
Why this is not really a slice
A new aggregate, a migration, CRUD screens, an application and validation path, a
change to the confirm flow, three locales, a spec amendment, glossary and Help.
That is #719's own shape. If it is picked up, expect it to become its own epic with
four or five slices under it, and size it properly before committing to a milestone.