package promotions import ( "math" "slices" "strings" "git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/platform/money" ) // ---------------------------- // 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, rule PromotionRule, 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{}, ActionBuyXGetY: buyXGetYEffect{}, ActionBundleDiscount: bundleDiscountEffect{}, } } // 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, res.Rule, 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, _ PromotionRule, 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, _ PromotionRule, _ 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, _ PromotionRule, 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 := money.Cents(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{ // Untyped JSON-presentation map: emit raw minor-unit int64 so consumers // (and JSON) see a plain number, not money.Cents. "remaining": (nextMin - total).Int64(), "threshold": nextMin.Int64(), "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.Int64(), "threshold": threshold.Int64()}, 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 money.Cents) 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) (money.Cents, 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 money.Cents(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 money.Cents MaxTotal money.Cents 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 money.Cents) (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 money.Cents, pct float64) money.Cents { return money.Cents(math.Round(float64(v.Int64()) * 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: money.Cents(int64(firstNum(m, "minTotal", "min_total"))), MaxTotal: money.Cents(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 } // ---------------------------- // buyXGetYEffect implementation // ---------------------------- type buyXGetYEffect struct{} func (buyXGetYEffect) Type() ActionType { return ActionBuyXGetY } type unitItem struct { item *cart.CartItem price money.Cents } func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*cart.Price, bool) { config := a.Config buy := int(firstNum(config, "buy")) if buy <= 0 { buy = 1 } get := int(firstNum(config, "get")) if get <= 0 { get = 1 } discount := firstNum(config, "discount") if discount <= 0 { discount = 100.0 // default free } eligible := eligibleItems(rule, g) var units []unitItem for _, item := range eligible { for k := uint16(0); k < item.Quantity; k++ { units = append(units, unitItem{ item: item, price: item.Price.IncVat, }) } } n := len(units) numFree := (n / (buy + get)) * get if numFree <= 0 { return nil, false } // Sort units by price ascending so we discount the cheapest ones slices.SortFunc(units, func(x, y unitItem) int { if x.price < y.price { return -1 } if x.price > y.price { return 1 } return 0 }) totalDiscount := cart.NewPrice() for i := 0; i < numFree; i++ { unitDiscount := scalePrice(&units[i].item.Price, discount) totalDiscount.Add(*unitDiscount) } if totalDiscount.IncVat <= 0 { return nil, false } // Never discount below zero. if totalDiscount.IncVat > g.TotalPrice.IncVat { totalDiscount = g.TotalPrice } g.TotalDiscount.Add(*totalDiscount) g.TotalPrice.Subtract(*totalDiscount) return totalDiscount, true } func (buyXGetYEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) { return nil, false } // ---------------------------- // bundleDiscountEffect implementation // ---------------------------- type bundleDiscountEffect struct{} func (bundleDiscountEffect) Type() ActionType { return ActionBundleDiscount } func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) { if a.BundleConfig == nil { return nil, false } // 1. Represent all cart items as individual units type bundleUnit struct { item *cart.CartItem used bool } var units []*bundleUnit for _, item := range g.Items { if item == nil { continue } for k := uint16(0); k < item.Quantity; k++ { units = append(units, &bundleUnit{ item: item, used: false, }) } } // 2. Greedy match bundles var formedBundles [][]*cart.CartItem for { // Attempt to form one bundle var matchedUnits []*bundleUnit possible := true for _, container := range a.BundleConfig.Containers { needed := container.Quantity found := 0 var containerMatched []*bundleUnit // Find unused units matching container's qualifyingRules for _, u := range units { if u.used { continue } // Check if this unit is already matched in this bundle instance to avoid double counting alreadyMatched := false for _, mu := range matchedUnits { if mu == u { alreadyMatched = true break } } if alreadyMatched { continue } if matchQualifyingRule(container.QualifyingRules, u.item) { containerMatched = append(containerMatched, u) found++ if found == needed { break } } } if found < needed { if a.BundleConfig.RequireAllContainers { possible = false break } } else { matchedUnits = append(matchedUnits, containerMatched...) } } if !possible || len(matchedUnits) == 0 { break } // Mark matched units as used var bundleItems []*cart.CartItem for _, mu := range matchedUnits { mu.used = true bundleItems = append(bundleItems, mu.item) } formedBundles = append(formedBundles, bundleItems) } if len(formedBundles) == 0 { return nil, false } totalDiscount := cart.NewPrice() for _, bundle := range formedBundles { // Calculate the original bundle price bundlePrice := cart.NewPrice() for _, item := range bundle { // Add a single unit's price bundlePrice.Add(item.Price) } if bundlePrice.IncVat <= 0 { continue } var bundleDiscount *cart.Price switch a.BundleConfig.Pricing.Type { case "fixed_price": targetPriceOre := money.FromFloatMajor(a.BundleConfig.Pricing.Value) if bundlePrice.IncVat > targetPriceOre { pct := float64((bundlePrice.IncVat-targetPriceOre).Int64()) / float64(bundlePrice.IncVat.Int64()) * 100 bundleDiscount = scalePrice(bundlePrice, pct) } case "percentage_discount": bundleDiscount = scalePrice(bundlePrice, a.BundleConfig.Pricing.Value) case "fixed_discount": discountOre := money.FromFloatMajor(a.BundleConfig.Pricing.Value) pct := float64(discountOre.Int64()) / float64(bundlePrice.IncVat.Int64()) * 100 bundleDiscount = scalePrice(bundlePrice, pct) } if bundleDiscount != nil && bundleDiscount.IncVat > 0 { totalDiscount.Add(*bundleDiscount) } } if totalDiscount.IncVat <= 0 { return nil, false } // Never discount below zero. if totalDiscount.IncVat > g.TotalPrice.IncVat { totalDiscount = g.TotalPrice } g.TotalDiscount.Add(*totalDiscount) g.TotalPrice.Subtract(*totalDiscount) return totalDiscount, true } func (bundleDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) { return nil, false } // ---------------------------- // Helpers // ---------------------------- func eligibleItems(rule PromotionRule, g *cart.CartGrain) []*cart.CartItem { var categories []string var productIDs []string 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 == CondProductCategory { if s, ok := v.Value.AsString(); ok { categories = append(categories, strings.ToLower(s)) } else if arr, ok := v.Value.AsStringSlice(); ok { for _, s := range arr { categories = append(categories, strings.ToLower(s)) } } } else if v.Type == CondProductID { if s, ok := v.Value.AsString(); ok { productIDs = append(productIDs, strings.ToLower(s)) } else if arr, ok := v.Value.AsStringSlice(); ok { for _, s := range arr { productIDs = append(productIDs, strings.ToLower(s)) } } } } } } walk(rule.Conditions) var out []*cart.CartItem for _, item := range g.Items { if item == nil { continue } match := true if len(categories) > 0 { cat := "" if item.Meta != nil { cat = strings.ToLower(item.Meta.Category) } found := false for _, c := range categories { if c == cat { found = true break } } if !found { match = false } } if len(productIDs) > 0 { sku := strings.ToLower(item.Sku) found := false for _, p := range productIDs { if p == sku { found = true break } } if !found { match = false } } if match { out = append(out, item) } } return out } func matchQualifyingRule(rule BundleQualifyingRules, item *cart.CartItem) bool { if item == nil { return false } valStr, isStr := rule.Value.(string) var valSlice []string if !isStr { if rawSlice, ok := rule.Value.([]interface{}); ok { for _, val := range rawSlice { if s, ok := val.(string); ok { valSlice = append(valSlice, strings.ToLower(s)) } } } else if strSlice, ok := rule.Value.([]string); ok { for _, s := range strSlice { valSlice = append(valSlice, strings.ToLower(s)) } } } else { valStr = strings.ToLower(valStr) } switch rule.Type { case "category": if item.Meta == nil { return false } cat := strings.ToLower(item.Meta.Category) if isStr { return cat == valStr } for _, c := range valSlice { if cat == c { return true } } return false case "product_ids": sku := strings.ToLower(item.Sku) if isStr { return sku == valStr } for _, s := range valSlice { if sku == s { return true } } return false case "all": return true default: return false } }