package promotions import ( "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 and the per-promotion breakdown (applied discounts plus pending // "spend X more for ..." nudges). type EvaluateResponse struct { TotalPrice *cart.Price `json:"totalPrice"` TotalDiscount *cart.Price `json:"totalDiscount"` AppliedPromotions []cart.AppliedPromotion `json:"appliedPromotions,omitempty"` } // 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) return EvaluateResponse{ TotalPrice: g.TotalPrice, TotalDiscount: g.TotalDiscount, AppliedPromotions: g.AppliedPromotions, } } // 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 { qty := it.Quantity if qty == 0 { qty = 1 } vat := it.VatRate 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) } 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 } // defaultVatRate returns the default VAT rate (as a float32 for NewPriceFromIncVat) // from the provider, or 25 if none is configured. func defaultVatRate(tp cart.TaxProvider) float32 { if tp == nil { return 25 } return float32(tp.DefaultTaxRate("")) } // contextOptions maps the optional customer/time fields onto context options. 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 }