package promotions import ( "math" "git.k6n.net/mats/go-cart-actor/pkg/cart" ) // ---------------------------- // Effects: apply + progress // ---------------------------- // // 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 // PromotionEvalContext.CartTotalIncVat. MaxTotal == 0 means "no upper bound". // // A tier matches when MinTotal <= cartTotal && (MaxTotal == 0 || cartTotal <= MaxTotal). // Bounds are allowed to touch (e.g. 40000 ends one tier and begins the next); // when a total lands exactly on a shared boundary the higher tier wins, since // selectTier keeps the last match while scanning in order. type DiscountTier struct { MinTotal int64 MaxTotal int64 Percent float64 } func applyTieredDiscount(g *cart.CartGrain, a Action) *cart.Price { tiers := parseTiers(a.Config) if len(tiers) == 0 { return nil } pct, ok := selectTier(tiers, g.TotalPrice.IncVat) if !ok { return nil } 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). 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 { return nil } // Never discount below zero. if discount.IncVat > g.TotalPrice.IncVat { discount = g.TotalPrice } g.TotalDiscount.Add(*discount) g.TotalPrice.Subtract(*discount) return discount } // selectTier returns the discount percent for the band the total falls into. // Scans in declared order and keeps the last match so shared boundaries favour // the higher tier. func selectTier(tiers []DiscountTier, total int64) (float64, bool) { pct := 0.0 found := false for _, t := range tiers { if total >= t.MinTotal && (t.MaxTotal == 0 || total <= t.MaxTotal) { pct = t.Percent found = true } } return pct, found } // scalePrice returns pct% of p, rounding each amount and the VAT breakdown // independently so the parts stay consistent with the reported total. func scalePrice(p *cart.Price, pct float64) *cart.Price { out := cart.NewPrice() out.IncVat = scale(p.IncVat, pct) for rate, amount := range p.VatRates { out.VatRates[rate] = scale(amount, pct) } return out } func scale(v int64, pct float64) int64 { return int64(math.Round(float64(v) * pct / 100.0)) } // parseTiers reads the "tiers" array out of an Action.Config. Because configs // are decoded via encoding/json into map[string]interface{}, every element is a // map[string]interface{} and every number is a float64. Accepts both // minTotal/maxTotal/percent and the snake_case min_total/max_total/discount_percent. func parseTiers(config map[string]interface{}) []DiscountTier { raw, ok := config["tiers"].([]interface{}) if !ok { return nil } tiers := make([]DiscountTier, 0, len(raw)) for _, r := range raw { m, ok := r.(map[string]interface{}) if !ok { continue } tiers = append(tiers, DiscountTier{ MinTotal: int64(firstNum(m, "minTotal", "min_total")), MaxTotal: int64(firstNum(m, "maxTotal", "max_total")), Percent: firstNum(m, "percent", "discount_percent"), }) } return tiers } func percentFromValue(v interface{}) float64 { if f, ok := v.(float64); ok { return f } return 0 } func firstNum(m map[string]interface{}, keys ...string) float64 { for _, k := range keys { if f, ok := m[k].(float64); ok { return f } } return 0 }