Skip to content

feat(sales): show what each order still owes, and filter the list to unpaid - #771

Merged
mforce merged 18 commits into
mainfrom
feat/769-orders-outstanding
Sep 12, 2026
Merged

mforce merged 18 commits into
mainfrom
feat/769-orders-outstanding

Conversation

@mforce

@mforce mforce commented Sep 12, 2026 •

Copy link
Copy Markdown
Owner

Why

The Orders list could not answer "which orders has nobody paid yet". Status cannot stand in for it: Confirmed means both "settled" and "owes everything", so a farm had to open orders one at a time. This adds an Outstanding column and an Unpaid only filter beside it.

The figure is one C# expression used for both the displayed value and the filter predicate, so the two cannot drift. It compiles to CASE WHEN "Status" = 'Confirmed' THEN "TotalMinorUnits" - (correlated sum over non-voided payments) END, which yields SQL NULL off Confirmed; NULL > 0 is untrue, so Draft, Cancelled and Voided fall out of the filter structurally rather than through a second status predicate. One query, no migration.

Closes #769

Scope

  • ISalesOrderRepository.ListAsync takes SalesOrderListFilter and returns SalesOrderListRow. SettlementScope is Hidden | Visible | UnpaidOnly, which makes "hide the money but filter by it" unrepresentable; Hidden emits SQL that never names Payments.
  • SaleEndpoints.ListSalesOrders gains bool? unpaid. SalesOrderResponse gains long? OutstandingMinorUnits. GetSalesOrder carries the same figure, per the existing "detail and list must answer identically" rule.
  • SalesPage.tsx renders the column after Total and owns the filter as unpaid=1 in the URL. onRecordPayment and onVoidPayment now wrap in orders.runWrite, because a payment-derived figure on the list made both handlers writers of it.
  • GLOSSARY.md, the Help prose and helpGlossary.ts in en/es/tl.

Tradeoffs

The money tier reads AuthPolicies.SalesAccess through IAuthorizationService rather than a new Roles predicate. That policy already gates the three payment routes, so a second encoding would let this column disagree with /customers/balances about who may see money. statusFilter stays local state while customerId and unpaid live in the URL, so a shared link restores two filters of three. Moving status is a separate slice.

Blast Radius

The list route stays SalesFlow, so Workers keep building orders. The money inside the response is gated separately: a Worker's rows carry no figure and unpaid=true from a Worker is a 403, never a quietly different answer.

Verification

Build clean at zero warnings. Domain 491, Application 290, AppHost 10, web 2907, and the five model-only SalesOrderListQueryTests all pass.

The integration suite passes in CI (Build and test, 10m32s), which covers the tier gate, the server-side unpaid predicate, the voided-payment exclusion and the new Cancelled and Voided cases. The Playwright smoke suite passes locally against a stack rebuilt at this head, 41 tests.

Four mutations were run by hand and each went red on the intended guard: running the settlement query under Hidden; dropping the SPA tier gate; deleting the Payments tenant filter; and weakening the unpaid predicate to TotalMinorUnits > 0. The last two were found by adversarial review, which also caught the stale-list defect above and a false "admin-only" claim about /customers/balances.

The E2E failure on the first push was the spec, not the product: the filter's state lives in the URL, React Router v7 commits that navigation inside startTransition, and Playwright's check() verifies the checked state in the same frame. Measured on the running stack, the box settles true at 57ms, keeps it, and its DOM node is never replaced. The spec now clicks and asserts the outcome with a retrying expect.

Before/after screenshots are attached in a comment below.

@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: c9bf79ed-20a0-4a2a-b0f5-b8075fb3f42f


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 commented Sep 12, 2026

Copy link
Copy Markdown
Owner Author

