Files
go-cart-actor/pkg/promotions/apply.go
T
mats e20793a6b3
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
better results
2026-07-08 00:55:05 +02:00

866 lines
25 KiB
Go

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, itemIds []uint32, 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, itemIds, applied := eff.Apply(g, res.Rule, a); applied {
entry.Discount = discount
entry.ItemIds = itemIds
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, []uint32, bool) {
d := applyPercentageDiscount(g, percentFromValue(a.Value))
return d, nil, 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, []uint32, bool) {
return nil, 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, []uint32, bool) {
d := applyTieredDiscount(g, a)
return d, nil, 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 each line's row total, sums
// the per-line amounts into the cart's TotalDiscount, and returns the
// total (nil if nothing was taken off). Per-line first then sum
// (instead of total first then split) avoids penny-level rounding
// gaps where the per-line amounts no longer sum to the reported
// total. The per-line amount is also added to each line's Discount
// field on top of the OrgPrice-based discount that UpdateTotals
// already set, so consumers (the /promotions/evaluate-with-cart
// preview, the checkout payload mapper) can see the effective unit
// price per item.
func applyPercentageDiscount(g *cart.CartGrain, pct float64) *cart.Price {
if pct <= 0 || g.TotalPrice.IncVat <= 0 {
return nil
}
totalDiscount := cart.NewPrice()
for _, item := range g.Items {
if item == nil || item.TotalPrice.IncVat <= 0 {
continue
}
itemDisc := scalePrice(&item.TotalPrice, pct)
if itemDisc.IncVat <= 0 {
continue
}
// Clamp to this line's row total so a single item can't go
// negative when its per-line discount (post-rounding) ends
// up larger than its row. itemDisc is *cart.Price; assign
// through the pointer to overwrite the rounded value with
// the raw row total when needed.
if itemDisc.IncVat > item.TotalPrice.IncVat {
*itemDisc = item.TotalPrice
}
if item.Discount == nil {
item.Discount = cart.NewPrice()
}
item.Discount.Add(*itemDisc)
totalDiscount.Add(*itemDisc)
}
if totalDiscount.IncVat <= 0 {
return nil
}
// Safety net for any aggregate rounding overshoot.
if totalDiscount.IncVat > g.TotalPrice.IncVat {
totalDiscount = g.TotalPrice
}
g.TotalDiscount.Add(*totalDiscount)
g.TotalPrice.Subtract(*totalDiscount)
return totalDiscount
}
// 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, []uint32, 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, 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()
affectedMap := make(map[uint32]bool)
for i := 0; i < numFree; i++ {
unitDiscount := scalePrice(&units[i].item.Price, discount)
totalDiscount.Add(*unitDiscount)
affectedMap[units[i].item.Id] = true
// Per-line distribution: add the per-unit discount to the
// item's Discount field on top of the OrgPrice-based
// discount that UpdateTotals already set. If the same item
// is the free unit multiple times (e.g. qty 3 with
// buy-2-get-1 and a second buy-2-get-1), Add is called
// per free unit so the cumulative amount lands on the
// right line.
if units[i].item.Discount == nil {
units[i].item.Discount = cart.NewPrice()
}
units[i].item.Discount.Add(*unitDiscount)
}
if totalDiscount.IncVat <= 0 {
return nil, nil, false
}
// Never discount below zero.
if totalDiscount.IncVat > g.TotalPrice.IncVat {
totalDiscount = g.TotalPrice
}
g.TotalDiscount.Add(*totalDiscount)
g.TotalPrice.Subtract(*totalDiscount)
var itemIds []uint32
for id := range affectedMap {
itemIds = append(itemIds, id)
}
slices.Sort(itemIds)
return totalDiscount, itemIds, 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, []uint32, bool) {
if a.BundleConfig == nil {
return nil, 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, nil, false
}
totalDiscount := cart.NewPrice()
affectedMap := make(map[uint32]bool)
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)
// Per-line distribution: split the bundle discount
// across the entries in `bundle` proportionally to
// each entry's per-unit price (same item can appear
// multiple times if it's in the bundle multiple
// times). distributeBundleDiscount walks the entries
// and adds the share to each item's Discount field,
// with the last entry absorbing any rounding remainder
// so the per-line sum matches bundleDiscount exactly.
distributeBundleDiscount(bundle, bundleDiscount)
for _, item := range bundle {
affectedMap[item.Id] = true
}
}
}
if totalDiscount.IncVat <= 0 {
return nil, nil, false
}
// Never discount below zero.
if totalDiscount.IncVat > g.TotalPrice.IncVat {
totalDiscount = g.TotalPrice
}
g.TotalDiscount.Add(*totalDiscount)
g.TotalPrice.Subtract(*totalDiscount)
var itemIds []uint32
for id := range affectedMap {
itemIds = append(itemIds, id)
}
slices.Sort(itemIds)
return totalDiscount, itemIds, true
}
func (bundleDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
return nil, false
}
// ----------------------------
// Helpers
// ----------------------------
// distributeBundleDiscount splits bundleDiscount across the entries
// in bundleItems proportionally to each entry's per-unit price, and
// adds the share to each item's Discount field. The same item may
// appear multiple times in bundleItems (when it's in the bundle
// multiple times); each entry gets its own share, and Add on the
// item's Discount field is cumulative. The last entry absorbs any
// rounding remainder on IncVat (and on each VAT rate) so the
// per-line sum matches bundleDiscount exactly. No-op when
// bundleDiscount is nil/zero or bundleItems is empty.
func distributeBundleDiscount(bundleItems []*cart.CartItem, bundleDiscount *cart.Price) {
if bundleDiscount == nil || bundleDiscount.IncVat <= 0 || len(bundleItems) == 0 {
return
}
total := int64(0)
prices := make([]int64, len(bundleItems))
for i, item := range bundleItems {
if item == nil {
continue
}
prices[i] = item.Price.IncVat.Int64()
total += prices[i]
}
if total <= 0 {
return
}
var allocated int64
for i, item := range bundleItems {
if item == nil {
continue
}
var shareIncVat int64
if i == len(bundleItems)-1 {
shareIncVat = bundleDiscount.IncVat.Int64() - allocated
} else {
shareIncVat = bundleDiscount.IncVat.Int64() * prices[i] / total
}
if shareIncVat <= 0 {
continue
}
sharePrice := &cart.Price{
IncVat: money.Cents(shareIncVat),
VatRates: make(map[int]money.Cents, len(bundleDiscount.VatRates)),
}
// Pro-rate each VAT rate by the same share fraction so the
// VAT breakdown on the per-line discount matches the
// proportional split. Guard against the (degenerate) case
// where bundleDiscount.IncVat is somehow 0 here — the
// outer guard at the top should prevent this, but be
// safe.
if bundleDiscount.IncVat.Int64() > 0 {
for rate, amount := range bundleDiscount.VatRates {
sharePrice.VatRates[rate] = money.Cents(amount.Int64() * shareIncVat / bundleDiscount.IncVat.Int64())
}
}
if item.Discount == nil {
item.Discount = cart.NewPrice()
}
item.Discount.Add(*sharePrice)
allocated += shareIncVat
}
}
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
}
}