Skip to content

EPIC: Discount visibility and control on sales #719

Closed

Description

@mforce

Why

Discounts were being given on sales without the owner knowing. Investigation of the
current code found the cause is not a missing screen — the system has no discount
concept at all
:

  • SalesOrderItem (src/Cluckwork.Domain/Sales/SalesOrder.cs) carries UnitPrice
    and nothing else. Product.DefaultPriceMinorUnits is the list price;
    SalesPage.tsx:890 prefills the price field from it and the seller overtypes it.
    The whole "discount" mechanism is an edited number in a text box.
  • The line snapshots ProductTypeSnapshot, EggGradeId, BaseUnitFactor and
    UnitPrice per spec §10.5 — but not the list price it was sold against.
    There is no stored fact saying a sale was discounted.
  • AddOrderItemHandler.cs:106 and UpdateOrderItemHandler.cs:30 do write audit
    events, but with no payload — the trail shows someone touched a line, never
    what price they set.

So the discounts were not hidden by a UI gap. They were never recorded as discounts,
and they are not reconstructible from what is stored today.

The decision that must land first (blocks everything else)

Snapshot the list price on the sales order line — ListUnitPriceMinorUnits on
SalesOrderItem, captured from Product.DefaultPriceMinorUnits at line creation —
and derive discount as ListUnitPrice − UnitPrice.

Without the snapshot, "was this discounted?" is computed live against today's
catalog price: raise a product's price next month and every past sale retroactively
becomes a discount; drop it and real discounts vanish. The order screen, the history
badge and the report would each drift, and they would drift differently. This is
the same rule already written into §10.5 for BaseUnitFactor — redefining a carton
must not reinterpret recorded orders. Same rule, same field.

Decide before implementing: products with a null DefaultPriceMinorUnits have no
list price, so "discount" is undefined for them. Either require a list price on egg
products, or render those lines as no list price — never as 0% off, which is the
version that lets a discount hide.

Data starts on the day this ships. Orders confirmed before the migration have no
list-price snapshot and must display as blank/unknown, not as zero discount. Every
screen and report in this epic states that explicitly.

Slices, in implementation order

Sized against the running app and the source, 2026-09-08. Blocks means the named
issue cannot start until this one ships.

# Slice Size Depends on Blocks
1 #720 Snapshot the list price on the order line M nothing every slice below
2 #722 Price change in the audit payload S #720 (for completeness, not to compile) nothing
3 #723 Mark discounted lines on the order screen M #720 #724 (terminology)
4 #724 Discount badge in the Orders list S #720, #723 nothing
5 #721 Require a discount reason at confirm L #720 #725, #726, #728 (reason column)
6 #727 Per-farm ceiling with Owner/Manager approval L #720, #721 #728 (threshold shape)
7 #725 Discount totals in report and export L #720, #721 #726
8 #726 Discount history on the customer record L #720, #721, #725 nothing
9 #728 Alert-centre entry M #720, #721, #727, the Phase 1.5 alert centre on #15 nothing
— #730 Coupon codes instead of typed prices L #720 —

Progress

What #720 leaves the rest of the epic: ListUnitPriceMinorUnits (nullable, snapshotted at line
creation, never re-resolved) and ListPriceBasis (Recorded | ProductUnpriced | NotComparable | PreDating), so a pre-migration line is permanently distinguishable from one where no comparable list
price existed. Plus a stale-price guard, and the translated terms List price, Discount and
Above list in en/es/tl with glossary entries — reuse them rather than coining new ones.

#720 ─┬─ #722
      │
      ├─ #723 ── #724
      │
      └─ #721 ─┬─ #727 ─┐
               │        ├─ #728  (also needs the alert centre, #15)
               ├─ #725 ─┘
               │    │
               │    └─ #726

#730 is deliberately unnumbered. It is on this epic but not in the sequence:
it needs a scoping decision, it is probably its own epic rather than a slice, and
it is the one item that would change what some of the nine above should be.

Related but outside this epic: #729 moves farm configuration and identity to
Owner-only. It came out of scoping #727 but #727 no longer depends on it.

Why this order

#720 first, because it defines "below list" for everything else. Additive and
nullable, so it breaks zero existing tests: SalesOrderItem.Create is internal
with one caller, and AddOrderItemHandler already loads the Product, so the value
is in hand and no new query is needed. Its real cost is the migration's generated
Designer file and a Docker-bound schema-docs run.