Screenshots (#662)

Captured at 1:1, 1280x720, Sales persona, from stacks rebuilt for each side: origin/main (455e673) for the before, this branch's head for the after.

Before — the Orders list ends at Total. Nothing on the row says whether the money arrived.

Orders list before, columns end at Total

After, no filter — an Outstanding column sits after Total, with a glossary link.

Orders list after, with an Outstanding column

After, Unpaid only ticked — the list narrows to orders still owing. SO-07B2E7D3 shows $8.10 outstanding against a $16.20 total with "part of this order is paid" beneath it, which is the partial state; the rest show the full total outstanding.

Orders list filtered to unpaid, showing a partial payment

Orders list before, columns end at Total

Orders list after, with an Outstanding column

Orders list filtered to unpaid, showing a partial payment

… body (#769)

The response field was duplicated into ConfirmSaleRequest, where it is
meaningless: /confirm's body carries the discount reason, and the handler
never reads an outstanding value. Left in, it would advertise an accepted
field on the write contract and in the OpenAPI schema that nothing honours.

No test covers the shape of that record, which is why the suite stayed
green with it present.
#769)

#769 put each confirmed order's outstanding amount on the Orders list, which
made both payment handlers writers of that list. They called refreshPayments
alone, so the row kept the figure that was owed before the money moved and a
settled order stayed in the "Unpaid only" view.

Both handlers now run their write through orders.runWrite, the same idiom
onConfirm, onCancel and onVoid already use: the list ticket is claimed before
the POST and every loaded page is re-walked after it. The key rotation, the
current() gating and the message ordering are unchanged.
Assert.Contains("ef_filter", sql) was satisfied by the tenant filters on
SalesOrders and SalesOrderItems. Deleting the Payments query filter outright
left it green while the correlated subquery read every farm's money, so the
assertion did not prove what its comment claimed.

The Payments subqueries are now isolated by their own span, from FROM
"Payments" to the paren that closes the SELECT they sit in, and both the
tenant predicate and the correlation to the outer order are asserted inside
that span. All of them, not the first: UnpaidOnly emits two.

Mutation: deleting builder.Entity<Payment>().HasQueryFilter(...) now fails
both theory cases; the old assertion passed against that same model.
…r zero (#769)

Assert.Contains("> 0") and Assert.Contains("CASE") both survived replacing the
unpaid predicate with o.TotalAmount.MinorUnits > 0, which admits a fully paid
order into the filter: the projected CASE keeps the "CASE" and the mutated
WHERE keeps the "> 0", so each assertion was answered by a different part of
the query than the one it named.

The projected expression and the filtered expression are now isolated and put
through one shared shape check: the Confirmed guard, the subtraction from
TotalMinorUnits, the sum over AmountMinorUnits, and a Payments subquery that
is tenant-scoped and correlated. One helper for both spans, so the two cannot
be pinned to different shapes.

Mutation: rows.Where(x => x.Order.TotalAmount.MinorUnits > 0) now fails; all
three old assertions passed against it.
…769)

The non-Confirmed case only ever constructed a Draft, so changing the
repository's `Status == SalesOrderStatus.Confirmed` to `Status != Draft` passed
it while handing Cancelled and Voided orders a live outstanding figure and
admitting them to ?unpaid=true.

The new case reaches each status the only way it can be reached: a draft with
a line, cancelled; and a confirmed, unpaid order, voided. Both keep a 1000
total, which is what makes the null outstanding a claim about the status
rather than about an empty order, and the unpaid page stays empty.

Docker-backed, so unexecuted here: `dotnet test` on this project fails with
DockerUnavailableException on this machine, and its mutation could not be run
either. Build is clean.
…ly (#769)

The entry said the Orders-list figure matched "who may record and view
payments rather than #89's stricter admin-only balances endpoint".
/customers/balances is gated with AuthPolicies.SalesAccess, so a Sales user
may call it: the endpoint is the same tier, and it was #769 that the sentence
made look like a relaxation.

Names the four routes that share the policy, and says where the admin gate
actually is — the Customers page renders its balance column for admins, which
is presentation and not a tier. SaleEndpoints.MaySeeMoneyAsync already said
this correctly; the glossary was the only copy of the error in the repo.
…)'s one-shot verify (#769)

The filter's state lives in the URL, and React Router v7 commits a
navigation inside startTransition, so the control reads its new value
~57ms after the click rather than in the same frame. Playwright's
check() verifies the checked state immediately after clicking and threw
'Clicking the checkbox did not change its state' inside that window.

Measured against the sim stack at this head: checked goes false -> true
once at 57ms and stays, and a MutationObserver shows the input's DOM
node is never replaced. The product is correct; the helper's one-shot
verification is what raced. A retrying expect on the observable outcome
is both correct and robust to a slower runner.
…null (#769)

The helper read the row as raw JSON and then collapsed the two cases raw
JSON exists to tell apart: a missing property and a present null both fell
to `null`. A typed `long?` DTO already distinguishes a number from a null,
so the only thing the raw read bought was literal presence, and it was not
checking it.

Presence is now asserted for every row whatever the caller's tier, and the
value is asserted separately: a number for the money tier, an explicit null
for a Worker. The distinction is real for the SPA, which branches on
`owed === null` in SalesPage.tsx; an absent property arrives as `undefined`,
takes neither the null branch nor the zero branch, and renders NaN.

Mutation: `[property: JsonIgnore(Condition = WhenWritingNull)]` on
SalesOrderResponse.OutstandingMinorUnits. The old helper passed under it,
the new one fails on the Worker row.
…s route (#769)

The previous wording replaced one wrong claim with another: it said
`/customers/balances`, `/sales/{id}/payments`, the record-payment route and
the Orders list all require AuthPolicies.SalesAccess. The Orders list does
not. `GET /api/v1/sales` is gated SalesFlow, which admits Workers on
purpose, because workers build orders; only the settlement figure and the
unpaid filter are SalesAccess, and the handler decides those. That split is
the design of this slice, so stating it wrongly is worse than the
admin-only error it replaced.

The glossary now separates the route gate from the money gate. The same
"all four routes" claim in the MaySeeMoneyAsync comment is corrected in
the same commit; every gate here was read off the RequireAuthorization
calls in SaleEndpoints.cs and PaymentEndpoints.cs.
…sent table (#769)

`toHaveCount(0)` on the settled order was satisfied by the Orders table
being gone, not by the row being excluded. `usePagedList` sets `reloading`
for a replacement request and SalesPage renders <p>Loading…</p> instead of
the whole table for that window. Measured on this stack with the list
response held: +57ms after the click the row locator resolves to 0
elements, one Loading node is on screen, and the checkbox still reads
unchecked. A server that wrongly kept returning the settled order would put
the row back after the assertion had already passed.

The `toBeChecked()` in front of it is not a barrier by design — it settled
at +3285ms here only because React commits the URL transition late. That is
ordering luck. The spec now waits for the replacement response and for the
table to be back before reading the row's absence, so the anchor is the
rendered filtered list.

Mutation: drop `unpaid` from the listOrders call in SalesPage.tsx, rebuild
the sim stack, run the spec. Red on the row assertion with "Expected: 0,
Received: 1". Reverted, rebuilt, green.
@mforce
mforce force-pushed the feat/769-orders-outstanding branch from 963a7f7 to f22ad8b Compare September 12, 2026 03:51
@mforce
mforce merged commit ca59d68 into main Sep 12, 2026
11 checks passed
@mforce
mforce deleted the feat/769-orders-outstanding branch September 12, 2026 04:07
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>
mforce added a commit that referenced this pull request Sep 12, 2026
Adds coverage measurement for the four backend test projects. **Report
only: there is no threshold, floor, or gate anywhere in this PR**, which
is #776's explicit instruction and the order `web/vite.config.ts`
followed for the SPA — measure first, decide whether to floor it later,
from actuals.

Closes #776

## What lands

`tools/coverage/collect.sh` builds once, runs each test project under
coverlet, merges the per-project cobertura output through
ReportGenerator into per-project and combined reports, and writes
`coverage-out/SUMMARY.md`. The script is table-driven: one `ROWS` array
of `name|csproj|meaning`, so a fifth test project is one row and nothing
else. `--project <name>` runs one project alone for local iteration.

`tools/coverage/coverlet.runsettings` carries exactly one interpretive
filter, the EF migrations exclusion. Everything else stays at coverlet's
default deliberately, so this first measurement is untuned.

`.github/workflows/coverage.yml` runs it Mondays at 05:00, on
`workflow_dispatch`, and on a `pull_request` scoped to the coverage
tooling's own paths. `ci.yml` is untouched.

`coverlet.collector` is versioned centrally per #684 with a bare
reference in `tests/Directory.Build.props`, and the ReportGenerator tool
joins the existing `dotnet-ef` manifest entry. All four
`packages.lock.json` files are regenerated in the same commit as the
manifest change.

## First measurement, 2026-09-12

| Project | Assemblies | Line | Branch |
|---|---|---|---|
| Domain | 1 | 81.4% (1480/1817) | 74.9% (651/869) |
| Application | 3 | 13.9% (1666/11901) | 3.6% (114/3129) |
| Integration | 4 | 93% (15698/16866) | 76.4% (3814/4989) |
| AppHost | 1 | 100% (30/30) | 100% (2/2) |
| Combined | 5 | 94% (15890/16896) | 79.5% (3968/4991) |

Integration's four assemblies split unevenly: `Cluckwork.Api`
92.3%/72.8%, `Cluckwork.Application` 95.4%/81.7%, `Cluckwork.Domain`
83.6%/66.2%, `Cluckwork.Infrastructure` 94.9%/84.4%.

## Two readings these numbers invite, and both are wrong

**The unit suites are not redundant, whatever the overlap says.** Domain
and Application together add 162 covered lines beyond what Integration
and AppHost already reach, over an identical 16,896-line denominator.
Read naively that says 781 unit tests are nearly free to delete.
Coverage cannot tell a test that asserts a domain invariant from one
that happens to execute the same line while asserting an HTTP status,
and #771 already found three fully-covered guards that survived the
exact mutations they were named for. The redundancy question needs
mutation testing; this is not it, and the decision record says so.

**Application's 13.9% is not "the Application layer is 14% tested."**
Integration measures `Cluckwork.Application` at 95.4%. The 13.9% is what
the `Application.Tests` project's own 290 tests, largely validators and
architectural guards, reach across the three assemblies they
incidentally touch.

## The three decisions #776 asked to be made explicitly

Per-project is the primary view and combined is secondary, because a
single figure averages a unit-tested Domain against an end-to-end suite.
Collection runs on a schedule rather than on every PR, because `Build
and test` is already the ~570s critical path that #775 exists to
shorten. Integration's EXECUTED-not-ASSERTED caveat is printed in the
report itself rather than left for a reader to infer. All three are
recorded in `docs/decisions/776-backend-coverage.md`.

## The one filter, and how it was checked

`src/Cluckwork.Infrastructure/Persistence/Migrations` is 39,088 of the
76,977 lines of C# under `src/`. It is EF-generated, #407 freezes it,
and every integration test executes all of it on container boot
regardless of what that test asserts, so including it would swamp the
measurement in both directions.

Verified against the run's output, not assumed from the config: the
Integration report lists zero `*.Migrations.*` classes, the raw
cobertura XML contains zero `Persistence/Migrations` paths (so the
exclusion drops files before cobertura records them, not just from the
rendered report), and all 33 files in that directory are EF-generated —
16 timestamped migrations as `.cs`/`.Designer.cs` pairs plus
`AppDbContextModelSnapshot.cs` — so nothing hand-written was dropped.

## Reviewing this

The measurement reproduces: `tools/coverage/collect.sh`, or
`tools/coverage/collect.sh --project Domain` for a fast single-project
check. The `pull_request` path filter means this PR runs the new
workflow on itself, so the CI run here is the proof the tooling works
rather than a description of it.

`ci.yml` is not modified and no security gate is touched. No
user-visible behaviour changes, so no screenshots and no glossary or
Help page update.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Added automated backend test coverage reporting across the test suite.
- Coverage reports include per-project and combined summaries in HTML,
Markdown, and text formats.
- Reports can be generated for the full suite or an individual test
area.

- **CI/CD**
- Coverage runs weekly, manually on demand, and for pull requests
affecting coverage tooling.
- Coverage results are report-only and do not block builds or releases.

- **Documentation**
- Added guidance explaining coverage measurement, scope, exclusions, and
limitations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
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: the Orders list cannot show which orders are unpaid

1 participant