Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 22 additions & 12 deletions specs/product/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,9 +485,12 @@ the order's currency** — not literally cents, though the spec column is named
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
order's — otherwise `null`. For a line the application wrote, that `null`
means "no comparable list price," a real answer distinct from missing data;
for a line the #720 backfill relabelled, it means only that the line predates
the column, which *is* missing data. `list_price_basis` below is what tells
the two apart. 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
Expand All @@ -497,13 +500,16 @@ 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.
predates this pair of columns entirely. It ships on the **JSON read API**
(`SalesOrderItemResponse`) by name since #773 — on both sales routes, which
share one mapper — so the **screen** renders two labels rather than one:
*No list price* for the two recorded facts, *List price not recorded* for a
pre-dating line. The **Admin-only CSV export** carries the basis by name too.
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, and #773 is that
same difference made visible to a reader.

**Discount (#720, #723, #724)** — a *derived* amount at two levels, computed
for display only and never stored. **Per line:** the gap between a **sales
Expand All @@ -512,8 +518,12 @@ of those below-list line amounts, with a percent taken over the *list value of
the order's comparable lines* — every line that HAS a list price, including
lines sold at or above it, since each contributed list value. An above-list
line therefore sits in the denominator and never nets against the amount. An
order that HAS lines, none of which carries a list price, reads as **unknown**,
never as a clean zero; one in which **some** line has none reports that its
order that HAS lines, none of which carries a list price, never reads as a
clean zero. It says one of two things: that no line has a list price, or —
when **every** one of its lines predates list-price capture — that the order
predates capture and its discount was never recordable. One line that
genuinely had no comparable price is enough to make that second claim false
of the order. An order in which **some** line has none reports that its
figure covers only part of the order. An order with no lines at all is not
unknown — there is nothing to measure — and reads as an em dash in the Orders
list, with no discount line shown on the order panel. Both are a different number from
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 @@ -308,7 +308,7 @@ private static SalesOrderResponse ToResponse(
i.Id, i.ProductId, i.EggGradeId, i.Unit.ToString(), i.BaseUnitFactor,
i.Quantity, i.QuantityBase,
i.UnitPrice.MinorUnits, i.UnitPrice.CurrencyCode, i.UnitPrice.CurrencyMinorUnit,
i.ListUnitPriceMinorUnits)).ToList(),
i.ListUnitPriceMinorUnits, i.ListPriceBasis.ToString())).ToList(),
p?.CreatedByEmail, p?.CreatedAtUtc, p?.LastChangedByEmail, p?.LastChangedAtUtc,
p?.MadeOfficialAtUtc,
customer?.Name,
Expand Down Expand Up @@ -454,6 +454,13 @@ public sealed record SalesOrderItemResponse(
int Quantity, int QuantityBase,
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);
// and minor unit as UnitPriceMinorUnits above. NULL is one of three
// states, and ListPriceBasis below is what names which; read surfaces
// render it as text, never as 0.
long? ListUnitPriceMinorUnits,
// #773 — WHY ListUnitPriceMinorUnits is what it is, as the enum MEMBER
// NAME, like DiscountReasonCode (#721). Required and never defaulted: the
// domain decides the value and its basis in one expression so they cannot
// disagree (AddOrderItemHandler.cs:142), and a default here would be the
// one place they could. The SPA renders it through i18n/enums.ts, never raw.
string ListPriceBasis);
9 changes: 6 additions & 3 deletions src/Cluckwork.Domain/Sales/SalesOrder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -443,9 +443,12 @@ public sealed class SalesOrderItem : Entity<Guid>
/// changes what the catalogue said when the line was added (INV-1). NULL
/// means "no comparable list price", which covers three cases: the product
/// had none, the line predates the column, or the denominations did not
/// match (#720). Not on the JSON read API — the ListPriceBasis property
/// below carries the distinction — and the screen renders all three
/// alike, but the Admin-only CSV export carries the basis by name. That
/// match (#720). #773 reversed the decision to keep the ListPriceBasis
/// property below off the JSON read API: a bare NULL cannot tell
/// ProductUnpriced from PreDating, so the screen was telling a reader the
/// product had no price when the line merely predates the column. The
/// basis now ships on SalesOrderItemResponse by name, alongside the
/// Admin-only CSV export that already carried it. That
/// denomination check is what makes a bare long? honest, and it
/// backstops a state the #123 currency lock makes unreachable today — a
/// priced product locks the farm currency (CurrencyBoundRowProbe.cs:24).
Expand Down
30 changes: 30 additions & 0 deletions tests/Cluckwork.Api.IntegrationTests/ReadEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ private sealed record OrderDto(Guid Id, string Status, List<OrderItemDto> Items)
// #769 — the settlement figure as the wire carries it.
private sealed record OrderMoneyDto(
Guid Id, string Status, long TotalMinorUnits, long? OutstandingMinorUnits);
// #773 — the line's basis as the wire carries it. Same shape on both sales
// routes, because both map through the one ToResponse.
private sealed record OrderBasisLineDto(Guid Id, string ListPriceBasis);
private sealed record OrderBasisDto(Guid Id, List<OrderBasisLineDto> Items);
private sealed record PaymentRowDto(Guid Id, int Version);
private sealed record PaymentsPageDto(List<PaymentRowDto> Items);

