diff --git a/cmd/cart/main.go b/cmd/cart/main.go index 32acba0..a314534 100644 --- a/cmd/cart/main.go +++ b/cmd/cart/main.go @@ -149,8 +149,11 @@ func main() { // any matched actions (e.g. the Volymrabatt volume discount), which adjust // TotalPrice/TotalDiscount on top of line items and vouchers. promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now())) - _, actions := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx) - promotions.ApplyActions(g, actions) + 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) g.Version++ return nil }), @@ -275,6 +278,11 @@ func main() { mux.Handle("/mcp", promotionMCP.Handler()) mux.Handle("/mcp/", promotionMCP.Handler()) + // Stateless promotion evaluation: POST a (possibly partial) eval context and + // get back the resulting totals plus the applied/pending effect breakdown, + // without creating or mutating a real cart. + mux.HandleFunc("POST /promotions/evaluate", newPromotionEvaluateHandler(promotionStore, promotionService)) + // 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 new file mode 100644 index 0000000..daa50e1 --- /dev/null +++ b/cmd/cart/promotions_evaluate.go @@ -0,0 +1,29 @@ +package main + +import ( + "encoding/json" + "errors" + "io" + "net/http" + + "git.k6n.net/mats/go-cart-actor/pkg/promotions" +) + +// newPromotionEvaluateHandler serves POST /promotions/evaluate: it takes a +// (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). +func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.PromotionService) 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 + } + 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) + } + } +} diff --git a/pkg/cart/cart-grain.go b/pkg/cart/cart-grain.go index 838360f..bd43b81 100644 --- a/pkg/cart/cart-grain.go +++ b/pkg/cart/cart-grain.go @@ -28,16 +28,16 @@ type ItemMeta struct { } type CartItem struct { - Id uint32 `json:"id"` - ItemId uint32 `json:"itemId,omitempty"` - ParentId *uint32 `json:"parentId,omitempty"` - Sku string `json:"sku"` - Price Price `json:"price"` - TotalPrice Price `json:"totalPrice"` - SellerId string `json:"sellerId,omitempty"` - OrgPrice *Price `json:"orgPrice,omitempty"` - Cgm string `json:"cgm,omitempty"` - Tax int + Id uint32 `json:"id"` + ItemId uint32 `json:"itemId,omitempty"` + ParentId *uint32 `json:"parentId,omitempty"` + Sku string `json:"sku"` + Price Price `json:"price"` + TotalPrice Price `json:"totalPrice"` + SellerId string `json:"sellerId,omitempty"` + OrgPrice *Price `json:"orgPrice,omitempty"` + Cgm string `json:"cgm,omitempty"` + Tax int `json:"tax"` Stock uint16 `json:"stock"` Quantity uint16 `json:"qty"` Discount *Price `json:"discount,omitempty"` @@ -97,6 +97,31 @@ type Marking struct { Text string `json:"text"` } +// AppliedPromotion records a single promotion action's effect on the cart. It is +// rebuilt every time promotions are (re-)applied so the API result exposes a +// per-promotion breakdown rather than only the aggregate TotalDiscount. +// +// An entry is one of two kinds, both living in the same list: +// - applied (Pending=false): the action took effect now. Discount holds the +// amount taken off (nil for non-monetary effects such as free_shipping). +// - pending (Pending=true): the action has NOT qualified yet but is within +// reach. Progress carries effect-specific data describing what's left to +// unlock it — e.g. {"remaining": 120000, "threshold": 500000} for a +// cart-total nudge. Keeping it an open map lets new effect types surface +// their own progress fields without changing this struct. This powers +// "spend X more for ..." nudges generically. +type AppliedPromotion struct { + PromotionId string `json:"promotionId,omitempty"` + Name string `json:"name,omitempty"` + ActionId string `json:"actionId,omitempty"` + Type string `json:"type,omitempty"` + Label string `json:"label,omitempty"` + Discount *Price `json:"discount,omitempty"` + + Pending bool `json:"pending,omitempty"` + Progress map[string]interface{} `json:"progress,omitempty"` +} + type CartGrain struct { mu sync.RWMutex lastItemId uint32 @@ -117,6 +142,7 @@ type CartGrain struct { OrderReference string `json:"orderReference,omitempty"` Vouchers []*Voucher `json:"vouchers,omitempty"` + AppliedPromotions []AppliedPromotion `json:"appliedPromotions,omitempty"` Notifications []CartNotification `json:"cartNotification,omitempty"` SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"` @@ -245,6 +271,7 @@ func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) { func (c *CartGrain) UpdateTotals() { c.TotalPrice = NewPrice() c.TotalDiscount = NewPrice() + c.AppliedPromotions = nil for _, item := range c.Items { rowTotal := MultiplyPrice(item.Price, int64(item.Quantity)) diff --git a/pkg/promotions/apply.go b/pkg/promotions/apply.go index 062298a..d7d3834 100644 --- a/pkg/promotions/apply.go +++ b/pkg/promotions/apply.go @@ -7,15 +7,249 @@ import ( ) // ---------------------------- -// Action application +// Effects: apply + progress // ---------------------------- // -// The evaluation engine (eval.go) decides *which* actions apply to a cart but -// deliberately does not mutate the cart. Applying an action — turning a -// "14% tiered discount" into actual öre off the total — happens here, because -// it needs to touch cart.Price math. main.go calls ApplyActions after -// UpdateTotals so the base totals (line items + vouchers) are settled before -// promotions reduce them. +// The evaluation engine (eval.go) decides *which* rules apply to a cart but +// deliberately does not mutate it. Turning a matched action into actual öre off +// the total — and describing how close an unmatched (or partially matched) +// action is to its next reward — happens here, behind the Effect interface. +// +// Every action type is one Effect. ApplyResults walks the rules and, for each +// action, asks its Effect to (a) apply itself if the rule qualifies and (b) +// report progress. Both contributions land in cart.AppliedPromotion, so the +// result exposes "you saved X" and "spend Y more for ..." through one list, for +// any effect type — including progress on an already-applied effect. + +// Effect is the behaviour for a single promotion action type. +type Effect interface { + // Type is the action type this effect handles. + Type() ActionType + // Apply mutates the cart for a qualifying action and returns the discount it + // took (nil for non-monetary effects such as free shipping) plus whether + // anything took effect worth recording. + Apply(g *cart.CartGrain, a Action) (discount *cart.Price, applied bool) + // Progress reports how far the cart is from (further) unlocking this action, + // as an open key/value payload. qualified says whether the owning rule + // currently applies, letting the effect distinguish "remaining to unlock" + // (pending) from "remaining to the next tier" (already applied). ok=false + // means there is nothing worth surfacing. + Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) +} + +// defaultEffects returns the built-in action handlers. +func defaultEffects() map[ActionType]Effect { + return map[ActionType]Effect{ + ActionPercentageDiscount: percentageDiscountEffect{}, + ActionTieredDiscount: tieredDiscountEffect{}, + ActionFreeShipping: freeShippingEffect{}, + } +} + +// ApplyResults applies the qualifying rules' actions to the cart in priority +// order and records every effect — applied or pending — in g.AppliedPromotions. +// An applied entry carries its Discount; a pending entry carries Pending=true. +// Either kind may carry a Progress payload. Must be called after g.UpdateTotals(). +// +// ctx is the same context used for EvaluateAll; progress is computed against the +// pre-promotion totals it captured. +func (s *PromotionService) ApplyResults(g *cart.CartGrain, results []EvaluationResult, ctx *PromotionEvalContext) { + if g == nil || g.TotalPrice == nil { + return + } + for _, res := range orderedByPriority(results) { + for _, a := range res.Rule.Actions { + eff, ok := s.effects[a.Type] + if !ok { + continue + } + entry := cart.AppliedPromotion{ + PromotionId: res.Rule.ID, + Name: res.Rule.Name, + ActionId: a.ID, + Type: string(a.Type), + Label: actionLabel(a), + } + recorded := false + if res.Applicable { + if discount, applied := eff.Apply(g, a); applied { + entry.Discount = discount + recorded = true + } + } + if progress, ok := eff.Progress(s, res.Rule, a, ctx, res.Applicable); ok { + entry.Progress = progress + entry.Pending = !res.Applicable + recorded = true + } + if recorded { + g.AppliedPromotions = append(g.AppliedPromotions, entry) + } + } + } +} + +func actionLabel(a Action) string { + if a.Label != nil { + return *a.Label + } + return "" +} + +// ---------------------------- +// Built-in effects +// ---------------------------- + +// percentageDiscountEffect: a flat percentage off the whole cart. It can only be +// on or off, so its only progress is "spend X more to reach the threshold". +type percentageDiscountEffect struct{} + +func (percentageDiscountEffect) Type() ActionType { return ActionPercentageDiscount } + +func (percentageDiscountEffect) Apply(g *cart.CartGrain, a Action) (*cart.Price, bool) { + d := applyPercentageDiscount(g, percentFromValue(a.Value)) + return d, d != nil +} + +func (percentageDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) { + if qualified { + return nil, false + } + return s.cartTotalProgress(rule, ctx) +} + +// freeShippingEffect: no cart-side amount (the fee lives in checkout). Applying +// it just marks qualification; below the threshold it reports the remaining gap. +type freeShippingEffect struct{} + +func (freeShippingEffect) Type() ActionType { return ActionFreeShipping } + +func (freeShippingEffect) Apply(_ *cart.CartGrain, _ Action) (*cart.Price, bool) { + return nil, true +} + +func (freeShippingEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) { + if qualified { + return nil, false + } + return s.cartTotalProgress(rule, ctx) +} + +// tieredDiscountEffect: a volume discount whose percentage grows with the cart +// total. Its progress points at the *next* tier, so it reports useful "spend X +// more for Y%" data both before the first tier (pending) and between tiers +// (already applied) — the canonical progress-on-applied-effect case. +type tieredDiscountEffect struct{} + +func (tieredDiscountEffect) Type() ActionType { return ActionTieredDiscount } + +func (tieredDiscountEffect) Apply(g *cart.CartGrain, a Action) (*cart.Price, bool) { + d := applyTieredDiscount(g, a) + return d, d != nil +} + +func (tieredDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) { + tiers := parseTiers(a.Config) + if len(tiers) == 0 { + return nil, false + } + total := ctx.CartTotalIncVat + currentPct, _ := selectTier(tiers, total) // 0 when below the first tier + + // Find the nearest tier whose floor is still above the current total. + nextMin := int64(0) + nextPct := 0.0 + found := false + for _, t := range tiers { + if t.MinTotal > total && (!found || t.MinTotal < nextMin) { + nextMin, nextPct, found = t.MinTotal, t.Percent, true + } + } + if !found { + return nil, false // already at or above the top tier + } + // If not yet qualified, only nudge when the total is the sole blocker. + if !qualified && !s.withinReach(rule, ctx, nextMin) { + return nil, false + } + return map[string]any{ + "remaining": nextMin - total, + "threshold": nextMin, + "currentPercent": currentPct, + "nextPercent": nextPct, + }, true +} + +// ---------------------------- +// Shared progress / threshold helpers +// ---------------------------- + +// cartTotalProgress reports the gap to a rule's cart-total threshold, but only +// when that threshold is the *sole* reason the rule isn't applying — so we never +// tell a customer to spend more when a different condition (segment, date, ...) +// is the real blocker. +func (s *PromotionService) cartTotalProgress(rule PromotionRule, ctx *PromotionEvalContext) (map[string]any, bool) { + threshold, ok := cartTotalThreshold(rule) + if !ok { + return nil, false + } + remaining := threshold - ctx.CartTotalIncVat + if remaining <= 0 || !s.withinReach(rule, ctx, threshold) { + return nil, false + } + return map[string]any{"remaining": remaining, "threshold": threshold}, true +} + +// withinReach reports whether the rule would apply if the cart total were +// atTotal — i.e. whether the cart-total threshold is the only thing holding it +// back. Cheap: it re-runs evaluation against a copy of the context. +func (s *PromotionService) withinReach(rule PromotionRule, ctx *PromotionEvalContext, atTotal int64) bool { + probe := *ctx + probe.CartTotalIncVat = atTotal + return s.EvaluateRule(rule, &probe).Applicable +} + +// cartTotalThreshold finds the lowest cart-total floor a rule requires (a +// cart_total >= / > condition), walking nested groups. Returns false when the +// rule has no such condition. +func cartTotalThreshold(rule PromotionRule) (int64, bool) { + var threshold int64 + found := false + var walk func(conds Conditions) + walk = func(conds Conditions) { + for _, c := range conds { + switch v := c.(type) { + case ConditionGroup: + walk(v.Conditions) + case BaseCondition: + if v.Type != CondCartTotal { + continue + } + op := normalizeOperator(string(v.Operator)) + if op != string(OpGreaterOrEqual) && op != string(OpGreaterThan) { + continue + } + f, ok := v.Value.AsFloat64() + if !ok { + continue + } + t := int64(f) + if op == string(OpGreaterThan) { + t++ // strictly greater: need at least one öre past the floor + } + if !found || t < threshold { + threshold, found = t, true + } + } + } + } + walk(rule.Conditions) + return threshold, found +} + +// ---------------------------- +// Discount math +// ---------------------------- // DiscountTier describes one band of a tiered (volume) discount. Bounds are the // cart total *including VAT*, in öre (the cart's native unit), matching @@ -31,44 +265,28 @@ type DiscountTier struct { Percent float64 } -// ApplyActions applies the matched promotion actions to the cart grain, -// reducing TotalPrice and growing TotalDiscount. It is safe to call with a nil -// or empty action slice. Must be called after g.UpdateTotals(). -func ApplyActions(g *cart.CartGrain, actions []Action) { - if g == nil || g.TotalPrice == nil { - return - } - for _, a := range actions { - switch a.Type { - case ActionTieredDiscount: - applyTieredDiscount(g, a) - case ActionPercentageDiscount: - applyPercentageDiscount(g, percentFromValue(a.Value)) - } - } -} - -func applyTieredDiscount(g *cart.CartGrain, a Action) { +func applyTieredDiscount(g *cart.CartGrain, a Action) *cart.Price { tiers := parseTiers(a.Config) if len(tiers) == 0 { - return + return nil } pct, ok := selectTier(tiers, g.TotalPrice.IncVat) if !ok { - return + return nil } - applyPercentageDiscount(g, pct) + return applyPercentageDiscount(g, pct) } // applyPercentageDiscount removes pct% of the current cart total, preserving the -// VAT-rate breakdown proportionally, and records it in TotalDiscount. -func applyPercentageDiscount(g *cart.CartGrain, pct float64) { +// VAT-rate breakdown proportionally, recording it in TotalDiscount and returning +// the discount applied (nil if nothing was taken off). +func applyPercentageDiscount(g *cart.CartGrain, pct float64) *cart.Price { if pct <= 0 || g.TotalPrice.IncVat <= 0 { - return + return nil } discount := scalePrice(g.TotalPrice, pct) if discount.IncVat <= 0 { - return + return nil } // Never discount below zero. if discount.IncVat > g.TotalPrice.IncVat { @@ -76,6 +294,7 @@ func applyPercentageDiscount(g *cart.CartGrain, pct float64) { } g.TotalDiscount.Add(*discount) g.TotalPrice.Subtract(*discount) + return discount } // selectTier returns the discount percent for the band the total falls into. diff --git a/pkg/promotions/apply_test.go b/pkg/promotions/apply_test.go index ddedeb4..d156c0f 100644 --- a/pkg/promotions/apply_test.go +++ b/pkg/promotions/apply_test.go @@ -48,7 +48,6 @@ func cartWithTotal(incVat int64) *cart.CartGrain { } func TestVolymrabattTiers(t *testing.T) { - svc := NewPromotionService(nil) rules := []PromotionRule{volymrabattRule()} cases := []struct { @@ -69,9 +68,11 @@ func TestVolymrabattTiers(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { + svc := NewPromotionService(nil) g := cartWithTotal(c.total) - _, actions := svc.EvaluateAll(rules, NewContextFromCart(g, WithNow(timeZero()))) - ApplyActions(g, actions) + results, _ := svc.EvaluateAll(rules, NewContextFromCart(g, WithNow(timeZero()))) + ctx := NewContextFromCart(g, WithNow(timeZero())) + svc.ApplyResults(g, results, ctx) if got := g.TotalDiscount.IncVat; got != c.wantDiscount { t.Errorf("discount = %d, want %d", got, c.wantDiscount) @@ -82,3 +83,66 @@ func TestVolymrabattTiers(t *testing.T) { }) } } + +// findApplied returns the first appliedPromotions entry for ruleID. +func findApplied(g *cart.CartGrain, ruleID string) (cart.AppliedPromotion, bool) { + for _, ap := range g.AppliedPromotions { + if ap.PromotionId == ruleID { + return ap, true + } + } + return cart.AppliedPromotion{}, false +} + +// TestApplyResultsRecordsEffects covers the applied/pending breakdown the result +// now exposes: an applied effect attributed to its rule, a pending nudge with +// progress when below the threshold, and progress-toward-the-next-tier on an +// already-applied tiered discount. +func TestApplyResultsRecordsEffects(t *testing.T) { + rules := []PromotionRule{volymrabattRule()} + apply := func(total int64) *cart.CartGrain { + svc := NewPromotionService(nil) + g := cartWithTotal(total) + results, _ := svc.EvaluateAll(rules, NewContextFromCart(g, WithNow(timeZero()))) + svc.ApplyResults(g, results, NewContextFromCart(g, WithNow(timeZero()))) + return g + } + + t.Run("applied effect records discount + next-tier progress", func(t *testing.T) { + g := apply(5000000) // qualifies at the 9% tier + ap, ok := findApplied(g, "volymrabatt") + if !ok { + t.Fatal("no volymrabatt entry recorded") + } + if ap.Pending { + t.Error("applied effect should not be pending") + } + if ap.Discount == nil || ap.Discount.IncVat != 450000 { + t.Errorf("discount = %v, want IncVat 450000", ap.Discount) + } + // 50 000 kr sits in the 9% tier; the next tier (14%) starts at 60 000 kr. + if got := ap.Progress["remaining"]; got != int64(1000000) { + t.Errorf("progress remaining = %v, want 1000000", got) + } + if got := ap.Progress["nextPercent"]; got != 14.0 { + t.Errorf("progress nextPercent = %v, want 14", got) + } + }) + + t.Run("pending nudge below threshold", func(t *testing.T) { + g := apply(1500000) // below the 20 000 kr entry tier + if g.TotalDiscount.IncVat != 0 { + t.Errorf("nothing should be applied, discount = %d", g.TotalDiscount.IncVat) + } + ap, ok := findApplied(g, "volymrabatt") + if !ok { + t.Fatal("expected a pending volymrabatt nudge") + } + if !ap.Pending || ap.Discount != nil { + t.Errorf("expected pending with no discount, got pending=%v discount=%v", ap.Pending, ap.Discount) + } + if got := ap.Progress["remaining"]; got != int64(500000) { + t.Errorf("progress remaining = %v, want 500000", got) + } + }) +} diff --git a/pkg/promotions/eval.go b/pkg/promotions/eval.go index 2c47d76..8b3b87b 100644 --- a/pkg/promotions/eval.go +++ b/pkg/promotions/eval.go @@ -100,14 +100,33 @@ func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEval // PromotionService evaluates PromotionRules against a PromotionEvalContext. type PromotionService struct { tracer Tracer + // effects is the per-action-type behaviour registry. Each Effect both applies + // its action to the cart (when the owning rule qualifies) and reports its own + // progress, so any effect type can surface "spend X more for ..." nudges — + // even one that has already applied (e.g. a tiered discount at 5% pointing at + // the next 9% tier). Register a new Effect to add a new action type. + effects map[ActionType]Effect } -// NewPromotionService constructs a PromotionService with an optional tracer. +// NewPromotionService constructs a PromotionService with an optional tracer and +// the built-in effect handlers. func NewPromotionService(t Tracer) *PromotionService { if t == nil { t = NoopTracer{} } - return &PromotionService{tracer: t} + return &PromotionService{ + tracer: t, + effects: defaultEffects(), + } +} + +// RegisterEffect adds (or overrides) the handler for an action type, so new +// applied/pending effects can be plugged in without touching the engine. +func (s *PromotionService) RegisterEffect(e Effect) { + if s.effects == nil { + s.effects = map[ActionType]Effect{} + } + s.effects[e.Type()] = e } // EvaluationResult holds the outcome of evaluating a single rule. diff --git a/pkg/promotions/evaluate.go b/pkg/promotions/evaluate.go new file mode 100644 index 0000000..059c7e8 --- /dev/null +++ b/pkg/promotions/evaluate.go @@ -0,0 +1,119 @@ +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() + 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. +func (req EvaluateRequest) toCart() *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 = 25 + } + 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 { + g.Items = append(g.Items, &cart.CartItem{ + Sku: "synthetic", + Quantity: 1, + Price: *cart.NewPriceFromIncVat(req.CartTotalIncVat, 25), + }) + } + return g +} + +// 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 +} diff --git a/pkg/promotions/evaluate_test.go b/pkg/promotions/evaluate_test.go new file mode 100644 index 0000000..be5324d --- /dev/null +++ b/pkg/promotions/evaluate_test.go @@ -0,0 +1,42 @@ +package promotions + +import "testing" + +// TestEvaluatePartialCart exercises the stateless evaluation path used by +// POST /promotions/evaluate: items drive the total, qualifying effects apply, +// and near-miss rules surface as pending nudges. +func TestEvaluatePartialCart(t *testing.T) { + svc := NewPromotionService(nil) + rules := []PromotionRule{volymrabattRule()} + now := timeZero() + + t.Run("items qualify for a tier", func(t *testing.T) { + // 2 × 25 000 kr (incl. VAT) = 50 000 kr -> 9% tier. + resp := svc.Evaluate(rules, EvaluateRequest{ + Now: &now, + Items: []EvalItem{ + {Sku: "A", Quantity: 2, PriceIncVat: 2500000}, + }, + }) + if resp.TotalDiscount.IncVat != 450000 { + t.Errorf("discount = %d, want 450000", resp.TotalDiscount.IncVat) + } + if len(resp.AppliedPromotions) != 1 || resp.AppliedPromotions[0].Pending { + t.Fatalf("want 1 applied (non-pending) effect, got %+v", resp.AppliedPromotions) + } + }) + + t.Run("total-only fallback surfaces a pending nudge", func(t *testing.T) { + // No items, just a total below the entry tier. + resp := svc.Evaluate(rules, EvaluateRequest{Now: &now, CartTotalIncVat: 1500000}) + if resp.TotalDiscount.IncVat != 0 { + t.Errorf("nothing should apply, discount = %d", resp.TotalDiscount.IncVat) + } + if len(resp.AppliedPromotions) != 1 || !resp.AppliedPromotions[0].Pending { + t.Fatalf("want 1 pending nudge, got %+v", resp.AppliedPromotions) + } + if got := resp.AppliedPromotions[0].Progress["remaining"]; got != int64(500000) { + t.Errorf("remaining = %v, want 500000", got) + } + }) +} diff --git a/pkg/promotions/mcp/tools.go b/pkg/promotions/mcp/tools.go index 86b76ef..87c957d 100644 --- a/pkg/promotions/mcp/tools.go +++ b/pkg/promotions/mcp/tools.go @@ -196,13 +196,14 @@ func (s *Server) buildTools() []tool { opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment)) } ctx := promotions.NewContextFromCart(g, opts...) - _, actions := s.eval.EvaluateAll(s.store.Snapshot(), ctx) - promotions.ApplyActions(g, actions) + results, actions := s.eval.EvaluateAll(s.store.Snapshot(), ctx) + s.eval.ApplyResults(g, results, ctx) return map[string]any{ - "subtotalIncVat": a.CartTotalIncVat, - "discountIncVat": g.TotalDiscount.IncVat, - "netIncVat": g.TotalPrice.IncVat, - "matchedActions": actions, + "subtotalIncVat": a.CartTotalIncVat, + "discountIncVat": g.TotalDiscount.IncVat, + "netIncVat": g.TotalPrice.IncVat, + "matchedActions": actions, + "appliedPromotions": g.AppliedPromotions, }, nil }, },