Repository navigation
feat(sales): show what each order still owes, and filter the list to unpaid - #771
Conversation
|
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 |
Screenshots (#662)Captured at 1:1, 1280x720, Sales persona, from stacks rebuilt for each side: Before — the Orders list ends at Total. Nothing on the row says whether the money arrived. After, no filter — an Outstanding column sits after Total, with a glossary link. 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. |
…ate and the emitted SQL (#769)
…the list to unpaid (#769)
…d unpaid filter (#769)
…t off a Worker's (#769)
… 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.
963a7f7 to
f22ad8b
Compare
🤖 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>
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 -->






Why
The Orders list could not answer "which orders has nobody paid yet".
Statuscannot stand in for it:Confirmedmeans 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 > 0is 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.ListAsynctakesSalesOrderListFilterand returnsSalesOrderListRow.SettlementScopeisHidden | Visible | UnpaidOnly, which makes "hide the money but filter by it" unrepresentable;Hiddenemits SQL that never namesPayments.SaleEndpoints.ListSalesOrdersgainsbool? unpaid.SalesOrderResponsegainslong? OutstandingMinorUnits.GetSalesOrdercarries the same figure, per the existing "detail and list must answer identically" rule.SalesPage.tsxrenders the column after Total and owns the filter asunpaid=1in the URL.onRecordPaymentandonVoidPaymentnow wrap inorders.runWrite, because a payment-derived figure on the list made both handlers writers of it.GLOSSARY.md, the Help prose andhelpGlossary.tsin en/es/tl.Tradeoffs
The money tier reads
AuthPolicies.SalesAccessthroughIAuthorizationServicerather than a newRolespredicate. That policy already gates the three payment routes, so a second encoding would let this column disagree with/customers/balancesabout who may see money.statusFilterstays local state whilecustomerIdandunpaidlive in the URL, so a shared link restores two filters of three. Movingstatusis 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 andunpaid=truefrom 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
SalesOrderListQueryTestsall 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 thePaymentstenant filter; and weakening the unpaid predicate toTotalMinorUnits > 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'scheck()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.