diff --git a/pkg/promotions/eval.go b/pkg/promotions/eval.go new file mode 100644 index 0000000..c30addc --- /dev/null +++ b/pkg/promotions/eval.go @@ -0,0 +1,730 @@ +package promotions + +import ( + "errors" + "fmt" + "strings" + "time" + + "git.tornberg.me/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 { + for _, it := range ctx.Items { + if pred(it) { + 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 { + for _, x := range list { + if x == v { + return true + } + } + return false +} + +// ---------------------------- +// 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 +// ---------------------------- diff --git a/pkg/promotions/eval_test.go b/pkg/promotions/eval_test.go new file mode 100644 index 0000000..8352fa5 --- /dev/null +++ b/pkg/promotions/eval_test.go @@ -0,0 +1,448 @@ +package promotions + +import ( + "encoding/json" + "slices" + "testing" + "time" + + "git.tornberg.me/go-cart-actor/pkg/cart" +) + +// --- Helpers --------------------------------------------------------------- + +func cvNum(n float64) ConditionValue { + b, _ := json.Marshal(n) + return ConditionValue{Raw: b} +} + +func cvString(s string) ConditionValue { + b, _ := json.Marshal(s) + return ConditionValue{Raw: b} +} + +func cvStrings(ss []string) ConditionValue { + b, _ := json.Marshal(ss) + return ConditionValue{Raw: b} +} + +// testTracer captures trace events for assertions. +type testTracer struct { + events []traceEvent +} + +type traceEvent struct { + event string + data map[string]any +} + +func (t *testTracer) Trace(event string, data map[string]any) { + t.events = append(t.events, traceEvent{event: event, data: data}) +} + +func (t *testTracer) HasEvent(name string) bool { + return slices.ContainsFunc(t.events, func(e traceEvent) bool { return e.event == name }) +} + +func (t *testTracer) Count(name string) int { + c := 0 + for _, e := range t.events { + if e.event == name { + c++ + } + } + return c +} + +// makeCart creates a cart with given total and items (each item quantity & price IncVat). +func makeCart(totalOverride int64, items []struct { + sku string + category string + qty int + priceInc int64 +}) *cart.CartGrain { + g := cart.NewCartGrain(1, time.Now()) + for _, it := range items { + p := cart.NewPriceFromIncVat(it.priceInc, 0.25) + g.Items = append(g.Items, &cart.CartItem{ + Id: uint32(len(g.Items) + 1), + Sku: it.sku, + Price: *p, + TotalPrice: cart.Price{ + IncVat: p.IncVat * int64(it.qty), + VatRates: p.VatRates, + }, + Quantity: it.qty, + Meta: &cart.ItemMeta{ + Category: it.category, + }, + }) + } + // Recalculate totals + g.UpdateTotals() + if totalOverride >= 0 { + g.TotalPrice.IncVat = totalOverride + } + return g +} + +// --- Tests ----------------------------------------------------------------- + +func TestEvaluateRuleBasicAND(t *testing.T) { + g := makeCart(12000, []struct { + sku string + category string + qty int + priceInc int64 + }{ + {"SKU-1", "summer", 2, 3000}, + {"SKU-2", "winter", 1, 6000}, + }) + + ctx := NewContextFromCart(g, + WithCustomerSegment("vip"), + WithOrderCount(10), + WithCustomerLifetimeValue(1234.56), + WithNow(time.Date(2024, 6, 10, 12, 0, 0, 0, time.UTC)), + ) + + // Conditions: cart_total >= 10000 AND item_quantity >= 3 AND customer_segment = vip + conds := Conditions{ + ConditionGroup{ + ID: "grp", + Type: "group", + Operator: LogicAND, + Conditions: Conditions{ + BaseCondition{ + ID: "c_cart_total", + Type: CondCartTotal, + Operator: OpGreaterOrEqual, + Value: cvNum(10000), + }, + BaseCondition{ + ID: "c_item_qty", + Type: CondItemQuantity, + Operator: OpGreaterOrEqual, + Value: cvNum(3), + }, + BaseCondition{ + ID: "c_segment", + Type: CondCustomerSegment, + Operator: OpEquals, + Value: cvString("vip"), + }, + }, + }, + } + + rule := PromotionRule{ + ID: "rule-AND", + Name: "VIP Summer", + Description: "Test rule", + Status: StatusActive, + Priority: 1, + StartDate: "2024-06-01", + EndDate: ptr("2024-06-30"), + Conditions: conds, + Actions: []Action{ + {ID: "a1", Type: ActionPercentageDiscount, Value: 10.0}, + }, + UsageLimit: ptrInt(100), + UsageCount: 5, + CreatedAt: time.Now().Format(time.RFC3339), + UpdatedAt: time.Now().Format(time.RFC3339), + CreatedBy: "tester", + } + + tracer := &testTracer{} + svc := NewPromotionService(tracer) + res := svc.EvaluateRule(rule, ctx) + if !res.Applicable { + t.Fatalf("expected rule to be applicable, failedReason=%s", res.FailedReason) + } + if len(res.MatchedActions) != 1 { + t.Fatalf("expected 1 action, got %d", len(res.MatchedActions)) + } + if !tracer.HasEvent("rule_applicable") { + t.Errorf("expected tracing event rule_applicable") + } +} + +func TestEvaluateRuleUsageLimitExhausted(t *testing.T) { + rule := PromotionRule{ + ID: "limit", + Name: "Limit", + Status: StatusActive, + Priority: 1, + StartDate: "2024-01-01", + EndDate: nil, + Conditions: Conditions{}, + UsageLimit: ptrInt(5), + UsageCount: 5, + } + + ctx := &PromotionEvalContext{Now: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)} + tracer := &testTracer{} + svc := NewPromotionService(tracer) + res := svc.EvaluateRule(rule, ctx) + if res.Applicable { + t.Fatalf("expected rule NOT applicable due to usage limit") + } + if res.FailedReason != "usage_limit_exhausted" { + t.Fatalf("expected failedReason usage_limit_exhausted, got %s", res.FailedReason) + } + if !tracer.HasEvent("rule_skip_usage_limit") { + t.Errorf("tracer missing rule_skip_usage_limit event") + } +} + +func TestEvaluateRuleDateWindow(t *testing.T) { + // Start in future + rule := PromotionRule{ + ID: "date", + Name: "DateWindow", + Status: StatusActive, + Priority: 1, + StartDate: "2025-01-01", + EndDate: ptr("2025-01-31"), + Conditions: Conditions{}, + } + ctx := &PromotionEvalContext{Now: time.Date(2024, 12, 15, 12, 0, 0, 0, time.UTC)} + tracer := &testTracer{} + svc := NewPromotionService(tracer) + res := svc.EvaluateRule(rule, ctx) + if res.Applicable { + t.Fatalf("expected rule NOT applicable (before start)") + } + if res.FailedReason != "before_start" { + t.Fatalf("expected failedReason before_start, got %s", res.FailedReason) + } + if !tracer.HasEvent("rule_skip_before_start") { + t.Errorf("missing rule_skip_before_start trace event") + } +} + +func TestEvaluateProductCategoryIN(t *testing.T) { + g := makeCart(-1, []struct { + sku string + category string + qty int + priceInc int64 + }{ + {"A", "shoes", 1, 5000}, + {"B", "bags", 1, 7000}, + }) + + ctx := NewContextFromCart(g) + conds := Conditions{ + BaseCondition{ + ID: "c_category", + Type: CondProductCategory, + Operator: OpIn, + Value: cvStrings([]string{"shoes", "hats"}), + }, + } + rule := PromotionRule{ + ID: "cat-in", + Status: StatusActive, + Priority: 10, + StartDate: "2024-01-01", + EndDate: nil, + Conditions: conds, + Actions: []Action{ + {ID: "discount", Type: ActionFixedDiscount, Value: 1000}, + }, + } + tracer := &testTracer{} + svc := NewPromotionService(tracer) + res := svc.EvaluateRule(rule, ctx) + if !res.Applicable { + t.Fatalf("expected category IN rule to apply") + } + if !tracer.HasEvent("rule_applicable") { + t.Errorf("tracing missing rule_applicable") + } +} + +func TestEvaluateGroupOR(t *testing.T) { + g := makeCart(3000, []struct { + sku string + category string + qty int + priceInc int64 + }{ + {"ONE", "x", 1, 3000}, + }) + + ctx := NewContextFromCart(g) + + // OR group: (cart_total >= 10000) OR (item_quantity >= 1) + group := ConditionGroup{ + ID: "grp-or", + Type: "group", + Operator: LogicOR, + Conditions: Conditions{ + BaseCondition{ + ID: "c_total", + Type: CondCartTotal, + Operator: OpGreaterOrEqual, + Value: cvNum(10000), + }, + BaseCondition{ + ID: "c_qty", + Type: CondItemQuantity, + Operator: OpGreaterOrEqual, + Value: cvNum(1), + }, + }, + } + rule := PromotionRule{ + ID: "or-rule", + Status: StatusActive, + Priority: 5, + StartDate: "2024-01-01", + EndDate: nil, + Conditions: Conditions{group}, + Actions: []Action{{ID: "a", Type: ActionFixedDiscount, Value: 500}}, + } + tracer := &testTracer{} + svc := NewPromotionService(tracer) + res := svc.EvaluateRule(rule, ctx) + if !res.Applicable { + t.Fatalf("expected OR rule to apply (second condition true)") + } + // Ensure group pass event + if !tracer.HasEvent("cond_group_or_pass") { + t.Errorf("expected cond_group_or_pass trace event") + } +} + +func TestEvaluateAllPriorityOrdering(t *testing.T) { + ctx := &PromotionEvalContext{ + CartTotalIncVat: 20000, + TotalItemQuantity: 2, + Items: []PromotionItem{ + {SKU: "X", Quantity: 2, Category: "general", PriceIncVat: 10000}, + }, + Now: time.Date(2024, 5, 10, 10, 0, 0, 0, time.UTC), + } + + // Rule A: priority 5 + ruleA := PromotionRule{ + ID: "A", + Status: StatusActive, + Priority: 5, + StartDate: "2024-01-01", + EndDate: nil, + Conditions: Conditions{}, + Actions: []Action{ + {ID: "actionA", Type: ActionFixedDiscount, Value: 100}, + }, + } + + // Rule B: priority 1 + ruleB := PromotionRule{ + ID: "B", + Status: StatusActive, + Priority: 1, + StartDate: "2024-01-01", + EndDate: nil, + Conditions: Conditions{}, + Actions: []Action{ + {ID: "actionB", Type: ActionFixedDiscount, Value: 200}, + }, + } + + tracer := &testTracer{} + svc := NewPromotionService(tracer) + results, actions := svc.EvaluateAll([]PromotionRule{ruleA, ruleB}, ctx) + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + if len(actions) != 2 { + t.Fatalf("expected 2 actions, got %d", len(actions)) + } + + // Actions should follow priority order: ruleB (1) then ruleA (5) + if actions[0].ID != "actionB" || actions[1].ID != "actionA" { + t.Fatalf("actions order invalid: %+v", actions) + } + if tracer.Count("actions_add") != 2 { + t.Errorf("expected 2 actions_add trace events, got %d", tracer.Count("actions_add")) + } + if !tracer.HasEvent("evaluation_complete") { + t.Errorf("missing evaluation_complete trace") + } +} + +func TestDayOfWeekAndTimeOfDay(t *testing.T) { + // Wednesday 14:30 UTC + now := time.Date(2024, 6, 12, 14, 30, 0, 0, time.UTC) // 2024-06-12 is Wednesday + ctx := &PromotionEvalContext{ + Now: now, + } + + condDay := BaseCondition{ + ID: "dow", + Type: CondDayOfWeek, + Operator: OpIn, + Value: cvStrings([]string{"wed", "fri"}), + } + condTime := BaseCondition{ + ID: "tod", + Type: CondTimeOfDay, + Operator: OpEquals, // operator is ignored for time-of-day internally + Value: cvString("13:00-15:00"), + } + + rule := PromotionRule{ + ID: "day-time", + Status: StatusActive, + Priority: 1, + StartDate: "2024-01-01", + EndDate: nil, + Conditions: Conditions{condDay, condTime}, + Actions: []Action{{ID: "a", Type: ActionPercentageDiscount, Value: 15}}, + } + + tracer := &testTracer{} + svc := NewPromotionService(tracer) + res := svc.EvaluateRule(rule, ctx) + if !res.Applicable { + t.Fatalf("expected rule to apply for Wednesday 14:30 in window 13-15") + } +} + +func TestDateRangeCondition(t *testing.T) { + now := time.Date(2024, 3, 15, 9, 0, 0, 0, time.UTC) + ctx := &PromotionEvalContext{Now: now} + + condRange := BaseCondition{ + ID: "date_range", + Type: CondDateRange, + Operator: OpEquals, // not used + Value: cvStrings([]string{"2024-03-01", "2024-03-31"}), + } + + rule := PromotionRule{ + ID: "range", + Status: StatusActive, + Priority: 1, + StartDate: "2024-01-01", + EndDate: nil, + Conditions: Conditions{condRange}, + Actions: []Action{{ID: "a", Type: ActionFixedDiscount, Value: 500}}, + } + + tracer := &testTracer{} + svc := NewPromotionService(tracer) + res := svc.EvaluateRule(rule, ctx) + if !res.Applicable { + t.Fatalf("expected date range rule to apply for 2024-03-15") + } +} + +// --- Utilities ------------------------------------------------------------- + +func ptr(s string) *string { return &s } +func ptrInt(i int) *int { return &i } diff --git a/pkg/promotions/types.go b/pkg/promotions/types.go index bf5b370..d23061e 100644 --- a/pkg/promotions/types.go +++ b/pkg/promotions/types.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "strconv" + "strings" ) // ----------------------------- @@ -15,12 +16,12 @@ import ( type ConditionOperator string const ( - OpEquals ConditionOperator = "equals" - OpNotEquals ConditionOperator = "not_equals" - OpGreaterThan ConditionOperator = "greater_than" - OpLessThan ConditionOperator = "less_than" - OpGreaterOrEqual ConditionOperator = "greater_or_equal" - OpLessOrEqual ConditionOperator = "less_or_equal" + OpEquals ConditionOperator = "=" + OpNotEquals ConditionOperator = "!=" + OpGreaterThan ConditionOperator = ">" + OpLessThan ConditionOperator = "<" + OpGreaterOrEqual ConditionOperator = ">=" + OpLessOrEqual ConditionOperator = "<=" OpContains ConditionOperator = "contains" OpNotContains ConditionOperator = "not_contains" OpIn ConditionOperator = "in" @@ -57,8 +58,8 @@ const ( type LogicOperator string const ( - LogicAND LogicOperator = "AND" - LogicOR LogicOperator = "OR" + LogicAND LogicOperator = "&&" + LogicOR LogicOperator = "||" ) type PromotionStatus string @@ -218,7 +219,14 @@ func (g *ConditionGroup) UnmarshalJSON(b []byte) error { g.ID = a.ID g.Type = a.Type - g.Operator = a.Operator + switch strings.ToLower(string(a.Operator)) { + case "and": + g.Operator = LogicAND + case "or": + g.Operator = LogicOR + default: + g.Operator = a.Operator + } g.Conditions = conds return nil } diff --git a/pkg/voucher/parser.go b/pkg/voucher/parser.go index c76102f..7d41e3d 100644 --- a/pkg/voucher/parser.go +++ b/pkg/voucher/parser.go @@ -3,6 +3,7 @@ package voucher import ( "errors" "fmt" + "slices" "strconv" "strings" "unicode" @@ -137,12 +138,7 @@ func (rs *RuleSet) Applies(ctx EvalContext) bool { // anyItem returns true if predicate matches any item. func anyItem(items []Item, pred func(Item) bool) bool { - for _, it := range items { - if pred(it) { - return true - } - } - return false + return slices.ContainsFunc(items, pred) } // ParseRules parses a rule expression into a RuleSet.