package promotions import ( "math" "git.k6n.net/mats/go-cart-actor/pkg/cart" ) // ---------------------------- // Action application // ---------------------------- // // 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. // 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 } // 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) { tiers := parseTiers(a.Config) if len(tiers) == 0 { return } pct, ok := selectTier(tiers, g.TotalPrice.IncVat) if !ok { 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) { if pct <= 0 || g.TotalPrice.IncVat <= 0 { return } discount := scalePrice(g.TotalPrice, pct) if discount.IncVat <= 0 { return } // Never discount below zero. if discount.IncVat > g.TotalPrice.IncVat { discount = g.TotalPrice } g.TotalDiscount.Add(*discount) g.TotalPrice.Subtract(*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 }