Repository navigation
Description
What
The Orders list cannot answer "which orders has nobody paid yet". Add an Outstanding column and
an unpaid filter, so a farm can find the orders owing money instead of opening them one at a time.
The gap, read from the code
The Orders list columns are Reference, Date, Customer, Status, Discount, Total, History
(web/src/routes/SalesPage.tsx:1693). SalesOrderResponse carries TotalMinorUnits and no paid or
outstanding figure (SaleEndpoints.cs:349).
Status cannot stand in for it. SalesOrderStatus is Draft, Confirmed, Shipped, Invoiced, Cancelled, Voided. There is no Paid and nothing in that enum moves when money arrives, so a fully
settled order and one that has paid nothing both read Confirmed.
The data exists, just nowhere useful for this question:
GET /sales/{orderId}/paymentsreturnsPaidMinorUnitsandOutstandingMinorUnits— per order,
so answering it for a list of 100 means opening 100 orders.GET /customers/balancesreturns per-customer confirmed total, paid and outstanding, and
CustomersPage.tsxalready renders it.
So "which CUSTOMER owes me money" is answerable today. "Which ORDERS are unpaid" is not.
Not a regression, and not an unmet criterion
#89 scoped exactly two SPA surfaces — "Sales order panel: payments section … outstanding line" and
"Customers page: outstanding balance column" — and shipped both. The Orders list was never in it.
specs/product/GLOSSARY.md:734 documents the same boundary in the present tense: outstanding is
"Shown on the order's payments panel and the Customers page (admins)." The documentation is
accurate; the capability is simply absent. This is unfilled work, not drift.
Nothing in specs/product/specs.md requests it, and Phase 1.5's epic #15 carries no payment items.
Decide before writing code
1 — who sees it. This is the blocking question. #89 made money admin-only end to end and
/customers/balances refuses Workers outright, but the Orders list is AuthPolicies.SalesFlow
(Owner, Manager, Sales and Worker). So this is not additive: it puts money on a screen that
currently shows none, for roles that currently see none.
Three coherent answers, and picking none of them ships an access decision by accident:
- Admin-only column. The column renders for Owner/Manager, absent for Sales and Worker. Matches
F21: Customer payments + balances — order-attached payments, outstanding per order/customer (admin-only) #89's tier exactly. A Sales user still cannot see what they are owed. - Sales tier sees it, Worker does not. Matches the glossary's own split — "Recording and viewing
payments is the Sales tier (Owner/Manager/Sales); voiding a payment is corrective". Probably the
right answer, and it makes the column useful to the people chasing the money. - A derived signal for the roles that may not see amounts — an unpaid marker with no figure, the
ShowFarmWideSaleAllocationNotice/yourMaxDiscountPercentpattern from Configurable Worker sales allocation scope by flock assignment #612 and Sales: per-farm discount ceiling, with Owner/Manager approval above it #727. More
surface; only worth it if a Worker genuinely needs to know an order is unsettled.
2 — column, filter, or both. A per-row badge alone does not solve the stated need: it marks
what is already on screen but does not let you find unpaid orders in a long list. The filter is the
half that answers the question. The list is paged (DefaultPageSize = 100, MaxPageSize = 500,
SaleEndpoints.cs:21-22), so an unpaid filter must be a server-side predicate, never a client
filter over the current page — otherwise it silently means "unpaid among the 100 you happen to be
looking at".
3 — what "unpaid" means. Partial payments are explicitly the normal case per #89. So the filter
needs a stated definition: nothing paid, or anything still outstanding. "Outstanding > 0" is the
useful one for chasing money, but it is a different set from "paid nothing", and the label must say
which. Draft orders have no outstanding concept at all — payments attach to Confirmed orders only.
The constraint that shapes the implementation
GLOSSARY.md:734 is emphatic that these are "server-side sums, never client-aggregated pages".
So the per-order outstanding must be computed in the same query as the page, as a join or lateral
aggregate over non-voided payments — not N follow-up queries, and not summed in the browser. Voided
payments must not count, and the sum must exclude them the same way
ListCustomerBalancesAsync already does; reuse that shape rather than re-deriving it.
Acceptance
- The Orders list shows each confirmed order's outstanding amount, for the roles decided in (1).
- A filter returns only orders matching the agreed definition of unpaid, evaluated server-side across
the whole result set rather than the current page. - A fully paid order is visually distinct from one that has paid nothing, and from a partial payment.
- Voided payments do not reduce the outstanding figure.
- Draft, Cancelled and Voided orders render no outstanding figure rather than a misleading zero.
- A role that may not see money sees no amount — and the API refuses it regardless of what the SPA
renders, per F21: Customer payments + balances — order-attached payments, outstanding per order/customer (admin-only) #89's own split.
Repo rules this touches
- Daily entry: require grading to reconcile sellable eggs before submit #394 — a list-response change is a read contract, but the filter is a new query parameter;
check the Playwright specs undertools/simulation/ui/, which DO run in CI on anysrc/**or
web/**change (AGENTS.md #394 says the Playwright specs are not in CI; they have been since 2026-08-08 #767), andtools/simulation/k6/, which does not. - i18n — column header, filter label and any badge in en/es/tl, or
catalogParityfails the
build. A badge key must start with a capital in all three locales (badgeCase.test.ts). - Glossary + Help —
GLOSSARY.md:734's "Shown on the order's payments panel and the Customers
page" becomes wrong the moment this ships, and the SPA Help page needs the same update. - Screenshots (AGENTS.md: three conventions earned while shipping #651 + #652 #662) — before and after of the Orders list, captured at 1:1 from a stack rebuilt
at the head under review.
Found while reviewing the Sales screen after #727.
Activity
mforce commentedon Sep 11, 2026
Decisions, 2026-09-11
Two of the three open questions are answered by the owner.
"Unpaid" means outstanding > 0, not "paid nothing". A partially-paid order is unpaid for this
purpose — the point is finding money still owed, and a 90%-settled order still owes something.
Partial payments are the normal case per #89, so the "paid nothing" reading would have hidden most of
what the filter exists to surface.
Workers do not see it. The column and the filter are the Sales tier — Owner, Manager and
Sales — matching the glossary's own split ("Recording and viewing payments is the Sales tier")
rather than #89's stricter admin-only balances endpoint. Workers see the Orders list without any
outstanding column, and the API must refuse them the figure regardless of what the SPA renders.
Note this makes the list response role-dependent, which it is not today. The established pattern
is a per-caller derived field computed server-side (
yourMaxDiscountPercenton #727,ShowFarmWideSaleAllocationNoticeon #612) rather than widening the endpoint and filtering in thebrowser. A Worker's response should not carry the number at all.
One thing I am deciding rather than asking
The unpaid filter is its own control, not a value in the Status dropdown. Adding "Unpaid" beside
Draft / Confirmed / Cancelled / Voidedwould conflate payment state with order state — the exactconflation that makes the current screen unable to answer this question, since
Confirmedalreadymeans both "settled" and "owes everything". They are orthogonal: you want to ask for confirmed AND
unpaid. Two controls, combinable.
Consequences for the acceptance criteria above
only, so "unpaid" is undefined for them, and a zero there would read as settled.
test(e2e): click the unpaid filter and assert the outcome, not check(…
test(sales): assert the outstanding field is present, not merely non-…
docs(glossary): the Orders list is SalesFlow, not a fourth SalesAcces…
test(e2e): assert the unpaid filter against a settled list, not an ab…
feat(sales): carry each confirmed order's outstanding figure on the o…
test(sales): guard the settlement tier, the server-side unpaid predic…
feat(web): show each confirmed order's outstanding amount and filter …
test(web): pin the outstanding column's three states and the URL-owne…
test(e2e): read the settlement state from the Orders list, and keep i…
fix(i18n): tl says nakabinbin for outstanding, in one word across the…
chore(web): tidy the outstanding cell's null comment (#769)
fix(sales): bind the caller's principal, not HttpContext, on the two …
fix(sales): drop the stray outstanding field from the confirm REQUEST…
fix(web): refresh the Orders list when a payment is recorded or voided (
test(sales): prove the Payments subquery itself is tenant-scoped (#769)
test(sales): pin the unpaid predicate's shape, not any expression ove…
test(sales): build a Cancelled and a Voided order, not only a Draft (#…
docs(glossary): the balances endpoint is the Sales tier, not admin-on…
test(e2e): click the unpaid filter and assert the outcome, not check(…
test(sales): assert the outstanding field is present, not merely non-…
docs(glossary): the Orders list is SalesFlow, not a fourth SalesAcces…
test(e2e): assert the unpaid filter against a settled list, not an ab…