Repository navigation
Description
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) carriesUnitPrice
and nothing else.Product.DefaultPriceMinorUnitsis the list price;
SalesPage.tsx:890prefills 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,BaseUnitFactorand
UnitPriceper spec §10.5 — but not the list price it was sold against.
There is no stored fact saying a sale was discounted. AddOrderItemHandler.cs:106andUpdateOrderItemHandler.cs:30do 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
- Sales: snapshot the list price on the order line (discount foundation) #720 Snapshot the list price on the order line — shipped 2026-09-09, PR feat(sales): snapshot the list price on the order line and show the discount #734, squashed to
cffed5e - Sales: record list/old/new price in the order-line audit payload #722 Price change in the audit payload — shipped 2026-09-10, PR feat(sales): record list, old and new price in the order-line audit payload (#722) #742, squashed to
97c866f - Sales: mark discounted lines and show a discount total on the order screen #723 Mark discounted lines on the order screen — shipped 2026-09-10, PR feat(sales): mark discounted lines, total the discount, and show it in the Orders list (#723, #724) #741, squashed to
1a07441 - Sales: discount badge with amount and percent in history and order detail #724 Discount badge in the Orders list — shipped 2026-09-10, PR feat(sales): mark discounted lines, total the discount, and show it in the Orders list (#723, #724) #741 (same PR as Sales: mark discounted lines and show a discount total on the order screen #723), squashed to
1a07441. The Sales: require a discount reason when confirming a below-list order #721 discount reason on order detail is deliberately not met and is deferred to Sales: require a discount reason when confirming a below-list order #721 — amendment on the issue. - Sales: require a discount reason when confirming a below-list order #721 Require a discount reason at confirm — shipped 2026-09-11, PR feat(sales): require a discount reason when confirming a below-list order #756, squashed to
c7864be. All five acceptance criteria met, including history: theSalesOrder.Confirmaudit row carries the reason (the earlier "deliberately not met" amendment was withdrawn). Answers epic open question 2 (per order, not per line) and question 4 (no, a markup above list does not trigger it —HasBelowListLineuses a strict<). Also found and filed Audit Details shows the list price as if it were the sale price on every order line #758: the Audit Details cell renders the LIST price as if it were the sale price on every order line, which hides exactly the discounts this epic exists to surface. - Audit Details shows the list price as if it were the sale price on every order line #758 Audit Details showed the list price as the sale price — shipped 2026-09-11, PR fix(audit): show the price a line sold for, not its list price #759, squashed to
e6b37d0. Regression from SPA: render audit detailsJson as a readable per-key table instead of a raw tooltip #745/feat(audit): show the sales-line audit payload as a readable Details column (#745) #749: theSalesOrder.AddItemDetails cell discardedunitPriceMinorUnitswhenever a list price existed, so every line readat list $Xwhatever it sold for — hiding the discounts this epic exists to surface, and understating one fixture line sold above list by 25%. An off-list line now rendersat $0.35 (list $0.45), with no direction marker, per this epic's open question 4 (a markup is not a discount). Rendering only: the audit payload already carried both prices correctly, verified against the seeded database. Four literal-text tests, each watched red under its own mutation. - Sales: per-farm discount ceiling, with Owner/Manager approval above it #727 Per-farm ceiling with Owner/Manager approval — shipped 2026-09-11, PR feat(sales): refuse an over-ceiling confirm from a Sales user (#727) #766, squashed to
8c0792a. All five acceptance criteria met, with the fifth amended: aPreDatingline is refused rather than waved through, because the Sales: snapshot the list price on the order line (discount foundation) #720 comment naming this issue says the three non-Recordedbases need opposite treatment, and a pre-Sales: snapshot the list price on the order line (discount foundation) #720 draft can still be re-priced today. Answers epic open question 3 (no in-app approval flow: an honest refusal, out of band). Measured per line, Owner and Manager may exceed, 422 not 403, stored as basis points. Four product defects were found in review and fixed before merge, two of them by an independent adversarial reviewer after CodeRabbit had passed the same code twice: a client-side gate that blocked confirms the server would allow, and a zero list price that waived the ceiling entirely. Also filed AGENTS.md #394 says the Playwright specs are not in CI; they have been since 2026-08-08 #767 (AGENTS.md wrongly says the Playwright specs are outside CI) and recorded a coupon-compatibility analysis on Future: coupon codes as the discount mechanism, instead of typed prices #730. - Reports: discount totals per salesperson and per customer, in report and CSV export #725 Discount totals in report and export
- Customers: discount history and running total on the customer record #726 Discount history on the customer record
- Alerts: raise an alert-centre entry for a large discount at confirm #728 Alert-centre entry — also needs the Phase 1.5 alert centre on EPIC: Phase 1.5 — Egg product hardening #15
- Future: coupon codes as the discount mechanism, instead of typed prices #730 Coupon codes — unscheduled, needs its own scoping decision
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
- feat(eggs): make cracked and dirty eggs sellable stock via condition grades (#396) #407 — one new migration per change; never fold a column into
InitialCreate. - chore(schema): generate PostgreSQL schema documentation #417 — regenerate
docs/schema/in the same PR; CI fails a stale one. - i18n: catalogParity compares key SETS, so a translation that drifts in MEANING passes every gate #688 / i18n — every new string in all three locales, and help/glossary prose
naming a control must use that control's label in each locale. Nothing enforces
this; it is review-only. - Owner directive —
specs/product/GLOSSARY.mdgains discount, list price
and discount reason; the SPA Help page and in-app glossary update in the same PR. - Daily entry: require grading to reconcile sellable eggs before submit #394 — if confirm gains a required reason, that is a write-contract change:
read the non-CI callers (DemoDataSeeder,SimulationDataSeeder,
tools/simulation/k6/,tools/simulation/ui/Playwright specs). A green baseline
hides them. - Version++ — any new
SalesOrder/SalesOrderItemmutation bumpsVersion,
with a parallel-race integration test. - Visual PRs — slices 4 and 5 attach 1:1 before/after screenshots from a stack
rebuilt at the head under review (AGENTS.md: three conventions earned while shipping #651 + #652 #662).
Open questions
- Is a null
DefaultPriceMinorUnitsallowed to stay, or does an egg product now
require a list price? (Decides slice 1's null handling.) - Should the discount reason be required on every discounted line, or once per
order? Per order is cheaper and probably enough. - Does an above-threshold order need an in-app approval flow, or is "Sales cannot
confirm it, tell a manager" sufficient for now? - Is a markup above list price also worth flagging, or only discounts?
Activity
mforce commentedon Sep 8, 2026
Mockups
Nine screens, one per slice, drawn on the shipped SPA design system (aubergine
#4a154b, Inter, 16px card radius, the existing.badgetints, hairline#e6e6e6tables on#faf7fc) rather than an invented look. Sources aredocs/images/discount-mockups/on branchdocs/719-discount-mockups.#720 List price snapshot
the one new column, before and after, plus three ways to get it wrong
#722 Audit payload
the price change readable in the audit trail
#723 Order screen
four line states, discount column, discount total row
#721 Confirm with reason
the per-line breakdown sits above the reason field
#724 History badge
the Sales order list, percent-led badges
#725 Report
discount summary and by-customer table
#726 Customer discounts
list columns plus a drilldown
#727 Ceiling and refusal
the setting, and what a Sales user sees above it
#728 Alert entry
alert-centre rows, two thresholds
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.
IAuditWriter.WriteAsyncalready takes anobject? detailsparameter thatAuditWriterserialises intoDetailsJson, and ~30 call sites already use it. No signature change, no migration, no column, no UI, no i18n.AuditVocabularyCoverageTestsinspects onlyargs[0]andargs[1], so adetails: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.SalesOrdershas 11 columns and none of them records a creating user. Attribution exists only by joiningAuditEventsonAction 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 aCreatedByUserIdcolumn as its own slice, or cut that half./customers/:idpage, ~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 (
/historyis daily entries). And the sales report has no CSV download today, so "discount in the CSV export" means two lines in the account-backupsales-order-itemsdataset, not a report download.mforce commentedon Sep 9, 2026
Amendment — the terminology decision moved from #723 to #720
This epic's Why this order section says:
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:
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:
lineDiscountinSalesPage.tsx); Reports: discount totals per salesperson and per customer, in report and CSV export #725 and Customers: discount history and running total on the customer record #726 are server-side and will re-derive it in C#. Two implementations of one financial rule, in two languages. Worth Reports: discount totals per salesperson and per customer, in report and CSV export #725 reusing a single stated rule rather than re-deriving.NULLresolves.ListUnitPriceMinorUnitsisNULLfor three deliberately indistinguishable reasons: the product had no list price, the line predates the migration, or the denominations were not comparable. Sales: per-farm discount ceiling, with Owner/Manager approval above it #727 gates an Owner/Manager approval on this column and must choose fail-open (those lines escape the ceiling forever) or fail-closed (every unpriced sale needs approval). One sentence settles it in Sales: per-farm discount ceiling, with Owner/Manager approval above it #727; discovering it mid-implementation does not.The body above is left as written, for history.
feat(sales): snapshot the list price on the order line and show the d…
feat(sales): mark discounted lines, total the discount, and show it i…
feat(sales): require a discount reason when confirming a below-list o…
fix(audit): show the price a line sold for, not its list price (#759)
feat(sales): refuse an over-ceiling confirm from a Sales user (#727) (#…
mforce commentedon Sep 13, 2026
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.
SalesOrderItemcarriedUnitPriceand nothing else; the "discount" was an edited number in a text box, and the auditevents had no payload, so the trail showed someone touched a line, never what price they set.
That is no longer true at any layer:
ListUnitPriceMinorUnitssnapshotted on the line, withListPriceBasisdistinguishing the four cases. A discount is a stored fact, not a live comparison against today's catalogue.SalesOrder.Confirmaudit row.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
HasBelowListLineuses a strict<, and Audit Details shows the list price as if it were the sale price on every order line #758'srenderer shows an off-list line with no direction marker.
Where the four remaining slices went
SalesOrdershas 11 columns and none records a creating user, so the per-salesperson breakdownneeds an actor column, a migration and a backfill decision first. And its premise — that a report
is the only thing standing between the farm and an unnoticed discount — was overtaken by Sales: require a discount reason when confirming a below-list order #721
and Sales: per-farm discount ceiling, with Owner/Manager approval above it #727.
exist: there is no customer detail page and no parameterised route anywhere in the SPA. It also
required sharing one aggregation query with Reports: discount totals per salesperson and per customer, in report and CSV export #725, which is now closed. The accepted gap is stated
on the issue: a standing arrangement built from many small reasoned discounts is still not
visible from any screen.
alertslabel alongside Alert centre: durable per-farm alerts with an SPA surface (specs §17.1) #803, thePhase 1.5 alert centre filed today. No longer
blocked: the surface it waits on now exists as anissue.
needs-scope. A domain-modelproposal, not a slice of this epic. What this epic leaves it — the list-price snapshot, the
DiscountReasonCodevocabulary, the basis-point ceiling and specs: §10.2/§10.4/§10.5 drift found while building #720 #737's spec amendments — is recordedthere.
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.