From e20793a6b3f9c70505affff75351a604ed3af06b Mon Sep 17 00:00:00 2001 From: matst80 Date: Wed, 8 Jul 2026 00:55:05 +0200 Subject: [PATCH] better results --- cmd/cart/main.go | 77 +++------- cmd/cart/promotions_evaluate.go | 176 ++++++++++++++++++++++ pkg/promotions/apply.go | 133 +++++++++++++++-- pkg/promotions/evaluate.go | 250 ++++++++++++++++++++++++++++---- 4 files changed, 545 insertions(+), 91 deletions(-) diff --git a/cmd/cart/main.go b/cmd/cart/main.go index 27e01da..502e5bc 100644 --- a/cmd/cart/main.go +++ b/cmd/cart/main.go @@ -202,59 +202,17 @@ func main() { actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error { _, span := tracer.Start(ctx, "Totals and promotions") defer span.End() - - // Clear bypass flags first - for _, v := range g.Vouchers { - if v != nil { - v.BypassedByPromotions = false - } - } - - g.UpdateTotals() - promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now())) - results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx) - - // Check if any qualified promotion rule has a coupon_code condition matching our vouchers - hasBypassed := false - for _, res := range results { - if res.Applicable { - promotions.WalkConditions(res.Rule.Conditions, func(c promotions.Condition) bool { - if bc, ok := c.(promotions.BaseCondition); ok && bc.Type == promotions.CondCouponCode { - var codes []string - if s, ok := bc.Value.AsString(); ok { - codes = append(codes, strings.ToLower(s)) - } else if arr, ok := bc.Value.AsStringSlice(); ok { - for _, s := range arr { - codes = append(codes, strings.ToLower(s)) - } - } - for _, code := range codes { - for _, v := range g.Vouchers { - if v != nil && strings.ToLower(v.Code) == code { - if !v.BypassedByPromotions { - v.BypassedByPromotions = true - hasBypassed = true - } - } - } - } - } - return true - }) - } - } - - if hasBypassed { - // Re-evaluate with bypassed vouchers - g.UpdateTotals() - promotionCtx = promotions.NewContextFromCart(g, promotions.WithNow(time.Now())) - results, _ = promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx) - } - - // ApplyResults applies qualifying actions in priority order and records - // every effect — both applied discounts and pending "spend X more for ..." - // nudges with their progress — in g.AppliedPromotions. - promotionService.ApplyResults(g, results, promotionCtx) + // Canonical promotion pipeline — same call site as the + // /promotions/evaluate-with-cart preview handler in + // promotions_evaluate.go. Going through the shared + // PromotionService.EvaluateAndApply means the bypass + // loop, the re-eval trigger, and ApplyResults stay in + // lockstep with the preview so a what-if always matches + // post-add behavior (including the coupon+promotion- + // overlap edge case). If you find yourself adding + // promotion logic here, add it to EvaluateAndApply + // instead — the preview must follow. + promotionService.EvaluateAndApply(promotionStore.Snapshot(), g, promotions.WithNow(time.Now())) g.Version++ return nil }), @@ -490,6 +448,19 @@ func main() { // without creating or mutating a real cart. mux.HandleFunc("POST /promotions/evaluate", newPromotionEvaluateHandler(promotionStore, promotionService)) + // Cart-aware promotion evaluation: reads the cartid cookie the storefront + // already maintains, fetches the actual cart grain (cross-pod via + // GetAnywhere), merges with the request's items (the items the user is + // considering adding), and runs the engine on the merged line list. The + // cart is the source of truth (vouchers, customer segment, customer + // lifetime value, total) so a serialised copy from the browser can race + // the live cart — letting the backend read the cookie + fetch the grain + // means exactly one source of truth and no race. Missing cookie or fetch + // failure gracefully degrades to a stateless evaluation of the request + // items alone. See newPromotionEvaluateWithCartHandler in + // promotions_evaluate.go for the full contract. + mux.HandleFunc("POST /promotions/evaluate-with-cart", newPromotionEvaluateWithCartHandler(promotionStore, promotionService, syncedServer)) + // only for local mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) { pool.AddRemote(r.PathValue("host")) diff --git a/cmd/cart/promotions_evaluate.go b/cmd/cart/promotions_evaluate.go index daa50e1..3713af3 100644 --- a/cmd/cart/promotions_evaluate.go +++ b/cmd/cart/promotions_evaluate.go @@ -4,8 +4,11 @@ import ( "encoding/json" "errors" "io" + "log" "net/http" + "time" + "git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/go-cart-actor/pkg/promotions" ) @@ -13,6 +16,7 @@ import ( // (possibly partial) evaluation context and returns the totals plus the // applied/pending promotion effects, without creating or mutating a real cart. // An empty body is treated as an empty context (evaluates against no items). +// Pure stateless — callers compose the line list themselves. func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.PromotionService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req promotions.EvaluateRequest @@ -27,3 +31,175 @@ func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.Promot } } } + +// newPromotionEvaluateWithCartHandler serves POST /promotions/evaluate-with-cart. +// Reads the `cartid` cookie the storefront already maintains (set by +// GET /cart on first visit), fetches the actual cart from the grain pool +// (cross-pod via GetAnywhere so a cart owned by another pod is fetched +// transparently), deep-copies the grain, merges the PDP request items +// into the copy (replace-by-sku semantic so the PDP qty wins for any +// product already in the cart), and runs the canonical +// promotions.PromotionService.EvaluateAndApply pipeline the live cart +// processor uses — so the preview sees the cart's vouchers, customer +// segment, customer-lifetime-value, and computed total, not just the +// line list, and the coupon+promotion-overlap edge case is handled +// the same way on both sides. Returns the same EvaluateResponse shape +// as the stateless endpoint. +// +// Why this exists: the storefront's "I kundkorgen" preview needs the +// engine's verdict for "what would the cart look like if I add this +// product". The cart is the source of truth (it has vouchers, customer +// segment, customer-lifetime-value, total, etc. that the engine needs), +// and a serialised copy sent from the browser can race the live cart. +// Letting the backend read the cookie + fetch the grain means there is +// exactly one source of truth and no race. The grain is deep-copied via +// JSON marshal/unmarshal so the preview never mutates the live state — +// every read is purely a what-if computation. +// +// Missing/invalid cookie (new visitor, preview-only tab): the handler +// treats the request as a pure stateless evaluation of the request +// items alone — the "what would I get just for the product I'm +// viewing" answer is still useful on its own. +// +// Cart fetch failure (the grain is gone, the pool is degraded, etc.): +// the handler logs and falls through to the same stateless evaluation +// of the request items. The preview degrades gracefully rather than +// erroring the whole block. +// +// "Replace" merge semantic: if the same sku is in both the cart and +// the request items, the request item wins (with its qty). This +// matches the PDP's intent ("I am previewing adding this product +// at this qty") and avoids double-counting the same product. +func newPromotionEvaluateWithCartHandler(store *promotions.Store, svc *promotions.PromotionService, server *PoolServer) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req promotions.EvaluateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + http.Error(w, "invalid JSON body: "+err.Error(), http.StatusBadRequest) + return + } + + var resp promotions.EvaluateResponse + if g := cloneCartForPreview(server, r); g != nil { + mergeRequestItemsIntoGrain(g, req.Items, svc.DefaultTaxProvider) + // One call to the canonical pipeline — same code path + // the live cart processor's reg.RegisterProcessor in + // 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. + svc.EvaluateAndApply(store.Snapshot(), g, previewContextOptions(req)...) + resp = promotions.EvaluateResponse{ + TotalPrice: g.TotalPrice, + TotalDiscount: g.TotalDiscount, + AppliedPromotions: g.AppliedPromotions, + Items: promotions.MapEvaluatedItems(g), + } + } else { + // No live cart (missing/invalid cookie, fetch failed, or + // deep-copy failed). Fall back to the pure stateless + // evaluation of the request items alone — the user still + // gets a useful preview of "what would the engine do for + // just the items I'm about to add". + resp = svc.Evaluate(store.Snapshot(), req) + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + +// cloneCartForPreview reads the cartid cookie, fetches the live grain +// (cross-pod via GetAnywhere), and returns a deep copy safe to mutate +// for the what-if preview. Returns nil (and logs) when the cookie is +// missing/invalid OR the grain fetch fails OR the deep-copy fails — +// the caller falls back to a stateless evaluation of the request items +// alone. +// +// The deep copy is via JSON marshal/unmarshal, which is intentional: it +// produces a fully independent *cart.CartGrain (re-allocating every +// pointer — Items, Vouchers, ItemMeta, Price, etc.) so the live grain +// is never touched, even if the live grain holds non-serializable +// unexported state like an in-memory event-log channel. Unexported +// fields are silently dropped by the json package, which is exactly +// what we want here — the engine only reads the grain's exported +// state (Items, Vouchers, TotalPrice, etc.), and the live grain's +// in-memory event log / channels must not be cloned or shared. +func cloneCartForPreview(server *PoolServer, r *http.Request) *cart.CartGrain { + cookie, err := r.Cookie("cartid") + if err != nil || cookie.Value == "" { + return nil + } + id, ok := cart.ParseCartId(cookie.Value) + if !ok { + log.Printf("promotions/evaluate-with-cart: invalid cartid cookie, falling back to stateless") + return nil + } + live, err := server.GetAnywhere(r.Context(), id) + if err != nil { + log.Printf("promotions/evaluate-with-cart: cart fetch failed for %s, falling back to stateless: %v", id, err) + return nil + } + if live == nil { + return nil + } + buf, err := json.Marshal(live) + if err != nil { + log.Printf("promotions/evaluate-with-cart: cart marshal failed for %s, falling back to stateless: %v", id, err) + return nil + } + var clone *cart.CartGrain + if err := json.Unmarshal(buf, &clone); err != nil { + log.Printf("promotions/evaluate-with-cart: cart unmarshal failed for %s, falling back to stateless: %v", id, err) + return nil + } + return clone +} + +// mergeRequestItemsIntoGrain merges reqItems into the grain's items with +// "replace" semantic: any cart line whose sku also appears in the +// request is dropped (the request item wins, so the PDP qty replaces +// the cart qty for that product). Remaining cart lines are kept +// verbatim, then the converted request items are appended. The grain +// is mutated in place — caller is expected to be holding a deep copy +// from cloneCartForPreview. The EvalItem→CartItem conversion uses the +// shared promotions.EvalItem.ToCartItem helper so the math (VAT rate +// rounding, qty default, ItemMeta, Price) matches the synthetic-cart +// conversion in the stateless endpoint exactly. +func mergeRequestItemsIntoGrain(g *cart.CartGrain, reqItems []promotions.EvalItem, tp cart.TaxProvider) { + requestSkus := make(map[string]struct{}, len(reqItems)) + for _, it := range reqItems { + if it.Sku != "" { + requestSkus[it.Sku] = struct{}{} + } + } + merged := make([]*cart.CartItem, 0, len(g.Items)+len(reqItems)) + for _, it := range g.Items { + if _, taken := requestSkus[it.Sku]; taken { + continue + } + merged = append(merged, it) + } + for _, it := range reqItems { + merged = append(merged, it.ToCartItem(tp)) + } + g.Items = merged +} + +// previewContextOptions builds the ContextOption list for the +// cart-aware preview. The shared EvaluateRequest.ContextOptions +// already includes WithNow when req.Now is set, so we only prepend a +// default WithNow(time.Now()) when the request didn't specify one — +// avoiding a duplicate WithNow in the opts list (the engine's +// NewContextFromCart applies options in order, but the last-one-wins +// rule for duplicates is not a contract we want to rely on). The +// other context options (CustomerSegment, CLV, OrderCount) come from +// req.ContextOptions unchanged. +func previewContextOptions(req promotions.EvaluateRequest) []promotions.ContextOption { + opts := req.ContextOptions() + if req.Now == nil { + opts = append([]promotions.ContextOption{promotions.WithNow(time.Now())}, opts...) + } + return opts +} diff --git a/pkg/promotions/apply.go b/pkg/promotions/apply.go index 7245096..8ce0e06 100644 --- a/pkg/promotions/apply.go +++ b/pkg/promotions/apply.go @@ -285,24 +285,53 @@ func applyTieredDiscount(g *cart.CartGrain, a Action) *cart.Price { return applyPercentageDiscount(g, pct) } -// applyPercentageDiscount removes pct% of the current cart total, preserving the -// VAT-rate breakdown proportionally, recording it in TotalDiscount and returning -// the discount applied (nil if nothing was taken off). +// applyPercentageDiscount removes pct% of each line's row total, sums +// the per-line amounts into the cart's TotalDiscount, and returns the +// total (nil if nothing was taken off). Per-line first then sum +// (instead of total first then split) avoids penny-level rounding +// gaps where the per-line amounts no longer sum to the reported +// total. The per-line amount is also added to each line's Discount +// field on top of the OrgPrice-based discount that UpdateTotals +// already set, so consumers (the /promotions/evaluate-with-cart +// preview, the checkout payload mapper) can see the effective unit +// price per item. func applyPercentageDiscount(g *cart.CartGrain, pct float64) *cart.Price { if pct <= 0 || g.TotalPrice.IncVat <= 0 { return nil } - discount := scalePrice(g.TotalPrice, pct) - if discount.IncVat <= 0 { + totalDiscount := cart.NewPrice() + for _, item := range g.Items { + if item == nil || item.TotalPrice.IncVat <= 0 { + continue + } + itemDisc := scalePrice(&item.TotalPrice, pct) + if itemDisc.IncVat <= 0 { + continue + } + // Clamp to this line's row total so a single item can't go + // negative when its per-line discount (post-rounding) ends + // up larger than its row. itemDisc is *cart.Price; assign + // through the pointer to overwrite the rounded value with + // the raw row total when needed. + if itemDisc.IncVat > item.TotalPrice.IncVat { + *itemDisc = item.TotalPrice + } + if item.Discount == nil { + item.Discount = cart.NewPrice() + } + item.Discount.Add(*itemDisc) + totalDiscount.Add(*itemDisc) + } + if totalDiscount.IncVat <= 0 { return nil } - // Never discount below zero. - if discount.IncVat > g.TotalPrice.IncVat { - discount = g.TotalPrice + // Safety net for any aggregate rounding overshoot. + if totalDiscount.IncVat > g.TotalPrice.IncVat { + totalDiscount = g.TotalPrice } - g.TotalDiscount.Add(*discount) - g.TotalPrice.Subtract(*discount) - return discount + g.TotalDiscount.Add(*totalDiscount) + g.TotalPrice.Subtract(*totalDiscount) + return totalDiscount } // selectTier returns the discount percent for the band the total falls into. @@ -437,6 +466,17 @@ func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*c unitDiscount := scalePrice(&units[i].item.Price, discount) totalDiscount.Add(*unitDiscount) affectedMap[units[i].item.Id] = true + // Per-line distribution: add the per-unit discount to the + // item's Discount field on top of the OrgPrice-based + // discount that UpdateTotals already set. If the same item + // is the free unit multiple times (e.g. qty 3 with + // buy-2-get-1 and a second buy-2-get-1), Add is called + // per free unit so the cumulative amount lands on the + // right line. + if units[i].item.Discount == nil { + units[i].item.Discount = cart.NewPrice() + } + units[i].item.Discount.Add(*unitDiscount) } if totalDiscount.IncVat <= 0 { @@ -590,6 +630,15 @@ func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) if bundleDiscount != nil && bundleDiscount.IncVat > 0 { totalDiscount.Add(*bundleDiscount) + // Per-line distribution: split the bundle discount + // across the entries in `bundle` proportionally to + // each entry's per-unit price (same item can appear + // multiple times if it's in the bundle multiple + // times). distributeBundleDiscount walks the entries + // and adds the share to each item's Discount field, + // with the last entry absorbing any rounding remainder + // so the per-line sum matches bundleDiscount exactly. + distributeBundleDiscount(bundle, bundleDiscount) for _, item := range bundle { affectedMap[item.Id] = true } @@ -623,6 +672,68 @@ func (bundleDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a // Helpers // ---------------------------- +// distributeBundleDiscount splits bundleDiscount across the entries +// in bundleItems proportionally to each entry's per-unit price, and +// adds the share to each item's Discount field. The same item may +// appear multiple times in bundleItems (when it's in the bundle +// multiple times); each entry gets its own share, and Add on the +// item's Discount field is cumulative. The last entry absorbs any +// rounding remainder on IncVat (and on each VAT rate) so the +// per-line sum matches bundleDiscount exactly. No-op when +// bundleDiscount is nil/zero or bundleItems is empty. +func distributeBundleDiscount(bundleItems []*cart.CartItem, bundleDiscount *cart.Price) { + if bundleDiscount == nil || bundleDiscount.IncVat <= 0 || len(bundleItems) == 0 { + return + } + total := int64(0) + prices := make([]int64, len(bundleItems)) + for i, item := range bundleItems { + if item == nil { + continue + } + prices[i] = item.Price.IncVat.Int64() + total += prices[i] + } + if total <= 0 { + return + } + var allocated int64 + for i, item := range bundleItems { + if item == nil { + continue + } + var shareIncVat int64 + if i == len(bundleItems)-1 { + shareIncVat = bundleDiscount.IncVat.Int64() - allocated + } else { + shareIncVat = bundleDiscount.IncVat.Int64() * prices[i] / total + } + if shareIncVat <= 0 { + continue + } + sharePrice := &cart.Price{ + IncVat: money.Cents(shareIncVat), + VatRates: make(map[int]money.Cents, len(bundleDiscount.VatRates)), + } + // Pro-rate each VAT rate by the same share fraction so the + // VAT breakdown on the per-line discount matches the + // proportional split. Guard against the (degenerate) case + // where bundleDiscount.IncVat is somehow 0 here — the + // outer guard at the top should prevent this, but be + // safe. + if bundleDiscount.IncVat.Int64() > 0 { + for rate, amount := range bundleDiscount.VatRates { + sharePrice.VatRates[rate] = money.Cents(amount.Int64() * shareIncVat / bundleDiscount.IncVat.Int64()) + } + } + if item.Discount == nil { + item.Discount = cart.NewPrice() + } + item.Discount.Add(*sharePrice) + allocated += shareIncVat + } +} + func eligibleItems(rule PromotionRule, g *cart.CartGrain) []*cart.CartItem { var categories []string var productIDs []string diff --git a/pkg/promotions/evaluate.go b/pkg/promotions/evaluate.go index a975226..e5bcae4 100644 --- a/pkg/promotions/evaluate.go +++ b/pkg/promotions/evaluate.go @@ -2,6 +2,7 @@ package promotions import ( "math" + "strings" "time" "git.k6n.net/mats/go-cart-actor/pkg/cart" @@ -42,29 +43,201 @@ type EvaluateRequest struct { } // EvaluateResponse is the outcome of a stateless evaluation: the totals after -// promotions and the per-promotion breakdown (applied discounts plus pending -// "spend X more for ..." nudges). +// 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. 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 } // Evaluate runs rules against a synthetic cart built from req and returns the // resulting totals and effects without touching any real cart state. func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest) EvaluateResponse { g := req.toCart(s.DefaultTaxProvider) - g.UpdateTotals() - ctx := NewContextFromCart(g, req.contextOptions()...) - results, _ := s.EvaluateAll(rules, ctx) - s.ApplyResults(g, results, ctx) + s.EvaluateAndApply(rules, g, req.ContextOptions()...) return EvaluateResponse{ TotalPrice: g.TotalPrice, TotalDiscount: g.TotalDiscount, AppliedPromotions: g.AppliedPromotions, + Items: MapEvaluatedItems(g), } } +// EvaluateAndApply runs the canonical promotion-evaluation pipeline on +// an existing grain. It is the single entry point every caller that +// needs "what would the engine do for this cart" MUST go through — +// the live cart processor in cmd/cart/main.go's reg.RegisterProcessor +// and the /promotions/evaluate-with-cart preview handler in +// cmd/cart/promotions_evaluate.go both call this method, so the +// preview and post-add behavior stay in lockstep (especially in the +// coupon+promotion-overlap edge case where a promotion rule with a +// coupon_code condition would otherwise stomp a voucher the customer +// has applied). If a new caller needs the same pipeline, add it here +// — do not duplicate the steps. +// +// The pipeline is: +// +//  1. Clear Vouchers.BypassedByPromotions on every voucher so the +//     re-eval trigger (step 5) works correctly on each invocation — +//     otherwise a coupon that was already bypassed on a previous +//     pass would not be re-marked and step 5 would not fire. +//  2. UpdateTotals on the grain (recomputes TotalPrice, TotalDiscount +//     from the current items + vouchers). +//  3. Build a context from the grain (via NewContextFromCart with the +//     supplied opts; defaults to WithNow(time.Now()) only when no +//     opts are passed at all — callers that pass non-empty opts +//     without WithNow get the engine's built-in default). +//  4. EvaluateAll against the rules. +//  5. markCouponsBypassed scans the results for applicable rules +//     with a coupon_code condition matching a voucher in the grain; +//     matching vouchers get BypassedByPromotions set. If any voucher +//     was newly marked, re-run steps 2-4 (the live cart has done this +//     since the bypass pass; the preview must match exactly). +//  6. ApplyResults records every effect (applied discounts + pending +//     "spend X more for ..." nudges) on the grain. +// +// The grain is mutated in place; for a read-only what-if, deep-copy +// the grain first (see cmd/cart/promotions_evaluate.go's +// cloneCartForPreview). +func (s *PromotionService) EvaluateAndApply(rules []PromotionRule, g *cart.CartGrain, opts ...ContextOption) { + for _, v := range g.Vouchers { + if v != nil { + v.BypassedByPromotions = false + } + } + if len(opts) == 0 { + opts = []ContextOption{WithNow(time.Now())} + } + g.UpdateTotals() + ctx := NewContextFromCart(g, opts...) + results, _ := s.EvaluateAll(rules, ctx) + if markCouponsBypassed(g, results) { + g.UpdateTotals() + ctx = NewContextFromCart(g, opts...) + results, _ = s.EvaluateAll(rules, ctx) + } + s.ApplyResults(g, results, ctx) +} + +// markCouponsBypassed scans the evaluation results for any applicable +// rule with a coupon_code condition whose value matches a voucher in +// the grain; marks matching vouchers' BypassedByPromotions flag and +// returns true if any were newly marked. Called by EvaluateAndApply +// between the first and second EvaluateAll pass — see that method's +// doc for why the re-eval is needed. +func markCouponsBypassed(g *cart.CartGrain, results []EvaluationResult) bool { + hasBypassed := false + for _, res := range results { + if !res.Applicable { + continue + } + WalkConditions(res.Rule.Conditions, func(c Condition) bool { + bc, ok := c.(BaseCondition) + if !ok || bc.Type != CondCouponCode { + return true + } + var codes []string + if s, ok := bc.Value.AsString(); ok { + codes = append(codes, strings.ToLower(s)) + } else if arr, ok := bc.Value.AsStringSlice(); ok { + for _, s := range arr { + codes = append(codes, strings.ToLower(s)) + } + } + for _, code := range codes { + for _, v := range g.Vouchers { + if v != nil && strings.ToLower(v.Code) == code && !v.BypassedByPromotions { + v.BypassedByPromotions = true + hasBypassed = true + } + } + } + return true + }) + } + return hasBypassed +} + // toCart materialises the request into a throwaway CartGrain. // tp is an optional TaxProvider for the default VAT rate; when nil, falls back to 25 %. func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain { @@ -74,25 +247,7 @@ func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain { } g := cart.NewCartGrain(0, now) for _, it := range req.Items { - qty := it.Quantity - if qty == 0 { - qty = 1 - } - // it.VatRate is the request rate in raw percent (external input); convert - // to the platform basis-point scale at this boundary. - vat := int(math.Round(float64(it.VatRate) * 100)) - if vat == 0 { - vat = defaultVatRate(tp) - } - item := &cart.CartItem{ - Sku: it.Sku, - Quantity: qty, - Price: *cart.NewPriceFromIncVat(it.PriceIncVat, vat), - } - if it.Category != "" { - item.Meta = &cart.ItemMeta{Category: it.Category} - } - g.Items = append(g.Items, item) + g.Items = append(g.Items, it.ToCartItem(tp)) } if len(g.Items) == 0 && req.CartTotalIncVat > 0 { vat := defaultVatRate(tp) @@ -105,6 +260,41 @@ func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain { return g } +// ToCartItem converts this EvalItem into a CartItem. tp is an optional +// TaxProvider for the default VAT rate; when nil, falls back to 25 %. +// This is the canonical EvalItem→CartItem conversion — shared by the +// stateless evaluator (req.toCart above) and any caller that needs to +// materialise an EvalItem into a real cart grain (e.g. the +// /promotions/evaluate-with-cart preview handler, which merges PDP +// request items into a deep-copied live grain so the engine sees the +// user's "what-if" line list on top of the cart's actual state). Keep +// the math identical to the inline conversion that used to live in +// req.toCart so the two paths can never drift. +// +// VatRate is the request rate in raw percent (external input); convert +// to the platform basis-point scale (× 100, rounded) at this boundary. +// Missing qty defaults to 1; missing VAT rate falls back to +// defaultVatRate(tp). Optional Category lands in ItemMeta. +func (it EvalItem) ToCartItem(tp cart.TaxProvider) *cart.CartItem { + qty := it.Quantity + if qty == 0 { + qty = 1 + } + vat := int(math.Round(float64(it.VatRate) * 100)) + if vat == 0 { + vat = defaultVatRate(tp) + } + out := &cart.CartItem{ + Sku: it.Sku, + Quantity: qty, + Price: *cart.NewPriceFromIncVat(it.PriceIncVat, vat), + } + if it.Category != "" { + out.Meta = &cart.ItemMeta{Category: it.Category} + } + return out +} + // defaultVatRate returns the default VAT rate in basis points (2500 = 25%) from // the provider, or 2500 if none is configured. func defaultVatRate(tp cart.TaxProvider) int { @@ -114,8 +304,14 @@ func defaultVatRate(tp cart.TaxProvider) int { return tp.DefaultTaxRate("") } -// contextOptions maps the optional customer/time fields onto context options. -func (req EvaluateRequest) contextOptions() []ContextOption { +// ContextOptions maps the optional customer/time fields onto context +// options. WithNow is only added when req.Now is explicitly set — +// callers that need a default "now" should append WithNow(time.Now()) +// themselves (or pass no opts and let EvaluateAndApply default it). +// Exported so callers that build a context from a request (e.g. the +// /promotions/evaluate-with-cart preview handler) can use the same +// mapping the stateless Evaluate path uses, keeping the two in sync. +func (req EvaluateRequest) ContextOptions() []ContextOption { var opts []ContextOption if req.Now != nil { opts = append(opts, WithNow(*req.Now))