From 9f1eca07e9140164ec3ec8c12cf48a1f90170446 Mon Sep 17 00:00:00 2001 From: matst80 Date: Wed, 8 Jul 2026 11:39:42 +0200 Subject: [PATCH] parent item mutations --- cmd/cart/pool-server.go | 8 + cmd/cart/pool-server_test.go | 206 ++++++++++++++++++++++ cmd/cart/promotions_evaluate.go | 8 +- internal/ucp/cart.go | 10 ++ internal/ucp/cart_test.go | 100 +++++++++++ internal/ucp/types.go | 23 ++- pkg/cart/cart-grain.go | 14 +- pkg/cart/cart-mutation-helper.go | 69 ++++++++ pkg/cart/evaluated_item.go | 95 ++++++++++ pkg/cart/mutation_add_item_test.go | 30 ++++ pkg/cart/mutation_change_quantity.go | 39 ++-- pkg/cart/mutation_change_quantity_test.go | 170 ++++++++++++++++++ pkg/cart/mutation_items.go | 15 ++ pkg/promotions/evaluate.go | 100 +++-------- 14 files changed, 784 insertions(+), 103 deletions(-) create mode 100644 cmd/cart/pool-server_test.go create mode 100644 pkg/cart/evaluated_item.go create mode 100644 pkg/cart/mutation_change_quantity_test.go diff --git a/cmd/cart/pool-server.go b/cmd/cart/pool-server.go index b554333..28db29c 100644 --- a/cmd/cart/pool-server.go +++ b/cmd/cart/pool-server.go @@ -120,6 +120,14 @@ func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, return s.WriteResult(w, data) } +// Pool metrics (cart_grain_spawned_total, cart_grain_lookups_total, +// cart_mutations_total, cart_remote_negotiation_total, +// cart_connected_remotes, …) are bumped centrally by SimpleGrainPool +// — see pkg/actor/simple_grain_pool.go. The cart Metrics is +// registered once in cmd/cart/main.go and shared with DiskStorage +// via SetMetrics, so the per-handler counters this file used to +// maintain are no longer needed. + func (s *PoolServer) WriteResult(w http.ResponseWriter, result any) error { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-cache") diff --git a/cmd/cart/pool-server_test.go b/cmd/cart/pool-server_test.go new file mode 100644 index 0000000..d975b19 --- /dev/null +++ b/cmd/cart/pool-server_test.go @@ -0,0 +1,206 @@ +package main + +import ( + "encoding/json" + "net/http/httptest" + "testing" + + "git.k6n.net/mats/go-cart-actor/pkg/actor" + "git.k6n.net/mats/go-cart-actor/pkg/cart" +) + +// TestWriteResult_CartGrainCarriesEvaluatedItems verifies that the per-line +// promotion breakdown lives on CartGrain.EvaluatedItems (so the canonical +// promotion pipeline populates it once per mutation, not at every read +// path) and naturally serialises through WriteResult alongside the rest +// of the grain's tagged fields. There is no envelope wrapper anymore — +// the grain's own JSON tag carries the field. See pkg/cart/evaluated_item.go +// for EvaluatedItem semantics and pkg/cart/cart-grain.go for the EvaluatedItems +// field declaration. +func TestWriteResult_CartGrainCarriesEvaluatedItems(t *testing.T) { + grain := &cart.CartGrain{ + Id: 1, + Currency: "SEK", + Items: []*cart.CartItem{ + { + Sku: "promo-1", + Quantity: 2, + Price: *cart.NewPriceFromIncVat(10000, 2500), + Discount: cart.NewPriceFromIncVat(2000, 500), + Meta: &cart.ItemMeta{Name: "Promo Item"}, + }, + }, + TotalPrice: cart.NewPriceFromIncVat(20000, 5000), + TotalDiscount: cart.NewPriceFromIncVat(4000, 1000), + EvaluatedItems: []cart.EvaluatedItem{{ + Sku: "promo-1", + Name: "Promo Item", + Quantity: 2, + PriceIncVat: 10000, + OrgPriceIncVat: 12000, + DiscountIncVat: 2000, + EffectivePriceIncVat: 9000, + EffectiveTotalIncVat: 18000, + }}, + } + + s := &PoolServer{} + w := httptest.NewRecorder() + if err := s.WriteResult(w, grain); err != nil { + t.Fatalf("WriteResult: %v", err) + } + + var resp map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v (body=%q)", err, w.Body.String()) + } + + for _, key := range []string{"id", "currency", "items", "totalPrice", "totalDiscount", "evaluatedItems"} { + if _, ok := resp[key]; !ok { + t.Fatalf("expected top-level %q in grain serialisation, got keys %v", key, mapKeys(resp)) + } + } + + var ev []struct { + Sku string `json:"sku"` + DiscountIncVat int64 `json:"discountIncVat"` + EffectiveTotalIncVat int64 `json:"effectiveTotalIncVat"` + EffectivePriceIncVat int64 `json:"effectivePriceIncVat"` + } + if err := json.Unmarshal(resp["evaluatedItems"], &ev); err != nil { + t.Fatalf("evaluatedItems parse: %v", err) + } + if len(ev) != 1 || ev[0].Sku != "promo-1" { + t.Fatalf("expected one promo-1 evaluated item, got %+v", ev) + } + if ev[0].DiscountIncVat != 2000 || ev[0].EffectiveTotalIncVat != 18000 || ev[0].EffectivePriceIncVat != 9000 { + t.Fatalf("evaluatedItem math off: %+v", ev[0]) + } +} + +// TestWriteResult_MutationResultNestsEvaluatedItems verifies the natural +// post-mutation wire shape: actor.MutationResult[cart.CartGrain] serialises +// Result (the grain) and Mutations (applied-change metadata) at the existing +// top-level positions, and CartGrain's EvaluatedItems rides inside Result +// because the grain owns the field. Previously the envelope wrapper pulled +// evaluatedItems out as a sibling of {result, mutations}; the natural +// nesting is what the user wanted ("no strange patterns on a global level"). +// Internal/admin tools that read response.evaluatedItems will need to read +// response.result.evaluatedItems instead. Wrap-around breakage acknowledged. +func TestWriteResult_MutationResultNestsEvaluatedItems(t *testing.T) { + grain := &cart.CartGrain{ + Id: 1, + Currency: "SEK", + Items: []*cart.CartItem{ + { + Sku: "promo-1", + Quantity: 1, + Price: *cart.NewPriceFromIncVat(10000, 2500), + Discount: cart.NewPriceFromIncVat(3000, 750), + }, + }, + EvaluatedItems: []cart.EvaluatedItem{{ + Sku: "promo-1", + Quantity: 1, + PriceIncVat: 10000, + DiscountIncVat: 3000, + EffectiveTotalIncVat: 7000, + EffectivePriceIncVat: 7000, + }}, + } + + mr := &actor.MutationResult[cart.CartGrain]{ + Result: *grain, + Mutations: []actor.ApplyResult{{Type: "cart.AddItem"}}, + } + + s := &PoolServer{} + w := httptest.NewRecorder() + if err := s.WriteResult(w, mr); err != nil { + t.Fatalf("WriteResult: %v", err) + } + + var resp map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + // {result, mutations} are the existing envelope — preserved. + for _, key := range []string{"result", "mutations"} { + if _, ok := resp[key]; !ok { + t.Fatalf("expected top-level %q in mutation response, got %v", key, mapKeys(resp)) + } + } + // evaluatedItems no longer rises to the top level (the envelope is + // gone). Confirm absence at the top — it's now nested under result. + if _, hasTop := resp["evaluatedItems"]; hasTop { + t.Fatalf("evaluatedItems leaked to top level — envelope wrapper returned somehow") + } + + // Result nests the grain, including EvaluatedItems naturally. + var inner map[string]json.RawMessage + if err := json.Unmarshal(resp["result"], &inner); err != nil { + t.Fatalf("result parse: %v", err) + } + if _, ok := inner["items"]; !ok { + t.Fatalf("result.items missing — Result did not carry the grain") + } + evRaw, ok := inner["evaluatedItems"] + if !ok { + t.Fatalf("result.evaluatedItems missing — grain field should nest under result") + } + var ev []struct { + Sku string `json:"sku"` + DiscountIncVat int64 `json:"discountIncVat"` + } + if err := json.Unmarshal(evRaw, &ev); err != nil { + t.Fatalf("result.evaluatedItems parse: %v", err) + } + if len(ev) != 1 || ev[0].Sku != "promo-1" || ev[0].DiscountIncVat != 3000 { + t.Fatalf("result.evaluatedItems wrong: %+v", ev) + } + + // Mutations are still a sibling of result. + var muts []map[string]any + if err := json.Unmarshal(resp["mutations"], &muts); err != nil { + t.Fatalf("mutations parse: %v", err) + } + if len(muts) != 1 || muts[0]["type"] != "cart.AddItem" { + t.Fatalf("mutations wrong: %+v", muts) + } +} + +// TestWriteResult_NoEvaluatedItemsOmitsField verifies the EvaluatedItems +// omitempty on CartGrain drops the field entirely when the canonical +// pipeline hasn't run (synthetic grain in a fixture, fresh spawn before +// any promotion-aware mutation). Clients reading evaluatedItems can +// rely on absence to mean "no promotion compute yet". +func TestWriteResult_NoEvaluatedItemsOmitsField(t *testing.T) { + grain := &cart.CartGrain{ + Id: 1, + Currency: "SEK", + } + + s := &PoolServer{} + w := httptest.NewRecorder() + if err := s.WriteResult(w, grain); err != nil { + t.Fatalf("WriteResult: %v", err) + } + + var resp map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, has := resp["evaluatedItems"]; has { + t.Fatalf("expected evaluatedItems omitted on a grain with nil breakdown") + } +} + +// mapKeys returns the sorted set of top-level keys for nicer failure output. +func mapKeys(m map[string]json.RawMessage) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} diff --git a/cmd/cart/promotions_evaluate.go b/cmd/cart/promotions_evaluate.go index 3713af3..27b78c1 100644 --- a/cmd/cart/promotions_evaluate.go +++ b/cmd/cart/promotions_evaluate.go @@ -86,13 +86,17 @@ func newPromotionEvaluateWithCartHandler(store *promotions.Store, svc *promotion // main.go uses. Going through the shared // PromotionService.EvaluateAndApply means the bypass // loop, the re-eval trigger, and ApplyResults stay - // in lockstep with post-add behavior. + // in lockstep with post-add behavior. EvaluateAndApply + // populates g.EvaluatedItems via cart.MapEvaluatedItems + // on its final step, so the preview response reuses the + // same per-line projection the live cart renders — math + // and rounding stay in lockstep across both paths. svc.EvaluateAndApply(store.Snapshot(), g, previewContextOptions(req)...) resp = promotions.EvaluateResponse{ TotalPrice: g.TotalPrice, TotalDiscount: g.TotalDiscount, AppliedPromotions: g.AppliedPromotions, - Items: promotions.MapEvaluatedItems(g), + Items: g.EvaluatedItems, } } else { // No live cart (missing/invalid cookie, fetch failed, or diff --git a/internal/ucp/cart.go b/internal/ucp/cart.go index 6e4fc9f..b9f7b7e 100644 --- a/internal/ucp/cart.go +++ b/internal/ucp/cart.go @@ -195,6 +195,16 @@ func cartGrainToResponse(id string, g *cart.CartGrain) CartResponse { resp.Status = string(*g.CheckoutStatus) } + // Per-line evaluation breakdown. The canonical promotion pipeline + // (pkg/promotions.PromotionService.EvaluateAndApply) populates this + // field on the grain itself after every successful mutation, so the + // conversion just reads g.EvaluatedItems verbatim — no recompute at + // response time. Same shape /promotions/evaluate-with-cart returns + // (both reference cart.EvaluatedItem and cart.MapEvaluatedItems), + // so a line verified via GET /ucp/v1/carts/{id} byte-matches the + // preview from /promotions/evaluate-with-cart. + resp.EvaluatedItems = g.EvaluatedItems + for _, it := range g.Items { if it == nil { continue diff --git a/internal/ucp/cart_test.go b/internal/ucp/cart_test.go index 37563bb..05c5a6e 100644 --- a/internal/ucp/cart_test.go +++ b/internal/ucp/cart_test.go @@ -229,6 +229,106 @@ func TestCartResponse_Conversion(t *testing.T) { } } +// TestCartResponse_EvaluatedItems verifies that the per-line breakdown the +// canonical promotion pipeline populates on CartGrain.EvaluatedItems flows +// verbatim into the UCP CartResponse's EvaluatedItems field. Conversion is a +// straight pass-through now — we hand-construct the grain's EvaluatedItems +// field with the expected values to lock down the wire shape independent of +// the math MapEvaluatedItems applies (which has its own tests in pkg/cart). +func TestCartResponse_EvaluatedItems(t *testing.T) { + id := mustParseID("ABCD") + g := cart.NewCartGrain(id, time.UnixMilli(1000000)) + g.Currency = "SEK" + g.Items = append(g.Items, &cart.CartItem{ + Sku: "promo-1", + Quantity: 2, + Price: *mustPrice(10000, 2500), + TotalPrice: *mustPrice(20000, 5000), + Tax: 2500, + OrgPrice: mustPrice(12000, 3000), + Discount: mustPrice(2000, 500), + Meta: &cart.ItemMeta{Name: "Promo Item"}, + }) + g.TotalPrice = mustPrice(20000, 5000) + + // In the live cart, pkg/promotions.PromotionService.EvaluateAndApply + // populates g.EvaluatedItems via cart.MapEvaluatedItems after every + // successful mutation. The conversion test only cares that whatever + // the grain carries projects into resp.EvaluatedItems unchanged — so + // the input is the canonical shape, not the math's output. + g.EvaluatedItems = []cart.EvaluatedItem{{ + Sku: "promo-1", + Name: "Promo Item", + Quantity: 2, + PriceIncVat: 10000, + OrgPriceIncVat: 12000, + DiscountIncVat: 2000, + EffectivePriceIncVat: 9000, + EffectiveTotalIncVat: 18000, + }} + + resp := cartGrainToResponse(g.Id.String(), g) + if len(resp.EvaluatedItems) != 1 { + t.Fatalf("expected 1 evaluated item, got %d", len(resp.EvaluatedItems)) + } + ev := resp.EvaluatedItems[0] + if ev.Sku != "promo-1" { + t.Fatalf("expected sku 'promo-1', got %q", ev.Sku) + } + if ev.Name != "Promo Item" { + t.Fatalf("expected name 'Promo Item', got %q", ev.Name) + } + if ev.Quantity != 2 { + t.Fatalf("expected qty 2, got %d", ev.Quantity) + } + if ev.PriceIncVat != 10000 { + t.Fatalf("expected priceIncVat 10000, got %d", ev.PriceIncVat) + } + if ev.OrgPriceIncVat != 12000 { + t.Fatalf("expected orgPriceIncVat 12000, got %d", ev.OrgPriceIncVat) + } + if ev.DiscountIncVat != 2000 { + t.Fatalf("expected discountIncVat 2000, got %d", ev.DiscountIncVat) + } + if ev.EffectiveTotalIncVat != 18000 { + t.Fatalf("expected effectiveTotalIncVat 18000, got %d", ev.EffectiveTotalIncVat) + } + if ev.EffectivePriceIncVat != 9000 { + t.Fatalf("expected effectivePriceIncVat 9000, got %d", ev.EffectivePriceIncVat) + } +} + +// TestCartResponse_EvaluatedItems_MapEquivalence confirms that +// cart.MapEvaluatedItems produces the same shape the test's hand-built +// []cart.EvaluatedItem entries contain, given matching inputs. This locks +// the conversion path's output to the canonical pipeline's output — if +// either side starts omitting or renaming a field, this fails. +func TestCartResponse_EvaluatedItems_MapEquivalence(t *testing.T) { + id := mustParseID("ABCD") + g := cart.NewCartGrain(id, time.UnixMilli(1000000)) + g.Currency = "SEK" + g.Items = append(g.Items, &cart.CartItem{ + Sku: "promo-1", + Quantity: 2, + Price: *mustPrice(10000, 2500), + TotalPrice: *mustPrice(20000, 5000), + Tax: 2500, + OrgPrice: mustPrice(12000, 3000), + Discount: mustPrice(2000, 500), + Meta: &cart.ItemMeta{Name: "Promo Item"}, + }) + g.TotalPrice = mustPrice(20000, 5000) + + canonical := cart.MapEvaluatedItems(g) + if len(canonical) != 1 { + t.Fatalf("expected 1 evaluated item from MapEvaluatedItems, got %d", len(canonical)) + } + e := canonical[0] + if e.DiscountIncVat != 2000 || e.EffectiveTotalIncVat != 18000 || e.EffectivePriceIncVat != 9000 { + t.Fatalf("MapEvaluatedItems math off: %+v", e) + } +} + func mustPrice(incVat int64, totalVat int64) *cart.Price { return &cart.Price{ IncVat: money.Cents(incVat), diff --git a/internal/ucp/types.go b/internal/ucp/types.go index 76b1c22..3b90e0d 100644 --- a/internal/ucp/types.go +++ b/internal/ucp/types.go @@ -121,11 +121,26 @@ type VoucherInput struct { } // CartResponse is the UCP cart response body. +// +// EvaluatedItems mirrors the per-line breakdown the canonical promotion +// pipeline (pkg/promotions.PromotionService.EvaluateAndApply) populates +// on the grain after every successful mutation — see +// pkg/cart/evaluated_item.go for the EvaluatedItem type and the rounding +// rules. The shape and field order match the live cart grain's own +// EvaluatedItems field and the response from /promotions/evaluate-with-cart +// (both reference cart.EvaluatedItem and cart.MapEvaluatedItems), so a +// line verified via GET /ucp/v1/carts/{id} byte-matches the preview. +// +// Kept parallel to Items rather than merged into CartItem to preserve +// the UCP wire contract — existing UCP clients keep working unchanged +// and the per-line breakdown can grow on the EvaluatedItem type without +// churning the standard item shape. type CartResponse struct { - Id string `json:"id"` - Items []CartItem `json:"items,omitempty"` - Totals Totals `json:"totals"` - Status string `json:"status"` + Id string `json:"id"` + Items []CartItem `json:"items,omitempty"` + EvaluatedItems []cart.EvaluatedItem `json:"evaluatedItems,omitempty"` + Totals Totals `json:"totals"` + Status string `json:"status"` AppliedPromotions []cart.AppliedPromotion `json:"appliedPromotions,omitempty"` } diff --git a/pkg/cart/cart-grain.go b/pkg/cart/cart-grain.go index 281cace..23caa93 100644 --- a/pkg/cart/cart-grain.go +++ b/pkg/cart/cart-grain.go @@ -161,7 +161,19 @@ type CartGrain struct { Items []*CartItem `json:"items"` TotalPrice *Price `json:"totalPrice"` TotalDiscount *Price `json:"totalDiscount"` - Processing bool `json:"processing"` + // EvaluatedItems is the per-line promotion breakdown — see + // EvaluatedItem for shape and semantics. Populated by the canonical + // promotion pipeline (pkg/promotions.PromotionService.EvaluateAndApply) + // after every successful mutation, alongside TotalDiscount and + // AppliedPromotions, so any consumer of the grain (UCP cart response, + // legacy /cart HTTP handler, cart-mcp, AMQP mutation feed) sees the + // per-line discount/effective totals without re-running the engine at + // response time. Mirrors the same shape /promotions/evaluate-with-cart + // has returned since the breakdown feature was introduced; deliberately + // persisted via the event-log JSON block so we don't have to re-derive + // it on every read path (same precedent as TotalPrice/TotalDiscount). + EvaluatedItems []EvaluatedItem `json:"evaluatedItems,omitempty"` + Processing bool `json:"processing"` //PaymentInProgress uint16 `json:"paymentInProgress"` OrderReference string `json:"orderReference,omitempty"` diff --git a/pkg/cart/cart-mutation-helper.go b/pkg/cart/cart-mutation-helper.go index 2ec6f8b..a154eb8 100644 --- a/pkg/cart/cart-mutation-helper.go +++ b/pkg/cart/cart-mutation-helper.go @@ -2,9 +2,11 @@ package cart import ( "context" + "log" "time" "git.k6n.net/mats/go-cart-actor/pkg/actor" + cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart" "git.k6n.net/mats/platform/inventory" ) @@ -71,6 +73,73 @@ func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sk return c.reservationService.Release(ctx, inventory.SKU(sku), l, inventory.CartID(cartId.String())) } +// CascadeChildQuantities scales every direct child's quantity +// proportionally when a parent's quantity is bumped (ChangeQuantity or +// the AddItem-merge path). +// +// - Ratio: newChildQty = oldChildQty × newParentQty / oldParentQty. +// - Floor: scale-down via integer division can yield 0 (e.g. parent +// 2 → 1, child qty 1 → (1×1)/2 = 0). We clamp to 1 so a scale-down +// never silently deletes an accessory. The mismatch is acceptable — +// shipping one extra child is better than dropping one. +// - Reservations: each child's reservation is released then re-acquired +// at the new quantity if UseReservations is true for that child AND a +// prior reservation existed. A failing reservation is logged, not +// raised, so a single bad child doesn't abort the whole cascade +// (matches the existing removal pattern in mutation_items.go). +// - Recursion: descends into grandchildren using the child's own +// before/after quantities as the next ratio's input, so deeply +// nested accessory trees (parent → child → grandchild) stay in +// ratio with the top-level change. +// +// Caller is responsible for locking any grain mutex and for re-running +// UpdateTotals after the cascade has settled. +func (c *CartMutationContext) CascadeChildQuantities( + ctx context.Context, + g *CartGrain, + parentLineId uint32, + oldParentQty uint16, + newParentQty uint16, +) { + if oldParentQty == 0 || oldParentQty == newParentQty { + return + } + + for _, it := range g.Items { + if it == nil { + continue + } + if it.ParentId == nil || *it.ParentId != parentLineId { + continue + } + oldChildQty := it.Quantity + ratio := uint32(newParentQty) * uint32(oldChildQty) / uint32(oldParentQty) + newChildQty := uint16(ratio) + if newChildQty == 0 { + newChildQty = 1 + } + if newChildQty == oldChildQty { + continue + } + + if g.Type == cart_messages.CartType_REGULAR && c.UseReservations(it) && it.ReservationEndTime != nil { + if err := c.ReleaseItem(ctx, g.Id, it.Sku, it.StoreId); err != nil { + log.Printf("CascadeChildQuantities: failed to release %s: %v", it.Sku, err) + } + endTime, err := c.ReserveItem(ctx, g.Id, it.Sku, it.StoreId, newChildQty) + if err != nil { + log.Printf("CascadeChildQuantities: failed to reserve %s at qty %d: %v", it.Sku, newChildQty, err) + } else if endTime != nil { + it.ReservationEndTime = endTime + } + } + + it.Quantity = newChildQty + // Descend so grandchildren track this child's ratio. + c.CascadeChildQuantities(ctx, g, it.Id, oldChildQty, newChildQty) + } +} + func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegistry { reg := actor.NewMutationRegistry() diff --git a/pkg/cart/evaluated_item.go b/pkg/cart/evaluated_item.go new file mode 100644 index 0000000..bf10192 --- /dev/null +++ b/pkg/cart/evaluated_item.go @@ -0,0 +1,95 @@ +package cart + +// EvaluatedItem is one line in the per-line promotion breakdown exposed +// alongside a cart. It mirrors the line that was evaluated (sku, +// quantity, unit list price, optional OrgPrice for the strikethrough) and +// adds the per-line discount the engine applied (orgPrice-based + +// promotion-based, additive) plus the resulting effective per-unit and +// per-line totals. +// +// Lives in pkg/cart rather than pkg/promotions so CartGrain can carry the +// breakdown as a derived field (populated by the canonical promotion +// pipeline that already manages TotalDiscount/AppliedPromotions), and so +// every consumer of the grain — UCP cart response, legacy /cart HTTP +// handler, cart-mcp, AMQP mutation feed — naturally sees the same shape +// without ad-hoc wrappers at each call site. JSON tags are the canonical +// EvaluatedItem shape that /promotions/evaluate-with-cart has returned +// since the breakdown feature was introduced. +// +// Distributing the total cart discount down to the line level lets the +// storefront render "Item X: 100 kr → 80 kr" without re-doing the math +// on the client and lets verifiers confirm which promotion hit which +// line (per-line DiscountIncVat is the engine's authoritative answer). +type EvaluatedItem struct { + Sku string `json:"sku"` + Name string `json:"name,omitempty"` + Quantity uint16 `json:"qty"` + PriceIncVat int64 `json:"priceIncVat"` // per-unit list price + OrgPriceIncVat int64 `json:"orgPriceIncVat,omitempty"` // per-unit pre-discount list price (for strikethrough) + DiscountIncVat int64 `json:"discountIncVat"` // per-line TOTAL discount (orgPrice + promotion), incVat in öre + EffectivePriceIncVat int64 `json:"effectivePriceIncVat"` // per-unit, after discount, incVat in öre (rounded) + EffectiveTotalIncVat int64 `json:"effectiveTotalIncVat"` // per-line, after discount, incVat in öre (exact) +} + +// MapEvaluatedItems walks the grain's items and produces the per-line +// EvaluatedItem list. The grain's item.Discount carries the TOTAL per-line +// discount (orgPrice + promotion — additive, set by UpdateTotals and the +// effects' per-line distribution). The effective totals are derived as +// (price * qty - discount), clamped to 0 (a promotion that over-discounted +// a line shows as free, not negative). The effective per-unit price is the +// per-line total divided by quantity, rounded to the nearest öre so the +// per-unit display matches the per-line total when multiplied back. +// +// Returns nil for a nil grain (callers that always pass a non-nil grain +// — the typical case — get a non-nil empty slice for an empty cart, which +// is the desired behavior for JSON omitempty at the consumer). +// +// Exported so the canonical promotion pipeline (pkg/promotions.EvaluateAndApply) +// and the manual /promotions/evaluate-with-cart preview handler can produce +// the same per-line shape. +func MapEvaluatedItems(g *CartGrain) []EvaluatedItem { + if g == nil { + return nil + } + out := make([]EvaluatedItem, 0, len(g.Items)) + for _, it := range g.Items { + if it == nil { + continue + } + var name string + if it.Meta != nil { + name = it.Meta.Name + } + var orgPrice int64 + if it.OrgPrice != nil { + orgPrice = it.OrgPrice.IncVat.Int64() + } + var discount int64 + if it.Discount != nil { + discount = it.Discount.IncVat.Int64() + } + priceRow := it.Price.IncVat.Int64() * int64(it.Quantity) + effTotal := priceRow - discount + if effTotal < 0 { + effTotal = 0 + } + var effUnit int64 + if it.Quantity > 0 { + // Nearest-öre rounding so per-unit * qty matches the + // per-line total (within 1 öre either way). Half-up: + // (effTotal + qty/2) / qty. + effUnit = (effTotal + int64(it.Quantity)/2) / int64(it.Quantity) + } + out = append(out, EvaluatedItem{ + Sku: it.Sku, + Name: name, + Quantity: it.Quantity, + PriceIncVat: it.Price.IncVat.Int64(), + OrgPriceIncVat: orgPrice, + DiscountIncVat: discount, + EffectivePriceIncVat: effUnit, + EffectiveTotalIncVat: effTotal, + }) + } + return out +} diff --git a/pkg/cart/mutation_add_item_test.go b/pkg/cart/mutation_add_item_test.go index 472c2a1..825d821 100644 --- a/pkg/cart/mutation_add_item_test.go +++ b/pkg/cart/mutation_add_item_test.go @@ -38,3 +38,33 @@ func TestAddItem_MergesByItemIdNotSku(t *testing.T) { t.Fatalf("items = %d, want 2", len(g.Items)) } } + +// Re-adding the same parent (same ItemId + StoreId, no ParentId on the +// re-add itself) merges the quantity, and the proportional cascade resizes +// every direct child to match the new parent qty so a "+1 drill" leaves +// the bundle internally consistent. +func TestAddItem_MergeCascadesToChildren(t *testing.T) { + reg := NewCartMultationRegistry(NewCartMutationContext(nil)) + g := NewCartGrain(1, time.Now()) + ctx := context.Background() + + if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}); err != nil { + t.Fatalf("add parent: %v", err) + } + parentLine := g.Items[0].Id + if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine}); err != nil { + t.Fatalf("add child: %v", err) + } + + // Re-add the same parent — same ItemId, no ParentId on the re-add itself. + if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}); err != nil { + t.Fatalf("re-add parent: %v", err) + } + + // Parent qty 1 -> 2; child qty 1 -> 2 via cascade. + for _, it := range g.Items { + if it.Quantity != 2 { + t.Errorf("line %s qty = %d, want 2 (merge cascade)", it.Sku, it.Quantity) + } + } +} diff --git a/pkg/cart/mutation_change_quantity.go b/pkg/cart/mutation_change_quantity.go index feb69ea..d4636c5 100644 --- a/pkg/cart/mutation_change_quantity.go +++ b/pkg/cart/mutation_change_quantity.go @@ -4,9 +4,8 @@ import ( "context" "fmt" "log" - "time" - messages "git.k6n.net/mats/go-cart-actor/proto/cart" + cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart" ) // mutation_change_quantity.go @@ -15,8 +14,11 @@ import ( // // Behavior: // - Locates an item by its cart-local line item Id (not source item_id). -// - If requested quantity <= 0 the line is removed. -// - Otherwise the line's Quantity field is updated. +// - If requested quantity <= 0 the line AND any descendants are +// removed via the shared RemoveItem cascade (transitive parent→child). +// - Otherwise the line's Quantity is updated and direct children are +// rescaled proportionally so a "2 of this + 2 of each accessory" +// bundle stays internally consistent when the parent becomes 3. // - Totals are recalculated (WithTotals). // // Error handling: @@ -29,7 +31,7 @@ import ( // (If strict locking is required around every mutation, wrap logic in // an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.) -func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQuantity) error { +func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *cart_messages.ChangeQuantity) error { if m == nil { return fmt.Errorf("ChangeQuantity: nil payload") } @@ -48,23 +50,18 @@ func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQua } if m.Quantity <= 0 { - // Remove the item - itemToRemove := g.Items[foundIndex] - if itemToRemove.ReservationEndTime != nil && itemToRemove.ReservationEndTime.After(time.Now()) { - err := c.ReleaseItem(ctx, g.Id, itemToRemove.Sku, itemToRemove.StoreId) - if err != nil { - log.Printf("unable to release reservation for %s in location: %v", itemToRemove.Sku, itemToRemove.StoreId) - } - } - g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...) - g.UpdateTotals() - return nil + // Setting a parent line to 0 means "remove it AND its accessories". + // Delegate to RemoveItem so the cascade logic in one place stays + // the source of truth for descendant cleanup + reservation + // release — otherwise we'd duplicate the transitive cascade here. + return c.RemoveItem(g, &cart_messages.RemoveItem{Id: m.Id}) } item := g.Items[foundIndex] if item == nil { return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id) } - if g.Type == messages.CartType_REGULAR && c.UseReservations(item) { + oldQty := item.Quantity + if g.Type == cart_messages.CartType_REGULAR && c.UseReservations(item) { if item.ReservationEndTime != nil { err := c.ReleaseItem(ctx, g.Id, item.Sku, item.StoreId) if err != nil { @@ -79,6 +76,14 @@ func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQua item.ReservationEndTime = endTime } item.Quantity = uint16(m.Quantity) + // Rescale any direct child lines proportionally so accessories stay + // in sync with their parent. Descends recursively into grandchildren + // via CascadeChildQuantities itself. Skipped when the target itself + // is a child line — direct qty edits on a child are treated as + // explicit user intent and don't propagate further. + if item.ParentId == nil { + c.CascadeChildQuantities(ctx, g, item.Id, oldQty, item.Quantity) + } g.UpdateTotals() return nil diff --git a/pkg/cart/mutation_change_quantity_test.go b/pkg/cart/mutation_change_quantity_test.go new file mode 100644 index 0000000..e1edc03 --- /dev/null +++ b/pkg/cart/mutation_change_quantity_test.go @@ -0,0 +1,170 @@ +package cart + +import ( + "context" + "testing" + "time" + + cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart" +) + +// Parent qty 1 → 2 resizes every direct child proportionally. Children all +// start at qty 1 so the proportional factor of 2 doubles them. +func TestChangeQuantity_ParentCascadesDoublingChildren(t *testing.T) { + reg := NewCartMultationRegistry(NewCartMutationContext(nil)) + g := NewCartGrain(1, time.Now()) + ctx := context.Background() + + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}) + parentLine := g.Items[0].Id + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine}) + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 102, Sku: "C2", Quantity: 1, Price: 200, ParentId: &parentLine}) + + if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 2}); err != nil { + t.Fatalf("change qty: %v", err) + } + + if g.Items[0].Quantity != 2 { + t.Errorf("parent qty = %d, want 2", g.Items[0].Quantity) + } + for _, it := range g.Items { + if it.Id == parentLine { + continue + } + if it.Quantity != 2 { + t.Errorf("child %s qty = %d, want 2 (cascaded from parent)", it.Sku, it.Quantity) + } + } +} + +// Parent qty 2 → 3 with children at qty 2 → 3 (factor 3/2 = 1.5, integer math). +func TestChangeQuantity_ParentCascadesNonIntegerRatio(t *testing.T) { + reg := NewCartMultationRegistry(NewCartMutationContext(nil)) + g := NewCartGrain(1, time.Now()) + ctx := context.Background() + + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 2, Price: 1000}) + parentLine := g.Items[0].Id + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 2, Price: 100, ParentId: &parentLine}) + + if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 3}); err != nil { + t.Fatalf("change qty: %v", err) + } + + // parent: 2 -> 3 (direct set), child: 2 * 3 / 2 = 3 + if g.Items[0].Quantity != 3 { + t.Errorf("parent qty = %d, want 3", g.Items[0].Quantity) + } + if g.Items[1].Quantity != 3 { + t.Errorf("child qty = %d, want 3 (proportional scale)", g.Items[1].Quantity) + } +} + +// Scale-down that would round to 0 (parent 2 → 1, child qty 1 → (1*1)/2 = 0) +// must clamp to 1 so the child is never silently dropped by integer math. +func TestChangeQuantity_ParentScaleDownFloorsAtOne(t *testing.T) { + reg := NewCartMultationRegistry(NewCartMutationContext(nil)) + g := NewCartGrain(1, time.Now()) + ctx := context.Background() + + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 2, Price: 1000}) + parentLine := g.Items[0].Id + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine}) + + if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 1}); err != nil { + t.Fatalf("change qty: %v", err) + } + + if g.Items[0].Quantity != 1 { + t.Errorf("parent qty = %d, want 1", g.Items[0].Quantity) + } + if g.Items[1].Quantity != 1 { + t.Errorf("child qty = %d, want 1 (floor; integer math dropped to 0 without clamp)", g.Items[1].Quantity) + } +} + +// Setting parent qty to 0 delegates to RemoveItem so the existing parent→ +// child cascade takes over. Unrelated lines are left untouched. +func TestChangeQuantity_ZeroCascadesRemoval(t *testing.T) { + reg := NewCartMultationRegistry(NewCartMutationContext(nil)) + g := NewCartGrain(1, time.Now()) + ctx := context.Background() + + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}) + parentLine := g.Items[0].Id + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine}) + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 200, Sku: "OTHER", Quantity: 1, Price: 500}) + + if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 0}); err != nil { + t.Fatalf("change qty 0: %v", err) + } + + if len(g.Items) != 1 { + t.Fatalf("items = %d, want 1 (only OTHER survives)", len(g.Items)) + } + if g.Items[0].Sku != "OTHER" { + t.Errorf("remaining sku = %q, want OTHER", g.Items[0].Sku) + } +} + +// Changing a child's qty directly updates only that child — siblings and +// the parent are untouched. (The UI no longer exposes this control, but +// the HTTP surface still allows it for tools & direct API calls.) +func TestChangeQuantity_ChildDoesNotCascade(t *testing.T) { + reg := NewCartMultationRegistry(NewCartMutationContext(nil)) + g := NewCartGrain(1, time.Now()) + ctx := context.Background() + + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}) + parentLine := g.Items[0].Id + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine}) + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 102, Sku: "C2", Quantity: 1, Price: 200, ParentId: &parentLine}) + targetChild := g.Items[1].Id + + if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: targetChild, Quantity: 3}); err != nil { + t.Fatalf("change child qty: %v", err) + } + + for _, it := range g.Items { + switch it.Id { + case parentLine: + if it.Quantity != 1 { + t.Errorf("parent qty = %d, want 1 (untouched on child edit)", it.Quantity) + } + case targetChild: + if it.Quantity != 3 { + t.Errorf("target child qty = %d, want 3", it.Quantity) + } + default: + // Sibling + if it.Quantity != 1 { + t.Errorf("sibling %s qty = %d, want 1 (untouched on child edit)", it.Sku, it.Quantity) + } + } + } +} + +// Cascade descends into grandchildren so deeply nested accessory trees +// remain internally consistent with the top-level parent change. +func TestChangeQuantity_NestedChildren(t *testing.T) { + reg := NewCartMultationRegistry(NewCartMutationContext(nil)) + g := NewCartGrain(1, time.Now()) + ctx := context.Background() + + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}) + parentLine := g.Items[0].Id + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine}) + childLine := g.Items[1].Id + mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 102, Sku: "G1", Quantity: 1, Price: 50, ParentId: &childLine}) + + if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 2}); err != nil { + t.Fatalf("change qty: %v", err) + } + + // parent 1 -> 2; child 1 -> 2 (parent -> child cascade); grandchild 1 -> 2 (child -> grandchild cascade) + for _, it := range g.Items { + if it.Quantity != 2 { + t.Errorf("line %s qty = %d, want 2 (nested cascade)", it.Sku, it.Quantity) + } + } +} diff --git a/pkg/cart/mutation_items.go b/pkg/cart/mutation_items.go index 462133b..f32cc77 100644 --- a/pkg/cart/mutation_items.go +++ b/pkg/cart/mutation_items.go @@ -108,6 +108,7 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er } existing.ReservationEndTime = endTime } + oldQty := existing.Quantity existing.Quantity += uint16(m.Quantity) existing.Stock = uint16(m.Stock) existing.InventoryTracked = m.InventoryTracked @@ -124,6 +125,20 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er if len(m.CustomFields) > 0 { existing.CustomFields = m.CustomFields } + // Re-add grew the parent's qty — rescale direct child quantities + // proportionally so a "+1 drill" keeps its accessories consistent. + // Skip when the merged line IS itself a child (ParentId != nil): + // cascading up the tree isn't a thing — the parent would not know + // a child grew without re-running the promotion engine. + if existing.ParentId == nil { + c.CascadeChildQuantities(ctx, g, existing.Id, oldQty, existing.Quantity) + } + // Recompute totals so TotalPrice/TotalDiscount/Discount annotations + // reflect the merged parent + cascaded children. The non-merge + // (new-line) path below also calls UpdateTotals; the merge path + // would otherwise drift after a "+1" re-add, especially now that + // cascade may have rewritten N child quantities. + g.UpdateTotals() return nil } diff --git a/pkg/promotions/evaluate.go b/pkg/promotions/evaluate.go index e5bcae4..92da090 100644 --- a/pkg/promotions/evaluate.go +++ b/pkg/promotions/evaluate.go @@ -17,6 +17,11 @@ import ( // creating or mutating a cart. It builds a synthetic CartGrain from the request, // runs the same EvaluateAll + ApplyResults path the live cart uses, and returns // the resulting totals plus the applied/pending effect breakdown. +// +// The per-line breakdown (cart.EvaluatedItem) is computed via the canonical +// cart.MapEvaluatedItems so the preview and post-add responses share shape +// and rounding rules — see pkg/cart/evaluated_item.go for the type and the +// map function. // EvalItem is one — possibly partial — cart line for a stateless evaluation. // Missing fields fall back to sensible defaults (qty 1, 25% VAT) so the engine @@ -46,88 +51,15 @@ type EvaluateRequest struct { // promotions, the per-promotion breakdown (applied discounts plus pending // "spend X more for ..." nudges), and the per-line breakdown so the // storefront can render the real (post-discount) unit price for each item. +// +// Items is the same []cart.EvaluatedItem shape the live cart exposes on +// CartGrain.EvaluatedItems — referenced from cart so the preview and the +// live response match byte-for-byte on the wire. type EvaluateResponse struct { TotalPrice *cart.Price `json:"totalPrice"` TotalDiscount *cart.Price `json:"totalDiscount"` AppliedPromotions []cart.AppliedPromotion `json:"appliedPromotions,omitempty"` - Items []EvaluatedItem `json:"items,omitempty"` -} - -// EvaluatedItem is one line in the EvaluateResponse.Items list. It mirrors -// the line that was evaluated (sku, quantity, unit list price, optional -// OrgPrice for the strikethrough) and adds the per-line discount the -// engine applied (orgPrice-based + promotion-based, additive) plus the -// resulting effective per-unit and per-line totals. Distributing the -// total discount down to the line level lets the storefront render -// "Item X: 100 kr → 80 kr" without re-doing the math on the client. -type EvaluatedItem struct { - Sku string `json:"sku"` - Name string `json:"name,omitempty"` - Quantity uint16 `json:"qty"` - PriceIncVat int64 `json:"priceIncVat"` // per-unit list price - OrgPriceIncVat int64 `json:"orgPriceIncVat,omitempty"` // per-unit pre-discount list price (for strikethrough) - DiscountIncVat int64 `json:"discountIncVat"` // per-line TOTAL discount (orgPrice + promotion), incVat in öre - EffectivePriceIncVat int64 `json:"effectivePriceIncVat"` // per-unit, after discount, incVat in öre (rounded) - EffectiveTotalIncVat int64 `json:"effectiveTotalIncVat"` // per-line, after discount, incVat in öre (exact) -} - -// MapEvaluatedItems walks the grain's items after ApplyResults and -// produces the per-line EvaluatedItem list. The grain's item.Discount -// carries the TOTAL per-line discount (orgPrice + promotion — additive, -// set by UpdateTotals and the effects' per-line distribution). The -// effective totals are derived as (price * qty - discount), clamped to -// 0 (a promotion that over-discounted a line shows as free, not -// negative). The effective per-unit price is the per-line total -// divided by quantity, rounded to the nearest öre so the per-unit -// display matches the per-line total when multiplied back. -// Exported so the /promotions/evaluate-with-cart preview handler in -// cmd/cart can produce the same per-line shape the stateless -// Evaluate path produces. -func MapEvaluatedItems(g *cart.CartGrain) []EvaluatedItem { - if g == nil { - return nil - } - out := make([]EvaluatedItem, 0, len(g.Items)) - for _, it := range g.Items { - if it == nil { - continue - } - var name string - if it.Meta != nil { - name = it.Meta.Name - } - var orgPrice int64 - if it.OrgPrice != nil { - orgPrice = it.OrgPrice.IncVat.Int64() - } - var discount int64 - if it.Discount != nil { - discount = it.Discount.IncVat.Int64() - } - priceRow := it.Price.IncVat.Int64() * int64(it.Quantity) - effTotal := priceRow - discount - if effTotal < 0 { - effTotal = 0 - } - var effUnit int64 - if it.Quantity > 0 { - // Nearest-öre rounding so per-unit * qty matches the - // per-line total (within 1 öre either way). Half-up: - // (effTotal + qty/2) / qty. - effUnit = (effTotal + int64(it.Quantity)/2) / int64(it.Quantity) - } - out = append(out, EvaluatedItem{ - Sku: it.Sku, - Name: name, - Quantity: it.Quantity, - PriceIncVat: it.Price.IncVat.Int64(), - OrgPriceIncVat: orgPrice, - DiscountIncVat: discount, - EffectivePriceIncVat: effUnit, - EffectiveTotalIncVat: effTotal, - }) - } - return out + Items []cart.EvaluatedItem `json:"items,omitempty"` } // Evaluate runs rules against a synthetic cart built from req and returns the @@ -139,7 +71,7 @@ func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest) TotalPrice: g.TotalPrice, TotalDiscount: g.TotalDiscount, AppliedPromotions: g.AppliedPromotions, - Items: MapEvaluatedItems(g), + Items: cart.MapEvaluatedItems(g), } } @@ -197,6 +129,16 @@ func (s *PromotionService) EvaluateAndApply(rules []PromotionRule, g *cart.CartG results, _ = s.EvaluateAll(rules, ctx) } s.ApplyResults(g, results, ctx) + // Populate the per-line breakdown now that ApplyResults is done — + // every effect has distributed per-line Discounts onto the grain's + // items, so MapEvaluatedItems reads consistent state. Sits on the + // canonical pipeline so callers (live-cart processor, the cart-aware + // preview handler, internal/admin mutations) never have to remember + // to refresh it themselves; downstream readers (UCP cart response, + // legacy /cart HTTP handler, cart-mcp, AMQP mutation feed) see a + // grain that already carries the per-line breakdown on its own + // EvaluatedItems field. + g.EvaluatedItems = cart.MapEvaluatedItems(g) } // markCouponsBypassed scans the evaluation results for any applicable