Expand Down Expand Up @@ -377,6 +381,32 @@ public async Task SalesOrderDetail_CarriesTheSameOutstandingAsTheList()
Assert.Equal(listed.OutstandingMinorUnits, detail!.OutstandingMinorUnits);
}

// #773 — a TRIPWIRE, weaker than the outstanding test above it, and worth
// saying so. Outstanding has two producers (:213 computes it for detail,
// :298 reads it from the repository for the list), so comparing them is a
// live parity check. The basis has ONE: both routes map through the single
// ToResponse, which makes a disagreement unreachable today. What this
// catches is the day someone gives the list route its own projection —
// verified by doing exactly that and watching it go red. The literal on
// the LIST row is what keeps it from comparing a value to itself.
[Fact]
public async Task SalesOrderDetail_CarriesTheSameListPriceBasisAsTheList()
{
var (client, accountId, farmId, grades) = await SetupAsync("Large");
var (_, customerId, productId) =
await SalesSetupAsync(accountId, farmId, grades["Large"], client);
var today = DateOnly.FromDateTime(DateTime.UtcNow.Date);
var orderId = await ConfirmedOrderAsync(client, customerId, productId, today, 10, 250);

var listed = (await client.GetFromJsonAsync<List<OrderBasisDto>>("/api/v1/sales"))!
.Single(o => o.Id == orderId);
var detail = await client.GetFromJsonAsync<OrderBasisDto>($"/api/v1/sales/{orderId}");

var listedLine = listed.Items.Single();
Assert.Equal("Recorded", listedLine.ListPriceBasis);
Assert.Equal(listedLine.ListPriceBasis, detail!.Items.Single(i => i.Id == listedLine.Id).ListPriceBasis);
}

[Fact]
public async Task SalesList_UnpaidWithMalformedValue_Is400()
{
Expand Down
62 changes: 48 additions & 14 deletions tests/Cluckwork.Api.IntegrationTests/SalesProductTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ private sealed record ItemCreated(Guid OrderId, Guid ItemId);
private sealed record ItemDto(
Guid Id, Guid ProductId, Guid EggGradeId, string Unit, int BaseUnitFactor,
int Quantity, int QuantityBase, long UnitPriceMinorUnits,
// #773 — typed `string`, not a defaulted one: the wire carries the enum
// MEMBER NAME, so a numeric basis fails to deserialize here instead of
// quietly reading back as an empty string nobody asserts on.
string ListPriceBasis,
long? ListUnitPriceMinorUnits = null);
private sealed record OrderDto(Guid Id, string Status, long TotalMinorUnits, List<ItemDto> Items);
private sealed record ConversionRow(Guid Id, string UnitCode, int EggsPerUnit, bool Active, int Version);
Expand Down Expand Up @@ -409,33 +413,32 @@ public async Task AddLine_UnpricedProduct_SnapshotsNoListPrice()
Assert.Null(order!.Items.Single().ListUnitPriceMinorUnits);
}

// #720 R4 — ListPriceBasis is deliberately not on the API response (the
// read surfaces render all three NULL reasons alike), so these read it
// back through the DbContext rather than the sales GET.
// #773 — ListPriceBasis now ships on the sales read API by name, because a
// bare NULL list price could not tell "the product had no price" from "this
// line predates the column". These read it back off the DETAIL route, which
// is the surface the screen consumes, rather than through the DbContext.
[Fact]
public async Task AddLine_RecordsBasis_Recorded_WhenPricedAndComparable()
{
var (client, accountId, _, _, productId) = await SetupAsync();
var (client, _, _, _, productId) = await SetupAsync();
var orderId = await CreateDraftAsync(client);

await AddLineAsync(client, orderId, productId, 1, price: 80);

var basis = await factory.WithTenantScopeAsync(accountId, async db =>
(await db.SalesOrderItems.SingleAsync(i => i.SalesOrderId == orderId)).ListPriceBasis);
Assert.Equal(Cluckwork.Domain.Sales.ListPriceBasis.Recorded, basis);
var order = await client.GetFromJsonAsync<OrderDto>($"/api/v1/sales/{orderId}");
Assert.Equal("Recorded", order!.Items.Single().ListPriceBasis);
}

