Skip to content

Future: coupon codes as the discount mechanism, instead of typed prices #730

Description

@mforce

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 only
user-role promote/demote. No aggregate, no spec section, no glossary entry, no
i18n keys. specs/product/specs.md:1376 currently states outright that "Cluckwork
does not derive or enforce a discount from another product", so §4.3 and §10 need a
deliberate amendment, not just an addition.

Rough shape

  • A Coupon aggregate: code, kind (percent or fixed amount), value, validity
    window, optional product or grade restriction, optional per-customer
    restriction, usage limit, active flag, Version.
  • AccountId as a plain non-nullable Guid, 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 gives
    it the tenant concurrency token automatically. No FlockId, so Add discovery guard for flock-scoped EF query filters #613's
    flock-scope walk does not apply.
  • Application at the line or the order: SalesOrderItem (or SalesOrder) records
    the 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.
  • CRUD screens under Setup, beside Grades and Products.
  • Validation on apply: expired, inactive, limit reached, product not eligible,
    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:

  1. Coupons only. The unit-price field becomes read-only. Strictest, and it
    breaks legitimate ad-hoc pricing (damaged stock, a one-off bulk deal).
  2. Both, equally. The seller may type a price or apply a coupon. Simplest to
    build and it changes almost nothing about the original problem.
  3. Both, tiered. A coupon is pre-authorised, so it applies without a reason and
    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

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.

Activity

  1. added
    sliceThin vertical work item
    area:apiAPI/endpoint layer
    epic-719Discount visibility and control on sales (epic #719)
    size:LSeveral days; wide blast radius or unresolved scope
    needs-scopeScope contradicts the code; decide before scheduling
    on Sep 8, 2026
  2. mforce commented on Sep 9, 2026

    @mforce
    OwnerAuthor

    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 Recorded and tells you nothing about the coupon. Do not extend ListPriceBasis with 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.md states "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_cents on sales_orders for 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 (LineTotal is computed and EF-ignored), and omits egg_grade_id entirely.

    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 (nullable long), 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.
    • UpdateItem deliberately 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.

  3. mforce commented on Sep 11, 2026

    @mforce
    OwnerAuthor

    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

    HasBelowListLine is 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. Give SalesOrderItem one 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.AgainstCeiling One arm. It sits on the entity that would own the coupon id.
    SalesOrder.HasBelowListLine The same exemption, for #721.
    web/src/lib/discountCeiling.ts lineExceedsCeiling only. Leave exceedsCeiling and 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.MaxDiscountBasisPoints column and its check constraint, ConfirmSaleHandler's gate (the
    exemption belongs behind FindCeilingBreach, not in the gate), and the per-caller
    yourMaxDiscountPercent on GET /account, which is about the actor and not the line.

    One trap worth knowing before you touch it

    FindCeilingBreach switches on LineCeilingStatus and handles Unmeasurable and Exceeds.
    Within falls through by omission. Add a fourth member like CouponAuthorised and 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.

  4. added and removed
    epic-719Discount visibility and control on sales (epic #719)
    on Sep 13, 2026
  5. mforce commented on Sep 13, 2026

    @mforce
    OwnerAuthor

    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, keeping needs-scope and size: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:

    Nothing here is scheduled. needs-scope is the accurate state.

  6. mforce commented on Sep 13, 2026

    @mforce
    OwnerAuthor

    #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_cents on sales_orders for 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 two discount means 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:apiAPI/endpoint layerarea:domainDomain layerarea:frontendReact/Vite web clientneeds-scopeScope contradicts the code; decide before schedulingpriority:tier4Deferred or speculativesize:LSeveral days; wide blast radius or unresolved scopesliceThin vertical work item

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions