Skip to content

fix(sales): say which kind of missing list price a line has - #774

Merged
mforce merged 5 commits into
mainfrom
feat/773-list-price-basis
Sep 12, 2026
Merged

mforce merged 5 commits into
mainfrom
feat/773-list-price-basis

Conversation

@mforce

@mforce mforce commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Why

A sales line with no list price rendered the same whether the farm never had a comparable price or the order simply predates the feature. The domain already knew which: ListPriceBasis models Recorded, ProductUnpriced, NotComparable and PreDating, and only PreDating is missing data. It reached no JSON API, so the SPA had one null for three reasons and told the reader the product had no price to compare — which it may well have had.

GLOSSARY.md called a null "a real answer distinct from missing data" and the Help text said "when the product had none to compare". Both are false for PreDating.

Closes #773

Scope

  • ListPriceBasis on SalesOrderItemResponse as the enum member name, like DiscountReasonCode (Sales: require a discount reason when confirming a below-list order #721). Both the list and detail routes share the mapper.
  • web/src/api/cluckwork.ts: required on OrderItem, so the screen branches on a real value.
  • listPriceBasisLabel in web/src/i18n/enums.ts. Two labels for four values: ProductUnpriced and NotComparable are both honestly "no comparable list price"; only PreDating says "List price not recorded".
  • Three render sites in SalesPage.tsx, plus the order-level sentence and the Orders-list cell.
  • Glossary and Help prose in en/es/tl.

Tradeoffs

An order-level statement cannot be read off one line. A mixed order (one line pre-dating, one genuinely unpriced) may claim neither universal, so it gets its own sentence rather than borrowing either; the Orders-list cell, having room for one short label, reports the weaker truth that at least one price was never recorded.

No backfill. The snapshot is the catalogue price as it stood when the line was added, and filling old lines from today's catalogue would invent discounts that never happened.

Blast Radius

Read-path only, no migration, no schema change. A line whose basis is Recorded never reaches a basis label, so priced lines render exactly as before.

Verification

Backend 491 + 10 + 290 + 1793, web 2916, typecheck clean, build clean at zero warnings.

Proved on a genuine fixture rather than a hand-made one: seeded at 5240d73 (which has neither list-price migration), then migrated forward so the migration's own default backfilled 17 order lines to PreDating — the same path every real farm's rows took. Screenshots below are that fixture.

Adversarial review by Codex (gpt-6-astra) found four defects, all fixed here. The substantive one: a mixed order asserted "No list price on any line", the same misstatement this issue exists to end, one level up. Its two tests had pinned the old behaviour and went red against the fix. Also corrected: a chip guard that counted text only and located its cell by an index borrowed from a different table, and two comments that had become false.

A line's ListPriceBasis reached the Admin-only CSV export but not the JSON
read API, so the SPA had one null for three reasons and rendered all alike
as "No list price" — telling a reader the product had no price when the
line merely predates the column.

SalesOrderItemResponse now carries the basis as the enum member name.
Required and never defaulted: the domain decides the value and its basis in
one expression, and a default on the response would be the one place they
could disagree. Both sales routes map through the one ToResponse, so list
and detail cannot diverge.

The three basis tests move off the DbContext onto the detail route, a new
test pins list/detail parity, and a fourth pins PreDating — which only the
backfill can write, so the row is made by raw SQL.
…een (#773)

The Sales screen read the basis the API now ships and stops calling every
null list price "No list price". Two labels for four values, and the
collapse lives in LIST_PRICE_BASIS_KEYS: NotComparable shares
ProductUnpriced's key because both are the recorded fact "no comparable list
price", while only PreDating is missing data.

An ORDER reports PreDating only when EVERY line predates capture. One line
that genuinely had no comparable price makes "this order predates
list-price capture" false of the order as a whole.

sales:noListPrice and sales:discountUnknown lose their last callers and go,
in all three locales. The new enums strings reuse each locale's own
sales:listPrice wording per #688.
…773)

GLOSSARY.md claimed a null list price was "a real answer distinct from
missing data" — true of ProductUnpriced and NotComparable, false of
PreDating — and that the basis reached neither the JSON read API nor the
screen. Both halves of that are now wrong.

The Discount entry, the in-app glossary and the Sales help prose all named
labels that no longer exist. Each locale quotes its OWN label text per #688,
checked against its own catalog entry rather than translated afresh.

helpGlossary.ts needs no entry: ListPrice and Discount already register the
two strings this changes.
…end (#773)

Completes the wire, render and doc work: ListPriceBasis reaches
SalesOrderItemResponse and the SPA client, the three render sites route
through listPriceBasisLabel instead of a blanket "No list price", and
the glossary and Help prose stop asserting the product had no price for
rows that simply predate the snapshot.

The orderListPriceBasis empty-order case is documented as verified by
mutation, not by reading: deleting orderDiscount's empty bail turns the
em-dash test red.
…ces (#773)

astra review round 1. An order with one PreDating line and one
ProductUnpriced line reported "No list price on any line", which is
false: the pre-dating line's price is unknown, not absent. Rejecting
"the whole order predates capture" never established the opposite. That
is the misstatement this issue exists to end, one level up from the line.

orderListPriceBasis is now three-way and a mixed order gets its own
sentence in all three locales; the Orders-list cell reports the weaker
truth. The two mixed-order tests pinned the old behaviour and went red
against the fix, which is what the review predicted.

Also: the chip guard counted text only and passed with both copies in
one cell, and located its cell by an index borrowed from the Orders list
that actually pointed at Unit price; it is now index-free. Two comments
that had become false are corrected.
@mforce

mforce commented Sep 12, 2026

Copy link
Copy Markdown
Owner Author

Screenshots (#662)

Same order, same viewport (1280x760), captured 1:1 from stacks rebuilt for each side against the same PreDating fixture.

Before — three claims that the product had no price, on a line whose price is simply unrecorded.

Order panel before: No list price badge, No list price in Discount, and "No list price on any line"

After — the same line now says its price was never recorded, and the order-level sentence names the reason.

Order panel after: List price not recorded badge and cell, and a sentence naming that the order predates list-price capture

The before shot caught the panel before its Payments block had loaded, so that section appears only in the after. It sits outside the region this change touches.

Order panel before, claiming the product had no list price

Order panel after, saying the list price was never recorded

@coderabbitai

coderabbitai Bot commented Sep 12, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 22baa4a2-57f4-48a1-b420-acca3f36a5ba


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mforce
mforce merged commit 489180e into main Sep 12, 2026
11 checks passed
@mforce
mforce deleted the feat/773-list-price-basis branch September 12, 2026 06:38
mforce pushed a commit that referenced this pull request Sep 12, 2026
🤖 I have created a release *beep* *boop*
---


## [0.1.0](v0.0.4...v0.1.0)
(2026-09-12)


### ⚠ BREAKING CHANGES

* log in by farm code, with per-account email identity
([#532](#532)) (#564)

### Features

* **accounts:** add Account.Slug (farm code), suspend/reactivate,
list-accounts verb
([#531](#531))
([3fe9754](3fe9754))
* **accounts:** provision additional farms
([#581](#581))
([006f298](006f298))
* add Aspire local development AppHost
([#567](#567))
([2c9e6b9](2c9e6b9))
* add configurable worker sale allocation
([#619](#619))
([0955095](0955095))
* add searchable entity pickers
([#642](#642))
([60d2053](60d2053))
* **api:** provision-account takes an optional --timezone at creation
([#603](#603))
([#694](#694))
([a0aee39](a0aee39))
* **audit:** show the sales-line audit payload as a readable Details
column ([#745](#745))
([#749](#749))
([d26d389](d26d389))
* **auth:** add ApplicationUser.StepUpLogoutEpoch column
([#338](#338))
([#554](#554))
([18306ee](18306ee))
* certify over-cap simulation fixture bands
([#633](#633))
([a67b2e1](a67b2e1)),
closes [#627](#627)
* **cli:** rename-account verb to change a farm code
([#732](#732))
([#733](#733))
([4b70559](4b70559))
* **customers:** edit existing customer details
([#625](#625))
([#626](#626))
([062a55c](062a55c))
* **jobs:** single-runner leader gate for the durable job worker
([#271](#271))
([#555](#555))
([4148f9b](4148f9b))
* let owners change user email addresses
([#605](#605))
([842347b](842347b))
* log in by farm code, with per-account email identity
([#532](#532))
([#564](#564))
([68adb62](68adb62))
* **ratelimit:** distributed IP-keyed auth limiters
([#544](#544))
([#558](#558))
([ec14972](ec14972))
* **ratelimit:** distributed per-account report concurrency cap with
local-ceiling fallback
([#545](#545))
([#559](#559))
([1522e4e](1522e4e))
* **sales:** mark discounted lines, total the discount, and show it in
the Orders list ([#723](#723),
[#724](#724))
([#741](#741))
([1a07441](1a07441))
* **sales:** record list, old and new price in the order-line audit
payload ([#722](#722))
([#742](#742))
([97c866f](97c866f))
* **sales:** refuse an over-ceiling confirm from a Sales user
([#727](#727))
([#766](#766))
([8c0792a](8c0792a))
* **sales:** show what each order still owes, and filter the list to
unpaid ([#771](#771))
([ca59d68](ca59d68))
* **sales:** snapshot the list price on the order line and show the
discount ([#734](#734))
([cffed5e](cffed5e))
* **sales:** snapshot the product name and unit in the order-line audit
payload ([#747](#747))
([#748](#748))
([0481c06](0481c06))
* scope Worker reads to assigned flocks
([#388](#388))
([#611](#611))
([5884a9a](5884a9a))
* shared-state ports with Redis + in-process fallback
([#543](#543))
([#552](#552))
([f767fa9](f767fa9))
* suspend-account / reactivate-account operator verbs
([#534](#534))
([#573](#573))
([d0be26c](d0be26c))
* **tenancy:** write-side tenant guard + single-assignment TenantContext
([#546](#546))
([#561](#561))
([f371f1d](f371f1d))
* **web:** dashboard rework — capture-status tiles, 14-day trend, stock
as a stacked bar
([#654](#654))
([396ba23](396ba23))
* **web:** date-range filters on audit and expenses, and the stock lot
filter gets its bounded toolbar
([#666](#666),
[#667](#667),
[#653](#653))
([94b188f](94b188f))
* **web:** elevation hierarchy and sentence-case labels
([#651](#651),
[#652](#652))
([#661](#661))
([28db4c7](28db4c7))
* **web:** Expenses and Audit keep a clear-filters control while rows
are still showing
([#679](#679))
([#697](#697))
([b859982](b859982))
* **web:** expenses filters by a date range like its sibling screens
([#667](#667))
([f13858f](f13858f))
* **web:** key the farm brand palette per farm
([#586](#586))
([#600](#600))
([7183a43](7183a43))
* **web:** let operators forget remembered farms
([#598](#598))
([577d94e](577d94e))
* **web:** one-line provenance, bounded date filters, and empty states
that invite action
([#653](#653),
[#655](#655))
([#668](#668))
([80b53f4](80b53f4))
* **web:** prefill the farm code from ?farm= and remember it
([#535](#535))
([#588](#588))
([b7f5cc6](b7f5cc6))
* **web:** split authenticated routes into lazy chunks
([#620](#620))
([5089271](5089271))
* **web:** the audit log filters by a date range, and says which window
is empty ([#666](#666))
([63027e0](63027e0))
* **web:** typeset numbers as numbers and refresh the Help glossary
([#650](#650),
[#657](#657))
([af4fe11](af4fe11))


### Bug fixes

* **api:** order same-instant audit events by a durable monotonic key
([#700](#700))
([8fcf084](8fcf084))
* **api:** print the farm code from bootstrap-admin
([#589](#589))
([#594](#594))
([34032ac](34032ac))
* **audit:** show the price a line sold for, not its list price
([#759](#759))
([e6b37d0](e6b37d0))
* **audit:** store catalog enums by name and guard the add-item
transaction shape
([#751](#751))
([23609ff](23609ff))
* **auth:** reject invalid account claims
([#622](#622))
([8d6c7fe](8d6c7fe))
* **auth:** require step-up for durable user access
([#360](#360))
([#607](#607))
([f767dce](f767dce))
* **ci:** bound the npm audit calls and give the web job room to finish
([#686](#686))
([153b7a8](153b7a8))
* **ci:** escalate the audit bound to SIGKILL, so it actually bounds
([#686](#686))
([a0c8f4e](a0c8f4e))
* **ci:** fail closed on invalid vulnerability config
([#621](#621))
([1690db8](1690db8))
* **ci:** lockfix covers the two AppHost lock files, derived from the
sln
([efb05e6](efb05e6))
* **ci:** lockfix covers the two AppHost lock files, derived from the
sln
([8986d77](8986d77))
* **ci:** remove invalid XML comment from nuget.lockfix.config
([#541](#541))
([5f1bc0a](5f1bc0a))
* **ci:** the advisory vuln gate no longer blocks on an unusable report
([#686](#686))
([aaf6934](aaf6934))
* **ci:** the advisory vuln gate no longer blocks on an unusable report
([#686](#686))
([64f1f53](64f1f53))
* **i18n:** tl help text names the saleable flag and unit-system setting
what their labels call them
([#688](#688))
([#696](#696))
([bfd24d7](bfd24d7))
* **infra:** AccountId must be a non-nullable Guid or both tenant write
layers refuse ([#673](#673))
([#695](#695))
([2470c4e](2470c4e))
* require step-up for flock scope changes
([#609](#609))
([4151f89](4151f89))
* **sales:** keep a line's discount markers agreeing while its price is
edited ([#752](#752))
([#753](#753))
([c159b4b](c159b4b))
* **sales:** say which kind of missing list price a line has
([#774](#774))
([489180e](489180e))
* scope legacy logout to selected farm
([#624](#624))
([fae8d82](fae8d82))
* **seed:** drain the daily-entry lock sweep so deep simulation fixtures
validate ([#644](#644))
([730fa23](730fa23)),
closes [#638](#638)
* **tenancy:** AccountId is a concurrency token, so the database refuses
a detached cross-tenant write
([#562](#562))
([4d1dfa3](4d1dfa3))
* **tenancy:** AspNetUserRoles carries a tenant column, so a role write
naming another farm's user is refused
([#670](#670))
([fc0552a](fc0552a))
* **tests:** bump the image-pin allow-list counts for the AppHost
LocalPorts tests
([#593](#593))
([58d3056](58d3056))
* **tests:** the OTLP collector survives a lost port race and ignores
traffic that is not an export
([#672](#672),
[#676](#676))
([#677](#677))
([965c737](965c737))
* **web:** a scoped audit view filtered to nothing names both the record
and the range ([#666](#666))
([41bbfe1](41bbfe1))
* **web:** an abandoned dialog attempt's success no longer hijacks the
replacement on Customers, Daily Entry, Flocks, Grades and Products
([#703](#703))
([#705](#705))
([85605db](85605db))
* **web:** an abandoned dialog attempt's success no longer hijacks the
replacement on Inventory, Expenses, History and Stock
([#703](#703))
([#706](#706))
([60a4997](60a4997))
* **web:** an abandoned edit's success no longer hijacks the dialog that
replaced it on Users
([#703](#703))
([#710](#710))
([778faab](778faab))
* **web:** an abandoned order attempt's success no longer hijacks the
dialog that replaced it
([#702](#702))
([522c699](522c699))
* **web:** capture screens open on the flock you last used, and
assigning one no longer guesses
([#646](#646))
([#699](#699))
([7f8f317](7f8f317))
* **web:** constrain dialog session helpers to declared scopes
([#715](#715))
([389e3c8](389e3c8))
* **web:** date validation gets one boundary table instead of one case
per review round
([#666](#666))
([215f830](215f830))
* **web:** keep a paged window and an item panel on the user's newest
intent ([#645](#645))
([d81bccf](d81bccf))
* **web:** keep Sales order panels closed after pending writes
([#711](#711))
([f0f7492](f0f7492))
* **web:** keep Sales panels closed after pending Open reads
([#716](#716))
([620411f](620411f))
* **web:** make login take the cross-tab cookie lock so a racing refresh
cannot restore the wrong session
([#648](#648))
([ff18beb](ff18beb))
* **web:** make the entity picker read as a search field and focus it on
open ([#736](#736))
([66ef667](66ef667)),
closes [#735](#735)
* **web:** page truncated customer and movement tables with usePagedList
([7cfe4d6](7cfe4d6))
* **web:** reconcile Sales line edits with refreshed orders
([#717](#717))
([d7dd2c9](d7dd2c9))
* **web:** the audit date filter accepts low-numbered years, and its
empty state covers every narrowing
([#666](#666))
([af52d25](af52d25))
* **web:** the audit date filter rejects impossible dates, and its
history guard actually guards
([#666](#666))
([8d51846](8d51846))
* **web:** the expense range bounds are not capped at today, which the
month-end default exceeds
([#667](#667))
([7e01864](7e01864))
* **web:** the help text calls the expiry field what the field calls
itself ([#666](#666))
([2fd1f3c](2fd1f3c))
* **web:** the stock lot date range sits in the bounded toolbar
([#653](#653))
([43dec5e](43dec5e))


### Refactoring

* **web:** extract SalesPage's dialog-write wrapper into a shared
useDialogAction hook
([#703](#703))
([#704](#704))
([60ee9d9](60ee9d9))


### Documentation

* add k6 preparation steps to the dev-database fixture runbook
([#643](#643))
([a4f1f09](a4f1f09))
* add runbook for loading the simulation fixture into a dev database
([#639](#639))
([2d143b8](2d143b8))
* **agents:** a PR closes its issue from the body, not the title
([#744](#744))
([39be13c](39be13c))
* **agents:** drop the commit and push gate, and require screenshots on
UI changes ([#757](#757))
([6225172](6225172))
* **agents:** find guards by grepping registry readers; amend issues a
PR overtakes ([#580](#580))
([fe3fde8](fe3fde8))
* **agents:** the Playwright specs have been in CI since 2026-08-08
([#768](#768))
([68ee612](68ee612))
* **aspire:** record the second local database and pin the AppHost
dashboard ports ([#623](#623))
([713b941](713b941))
* compress AGENTS.md to one paragraph per rule, and draw the two orders
that matter ([#551](#551))
([997ae8a](997ae8a))
* item 7 names each screen's actual initial filter value
([#666](#666))
([70a53d8](70a53d8))
* multi-farm tenancy decision record and AGENTS/GLOSSARY sync
([#537](#537))
([#601](#601))
([2c34771](2c34771))
* name the scoped filtered-empty key and state the
[#653](#653) relationship
plainly ([#666](#666))
([0e93dac](0e93dac))
* note that a PackageReference in Directory.Build.props is invisible to
the dependency graph
([4845724](4845724))
* **plans:** commit the
[#722](#722) and
[#745](#745) design records
([#754](#754))
([c942fcd](c942fcd))
* record [#579](#579) as
won't-fix — suspension is immediate for use, not issuance
([#582](#582))
([7a3be40](7a3be40))
* record the [#508](#508)
audit ordering key and the tracked-file guard lesson
([#701](#701))
([08964e9](08964e9))
* **runbooks:** add procedure to rename the default farm's code after
upgrade ([#731](#731))
([2f6e242](2f6e242))
* screenshots of the running SPA in the README
([#550](#550))
([711488a](711488a))
* **sim:** commit the dashboard screenshot, capture the palette matrix,
and record the
[#651](https://github.com/mforce/cluckwork/issues/651)/[#652](https://github.com/mforce/cluckwork/issues/652)
conventions ([#660](#660),
[#662](#662),
[#663](#663),
[#664](#664))
([#665](#665))
([930ea30](930ea30))
* specify searchable entity picker
([#641](#641))
([91d4300](91d4300))
* split the README into audience-scoped docs and adopt repo-template
scaffolding ([#548](#548))
([b3f3fcf](b3f3fcf))
* surface Aspire local development workflow
([#568](#568))
([a343baa](a343baa))
* **web:** record the per-screen idempotency-key policies and runWrite's
refresh contract
([#703](#703))
([#707](#707))
([8bee651](8bee651))
* **web:** the date-cap help text covers every stocked item, not only
feed ([#666](#666),
[#667](#667))
([c8433c5](c8433c5))
* **web:** the help text claims only what is true of recording, and says
nothing about filter caps
([#666](#666),
[#667](#667))
([e2f63d1](e2f63d1))
* **web:** the help text describes the date-range filters that shipped
([#666](#666),
[#667](#667))
([c3275b7](c3275b7))
* **web:** the help text stops describing a cap the filters no longer
have ([#666](#666),
[#667](#667))
([49654cd](49654cd))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: cluckwork-lockfix[bot] <309265648+cluckwork-lockfix[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sales: an old order's missing list price reads as "the product had no price"

1 participant