package promotions import ( "math" "strings" "time" "git.k6n.net/mats/go-cart-actor/pkg/cart" ) // ---------------------------- // Stateless evaluation // ---------------------------- // // Evaluate runs the promotion engine against a caller-supplied context instead // of a real cart, so a storefront can ask "what would these items get?" without // 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. // 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 // does the best it can with whatever the caller provides. type EvalItem struct { Sku string `json:"sku,omitempty"` Category string `json:"category,omitempty"` Quantity uint16 `json:"qty,omitempty"` PriceIncVat int64 `json:"priceIncVat,omitempty"` VatRate float32 `json:"vatRate,omitempty"` } // EvaluateRequest is a stateless promotion-evaluation context. Every field is // optional. Items drive the cart total and the item-scoped conditions; when no // items are given, CartTotalIncVat synthesises a single line so total-only rules // (volume discounts, free-shipping thresholds) can still be previewed. type EvaluateRequest struct { Items []EvalItem `json:"items,omitempty"` CartTotalIncVat int64 `json:"cartTotalIncVat,omitempty"` CustomerSegment string `json:"customerSegment,omitempty"` CustomerLifetimeValue float64 `json:"customerLifetimeValue,omitempty"` OrderCount int `json:"orderCount,omitempty"` Now *time.Time `json:"now,omitempty"` } // EvaluateResponse is the outcome of a stateless evaluation: the totals after // 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) 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 { now := time.Now() if req.Now != nil { now = *req.Now } g := cart.NewCartGrain(0, now) for _, it := range req.Items { g.Items = append(g.Items, it.ToCartItem(tp)) } if len(g.Items) == 0 && req.CartTotalIncVat > 0 { vat := defaultVatRate(tp) g.Items = append(g.Items, &cart.CartItem{ Sku: "synthetic", Quantity: 1, Price: *cart.NewPriceFromIncVat(req.CartTotalIncVat, vat), }) } 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 { if tp == nil { return 2500 } return tp.DefaultTaxRate("") } // 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)) } if req.CustomerSegment != "" { opts = append(opts, WithCustomerSegment(req.CustomerSegment)) } if req.CustomerLifetimeValue != 0 { opts = append(opts, WithCustomerLifetimeValue(req.CustomerLifetimeValue)) } if req.OrderCount != 0 { opts = append(opts, WithOrderCount(req.OrderCount)) } return opts }