#722 second, and it does not strictly need #720 to compile — but it does to be
complete.
AddOrderItemHandler already has the list price
(command.UnitPriceMinorUnits ?? product.DefaultPriceMinorUnits), so the add half
stands alone. UpdateOrderItemHandler does not: its constructor is
(ISalesOrderRepository, IAuditWriter, IUnitOfWork) with no product repository, so
before #720 an update event can carry old and new unit price but no list price.
Shipping it first would write rows where the list price is absent because the
column did not exist yet — indistinguishable from a product that had no list price,
which is exactly the null-means-two-things trap #720 warns about. After #720 the
snapshot is on the item the handler already holds, so the whole payload lands in
one change with no new query. Roughly two source files either way.

#723 before #724 for a reason that is not size. Both need a translated word for
discount, catalogParity compares key sets only, and #688 says nothing enforces
that two screens agree per locale. Ship them adjacent so one person picks the term
once.

#721 before #725, #726 and #728 because the reason is a column those three
display. Building them first means adding the column later and revisiting three
read surfaces.

#727 after #721 because both change the confirm path, and doing them in one
region avoids two passes over ConfirmSaleHandler's 265 lines of ordered locking.

If #730 is close, cut #721 down. A coupon code is a structured reason, so
#721's five-value picklist would be superseded within a release. Ship #721 as free
text in that case — useConfirm.askReason() already provides a required non-blank
reason and is used by Void on the same screen, which makes that version nearly
free. Decide #730's fate before starting #721, not after.

#725 before #726 because #726's per-customer totals must come from #725's query.
Two customer-discount numbers that can disagree is a defect waiting to be filed.

Two slices need a decision before they are scheduled

#725 promises a per-salesperson breakdown that cannot be built. SalesOrders
has 11 columns and none records a creating user. The Orders list's History column
reads Today · admin from audit rows, not from the order. Attribution needs
Action LIKE '%.Create', which has no supporting index and is permanently blank
before #494. Add a CreatedByUserId column as its own slice, or cut that half.

#726 targets a customer detail screen that does not exist, and the app has no
parameterised route in any of its 24 routes. As written it is the app's first
/customers/:id page, 10 to 12 files, most of them new. The cheaper reading is two
columns on the existing list plus a panel, about 4 files.

Repo rules this epic touches