[Fact]
public async Task AddLine_RecordsBasis_ProductUnpriced_ForAnUnpricedProduct()
{
var (client, accountId, _, _, productId) = await SetupAsync(defaultPrice: null);
var (client, _, _, _, productId) = await SetupAsync(defaultPrice: null);
var orderId = await CreateDraftAsync(client);

await AddLineAsync(client, orderId, productId, 1, price: 80);

var basis = await factory.WithTenantScopeAsync(accountId, async db =>
(await db.SalesOrderItems.SingleAsync(i => i.SalesOrderId == orderId)).ListPriceBasis);
Assert.Equal(Cluckwork.Domain.Sales.ListPriceBasis.ProductUnpriced, basis);
var order = await client.GetFromJsonAsync<OrderDto>($"/api/v1/sales/{orderId}");
Assert.Equal("ProductUnpriced", order!.Items.Single().ListPriceBasis);
}

[Fact]
Expand All @@ -460,9 +463,40 @@ await factory.WithTenantScopeAsync(accountId, async db =>

await AddLineAsync(client, orderId, skewed, 1, price: 80);

var basis = await factory.WithTenantScopeAsync(accountId, async db =>
(await db.SalesOrderItems.SingleAsync(i => i.SalesOrderId == orderId)).ListPriceBasis);
Assert.Equal(Cluckwork.Domain.Sales.ListPriceBasis.NotComparable, basis);
var order = await client.GetFromJsonAsync<OrderDto>($"/api/v1/sales/{orderId}");
Assert.Equal("NotComparable", order!.Items.Single().ListPriceBasis);
}

// #773 — the fourth basis, and the only one this test cannot produce
// through the API: SalesOrder.cs:547 throws on PreDating precisely so the
// application can never write one. So reproduce #720's backfill the way
// SalesDiscountCeilingTests.cs:94 does — the column holds the member NAME,
// and a backfilled row carries no list price. This is the whole point of
// the issue: the reader must see "we do not know" here, not the recorded
// fact that the product had no price.
[Fact]
public async Task Basis_PreDating_ReachesTheWireByName()
{
var (client, accountId, _, _, productId) = await SetupAsync();
var orderId = await CreateDraftAsync(client);
await AddLineAsync(client, orderId, productId, 1, price: 80);

await factory.WithTenantScopeAsync(accountId, db => db.Database.ExecuteSqlInterpolatedAsync(
$"""
UPDATE "SalesOrderItems"
SET "ListPriceBasis" = 'PreDating', "ListUnitPriceMinorUnits" = NULL
WHERE "SalesOrderId" = {orderId}
"""));

var order = await client.GetFromJsonAsync<OrderDto>($"/api/v1/sales/{orderId}");
var line = order!.Items.Single();
Assert.Equal("PreDating", line.ListPriceBasis);
// The other half of the pair. NOT a control for the UPDATE landing —
// the PreDating assertion above already fails if it did not, because
// the seeded row is Recorded (astra review round 1 corrected this
// comment). What this pins is that the two travel together: a basis of
// PreDating must never arrive beside a non-null price.
Assert.Null(line.ListUnitPriceMinorUnits);
}

[Fact]
Expand Down
9 changes: 8 additions & 1 deletion web/src/api/cluckwork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,9 +338,16 @@ export interface OrderItem {
currencyMinorUnit: number;
// #720 — REQUIRED, not optional, and deliberately so: an optional field is
// how a consumer silently forgets to render a state, and null here is a
// state the screen must show ("No list price"), not an absent value.
// state the screen must show, not an absent value. WHICH state it is comes
// from listPriceBasis below, not from this field (#773): a null is no longer
// uniformly "No list price".
// Same currency and minor unit as unitPriceMinorUnits.
listUnitPriceMinorUnits: number | null;
// #773 — which kind of nothing a null listUnitPriceMinorUnits is. REQUIRED
// for the same reason the field above is: the screen must branch on a real
// value, and only PreDating means "we do not know". Rendered through
// i18n/enums.ts (listPriceBasisLabel), never raw.
listPriceBasis: string;
}

export interface SalesOrder extends RecordHistory {
Expand Down
Loading
Loading