Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c31cff7
feat(sales): carry each confirmed order's outstanding figure on the o…
mforce Sep 11, 2026
3bf0338
test(sales): guard the settlement tier, the server-side unpaid predic…
mforce Sep 11, 2026
f9bf9b3
feat(web): show each confirmed order's outstanding amount and filter …
mforce Sep 11, 2026
6f7f4a6
test(web): pin the outstanding column's three states and the URL-owne…
mforce Sep 11, 2026
0aa058e
test(e2e): read the settlement state from the Orders list, and keep i…
mforce Sep 11, 2026
280c792
fix(i18n): tl says nakabinbin for outstanding, in one word across the…
mforce Sep 11, 2026
585bfa8
chore(web): tidy the outstanding cell's null comment (#769)
mforce Sep 11, 2026
9452660
fix(sales): bind the caller's principal, not HttpContext, on the two …
mforce Sep 11, 2026
130007e
fix(sales): drop the stray outstanding field from the confirm REQUEST…
mforce Sep 11, 2026
7c84bd4
fix(web): refresh the Orders list when a payment is recorded or voide…
mforce Sep 12, 2026
fec3378
test(sales): prove the Payments subquery itself is tenant-scoped (#769)
mforce Sep 12, 2026
057eeed
test(sales): pin the unpaid predicate's shape, not any expression ove…
mforce Sep 12, 2026
4ec6ecf
test(sales): build a Cancelled and a Voided order, not only a Draft (…
mforce Sep 12, 2026
7618272
docs(glossary): the balances endpoint is the Sales tier, not admin-on…
mforce Sep 12, 2026
8ca04b0
test(e2e): click the unpaid filter and assert the outcome, not check(…
mforce Sep 12, 2026
58e8548
test(sales): assert the outstanding field is present, not merely non-…
mforce Sep 12, 2026
227b449
docs(glossary): the Orders list is SalesFlow, not a fourth SalesAcces…
mforce Sep 12, 2026
f22ad8b
test(e2e): assert the unpaid filter against a settled list, not an ab…
mforce Sep 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions specs/product/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -731,10 +731,34 @@ Recording and viewing payments is the Sales tier (Owner/Manager/Sales,
spec §5.1); voiding a payment is corrective (Owner/Manager only), like every
other undo.

**Outstanding balance (#89)** — per order: confirmed total − non-voided
**Outstanding balance (#89, #769)** — per order: confirmed total − non-voided
payments; per customer: the same summed across their confirmed orders
(server-side sums, never client-aggregated pages). Shown on the order's
payments panel and the Customers page (admins).
payments panel, the Customers page (whose column renders for admins only), and
— since #769 — as an **Outstanding** column on the Orders list, with an
**Unpaid only** filter beside it. Seeing the money is the **Sales tier**
(Owner/Manager/Sales) everywhere, but the gate sits in two different places and
that difference is the whole design of #769. Three routes are gated at the
route: `/customers/balances`, `/sales/{id}/payments` and the record-payment
route each carry `.RequireAuthorization(AuthPolicies.SalesAccess)`. The **Orders
list is not one of them** — `GET /api/v1/sales` is `AuthPolicies.SalesFlow`,
which admits Workers deliberately, because workers build orders. Inside that
route the handler asks the *same* `SalesAccess` policy, and its answer decides
exactly two things: whether the row carries an outstanding figure, and whether
`unpaid=true` is honoured. So widening the money tier means moving that one
policy, which moves the three routes and the in-handler check together; it never
means widening the Orders list's own route gate. The Customers page's
admin gate is presentation only — a Sales user may call `/customers/balances` —
so #89's endpoint is not a stricter tier and #769 relaxed nothing. A Worker
reaches the Orders list and its response carries no outstanding figure at all,
and `unpaid=true` from a Worker is **refused**, never silently ignored. The
list figure is computed in the same paged query as the rows, as a correlated
sum over non-voided payments, and the unpaid filter is a server-side predicate
over the whole result set rather than the current page.
"Unpaid" means **outstanding > 0**, so a partly-paid order is unpaid; Draft,
Cancelled and Voided orders carry **no** figure (payments attach to confirmed
orders only, and a zero there would read as settled) and the filter never
returns them.

**Expense (#87)** — a money-out record (spec §16, basic cut): date, category,
description, amount in minor units, optional flock link, optional note. The
Expand Down
86 changes: 74 additions & 12 deletions src/Cluckwork.Api/Endpoints/Sales/SaleEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ namespace Cluckwork.Api.Endpoints.Sales;
using Cluckwork.Domain.Sales;
using Cluckwork.Infrastructure.Persistence;
using Cluckwork.Application.Features.Customers;
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;

public static class SaleEndpoints
{
Expand Down Expand Up @@ -77,7 +79,7 @@ public static RouteGroupBuilder MapSaleEndpoints(this RouteGroupBuilder group)

group.MapGet("/", ListSalesOrders)
.WithName("ListSalesOrders")
.WithSummary("List sales orders, newest first (optional status/customer filters, paged).")
.WithSummary("List sales orders, newest first (optional status/customer/unpaid filters, paged).")
.RequireAuthorization(AuthPolicies.SalesFlow);

return group;
Expand Down Expand Up @@ -194,7 +196,9 @@ private static async Task<IResult> CancelSalesOrder(

private static async Task<IResult> GetSalesOrder(
Guid id, ISalesOrderRepository orders, ICustomerRepository customers,
IAuditEventRepository audit, TenantContext tenant, CancellationToken ct)
IAuditEventRepository audit, IPaymentRepository payments,
IAuthorizationService authorization, ClaimsPrincipal caller,
TenantContext tenant, CancellationToken ct)
{
if (!tenant.IsResolved) return Results.Unauthorized();
var order = await orders.GetReadOnlyAsync(id, ct);
Expand All @@ -203,17 +207,47 @@ private static async Task<IResult> GetSalesOrder(
// Detail and list must answer identically (#512): same read, one id.
var customer = (await customers.GetDisplayNamesAsync([order.CustomerId], ct))
.GetValueOrDefault(order.CustomerId);
return Results.Ok(ToResponse(order, provenance.GetValueOrDefault(id), customer));
// #769 — same figure, same tier, same NULL-off-Confirmed rule as the
// list. Leaving this null while the list carries it would make the two
// surfaces disagree about the same order, which #512 forbids.
long? outstanding = null;
if (order.Status == SalesOrderStatus.Confirmed && await MaySeeMoneyAsync(authorization, caller))
outstanding = order.TotalAmount.MinorUnits
- await payments.SumNonVoidedByOrderAsync(id, ct);
return Results.Ok(ToResponse(
order, provenance.GetValueOrDefault(id), customer, outstanding));
}

// #769 — the money tier is AuthPolicies.SalesAccess, asked through the
// authorization service rather than re-encoded as a Roles.* predicate.
// /sales/{id}/payments, the record-payment route and /customers/balances
// already require that SAME policy, and only this endpoint needs to derive
// a hint from it, so a second encoding (the Roles.MayExceedDiscountCeiling
// shape, where a handler and an endpoint both decide and must not drift)
// would buy nothing and would let this column disagree with
// /customers/balances about who may see money. This route is NOT a fourth
// SalesAccess route: GET /api/v1/sales is SalesFlow, so a Worker reaches
// the list itself, and the policy asked here gates only the figure and the
// unpaid filter. Widening the money tier moves this one policy — never this
// route's own gate.
//
// ClaimsPrincipal, not HttpContext: an HttpContext parameter makes the
// handler body-capable, and BodyReadingEndpointTests then demands a row in
// ReviewedAsNotReadingTheBody for a GET that has no body to read. The
// principal is the only thing the policy needs.
private static async Task<bool> MaySeeMoneyAsync(
IAuthorizationService authorization, ClaimsPrincipal caller) =>
(await authorization.AuthorizeAsync(caller, AuthPolicies.SalesAccess)).Succeeded;

private static async Task<IResult> ListSalesOrders(
ISalesOrderRepository orders,
Cluckwork.Application.Features.Customers.ICustomerRepository customers,
IAuditEventRepository audit,
IAuthorizationService authorization, ClaimsPrincipal caller,
TenantContext tenant, CancellationToken ct,
string? status = null, Guid? customerId = null,
DateOnly? from = null, DateOnly? to = null,
int? limit = null, int? offset = null)
int? limit = null, int? offset = null, bool? unpaid = null)
{
if (!tenant.IsResolved) return Results.Unauthorized();

Expand All @@ -233,21 +267,40 @@ private static async Task<IResult> ListSalesOrders(
var take = Math.Clamp(limit ?? DefaultPageSize, 1, MaxPageSize);
var skip = Math.Max(offset ?? 0, 0);

var list = await orders.ListAsync(statusFilter, customerId, from, to, take, skip, ct);
// #769 — the route stays SalesFlow (workers build orders, so they must
// keep reaching the list), and the MONEY inside the response is gated
// separately. `unpaid=true` from outside the tier is refused outright
// rather than ignored: silently dropping it would answer a different
// question than the one asked, which is the exact defect this issue
// exists to end.
var maySeeMoney = await MaySeeMoneyAsync(authorization, caller);
if (unpaid == true && !maySeeMoney)
return Results.Problem(
"Filtering orders by what they still owe is the Sales tier (Owner, Manager, Sales).",
statusCode: StatusCodes.Status403Forbidden, title: "Auth.Forbidden");
var settlement = !maySeeMoney ? SettlementScope.Hidden
: unpaid == true ? SettlementScope.UnpaidOnly
: SettlementScope.Visible;

var list = await orders.ListAsync(
new SalesOrderListFilter(statusFilter, customerId, from, to, settlement),
take, skip, ct);
var provenance = await audit.GetProvenanceAsync(
nameof(SalesOrder), list.Select(o => o.Id).ToList(), ct);
nameof(SalesOrder), list.Select(r => r.Order.Id).ToList(), ct);
// #512 T048 — one scoped bulk customer read for the page, not one per
// order. A missing key means the customer left this tenant, which the
// tenant filter makes unreachable on a scoped route; the row then carries
// a null name rather than an identifier fragment.
var names = await customers.GetDisplayNamesAsync(
list.Select(o => o.CustomerId).ToList(), ct);
return Results.Ok(list.Select(o => ToResponse(
o, provenance.GetValueOrDefault(o.Id), names.GetValueOrDefault(o.CustomerId))));
list.Select(r => r.Order.CustomerId).ToList(), ct);
return Results.Ok(list.Select(r => ToResponse(
r.Order, provenance.GetValueOrDefault(r.Order.Id),
names.GetValueOrDefault(r.Order.CustomerId), r.OutstandingMinorUnits)));
}

private static SalesOrderResponse ToResponse(
SalesOrder o, EntityProvenance? p, CustomerReference? customer = null) => new(
SalesOrder o, EntityProvenance? p, CustomerReference? customer = null,
long? outstandingMinorUnits = null) => new(
o.Id, o.CustomerId, o.ReferenceNumber, o.OrderDate, o.Status.ToString(),
o.TotalAmount.MinorUnits, o.TotalAmount.CurrencyCode, o.TotalAmount.CurrencyMinorUnit,
o.VoidReason,
Expand All @@ -259,7 +312,8 @@ private static SalesOrderResponse ToResponse(
p?.CreatedByEmail, p?.CreatedAtUtc, p?.LastChangedByEmail, p?.LastChangedAtUtc,
p?.MadeOfficialAtUtc,
customer?.Name,
o.DiscountReasonCode?.ToString(), o.DiscountReasonNote);
o.DiscountReasonCode?.ToString(), o.DiscountReasonNote,
outstandingMinorUnits);

private static async Task<IResult> VoidSale(
Guid id,
Expand Down Expand Up @@ -366,7 +420,15 @@ public sealed record SalesOrderResponse(
// before that shipped (no backfill), which reads as "not recorded", never as
// "no discount". The code is the enum MEMBER NAME; the SPA renders it
// through i18n/enums.ts and never displays it raw.
string? DiscountReasonCode = null, string? DiscountReasonNote = null);
string? DiscountReasonCode = null, string? DiscountReasonNote = null,
// #769 — what this order still owes: confirmed total − non-voided
// payments. NULL means the figure does not exist for this row, and it has
// exactly two causes: the order is not Confirmed (payments attach to
// confirmed orders only, so a 0 would read as settled), or the caller is
// outside the money tier — for whom the repository never queries Payments
// at all. One field, never a paid companion: paid is total − outstanding,
// so there is no second number to drift.
long? OutstandingMinorUnits = null);

public sealed record CreateSalesOrderRequest(Guid CustomerId, DateOnly OrderDate);

Expand Down
37 changes: 33 additions & 4 deletions src/Cluckwork.Application/Features/Sales/ISalesOrderRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,37 @@ public interface ISalesOrderRepository : IRepository<SalesOrder, Guid>
// never even attempt the row lock, let alone wait on it.
Task<SalesOrder?> GetByIdLockedAsync(Guid accountId, Guid id, CancellationToken ct = default);

// Paged, newest-first, items included. Optional status/customer/date filters.
Task<IReadOnlyList<SalesOrder>> ListAsync(
SalesOrderStatus? status, Guid? customerId, DateOnly? from, DateOnly? to,
int limit, int offset, CancellationToken ct = default);
// Paged, newest-first, items included. Optional status/customer/date
// filters, plus the settlement scope the caller's tier earns (#769).
Task<IReadOnlyList<SalesOrderListRow>> ListAsync(
SalesOrderListFilter filter, int limit, int offset, CancellationToken ct = default);
}

// #769 — how much of the settlement figure this read may touch. An enum rather
// than a `bool includeOutstanding` + `bool unpaidOnly` pair because that pair
// makes "hide the money but filter by it" representable, and answering a
// question the caller may not see the answer to is the defect this issue
// exists to end.
public enum SettlementScope
{
// No settlement figure and no settlement predicate. The query must not
// name Payments at all — a structural gate, not a null applied afterwards.
Hidden,

// Every row carries its outstanding figure; no settlement predicate.
Visible,

// Every row carries its outstanding figure AND the page is restricted to
// orders still owing money.
UnpaidOnly,
}

public sealed record SalesOrderListFilter(
SalesOrderStatus? Status, Guid? CustomerId, DateOnly? From, DateOnly? To,
SettlementScope Settlement);

// The order plus what it still owes: confirmed total − non-voided payments.
// NULL for anything not Confirmed (payments attach to confirmed orders only,
// so "outstanding" is undefined there and a 0 would read as settled) and for a
// caller outside the money tier.
public sealed record SalesOrderListRow(SalesOrder Order, long? OutstandingMinorUnits);
93 changes: 86 additions & 7 deletions src/Cluckwork.Infrastructure/Repositories/SalesOrderRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,99 @@ public sealed class SalesOrderRepository(AppDbContext db) : ISalesOrderRepositor
return order;
}

public async Task<IReadOnlyList<SalesOrder>> ListAsync(
SalesOrderStatus? status, Guid? customerId, DateOnly? from, DateOnly? to,
int limit, int offset, CancellationToken ct = default) =>
await db.SalesOrders
public async Task<IReadOnlyList<SalesOrderListRow>> ListAsync(
SalesOrderListFilter filter, int limit, int offset, CancellationToken ct = default)
{
if (filter.Settlement == SettlementScope.Hidden)
{
var page = await HiddenPage(filter, limit, offset).ToListAsync(ct);
return page.Select(o => new SalesOrderListRow(o, null)).ToList();
}

return await SettlementPage(filter, limit, offset).ToListAsync(ct);
}

// The SQL ListAsync would run, for SalesOrderListQueryTests — a model-only
// guard, no database. Returning the string rather than the IQueryable keeps
// the two branches' differing element types out of one signature.
public string ListQuerySql(SalesOrderListFilter filter, int limit, int offset) =>
filter.Settlement == SettlementScope.Hidden
? HiddenPage(filter, limit, offset).ToQueryString()
: SettlementPage(filter, limit, offset).ToQueryString();

// Locals, not filter.X in the expression tree: EF parameterises a captured
// member the same way it parameterises a local, and keeping the shape
// identical to the pre-#769 query keeps its emitted SQL identical too.
private IQueryable<SalesOrder> Filtered(SalesOrderListFilter filter)
{
var status = filter.Status;
var customerId = filter.CustomerId;
var from = filter.From;
var to = filter.To;
return db.SalesOrders
.AsNoTracking()
.Include(o => o.Items)
.Where(o => (status == null || o.Status == status)
&& (customerId == null || o.CustomerId == customerId)
&& (from == null || o.OrderDate >= from)
&& (to == null || o.OrderDate <= to))
&& (to == null || o.OrderDate <= to));
}

// #769 SettlementScope.Hidden — the pre-#769 query, untouched. A caller
// outside the money tier gets SQL that never names Payments; the null
// outstanding is then a fact about the query, not a field blanked after it.
private IQueryable<SalesOrder> HiddenPage(SalesOrderListFilter filter, int limit, int offset) =>
Filtered(filter)
.OrderByDescending(o => o.OrderDate).ThenByDescending(o => o.Id)
.Skip(offset)
.Take(limit)
.ToListAsync(ct);
.Take(limit);

// #769 — one query for the page AND its settlement figures. The figure is
// ONE C# expression used for both the value and the unpaid predicate, so
// the two cannot disagree about what "unpaid" means.
//
// CASE WHEN "Status" = Confirmed THEN total - (correlated sum) END yields
// SQL NULL for every other status, and `NULL > 0` is not true — so Draft,
// Cancelled and Voided fall out of the unpaid filter structurally rather
// than through a second hand-written status predicate that could drift
// from the one above it. Keep that shape.
//
// Include(o => o.Items) survives this wrapper projection: LIMIT/OFFSET
// apply in an inner subquery before the items LEFT JOIN, so paging counts
// ORDERS and not order lines. The tenant query filter reaches the Payments
// subquery on its own. Both pinned by SalesOrderListQueryTests.
private IQueryable<SalesOrderListRow> SettlementPage(
SalesOrderListFilter filter, int limit, int offset)
{
// Ordered before the projection and projected into an ANONYMOUS type,
// both forced by the translator rather than chosen. Ordering the
// projection by `x.Order.OrderDate`, and filtering it by
// `x.OutstandingMinorUnits`, each inline the whole `new
// SalesOrderListRow(...)` into the ORDER BY / WHERE and EF refuses to
// translate the member access back off a positional record whose other
// member is an entity. It reduces member access off an anonymous type
// without complaint, so the figure below is still written ONCE and the
// filter is literally that same expression.
var rows = Filtered(filter)
.OrderByDescending(o => o.OrderDate).ThenByDescending(o => o.Id)
.Select(o => new
{
Order = o,
Outstanding = o.Status == SalesOrderStatus.Confirmed
? o.TotalAmount.MinorUnits - db.Payments
.Where(p => p.SalesOrderId == o.Id && !p.Voided)
.Sum(p => p.AmountMinorUnits)
: (long?)null,
});

if (filter.Settlement == SettlementScope.UnpaidOnly)
rows = rows.Where(x => x.Outstanding > 0);

return rows
.Select(x => new SalesOrderListRow(x.Order, x.Outstanding))
.Skip(offset)
.Take(limit);
}

public async Task AddAsync(SalesOrder entity, CancellationToken ct = default) =>
await db.SalesOrders.AddAsync(entity, ct);
Expand Down
Loading
Loading