Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c1fedb8
feat(sales): carry a list-price snapshot on the order line
mforce Sep 9, 2026
8ea29e7
feat(sales): add the ListUnitPriceMinorUnits column
mforce Sep 9, 2026
eb6d9dd
feat(api): expose the line's list price on the sales order response
mforce Sep 9, 2026
653e377
feat(sales): snapshot the list price when the denomination matches
mforce Sep 9, 2026
f888464
feat(sales): refuse a line whose list price moved under the seller
mforce Sep 9, 2026
8f8febf
test(sales): the add-item race test reads the list-price snapshot
mforce Sep 9, 2026
4e65eca
feat(web): carry the line's list price through the API type
mforce Sep 9, 2026
14ec175
feat(web): show the list price and the discount on each order line
mforce Sep 9, 2026
f127222
feat(web): hint the list price gap while the price is typed
mforce Sep 9, 2026
18f6b95
docs(glossary): define list price, discount and above list
mforce Sep 9, 2026
ce4fc06
docs(help): explain the list price and discount columns
mforce Sep 9, 2026
7bd0da0
docs(spec): name the list-price snapshot in 10.5
mforce Sep 9, 2026
729b3a4
test(sim): seed a discounted, an above-list and an unpriced-product line
mforce Sep 9, 2026
ccc1fe0
Merge remote-tracking branch 'origin/main' into feat/720-list-price-s…
mforce Sep 9, 2026
50f2b8d
fix(web): put No list price in the discount cell, not the list price …
mforce Sep 9, 2026
11c266c
test(sales): pin the minor-unit half of the list-price denomination c…
mforce Sep 9, 2026
27c0ae6
fix(sales): refuse a line when an unpriced product gained a list price
mforce Sep 9, 2026
fdeec23
docs(glossary): state when ListPriceChanged fires, and drop cents
mforce Sep 9, 2026
6ba2ae9
fix(sim): filter k6 sales bundle to priced products (#394)
mforce Sep 9, 2026
87a6293
fix(web): guard the below-list hint's divide-by-zero; fix a stale doc…
mforce Sep 9, 2026
c69b878
feat(sales): record why a line's list price is null (ListPriceBasis)
mforce Sep 9, 2026
5e03d87
fix(sales): make the impossible ListPriceBasis pairing impossible
mforce Sep 9, 2026
6a3dde9
feat(export): add ListPriceBasis columns to the sales-order-items exp…
mforce Sep 9, 2026
84d0421
fix(sales): correct the ListPriceBasis exposure claim; pin the export…
mforce Sep 9, 2026
508237d
fix(web): emphasise the below-list discount to match the mockup
mforce Sep 9, 2026
fa5a9f1
fix(web): keep Add line inline with the unit-price field, close a dis…
mforce Sep 9, 2026
b2ddff3
Merge remote-tracking branch 'origin/main' into feat/720-list-price-s…
mforce Sep 9, 2026
36fb372
fix(web): take the unit-price hint out of flow so it can't displace A…
mforce Sep 9, 2026
6789e30
fix(domain): reject an undefined ListPriceBasis on AddItem (#720)
mforce Sep 9, 2026
64c2db7
fix(web): take the price hint out of .form-grid entirely, third attem…
mforce Sep 9, 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
4 changes: 3 additions & 1 deletion docs/schema/README.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions docs/schema/public.EggGrades.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions docs/schema/public.Products.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions docs/schema/public.SalesOrderAllocations.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions docs/schema/public.SalesOrderItems.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions docs/schema/public.SalesOrders.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion docs/schema/viewpoint-2.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 39 additions & 0 deletions specs/product/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,45 @@ redefining a packed unit only affects future lines, never recorded ones.
Price is per selling unit (decimal money, stored as integer minor units),
prefilled from the product's default and editable per line.

**List price (#720)** — a **sales line's** product's default price as it stood
the moment the line was added, snapshotted onto the line in **minor units of
the order's currency** — not literally cents, though the spec column is named
`list_unit_price_cents` (spec §10.5, that section's own `_cents` naming
convention throughout; the persisted/API name is `ListUnitPriceMinorUnits`) —
so a later catalogue re-price can never reinterpret a recorded order.
Recorded only when the product's currency code and minor unit both match the
order's — otherwise `null`, meaning "no comparable list price," a real answer
distinct from missing data. Adding a line refuses (`SalesOrder.ListPriceChanged`)
only when the caller states what list price it last saw and that no longer
matches the catalogue's current one: the SPA states it whenever the selected
product is still in its current product list — including stating that it saw
no list price at all — but sends neither field once that product has dropped
out of the list (a pinned test covers exactly this after a rejection-triggered
refresh), and a raw API caller or either seeder that passes no expectation is
deliberately unaffected by a catalogue move.

A `null` list price now carries a recorded reason (`list_price_basis`): the
product had no default price, the denominations did not match, or the line
predates this pair of columns entirely. Not on the **JSON read API**
(`SalesOrderItemResponse`), and the **screen** renders every reason alike —
but the **Admin-only CSV export** carries the basis by name. That last reason
is distinguishable from the first two on purpose — a pre-migration line's
`null` means *we do not know* whether it was discounted, while the other two
are recorded facts that no discount is computable at all. #727 gates an
Owner/Manager approval on that difference.

**Discount (#720)** — a *derived*, *per-line* amount: the gap between a
**sales line's** **list price** and what it actually sold for, computed for
display only and never stored. This is a different number from
`sales_orders.discount_cents` (spec §10.4), which is an *entered*,
*order-level* amount a seller applies manually to the whole order — the two
share a name and nothing else.

**Above list (#720)** — the state where a **sales line** sold for more than
its **list price**. Shown next to the List price and Discount columns on the
order line table; a line at exactly list price shows an em dash instead of a
discount, and a line with no comparable list price shows neither.

**Void** — undo of a mistaken confirm (requires a reason): the allocated
quantities return to the *exact* egg lots they were drawn from (recorded at
confirm as lot-level allocations), preserving FIFO order and any withdrawal
Expand Down
2 changes: 2 additions & 0 deletions specs/product/specs.md
Original file line number Diff line number Diff line change
Expand Up @@ -1438,6 +1438,8 @@ sales_order_items
- base_unit_factor (eggs-per-unit snapshot at line creation; see §9.7)
- quantity_base
- unit_price_cents
- list_unit_price_cents (nullable; product's list price snapshotted at line creation, see #720)
- list_price_basis (non-nullable; why list_unit_price_cents is null when it is, see #720)
- line_total_cents
- created_at
- updated_at
Expand Down
15 changes: 11 additions & 4 deletions src/Cluckwork.Api/Endpoints/Sales/SaleEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ private static async Task<IResult> AddOrderItem(

var command = new AddOrderItemCommand(
id, request.ProductId, request.Quantity, request.Unit, request.UnitPriceMinorUnits,
request.ExpectedEggsPerUnit);
request.ExpectedEggsPerUnit, request.ExpectedListUnitPriceMinorUnits,
request.ExpectedListPriceIsUnset);
var validation = await validator.ValidateAsync(command, ct);
if (!validation.IsValid)
return ValidationResponse.Problem(validation);
Expand Down Expand Up @@ -246,7 +247,8 @@ private static SalesOrderResponse ToResponse(
o.Items.Select(i => new SalesOrderItemResponse(
i.Id, i.ProductId, i.EggGradeId, i.Unit.ToString(), i.BaseUnitFactor,
i.Quantity, i.QuantityBase,
i.UnitPrice.MinorUnits, i.UnitPrice.CurrencyCode, i.UnitPrice.CurrencyMinorUnit)).ToList(),
i.UnitPrice.MinorUnits, i.UnitPrice.CurrencyCode, i.UnitPrice.CurrencyMinorUnit,
i.ListUnitPriceMinorUnits)).ToList(),
p?.CreatedByEmail, p?.CreatedAtUtc, p?.LastChangedByEmail, p?.LastChangedAtUtc,
p?.MadeOfficialAtUtc,
customer?.Name);
Expand Down Expand Up @@ -351,7 +353,8 @@ public sealed record VoidSaleRequest(string Reason);

public sealed record AddOrderItemRequest(
Guid ProductId, int Quantity, string? Unit, long? UnitPriceMinorUnits,
int? ExpectedEggsPerUnit = null);
int? ExpectedEggsPerUnit = null, long? ExpectedListUnitPriceMinorUnits = null,
bool ExpectedListPriceIsUnset = false);

public sealed record UpdateOrderItemRequest(int Quantity, long UnitPriceMinorUnits);

Expand All @@ -360,4 +363,8 @@ public sealed record UpdateOrderItemRequest(int Quantity, long UnitPriceMinorUni
public sealed record SalesOrderItemResponse(
Guid Id, Guid ProductId, Guid EggGradeId, string Unit, int BaseUnitFactor,
int Quantity, int QuantityBase,
long UnitPriceMinorUnits, string CurrencyCode, int CurrencyMinorUnit);
long UnitPriceMinorUnits, string CurrencyCode, int CurrencyMinorUnit,
// #720 — the list price this line was sold against, in the SAME currency
// and minor unit as UnitPriceMinorUnits above. NULL means no comparable
// list price; read surfaces render that as "No list price", never as 0.
long? ListUnitPriceMinorUnits = null);
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ namespace Cluckwork.Application.Features.Sales.AddOrderItem;
// callers and the seeders, which show no preview, skip the check with null.
public sealed record AddOrderItemCommand(
Guid SalesOrderId, Guid ProductId, int Quantity,
string? Unit, long? UnitPriceMinorUnits, int? ExpectedEggsPerUnit = null);
string? Unit, long? UnitPriceMinorUnits, int? ExpectedEggsPerUnit = null,
long? ExpectedListUnitPriceMinorUnits = null,
bool ExpectedListPriceIsUnset = false);
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,28 @@ public async Task<Result<Guid>> HandleAsync(
$"The eggs-per-unit definition for '{unit}' is now {conversion.EggsPerUnit}, not {expected} — " +
"re-check the quantity and try again."));

// #720 — "did the catalogue move under the seller?" A bare long? cannot
// tell OMITTED (no opinion: raw API callers, both seeders) from
// EXPECTED-UNSET (the seller looked and saw no list price). Zero is a
// legal list price, so it cannot be a sentinel. Hence the companion
// flag: an expectation exists when either is present, and then the
// comparison runs on the raw nullable values, so ALL FOUR transitions
// are covered — unchanged, number→number, number→null, and null→number.
// That last one is the case this guard originally missed: a seller who
// saw "No list price" while an admin was pricing the product would
// otherwise have the line snapshot a number nobody had shown them.
var listPriceExpectationGiven =
command.ExpectedListUnitPriceMinorUnits is not null
|| command.ExpectedListPriceIsUnset;
if (listPriceExpectationGiven
&& command.ExpectedListUnitPriceMinorUnits != product.DefaultPriceMinorUnits)
return Result.Failure<Guid>(Error.Validation(
"SalesOrder.ListPriceChanged",
$"This product's list price is now " +
$"{(product.DefaultPriceMinorUnits?.ToString() ?? "unset")}, not " +
$"{(command.ExpectedListUnitPriceMinorUnits?.ToString() ?? "unset")} — " +
"re-check the price and try again."));

// Price defaults from the product (per selling unit).
var priceMinorUnits = command.UnitPriceMinorUnits ?? product.DefaultPriceMinorUnits;
if (priceMinorUnits is null)
Expand Down Expand Up @@ -96,9 +118,42 @@ public async Task<Result<Guid>> HandleAsync(
order.TotalAmount.CurrencyCode,
order.TotalAmount.CurrencyMinorUnit);

// #720 — the LIST price this line was sold against, snapshotted so a
// later catalogue re-price can never reinterpret a recorded order
// (spec §10.5 — the rule BaseUnitFactor already follows).
//
// Recorded ONLY when the product's denomination matches the order's:
// the same currency CODE and the same MINOR UNIT. The column is a bare
// long? with no currency of its own, so it is meaningful only if the
// line's UnitPrice columns describe it — and they do exactly when this
// condition holds. On a mismatch we store NULL: "no comparable list
// price" is a true statement, where the raw integer would be a number
// in an unknown denomination.
//
// Note the minor-unit half. The guard above compares CODE only, which
// is the gap SalesPage.tsx:191-203 records: a prefill 100x out,
// arriving as an EXPLICIT price, on the one path that guard skips.
// #123's currency lock makes a mismatch unreachable through the API
// today; this is recorded history, so "unreachable" is not enough.
// #720 — the value and its BASIS are decided together, in one expression,
// so they cannot disagree. Every branch below is reachable: an unpriced
// product is legal, and the denomination branch is the backstop #123's
// currency lock makes unreachable through the API today.
var (listUnitPriceMinorUnits, listPriceBasis) =
product.DefaultPriceMinorUnits is not { } catalogListPrice
? ((long?)null, ListPriceBasis.ProductUnpriced)
: !string.Equals(
product.CurrencyCode,
order.TotalAmount.CurrencyCode,
StringComparison.OrdinalIgnoreCase)
|| product.CurrencyMinorUnit != order.TotalAmount.CurrencyMinorUnit
? ((long?)null, ListPriceBasis.NotComparable)
: (catalogListPrice, ListPriceBasis.Recorded);

var result = order.AddItem(
product.Id, product.ProductType, grade.Id,
unit, conversion.EggsPerUnit, command.Quantity, unitPrice);
unit, conversion.EggsPerUnit, command.Quantity, unitPrice,
listUnitPriceMinorUnits, listPriceBasis);
if (result.IsFailure)
return Result.Failure<Guid>(result.Error);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ public AddOrderItemValidator()
RuleFor(x => x.ExpectedEggsPerUnit).GreaterThan(0)
.WithErrorCode("OrderItem.ExpectedEggsPerUnit.Positive")
.When(x => x.ExpectedEggsPerUnit is not null);
// #720 — GreaterThanOrEqualTo, NOT GreaterThan like its sibling above.
// A conversion factor is floored at 1, but a list price of ZERO is a
// legal product price (Product.cs rejects only negatives), so a
// GreaterThan(0) rule here would refuse a valid expectation for a
// zero-priced product. This matches UnitPriceMinorUnits' own rule.
RuleFor(x => x.ExpectedListUnitPriceMinorUnits).GreaterThanOrEqualTo(0)
.WithErrorCode("OrderItem.ExpectedListUnitPrice.NonNegative")
.When(x => x.ExpectedListUnitPriceMinorUnits is not null);
// A caller cannot both name an expected price and say it saw none.
RuleFor(x => x)
.Must(x => !(x.ExpectedListPriceIsUnset && x.ExpectedListUnitPriceMinorUnits is not null))
.WithName("ExpectedListPriceIsUnset")
.WithMessage("Cannot expect an unset list price and a value at the same time.")
.WithErrorCode("OrderItem.ExpectedListPrice.Contradictory");
// quantity * price must not overflow long (Money.Multiply is unchecked) —
// wrap-around would store a negative line/order total.
RuleFor(x => x)
Expand Down
Loading
Loading