package promotions import ( "errors" "fmt" "slices" "strings" "time" "git.k6n.net/go-cart-actor/pkg/cart" ) var errInvalidTimeFormat = errors.New("invalid time format") // Tracer allows callers to receive structured debug information during evaluation. type Tracer interface { Trace(event string, data map[string]any) } // NoopTracer is used when no tracer provided. type NoopTracer struct{} func (NoopTracer) Trace(string, map[string]any) {} // PromotionItem is a lightweight abstraction derived from cart.CartItem // for the purpose of promotion condition evaluation. type PromotionItem struct { SKU string Quantity int Category string PriceIncVat int64 } // PromotionEvalContext carries all dynamic data required to evaluate promotion // conditions. It can be constructed from a cart.CartGrain plus optional // customer/order metadata. type PromotionEvalContext struct { CartTotalIncVat int64 TotalItemQuantity int Items []PromotionItem CustomerSegment string CustomerLifetimeValue float64 OrderCount int Now time.Time } // ContextOption allows customization of fields when building a PromotionEvalContext. type ContextOption func(*PromotionEvalContext) // WithCustomerSegment sets the customer segment. func WithCustomerSegment(seg string) ContextOption { return func(c *PromotionEvalContext) { c.CustomerSegment = seg } } // WithCustomerLifetimeValue sets lifetime value metric. func WithCustomerLifetimeValue(v float64) ContextOption { return func(c *PromotionEvalContext) { c.CustomerLifetimeValue = v } } // WithOrderCount sets historical order count. func WithOrderCount(n int) ContextOption { return func(c *PromotionEvalContext) { c.OrderCount = n } } // WithNow overrides the timestamp used for date/time related conditions. func WithNow(t time.Time) ContextOption { return func(c *PromotionEvalContext) { c.Now = t } } // NewContextFromCart builds a PromotionEvalContext from a CartGrain and optional metadata. func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEvalContext { ctx := &PromotionEvalContext{ Items: make([]PromotionItem, 0, len(g.Items)), CartTotalIncVat: 0, TotalItemQuantity: 0, Now: time.Now(), } if g.TotalPrice != nil { ctx.CartTotalIncVat = g.TotalPrice.IncVat } for _, it := range g.Items { category := "" if it.Meta != nil { category = it.Meta.Category } ctx.Items = append(ctx.Items, PromotionItem{ SKU: it.Sku, Quantity: it.Quantity, Category: strings.ToLower(category), PriceIncVat: it.Price.IncVat, }) ctx.TotalItemQuantity += it.Quantity } for _, o := range opts { o(ctx) } return ctx } // PromotionService evaluates PromotionRules against a PromotionEvalContext. type PromotionService struct { tracer Tracer } // NewPromotionService constructs a PromotionService with an optional tracer. func NewPromotionService(t Tracer) *PromotionService { if t == nil { t = NoopTracer{} } return &PromotionService{tracer: t} } // EvaluationResult holds the outcome of evaluating a single rule. type EvaluationResult struct { Rule PromotionRule Applicable bool FailedReason string MatchedActions []Action } // EvaluateRule determines if a single PromotionRule applies to the provided context. // Returns an EvaluationResult with applicability and actions (if applicable). func (s *PromotionService) EvaluateRule(rule PromotionRule, ctx *PromotionEvalContext) EvaluationResult { s.tracer.Trace("rule_start", map[string]any{ "rule_id": rule.ID, "priority": rule.Priority, "status": rule.Status, "startDate": rule.StartDate, "endDate": rule.EndDate, }) // Status gate now := ctx.Now switch rule.Status { case StatusInactive, StatusExpired: s.tracer.Trace("rule_skip_status", map[string]any{"rule_id": rule.ID, "status": rule.Status}) return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "status"} case StatusScheduled: // Allow scheduled only if current time >= startDate (and within endDate if present) } // Date window checks (if parseable) if rule.StartDate != "" { if tStart, err := parseDate(rule.StartDate); err == nil { if now.Before(tStart) { s.tracer.Trace("rule_skip_before_start", map[string]any{"rule_id": rule.ID}) return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "before_start"} } } } if rule.EndDate != nil && *rule.EndDate != "" { if tEnd, err := parseDate(*rule.EndDate); err == nil { if now.After(tEnd.Add(23*time.Hour + 59*time.Minute + 59*time.Second)) { // inclusive day s.tracer.Trace("rule_skip_after_end", map[string]any{"rule_id": rule.ID}) return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "after_end"} } } } // Usage limit if rule.UsageLimit != nil && rule.UsageCount >= *rule.UsageLimit { s.tracer.Trace("rule_skip_usage_limit", map[string]any{"rule_id": rule.ID, "usageCount": rule.UsageCount, "limit": *rule.UsageLimit}) return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "usage_limit_exhausted"} } if !evaluateConditionsTrace(rule.Conditions, ctx, s.tracer, rule.ID) { s.tracer.Trace("rule_conditions_failed", map[string]any{"rule_id": rule.ID}) return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "conditions"} } s.tracer.Trace("rule_applicable", map[string]any{"rule_id": rule.ID}) return EvaluationResult{ Rule: rule, Applicable: true, MatchedActions: rule.Actions, } } // EvaluateAll returns all applicable promotion actions given a list of rules and context. // Rules marked Applicable are sorted by Priority (ascending: lower number = higher precedence). func (s *PromotionService) EvaluateAll(rules []PromotionRule, ctx *PromotionEvalContext) ([]EvaluationResult, []Action) { results := make([]EvaluationResult, 0, len(rules)) for _, r := range rules { res := s.EvaluateRule(r, ctx) results = append(results, res) } actions := make([]Action, 0) for _, res := range orderedByPriority(results) { if res.Applicable { s.tracer.Trace("actions_add", map[string]any{ "rule_id": res.Rule.ID, "count": len(res.MatchedActions), "priority": res.Rule.Priority, }) actions = append(actions, res.MatchedActions...) } } s.tracer.Trace("evaluation_complete", map[string]any{ "rules_total": len(rules), "actions": len(actions), }) return results, actions } // orderedByPriority returns results sorted by PromotionRule.Priority ascending (stable). func orderedByPriority(in []EvaluationResult) []EvaluationResult { out := make([]EvaluationResult, len(in)) copy(out, in) for i := 1; i < len(out); i++ { j := i for j > 0 && out[j-1].Rule.Priority > out[j].Rule.Priority { out[j-1], out[j] = out[j], out[j-1] j-- } } return out } // ---------------------------- // Condition evaluation (with tracing) // ---------------------------- func evaluateConditionsTrace(conds Conditions, ctx *PromotionEvalContext, t Tracer, ruleID string) bool { for idx, c := range conds { if !evaluateConditionTrace(c, ctx, t, ruleID, fmt.Sprintf("root[%d]", idx)) { return false } } return true } func evaluateConditionTrace(c Condition, ctx *PromotionEvalContext, t Tracer, ruleID, path string) bool { if grp, ok := c.(ConditionGroup); ok { return evaluateGroupTrace(grp, ctx, t, ruleID, path) } bc, ok := c.(BaseCondition) if !ok { t.Trace("cond_invalid_type", map[string]any{"rule_id": ruleID, "path": path}) return false } res := evaluateBaseCondition(bc, ctx) t.Trace("cond_base", map[string]any{ "rule_id": ruleID, "path": path, "type": bc.Type, "op": bc.Operator, "value": bc.Value.String(), "result": res, }) return res } func evaluateGroupTrace(g ConditionGroup, ctx *PromotionEvalContext, t Tracer, ruleID, path string) bool { op := normalizeLogicOperator(string(g.Operator)) if len(g.Conditions) == 0 { t.Trace("cond_group_empty", map[string]any{"rule_id": ruleID, "path": path}) return true } if op == string(LogicAND) { for i, child := range g.Conditions { if !evaluateConditionTrace(child, ctx, t, ruleID, path+fmt.Sprintf(".AND[%d]", i)) { t.Trace("cond_group_and_fail", map[string]any{"rule_id": ruleID, "path": path}) return false } } t.Trace("cond_group_and_pass", map[string]any{"rule_id": ruleID, "path": path}) return true } for i, child := range g.Conditions { if evaluateConditionTrace(child, ctx, t, ruleID, path+fmt.Sprintf(".OR[%d]", i)) { t.Trace("cond_group_or_pass", map[string]any{"rule_id": ruleID, "path": path}) return true } } t.Trace("cond_group_or_fail", map[string]any{"rule_id": ruleID, "path": path}) return false } // Fallback non-traced evaluation (used internally by traced path) func evaluateConditions(conds Conditions, ctx *PromotionEvalContext) bool { for _, c := range conds { if !evaluateCondition(c, ctx) { return false } } return true } func evaluateCondition(c Condition, ctx *PromotionEvalContext) bool { if grp, ok := c.(ConditionGroup); ok { return evaluateGroup(grp, ctx) } bc, ok := c.(BaseCondition) if !ok { return false } return evaluateBaseCondition(bc, ctx) } func evaluateGroup(g ConditionGroup, ctx *PromotionEvalContext) bool { op := normalizeLogicOperator(string(g.Operator)) if len(g.Conditions) == 0 { return true } if op == string(LogicAND) { for _, child := range g.Conditions { if !evaluateCondition(child, ctx) { return false } } return true } for _, child := range g.Conditions { if evaluateCondition(child, ctx) { return true } } return false } func evaluateBaseCondition(b BaseCondition, ctx *PromotionEvalContext) bool { switch b.Type { case CondCartTotal: return evalNumberCompare(float64(ctx.CartTotalIncVat), b) case CondItemQuantity: return evalNumberCompare(float64(ctx.TotalItemQuantity), b) case CondCustomerSegment: return evalStringCompare(ctx.CustomerSegment, b) case CondProductCategory: return evalAnyItemMatch(func(it PromotionItem) bool { return evalValueAgainstTarget(strings.ToLower(it.Category), b) }, b, ctx) case CondProductID: return evalAnyItemMatch(func(it PromotionItem) bool { return evalValueAgainstTarget(strings.ToLower(it.SKU), b) }, b, ctx) case CondCustomerLifetime: return evalNumberCompare(ctx.CustomerLifetimeValue, b) case CondOrderCount: return evalNumberCompare(float64(ctx.OrderCount), b) case CondDateRange: return evalDateRange(ctx.Now, b) case CondDayOfWeek: return evalDayOfWeek(ctx.Now, b) case CondTimeOfDay: return evalTimeOfDay(ctx.Now, b) default: return false } } func evalAnyItemMatch(pred func(PromotionItem) bool, b BaseCondition, ctx *PromotionEvalContext) bool { if slices.ContainsFunc(ctx.Items, pred) { return true } switch normalizeOperator(string(b.Operator)) { case string(OpNotIn), string(OpNotContains): return true } return false } // ---------------------------- // Primitive evaluators // ---------------------------- func evalNumberCompare(target float64, b BaseCondition) bool { op := normalizeOperator(string(b.Operator)) cond, ok := b.Value.AsFloat64() if !ok { if list, okL := b.Value.AsFloat64Slice(); okL && (op == string(OpIn) || op == string(OpNotIn)) { found := sliceFloatContains(list, target) if op == string(OpIn) { return found } return !found } return false } switch op { case string(OpEquals): return target == cond case string(OpNotEquals): return target != cond case string(OpGreaterThan): return target > cond case string(OpLessThan): return target < cond case string(OpGreaterOrEqual): return target >= cond case string(OpLessOrEqual): return target <= cond default: return false } } func evalStringCompare(target string, b BaseCondition) bool { op := normalizeOperator(string(b.Operator)) targetLower := strings.ToLower(target) if s, ok := b.Value.AsString(); ok { condLower := strings.ToLower(s) switch op { case string(OpEquals): return targetLower == condLower case string(OpNotEquals): return targetLower != condLower case string(OpContains): return strings.Contains(targetLower, condLower) case string(OpNotContains): return !strings.Contains(targetLower, condLower) } } if arr, ok := b.Value.AsStringSlice(); ok { switch op { case string(OpIn): for _, v := range arr { if targetLower == strings.ToLower(v) { return true } } return false case string(OpNotIn): for _, v := range arr { if targetLower == strings.ToLower(v) { return false } } return true case string(OpContains): for _, v := range arr { if strings.Contains(targetLower, strings.ToLower(v)) { return true } } return false case string(OpNotContains): for _, v := range arr { if strings.Contains(targetLower, strings.ToLower(v)) { return false } } return true } } return false } func evalValueAgainstTarget(target string, b BaseCondition) bool { op := normalizeOperator(string(b.Operator)) tLower := strings.ToLower(target) if s, ok := b.Value.AsString(); ok { vLower := strings.ToLower(s) switch op { case string(OpEquals): return tLower == vLower case string(OpNotEquals): return tLower != vLower case string(OpContains): return strings.Contains(tLower, vLower) case string(OpNotContains): return !strings.Contains(tLower, vLower) case string(OpIn): return tLower == vLower case string(OpNotIn): return tLower != vLower } } if list, ok := b.Value.AsStringSlice(); ok { found := false for _, v := range list { if tLower == strings.ToLower(v) { found = true break } } switch op { case string(OpIn): return found case string(OpNotIn): return !found case string(OpContains): for _, v := range list { if strings.Contains(tLower, strings.ToLower(v)) { return true } } return false case string(OpNotContains): for _, v := range list { if strings.Contains(tLower, strings.ToLower(v)) { return false } } return true } } return false } func evalDateRange(now time.Time, b BaseCondition) bool { var start, end time.Time if ss, ok := b.Value.AsStringSlice(); ok && len(ss) == 2 { t0, e0 := parseDate(ss[0]) t1, e1 := parseDate(ss[1]) if e0 != nil || e1 != nil { return false } start, end = t0, t1 } else if s, ok := b.Value.AsString(); ok { parts := strings.Split(s, "..") if len(parts) != 2 { return false } t0, e0 := parseDate(parts[0]) t1, e1 := parseDate(parts[1]) if e0 != nil || e1 != nil { return false } start, end = t0, t1 } else { return false } endInclusive := end.Add(23*time.Hour + 59*time.Minute + 59*time.Second) return (now.Equal(start) || now.After(start)) && (now.Equal(endInclusive) || now.Before(endInclusive)) } func evalDayOfWeek(now time.Time, b BaseCondition) bool { dow := int(now.Weekday()) allowed := make(map[int]struct{}) if arr, ok := b.Value.AsStringSlice(); ok { for _, v := range arr { if idx, ok := parseDayOfWeek(v); ok { allowed[idx] = struct{}{} } } } else if s, ok := b.Value.AsString(); ok { for _, part := range strings.Split(s, "|") { if idx, ok := parseDayOfWeek(strings.TrimSpace(part)); ok { allowed[idx] = struct{}{} } } } else { return false } if len(allowed) == 0 { return false } op := normalizeOperator(string(b.Operator)) _, present := allowed[dow] if op == string(OpIn) || op == string(OpEquals) || op == "" { return present } if op == string(OpNotIn) || op == string(OpNotEquals) { return !present } return present } func evalTimeOfDay(now time.Time, b BaseCondition) bool { var startMin, endMin int if arr, ok := b.Value.AsStringSlice(); ok && len(arr) == 2 { s0, e0 := parseClock(arr[0]) s1, e1 := parseClock(arr[1]) if e0 != nil || e1 != nil { return false } startMin, endMin = s0, s1 } else if s, ok := b.Value.AsString(); ok { parts := strings.Split(s, "-") if len(parts) != 2 { return false } s0, e0 := parseClock(parts[0]) s1, e1 := parseClock(parts[1]) if e0 != nil || e1 != nil { return false } startMin, endMin = s0, s1 } else { return false } todayMin := now.Hour()*60 + now.Minute() if startMin > endMin { return todayMin >= startMin || todayMin <= endMin } return todayMin >= startMin && todayMin <= endMin } // ---------------------------- // Parsing helpers // ---------------------------- func parseDate(s string) (time.Time, error) { layouts := []string{ "2006-01-02", time.RFC3339, "2006-01-02T15:04:05Z07:00", } for _, l := range layouts { if t, err := time.Parse(l, strings.TrimSpace(s)); err == nil { return t, nil } } return time.Time{}, errInvalidTimeFormat } func parseDayOfWeek(s string) (int, bool) { sl := strings.ToLower(strings.TrimSpace(s)) switch sl { case "sun", "sunday", "0": return 0, true case "mon", "monday", "1": return 1, true case "tue", "tues", "tuesday", "2": return 2, true case "wed", "weds", "wednesday", "3": return 3, true case "thu", "thur", "thurs", "thursday", "4": return 4, true case "fri", "friday", "5": return 5, true case "sat", "saturday", "6": return 6, true default: return 0, false } } func parseClock(s string) (int, error) { s = strings.TrimSpace(s) parts := strings.Split(s, ":") if len(parts) != 2 { return 0, errInvalidTimeFormat } h, err := parsePositiveInt(parts[0]) if err != nil { return 0, err } m, err := parsePositiveInt(parts[1]) if err != nil { return 0, err } if h < 0 || h > 23 || m < 0 || m > 59 { return 0, errInvalidTimeFormat } return h*60 + m, nil } func parsePositiveInt(s string) (int, error) { n := 0 for _, r := range s { if r < '0' || r > '9' { return 0, errInvalidTimeFormat } n = n*10 + int(r-'0') } return n, nil } func normalizeOperator(op string) string { o := strings.ToLower(strings.TrimSpace(op)) switch o { case "=", "equals", "eq": return string(OpEquals) case "!=", "not_equals", "neq": return string(OpNotEquals) case ">", "greater_than", "gt": return string(OpGreaterThan) case "<", "less_than", "lt": return string(OpLessThan) case ">=", "greater_or_equal", "ge", "gte": return string(OpGreaterOrEqual) case "<=", "less_or_equal", "le", "lte": return string(OpLessOrEqual) case "contains": return string(OpContains) case "not_contains": return string(OpNotContains) case "in": return string(OpIn) case "not_in": return string(OpNotIn) default: return o } } func normalizeLogicOperator(op string) string { o := strings.ToLower(strings.TrimSpace(op)) switch o { case "&&", "and": return string(LogicAND) case "||", "or": return string(LogicOR) default: return o } } func sliceFloatContains(list []float64, v float64) bool { return slices.Contains(list, v) } // ---------------------------- // Potential extension hooks // ---------------------------- // // Future ideas: // - Conflict resolution strategies (e.g., best discount wins, stackable tags) // - Action transformation (e.g., applying tiered logic or bundles carefully) // - Recording evaluation traces for debugging / analytics. // - Tracing instrumentation for condition evaluation. // // These can be integrated by adding strategy interfaces or injecting evaluators // into PromotionService. // // ---------------------------- // End of file // ----------------------------