Repository navigation
feat(sales): require a discount reason when confirming a below-list order - #756
Conversation
…rm route The route now takes a nullable ConfirmSaleRequest carrying the discount reason fields #721 will populate. A typed body parameter attaches application/json Accepts metadata, which the consumes matcher turns into a route constraint, so every caller that POSTs /confirm with no Content-Type at all started 404-ing through Program.cs's /api/{**rest} catch-all. Declaring */* alongside application/json keeps those callers matching, and the new Confirm_WithNoContentTypeAtAll_StillConfirms pins it.
SalesOrder gains DiscountReasonCode and DiscountReasonNote, both nullable and both written only by Confirm. HasBelowListLine compares the sale price strictly against the line's snapshotted list price, so a NULL list price and an above-list price are both left alone. The whole rule lives on the aggregate because the seeders reach Confirm through the handler and never see a validator. One migration, columns nullable with no backfill: NULL on a confirmed order means the reason was not recorded, never that no discount was given. The code is stored by name so reordering the enum cannot relabel history. Schema docs regenerated.
…e export The confirm command gains the two optional fields, ConfirmSaleValidator turns a malformed code or an overlong note into a 400 before any lock is taken, and the handler refuses an unknown code before opening the transaction — the seeders build the command directly and never pass a validator. One parser, DiscountReason.TryParseCode, backs both, so the boundary and the handler cannot disagree about what a legal code is; it rejects a numeric string, an undefined number and a comma-separated combination that Enum.TryParse alone would accept. The discount rules moved into CheckCanConfirm beside NotDraft and NoItems, so a missing reason is refused before the FIFO plan runs rather than after it, and SalesOrderResponse and the sales-orders export both carry the stored reason.
…rder The confirm button opens a picklist instead of the plain yes/no when any line is priced below list, decided by the same lineDiscount predicate the row markers already use rather than a second one that could drift from it. The reason and its note ride on the confirm, and the stored reason then shows on the confirmed order beside the give-away it explains and in the Orders list. useConfirm gains a third shape rather than the screen gaining a second dialog idiom: askChoice keeps the inline refusal askReason already has, so a missing note never costs the user the option they had chosen. confirm and askReason are untouched and their tests pass as written. Strings in en, es and tl, and the codes render through i18n/enums.ts.
…spec worker-sale-allocation.spec.ts sells at 0.01 against a seeded list price of 45, so the confirm button now opens the reason picklist and its waitForResponse on POST /confirm could never settle. Nothing in CI runs that spec, so it would have hung rather than failed. The deep discount stays; the spec picks a reason. sales.spec.ts sells at 0.50 against the same 45 and is unaffected, which is now stated where the price is defined. SimulationDataSeeder's below-list line sits on a draft that is never confirmed, pinned the same way.
… glossary Same PR as the behaviour, per the owner directive. Each locale names the control with its own label — Discount reason, Motivo del descuento, Dahilan ng diskwento — so no locale disagrees with itself about what the field is called (#688).
A fieldset maps to role="group", which does not support aria-invalid, so the attribute was accessibility that was not there. aria-describedby is global and already carries the error; the focus return to the first radio is what actually takes the user to it.
…se the review gaps The confirm dialog now matches the mockup on #721: it keeps its title and its FIFO prose and gains the order's discount figure and the below-list lines it comes from, both read from the same orderDiscount/lineDiscount the screen behind it renders, so the dialog can never quote a different number. From the adversarial pass over the diff: - DiscountReasonCode was mirrored into enums.ts by hand with nothing coupling them, so a member added on either side would silently never reach the other. DiscountReasonVocabularyTests pins both directions, the way AuditVocabularyCoverageTests already pins the two audit vocabularies, and the Other literal that drives the inline note rule is pinned with it. - ConfirmSaleValidator had no test at its own layer; the integration tests can only see the status code, not which layer refused. - The picklist test compared the rendered radios to the constant the page maps over, so a rename moved both sides together. It asserts literal labels now. - askChoice had no supersede and no unmount test, the one arm of the widened settle union the design named as this slice's risk. - The two discount refusals a seller can actually reach are races the design predicted, so they get es and tl strings rather than server English. - The design record now states the two rules the implementation had to amend: the checks live in CheckCanConfirm so a missing reason is refused before the FIFO plan, and rule 5 also refuses a note with no code beside it.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…on UI changes (#757) Two rule changes to `AGENTS.md`, both owner directives, 2026-09-11. ## 1. Removes the commit/push gate Drops `AGENTS.md:210`, "Only commit/push when the human asks". Agents may now commit, push a branch and open a PR without asking. The merge stays the owner's, and `main` is protected, so nothing reaches `main` unreviewed either way. **Why it goes rather than gets narrowed.** The rule carried no `→` decision-record link, so by this file's own preamble it was "a plain convention that has not yet cost anything" rather than an earned rule — no archaeology to follow before changing it. It was also ambiguous about where the gate actually sat: an agent working in an isolated worktree has to decide whether *every commit* waits or only the push, and the two readings differ a lot in practice. That ambiguity cost a round of clarification mid-way through #721. **What it changes for an agent.** Everything up to and including opening the PR becomes autonomous. Worth being explicit about the consequence, since it is the one thing the rule was implicitly buying: a PR opened without asking starts CI and spends a round of the review bot's included budget. That is the trade being accepted, not an oversight. No other file restates the gate — checked `CONTRIBUTING.md`, `docs/` and `.github/`. ## 2. Broadens the screenshot rule The #662 rule already required a 1:1 before/after comparison, but only for **a PR whose *purpose* is visual**. That trigger is too narrow: a PR whose purpose is a domain rule can still add a dialog, a field or a label, and those are exactly the changes a reader cannot check from the diff. It now reads: **any PR that changes what a user sees attaches screenshots**, before and after wherever a before exists, same viewport and same scenario in both, after-only where the UI is net-new. Two additions to the mechanics beside the existing ones (capture at 1:1, rebuild the stack at the head under review): - **Attach with `gh pr comment --attach`, never by committing images to a branch.** A branch is deletable, so routine cleanup silently breaks the images on a merged PR while the prose around them still claims they exist. - A **pointer from the Git / PR workflow section**, which is what people actually read while opening a PR. The mechanics stay stated once, under Operations and callers, so the two cannot drift. This PR changes no UI, so rule 2 does not apply to it. ## Related #756 is the slice that surfaced both: its own ambiguity about the commit gate, and a confirm dialog that the old screenshot trigger would not have required. --------- Co-authored-by: mforce <mforce@users.noreply.github.com>
Screenshots (#662)Captured at 1:1 (1280×900, Before —
|
History and the #745 Details column read audit EVENTS, never the aggregate, so the SalesOrders columns alone left #721's "survives to history" acceptance unmet. Not a second source of truth: the reason is write-once, set only by Confirm and never edited afterwards, and the audit row commits in the same transaction as the columns, so the two cannot disagree. Omitted entirely, never written as nulls, when the order gave nothing away. The AuditPage Details branch sits ABOVE the productName/currency guard on purpose: a confirm payload carries neither field, so a branch placed below that guard renders nothing at all — proved by moving it and watching the render test go red, then reverting. Updates the #721 design doc's non-goal: the owner reversed the earlier "no audit payload" decision.
…vent rows Each row now collapses to When (UTC)/Who/Action by default, with a per-row disclosure toggle (accessible name "Details", aria-expanded) revealing Entity and Details in place — a genuine expandable-row interaction rather than a cosmetic relabel, kept on real <table>/role="row" semantics (not a free-floating Accordion list) specifically to preserve the screen's dense existing invariant coverage (#493's stale-scope handling, #666's date-window validation, #679's filter/clear discipline) rather than rewriting it around a new locator strategy. Filters, actor, action, entity scope, UTC meaning (the "When (UTC)" header is unchanged), Details rendering (#745/#756/#758/#720's price-summary logic) and pagination are all untouched; still no mutation controls — the only buttons a read-only row carries are its own disclosure toggles, proven by a rewritten test that checks every button is a collapsed "Details" toggle, not just that none exist. Rows default COLLAPSED (not expanded) so the visual result actually matches #833's Concept C reference renders rather than looking unchanged — worth the larger test diff: ~20 existing assertions that read Entity or Details now call a new expandRow() helper first (mirroring AccountPage.test.tsx's expandChangePassword() pattern), and two columnheader-based tests were rewritten since Entity/Details are no longer table columns. New tests cover the toggle itself: collapsed by default, expands/collapses on click (mutation-checked), and each row toggles independently (mutation-checked by making the toggle a shared boolean and watching it fail before reverting) — plus that expanding a row never re-queries listAuditEvents.
…vent rows Each row now collapses to When (UTC)/Who/Action by default, with a per-row disclosure toggle (accessible name "Details", aria-expanded) revealing Entity and Details in place — a genuine expandable-row interaction rather than a cosmetic relabel, kept on real <table>/role="row" semantics (not a free-floating Accordion list) specifically to preserve the screen's dense existing invariant coverage (#493's stale-scope handling, #666's date-window validation, #679's filter/clear discipline) rather than rewriting it around a new locator strategy. Filters, actor, action, entity scope, UTC meaning (the "When (UTC)" header is unchanged), Details rendering (#745/#756/#758/#720's price-summary logic) and pagination are all untouched; still no mutation controls — the only buttons a read-only row carries are its own disclosure toggles, proven by a rewritten test that checks every button is a collapsed "Details" toggle, not just that none exist. Rows default COLLAPSED (not expanded) so the visual result actually matches #833's Concept C reference renders rather than looking unchanged — worth the larger test diff: ~20 existing assertions that read Entity or Details now call a new expandRow() helper first (mirroring AccountPage.test.tsx's expandChangePassword() pattern), and two columnheader-based tests were rewritten since Entity/Details are no longer table columns. New tests cover the toggle itself: collapsed by default, expands/collapses on click (mutation-checked), and each row toggles independently (mutation-checked by making the toggle a shared boolean and watching it fail before reverting) — plus that expanding a row never re-queries listAuditEvents.






Closes #721
Epic #719, slice 5. Confirming a sales order with any line priced below its
snapshotted list price now requires a reason, chosen from a closed picklist with
an optional note that becomes required for Other. The reason is stored on the
order and surfaced on the order panel, in the Orders list and in the
sales-ordersCSV export.Design record:
docs/plans/721-discount-reason/01-design.md.What decides "discounted"
SalesOrder.HasBelowListLine— a line counts only when a comparable list priceexists and the sale price is strictly under it. That answers two of the epic's
open questions: a
NULLlist price is not a discount (nothing to compareagainst), and a price above list is not one either. Same two branches the
SPA's
lineDiscountalready uses, so the screen and the server cannot disagree.The reason is per order, not per line (epic open question 2). Per line
multiplies clicks on the app's most routine write and buys precision no
consumer — #725, #726, #728 — asks for.
Two corrections the work made to its own design
The optional body does not bind the way the design claimed. The design cited
DisableUseras proof that a bodyless POST still reaches an endpoint with anullable body parameter. It proves the opposite:
DisableUserTests.Disable_WithNoContentTypeAtAll_Is404_NotUnsupportedMediaTypepins that exact 404. A typed body parameter attaches
application/jsonAcceptsmetadata, the consumes matcher turns it into a route constraint, and a POST with
no
Content-Type— which is what every existing caller sends — stops matchingand falls through to
Program.cs's/api/{**rest}catch-all. Four tests wentred the moment the parameter landed. The fix is one line of route metadata,
.Accepts<ConfirmSaleRequest>(isOptional: true, "application/json", "*/*"),which changes no caller;
Confirm_WithNoContentTypeAtAll_StillConfirmspins it.The rules belong in
CheckCanConfirm, not inConfirm. The design put themin
Confirm, which meant a missing reason was refused only afterConfirmSaleHandlerhad planned and applied a whole FIFO allocation — and thehandler's "contradicted its own CheckCanConfirm" throw then turned that 422 into
a 500. Both corrections are written back into the design record.
#394 — the callers CI does not cover
Read from source against the seeded catalog, not inferred from a green run.
tools/simulation/ui/specs/worker-sale-allocation.spec.tsbroke. It pricesa line at
0.01againstSim Large Eggsat45, so after this change thefirst click opens the reason dialog, the spec's wait for
sales:confirmOrderTitlenever resolves, no/confirmPOST is sent, and itswaitForResponsecan never settle. Fixed here by driving the new dialog whilekeeping
0.01and every Configurable Worker sales allocation scope by flock assignment #612 assertion, so it now covers both paths.DemoDataSeederandSimulationDataSeederare unaffected: both add lines witha
nullunit price, so the handler prices them at list. The simulation seederdoes seed below-list lines via
EnsureExtraLineAsync, but only onto draftsthat are never confirmed — a near miss worth knowing about before moving them.
tools/simulation/k6/never confirms a sale at all (bundles.js:391-393).tools/simulation/ui/specs/sales.spec.tssurvives by five minor units — itsells at
0.50against a45list — and now says so in a comment.Verification
Driver-verified (run and read by the reviewing agent, on the final source
state, not quoted from the implementer):
dotnet build Cluckwork.slnis 0 warnings / 0 errors,tsc -b --noEmitisclean, and
tools/schema-docs/generate.sh --checkreportsdocs/schema/ is up to date(#417).One mutation planted and observed by the driver, rather than trusting the
implementer's report: flipping
HasBelowListLine's strict<to<=turns fourdomain tests red; restored, re-run, 428 pass and the file diffs clean.
Implementer-attested, not driver-verified: 39 further mutations across the
domain rules, the API surface, the SPA and the new guards, all reported red with
none surviving.
git grep MUTANTreturns only the repo's own pre-existingPlaywright harness.
CI-verified (corrects an earlier claim in this body): the
Playwright smoke over the simulation fixturejob passed in 4m8s against a realseeded stack, so both specs were exercised — including
worker-sale-allocation.spec.ts, the one this change broke and repairs here. Anearlier revision said neither suite had been run by anyone; that was written
before the job reported and was wrong.
Still owed:
under the rule broadened in docs(agents): drop the commit and push gate, and require screenshots on UI changes #757. Blocked on a local dev admin credential, not
on the work.
Reviewer status. CodeRabbit did not review this PR. Its check reports
pass,but the detail reads
Review skipped: manual review required for this OSS repository— the green badge here means no review ran, not that nothing wasfound. Recorded so the silence is not read as a clean pass.
Every acceptance criterion is met
The reason survives to order detail, the Orders list, the
sales-ordersCSV export and, as of
c364f93, history — theSalesOrder.Confirmauditrow carries
discountReasonCodeanddiscountReasonNote, and the Audit page'sDetails column renders them.
An earlier revision of this body argued for leaving history out, on the grounds
that a copy in the audit row would be a second source of the same truth. The
owner reversed it, and the reversal is right: the reason is write-once and the
audit row commits in the same transaction as the columns, so the two cannot
disagree. The duplication risk that argument guarded against does not exist. The
design record's non-goal is struck through with that reasoning rather than
quietly rewritten.
Two behaviours worth knowing. An order confirmed at list writes no payload at
all rather than a payload of nulls. And there is no backfill: an order
confirmed before this shipped shows an em dash in Details even where its columns
carry a reason, which is the honest rendering of "recorded somewhere this view
cannot reach".