Open questions

  1. Is a null DefaultPriceMinorUnits allowed to stay, or does an egg product now
    require a list price? (Decides slice 1's null handling.)
  2. Should the discount reason be required on every discounted line, or once per
    order? Per order is cheaper and probably enough.
  3. Does an above-threshold order need an in-app approval flow, or is "Sales cannot
    confirm it, tell a manager" sufficient for now?
  4. Is a markup above list price also worth flagging, or only discounts?

Activity

  1. added this to the Phase 1.5 — Hardening milestone on Sep 8, 2026
  2. added
    epicPhase-level tracking issue
    epic-719Discount visibility and control on sales (epic #719)
    on Sep 8, 2026
  3. mforce commented on Sep 8, 2026

    @mforce
    OwnerAuthor

    Mockups

    Nine screens, one per slice, drawn on the shipped SPA design system (aubergine #4a154b, Inter, 16px card radius, the existing .badge tints, hairline #e6e6e6 tables on #faf7fc) rather than an invented look. Sources are docs/images/discount-mockups/ on branch docs/719-discount-mockups.

    #720 List price snapshot

    the one new column, before and after, plus three ways to get it wrong

    #720 List price snapshot

    #722 Audit payload

    the price change readable in the audit trail

    #722 Audit payload

    #723 Order screen

    four line states, discount column, discount total row

    #723 Order screen

    #721 Confirm with reason

    the per-line breakdown sits above the reason field

    #721 Confirm with reason

    #724 History badge

    the Sales order list, percent-led badges

    #724 History badge

    #725 Report

    discount summary and by-customer table

    #725 Report

    #726 Customer discounts

    list columns plus a drilldown

    #726 Customer discounts

    #727 Ceiling and refusal

    the setting, and what a Sales user sees above it

    #727 Ceiling and refusal

    #728 Alert entry

    alert-centre rows, two thresholds

    #728 Alert entry

    Three corrections from reading the code

    Sizing the slices against the real source contradicted three things in this epic as filed. Details in the individual issues; summarised here because they change the plan.

    1. Sales: record list/old/new price in the order-line audit payload #722 is far smaller than written. IAuditWriter.WriteAsync already takes an object? details parameter that AuditWriter serialises into DetailsJson, and ~30 call sites already use it. No signature change, no migration, no column, no UI, no i18n. AuditVocabularyCoverageTests inspects only args[0] and args[1], so a details: argument is invisible to it. This is the cheapest slice in the epic and it does not depend on Sales: snapshot the list price on the order line (discount foundation) #720 if it logs old/new unit price only.
    2. Reports: discount totals per salesperson and per customer, in report and CSV export #725's per-salesperson breakdown is not buildable. SalesOrders has 11 columns and none of them records a creating user. Attribution exists only by joining AuditEvents on Action LIKE '%.Create', which has no supporting index and is permanently blank for orders written before Show "created by / when" and "last changed by / when" inline on record pages #494. Either add a CreatedByUserId column as its own slice, or cut that half.
    3. Customers: discount history and running total on the customer record #726 targets a screen that does not exist. There is no customer detail page, and the SPA has no parameterised route anywhere in its 24 routes. As written it means the app's first /customers/:id page, ~10-12 files, most of them new.

    Two smaller notes. #724's "sales history" is the Sales order list; there is no separate sales-history surface (/history is daily entries). And the sales report has no CSV download today, so "discount in the CSV export" means two lines in the account-backup sales-order-items dataset, not a report download.

  4. mforce commented on Sep 9, 2026

    @mforce
    OwnerAuthor

    Amendment — the terminology decision moved from #723 to #720

    This epic's Why this order section says:

    #723 before #724 for a reason that is not size. Both need a translated word for discount, catalogParity compares key sets only, and #688 says nothing enforces that two screens agree per locale. Ship them adjacent so one person picks the term once.

    That is no longer accurate. An owner scope change to #720 (2026-09-08) added a Discount column and a live price hint to the sales order screen, so #720 coined the terms first — in en, es and tl, with glossary entries:

    Term Where
    List price column header, glossary, Help page
    Discount column header, glossary, Help page
    Above list cell text, hint, glossary

    The rule the epic states still holds — one person picks the term once — but that person was #720, not #723. #723 and #724 should reuse these strings rather than coining their own. #723 has been amended with the same note.

    The dependency ordering is otherwise unchanged: #723 still needs #720's snapshot, and #724 still follows #723.

    Also worth recording against this epic, from building #720:

    The body above is left as written, for history.

  5. mforce commented on Sep 13, 2026

    @mforce
    OwnerAuthor

    Closing at the value line — the problem this epic was opened for is solved

    Closed on 2026-09-13 during the issue cleanup. Seven of nine slices shipped in five days, which
    is the fastest-moving work in this repo, and the original failure is fixed.

    The failure, and why it is now fixed

    Discounts were being given without the owner knowing, and the investigation found the cause was not
    a missing screen — the system had no discount concept at all. SalesOrderItem carried
    UnitPrice and nothing else; the "discount" was an edited number in a text box, and the audit
    events had no payload, so the trail showed someone touched a line, never what price they set.

    That is no longer true at any layer:

    #720 ListUnitPriceMinorUnits snapshotted on the line, with ListPriceBasis distinguishing the four cases. A discount is a stored fact, not a live comparison against today's catalogue.
    #722 List, old and new price in the order-line audit payload.
    #723 Discounted lines marked on the order screen.
    #724 Discount badge and total in the Orders list.
    #721 A reason is required at confirm for a below-list order, carried on the SalesOrder.Confirm audit row.
    #727 A per-farm ceiling refuses an over-ceiling confirm from a Sales user — per line, basis points, Owner and Manager may exceed, 422 not 403.
    #758 Audit Details had been rendering the list price as the sale price on every line, hiding exactly the discounts this epic exists to surface. Fixed.

    The control moved from detection to prevention, which is a better answer than the one this epic
    originally scoped. A discount cannot be recorded silently: it needs a reason, and above the ceiling
    a Sales user cannot record it at all.

    Open questions, answered

    Where the four remaining slices went

    What is deliberately NOT available

    No discount aggregation anywhere: no totals in the report or CSV export, no per-salesperson view, no
    per-customer view. Discount analysis over a period means reading the Orders list, which carries the
    #724 badge and per-order total. That is the accepted cost of closing here, and #725/#726 record the
    conditions that would reopen it.

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

    epicPhase-level tracking issueepic-719Discount visibility and control on sales (epic #719)

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions