From 918aa7d26528b67c9c2c7a59ae984cccb25668bc Mon Sep 17 00:00:00 2001 From: matst80 Date: Fri, 17 Oct 2025 09:23:05 +0200 Subject: [PATCH] promotion types --- cmd/backoffice/fileserver.go | 16 ++ cmd/backoffice/main.go | 1 + pkg/promotions/type_test.go | 443 +++++++++++++++++++++++++++++++++++ pkg/promotions/types.go | 371 +++++++++++++++++++++++++++++ 4 files changed, 831 insertions(+) create mode 100644 pkg/promotions/type_test.go create mode 100644 pkg/promotions/types.go diff --git a/cmd/backoffice/fileserver.go b/cmd/backoffice/fileserver.go index 15dbe62..21106b0 100644 --- a/cmd/backoffice/fileserver.go +++ b/cmd/backoffice/fileserver.go @@ -184,6 +184,22 @@ func (fs *FileServer) PromotionsHandler(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusMethodNotAllowed) } +func (fs *FileServer) PromotionPartHandler(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + if idStr == "" { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "missing id") + return + } + _, ok := isValidId(idStr) + if !ok { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "invalid id %s", idStr) + return + } + w.WriteHeader(http.StatusNotImplemented) +} + type JsonError struct { Error string `json:"error"` } diff --git a/cmd/backoffice/main.go b/cmd/backoffice/main.go index 5f9ac87..7f489ce 100644 --- a/cmd/backoffice/main.go +++ b/cmd/backoffice/main.go @@ -90,6 +90,7 @@ func main() { mux.HandleFunc("GET /carts", fs.CartsHandler) mux.HandleFunc("GET /cart/{id}", fs.CartHandler) mux.HandleFunc("/promotions", fs.PromotionsHandler) + mux.HandleFunc("/promotion/{id}", fs.PromotionPartHandler) mux.HandleFunc("/ws", hub.ServeWS) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/promotions/type_test.go b/pkg/promotions/type_test.go new file mode 100644 index 0000000..e65efed --- /dev/null +++ b/pkg/promotions/type_test.go @@ -0,0 +1,443 @@ +package promotions + +import ( + "encoding/json" + "testing" +) + +// sampleJSON mirrors the user's full example data (all three rules) +var sampleJSON = []byte(`[ + { + "id": "1", + "name": "Summer Sale 2024", + "description": "20% off on all summer items", + "status": "active", + "priority": 1, + "startDate": "2024-06-01", + "endDate": "2024-08-31", + "conditions": [ + { + "id": "group1", + "type": "group", + "operator": "AND", + "conditions": [ + { + "id": "c1", + "type": "product_category", + "operator": "in", + "value": ["summer", "beachwear"], + "label": "Product category is Summer or Beachwear" + }, + { + "id": "c2", + "type": "cart_total", + "operator": "greater_or_equal", + "value": 50, + "label": "Cart total is at least $50" + } + ] + } + ], + "actions": [ + { + "id": "a1", + "type": "percentage_discount", + "value": 20, + "label": "20% discount" + } + ], + "usageLimit": 1000, + "usageCount": 342, + "createdAt": "2024-05-15T10:00:00Z", + "updatedAt": "2024-05-20T14:30:00Z", + "createdBy": "admin@example.com", + "tags": ["seasonal", "summer"] + }, + { + "id": "2", + "name": "VIP Customer Exclusive", + "description": "Free shipping for VIP customers", + "status": "active", + "priority": 2, + "startDate": "2024-01-01", + "endDate": null, + "conditions": [ + { + "id": "c3", + "type": "customer_segment", + "operator": "equals", + "value": "vip", + "label": "Customer segment is VIP" + } + ], + "actions": [ + { + "id": "a2", + "type": "free_shipping", + "value": 0, + "label": "Free shipping" + } + ], + "usageCount": 1523, + "createdAt": "2023-12-15T09:00:00Z", + "updatedAt": "2024-01-05T11:20:00Z", + "createdBy": "marketing@example.com", + "tags": ["vip", "loyalty"] + }, + { + "id": "3", + "name": "Buy 2 Get 1 Free", + "description": "Buy 2 items, get the cheapest one free", + "status": "scheduled", + "priority": 3, + "startDate": "2024-12-01", + "endDate": "2024-12-25", + "conditions": [ + { + "id": "c4", + "type": "item_quantity", + "operator": "greater_or_equal", + "value": 3, + "label": "Cart has at least 3 items" + } + ], + "actions": [ + { + "id": "a3", + "type": "buy_x_get_y", + "value": 0, + "config": { "buy": 2, "get": 1, "discount": 100 }, + "label": "Buy 2 Get 1 Free" + } + ], + "usageCount": 0, + "createdAt": "2024-11-01T08:00:00Z", + "updatedAt": "2024-11-01T08:00:00Z", + "createdBy": "admin@example.com", + "tags": ["holiday", "christmas"] + } +]`) + +func TestDecodePromotionRulesBasic(t *testing.T) { + rules, err := DecodePromotionRules(sampleJSON) + if err != nil { + t.Fatalf("DecodePromotionRules failed: %v", err) + } + if len(rules) != 3 { + t.Fatalf("expected 3 rules, got %d", len(rules)) + } + + // Rule 1 checks + r1 := rules[0] + if r1.ID != "1" { + t.Errorf("rule[0].ID = %s, want 1", r1.ID) + } + if r1.Status != StatusActive { + t.Errorf("rule[0].Status = %s, want %s", r1.Status, StatusActive) + } + if r1.EndDate == nil || *r1.EndDate != "2024-08-31" { + t.Errorf("rule[0].EndDate = %v, want 2024-08-31", r1.EndDate) + } + if r1.UsageLimit == nil || *r1.UsageLimit != 1000 { + t.Errorf("rule[0].UsageLimit = %v, want 1000", r1.UsageLimit) + } + + // Rule 2 checks + r2 := rules[1] + if r2.ID != "2" { + t.Errorf("rule[1].ID = %s, want 2", r2.ID) + } + if r2.EndDate != nil { + t.Errorf("rule[1].EndDate should be nil (from null), got %v", *r2.EndDate) + } + if r2.UsageLimit != nil { + t.Errorf("rule[1].UsageLimit should be nil (missing), got %v", *r2.UsageLimit) + } +} + +func TestConditionDecoding(t *testing.T) { + rules, err := DecodePromotionRules(sampleJSON) + if err != nil { + t.Fatalf("DecodePromotionRules failed: %v", err) + } + r1 := rules[0] + if len(r1.Conditions) != 1 { + t.Fatalf("expected 1 top-level condition group, got %d", len(r1.Conditions)) + } + + grp, ok := r1.Conditions[0].(ConditionGroup) + if !ok { + t.Fatalf("top-level condition is not a group, type=%T", r1.Conditions[0]) + } + if grp.Operator != LogicAND { + t.Errorf("group operator = %s, want AND", grp.Operator) + } + + if len(grp.Conditions) != 2 { + t.Fatalf("expected 2 child conditions, got %d", len(grp.Conditions)) + } + + // First child: product_category condition with slice value + c0, ok := grp.Conditions[0].(BaseCondition) + if !ok { + t.Fatalf("first child not BaseCondition, got %T", grp.Conditions[0]) + } + if c0.Type != CondProductCategory { + t.Errorf("first child type = %s, want %s", c0.Type, CondProductCategory) + } + if c0.Operator != OpIn { + t.Errorf("first child operator = %s, want %s", c0.Operator, OpIn) + } + if arr, ok := c0.Value.AsStringSlice(); !ok || len(arr) != 2 || arr[0] != "summer" { + t.Errorf("expected string slice value [summer,...], got %v", arr) + } + + // Second child: cart_total numeric + c1, ok := grp.Conditions[1].(BaseCondition) + if !ok { + t.Fatalf("second child not BaseCondition, got %T", grp.Conditions[1]) + } + if c1.Type != CondCartTotal { + t.Errorf("second child type = %s, want %s", c1.Type, CondCartTotal) + } + if val, ok := c1.Value.AsFloat64(); !ok || val != 50 { + t.Errorf("expected numeric value 50, got %v (ok=%v)", val, ok) + } +} + +func TestConditionValueHelpers(t *testing.T) { + tests := []struct { + name string + jsonVal string + wantStr string + wantNum *float64 + wantSS []string + wantFS []float64 + }{ + { + name: "string value", + jsonVal: `"vip"`, + wantStr: "vip", + }, + { + name: "number value", + jsonVal: `42`, + wantNum: floatPtr(42), + }, + { + name: "string slice", + jsonVal: `["a","b"]`, + wantSS: []string{"a", "b"}, + }, + { + name: "number slice int", + jsonVal: `[1,2,3]`, + wantFS: []float64{1, 2, 3}, + }, + { + name: "number slice float", + jsonVal: `[1.5,2.25]`, + wantFS: []float64{1.5, 2.25}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var cv ConditionValue + if err := json.Unmarshal([]byte(tc.jsonVal), &cv); err != nil { + t.Fatalf("unmarshal value failed: %v", err) + } + if tc.wantStr != "" { + if got, ok := cv.AsString(); !ok || got != tc.wantStr { + t.Errorf("AsString got=%q ok=%v want=%q", got, ok, tc.wantStr) + } + } + if tc.wantNum != nil { + if got, ok := cv.AsFloat64(); !ok || got != *tc.wantNum { + t.Errorf("AsFloat64 got=%v ok=%v want=%v", got, ok, *tc.wantNum) + } + } + if tc.wantSS != nil { + if got, ok := cv.AsStringSlice(); !ok || len(got) != len(tc.wantSS) || got[0] != tc.wantSS[0] { + t.Errorf("AsStringSlice got=%v ok=%v want=%v", got, ok, tc.wantSS) + } + } + if tc.wantFS != nil { + if got, ok := cv.AsFloat64Slice(); !ok || len(got) != len(tc.wantFS) { + t.Errorf("AsFloat64Slice got=%v ok=%v want=%v", got, ok, tc.wantFS) + } else { + for i := range got { + if got[i] != tc.wantFS[i] { + t.Errorf("AsFloat64Slice[%d]=%v want=%v", i, got[i], tc.wantFS[i]) + } + } + } + } + }) + } +} + +func TestWalkConditionsTraversal(t *testing.T) { + rules, err := DecodePromotionRules(sampleJSON) + if err != nil { + t.Fatalf("DecodePromotionRules failed: %v", err) + } + visited := 0 + WalkConditions(rules[0].Conditions, func(c Condition) bool { + visited++ + return true + }) + // group + 2 children + if visited != 3 { + t.Errorf("expected 3 visited conditions, got %d", visited) + } +} + +func TestActionBundleConfigParsing(t *testing.T) { + jsonData := []byte(`[ + { + "id": "bundle-1", + "name": "Bundle Deal", + "description": "Fixed price bundle", + "status": "active", + "priority": 1, + "startDate": "2024-01-01", + "endDate": null, + "conditions": [], + "actions": [ + { + "id": "act-bundle", + "type": "bundle_discount", + "value": 0, + "bundleConfig": { + "containers": [ + { + "id": "cont1", + "name": "Shoes", + "quantity": 2, + "selectionType": "any", + "qualifyingRules": { + "type": "category", + "value": "shoes" + } + }, + { + "id": "cont2", + "name": "Socks", + "quantity": 3, + "selectionType": "specific", + "qualifyingRules": { + "type": "product_ids", + "value": ["sock-1", "sock-2"] + }, + "allowedProducts": ["sock-1","sock-2"] + } + ], + "pricing": { + "type": "fixed_price", + "value": 49.99 + }, + "requireAllContainers": true + }, + "config": { "note": "Bundle applies to footwear + socks" } + } + ], + "usageCount": 0, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "createdBy": "bundle@example.com", + "tags": ["bundle","footwear"] + } + ]`) + rules, err := DecodePromotionRules(jsonData) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + if len(rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(rules)) + } + if len(rules[0].Actions) != 1 { + t.Fatalf("expected 1 action, got %d", len(rules[0].Actions)) + } + act := rules[0].Actions[0] + if act.Type != ActionBundleDiscount { + t.Fatalf("action type = %s, want %s", act.Type, ActionBundleDiscount) + } + if act.BundleConfig == nil { + t.Fatalf("bundleConfig nil") + } + if act.BundleConfig.Pricing.Type != "fixed_price" { + t.Errorf("pricing.type = %s, want fixed_price", act.BundleConfig.Pricing.Type) + } + if act.BundleConfig.Pricing.Value != 49.99 { + t.Errorf("pricing.value = %v, want 49.99", act.BundleConfig.Pricing.Value) + } + if !act.BundleConfig.RequireAllContainers { + t.Errorf("RequireAllContainers expected true") + } + if len(act.BundleConfig.Containers) != 2 { + t.Fatalf("expected 2 containers, got %d", len(act.BundleConfig.Containers)) + } + if act.Config == nil || act.Config["note"] != "Bundle applies to footwear + socks" { + t.Errorf("config.note mismatch: %v", act.Config) + } +} + +func TestConditionValueInvalidTypes(t *testing.T) { + cases := []struct { + name string + raw string + }{ + {"object", `{}`}, + {"booleanTrue", `true`}, + {"booleanFalse", `false`}, + {"null", `null`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var cv ConditionValue + if err := json.Unmarshal([]byte(tc.raw), &cv); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if s, ok := cv.AsString(); ok { + t.Errorf("AsString unexpectedly succeeded (%q) for %s", s, tc.name) + } + if n, ok := cv.AsFloat64(); ok { + t.Errorf("AsFloat64 unexpectedly succeeded (%v) for %s", n, tc.name) + } + if ss, ok := cv.AsStringSlice(); ok { + t.Errorf("AsStringSlice unexpectedly succeeded (%v) for %s", ss, tc.name) + } + if fs, ok := cv.AsFloat64Slice(); ok { + t.Errorf("AsFloat64Slice unexpectedly succeeded (%v) for %s", fs, tc.name) + } + }) + } +} + +func TestPromotionRuleRoundTrip(t *testing.T) { + orig, err := DecodePromotionRules(sampleJSON) + if err != nil { + t.Fatalf("initial decode failed: %v", err) + } + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + round, err := DecodePromotionRules(data) + if err != nil { + t.Fatalf("round-trip decode failed: %v", err) + } + if len(orig) != len(round) { + t.Fatalf("rule count mismatch: orig=%d round=%d", len(orig), len(round)) + } + // spot-check first rule + if orig[0].Name != round[0].Name { + t.Errorf("first rule name mismatch: %s vs %s", orig[0].Name, round[0].Name) + } + if len(orig[0].Conditions) != len(round[0].Conditions) { + t.Errorf("first rule condition count mismatch: %d vs %d", len(orig[0].Conditions), len(round[0].Conditions)) + } +} + +func floatPtr(f float64) *float64 { return &f } diff --git a/pkg/promotions/types.go b/pkg/promotions/types.go new file mode 100644 index 0000000..3646c60 --- /dev/null +++ b/pkg/promotions/types.go @@ -0,0 +1,371 @@ +package promotions + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" +) + +// ----------------------------- +// Enum-like string types +// ----------------------------- + +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" + OpContains ConditionOperator = "contains" + OpNotContains ConditionOperator = "not_contains" + OpIn ConditionOperator = "in" + OpNotIn ConditionOperator = "not_in" +) + +type ConditionType string + +const ( + CondCartTotal ConditionType = "cart_total" + CondItemQuantity ConditionType = "item_quantity" + CondCustomerSegment ConditionType = "customer_segment" + CondProductCategory ConditionType = "product_category" + CondProductID ConditionType = "product_id" + CondCustomerLifetime ConditionType = "customer_lifetime_value" + CondOrderCount ConditionType = "order_count" + CondDateRange ConditionType = "date_range" + CondDayOfWeek ConditionType = "day_of_week" + CondTimeOfDay ConditionType = "time_of_day" + CondGroup ConditionType = "group" // synthetic value for groups +) + +type ActionType string + +const ( + ActionPercentageDiscount ActionType = "percentage_discount" + ActionFixedDiscount ActionType = "fixed_discount" + ActionFreeShipping ActionType = "free_shipping" + ActionBuyXGetY ActionType = "buy_x_get_y" + ActionTieredDiscount ActionType = "tiered_discount" + ActionBundleDiscount ActionType = "bundle_discount" +) + +type LogicOperator string + +const ( + LogicAND LogicOperator = "AND" + LogicOR LogicOperator = "OR" +) + +type PromotionStatus string + +const ( + StatusActive PromotionStatus = "active" + StatusInactive PromotionStatus = "inactive" + StatusScheduled PromotionStatus = "scheduled" + StatusExpired PromotionStatus = "expired" +) + +// ----------------------------- +// Condition Value (union) +// ----------------------------- +// +// Represents: string | number | []string | []number +// We store raw JSON and lazily interpret. + +type ConditionValue struct { + Raw json.RawMessage +} + +func (cv *ConditionValue) UnmarshalJSON(b []byte) error { + if len(b) == 0 { + return errors.New("empty ConditionValue") + } + // Just store raw; interpretation happens via helpers. + cv.Raw = append(cv.Raw[0:0], b...) + return nil +} + +// Helpers to interpret value: +func (cv ConditionValue) AsString() (string, bool) { + // Treat explicit JSON null as invalid + if string(cv.Raw) == "null" { + return "", false + } + var s string + if err := json.Unmarshal(cv.Raw, &s); err == nil { + return s, true + } + return "", false +} + +func (cv ConditionValue) AsFloat64() (float64, bool) { + if string(cv.Raw) == "null" { + return 0, false + } + var f float64 + if err := json.Unmarshal(cv.Raw, &f); err == nil { + return f, true + } + // Attempt integer decode into float64 + var i int64 + if err := json.Unmarshal(cv.Raw, &i); err == nil { + return float64(i), true + } + return 0, false +} + +func (cv ConditionValue) AsStringSlice() ([]string, bool) { + if string(cv.Raw) == "null" { + return nil, false + } + var arr []string + if err := json.Unmarshal(cv.Raw, &arr); err == nil { + return arr, true + } + return nil, false +} + +func (cv ConditionValue) AsFloat64Slice() ([]float64, bool) { + if string(cv.Raw) == "null" { + return nil, false + } + var arrNum []float64 + if err := json.Unmarshal(cv.Raw, &arrNum); err == nil { + return arrNum, true + } + // Try []int -> []float64 + var arrInt []int64 + if err := json.Unmarshal(cv.Raw, &arrInt); err == nil { + out := make([]float64, len(arrInt)) + for i, v := range arrInt { + out[i] = float64(v) + } + return out, true + } + return nil, false +} + +func (cv ConditionValue) String() string { + if s, ok := cv.AsString(); ok { + return s + } + if f, ok := cv.AsFloat64(); ok { + return strconv.FormatFloat(f, 'f', -1, 64) + } + if ss, ok := cv.AsStringSlice(); ok { + return fmt.Sprintf("%v", ss) + } + if fs, ok := cv.AsFloat64Slice(); ok { + return fmt.Sprintf("%v", fs) + } + return string(cv.Raw) +} + +// ----------------------------- +// BaseCondition +// ----------------------------- + +type BaseCondition struct { + ID string `json:"id"` + Type ConditionType `json:"type"` + Operator ConditionOperator `json:"operator"` + Value ConditionValue `json:"value"` + Label *string `json:"label,omitempty"` +} + +func (b BaseCondition) IsGroup() bool { return false } + +// ----------------------------- +// ConditionGroup +// ----------------------------- + +type ConditionGroup struct { + ID string `json:"id"` + Type string `json:"type"` // always "group" + Operator LogicOperator `json:"operator"` + Conditions Conditions `json:"conditions"` +} + +// Custom unmarshaller ensures nested polymorphic conditions are decoded +// using the Conditions type (which applies the raw element discriminator). +func (g *ConditionGroup) UnmarshalJSON(b []byte) error { + type alias struct { + ID string `json:"id"` + Type string `json:"type"` + Operator LogicOperator `json:"operator"` + Conditions json.RawMessage `json:"conditions"` + } + var a alias + if err := json.Unmarshal(b, &a); err != nil { + return err + } + // Basic validation + if a.Type != "group" { + return fmt.Errorf("ConditionGroup expected type 'group', got %q", a.Type) + } + + var conds Conditions + if len(a.Conditions) > 0 { + if err := json.Unmarshal(a.Conditions, &conds); err != nil { + return err + } + } + + g.ID = a.ID + g.Type = a.Type + g.Operator = a.Operator + g.Conditions = conds + return nil +} + +func (g ConditionGroup) IsGroup() bool { return true } + +// ----------------------------- +// Condition interface + slice +// ----------------------------- + +type Condition interface { + IsGroup() bool +} + +// Internal wrapper to help decode each element. +type rawCond struct { + Type string `json:"type"` +} + +// Custom unmarshaler for a Condition slice +type Conditions []Condition + +func (cs *Conditions) UnmarshalJSON(b []byte) error { + var rawList []json.RawMessage + if err := json.Unmarshal(b, &rawList); err != nil { + return err + } + out := make([]Condition, 0, len(rawList)) + for _, elem := range rawList { + var hdr rawCond + if err := json.Unmarshal(elem, &hdr); err != nil { + return err + } + if hdr.Type == "group" { + var grp ConditionGroup + if err := json.Unmarshal(elem, &grp); err != nil { + return err + } + // Recursively decode grp.Conditions (already handled because field type is []Condition) + out = append(out, grp) + } else { + var bc BaseCondition + if err := json.Unmarshal(elem, &bc); err != nil { + return err + } + out = append(out, bc) + } + } + *cs = out + return nil +} + +// ----------------------------- +// Bundle Structures +// ----------------------------- + +type BundleQualifyingRules struct { + Type string `json:"type"` // "category" | "product_ids" | "tag" | "all" + Value interface{} `json:"value"` // string or []string +} + +type BundleContainer struct { + ID string `json:"id"` + Name string `json:"name"` + Quantity int `json:"quantity"` + SelectionType string `json:"selectionType"` // "any" | "specific" + QualifyingRules BundleQualifyingRules `json:"qualifyingRules"` + AllowedProducts []string `json:"allowedProducts,omitempty"` +} + +type BundlePricing struct { + Type string `json:"type"` // "fixed_price" | "percentage_discount" | "fixed_discount" + Value float64 `json:"value"` +} + +type BundleConfig struct { + Containers []BundleContainer `json:"containers"` + Pricing BundlePricing `json:"pricing"` + RequireAllContainers bool `json:"requireAllContainers"` +} + +// ----------------------------- +// Action +// ----------------------------- + +type Action struct { + ID string `json:"id"` + Type ActionType `json:"type"` + Value interface{} `json:"value"` // number or string + Config map[string]interface{} `json:"config,omitempty"` + BundleConfig *BundleConfig `json:"bundleConfig,omitempty"` + Label *string `json:"label,omitempty"` +} + +// ----------------------------- +// Promotion Rule +// ----------------------------- + +type PromotionRule struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Status PromotionStatus `json:"status"` + Priority int `json:"priority"` + StartDate string `json:"startDate"` + EndDate *string `json:"endDate"` // null -> nil + Conditions Conditions `json:"conditions"` + Actions []Action `json:"actions"` + UsageLimit *int `json:"usageLimit,omitempty"` + UsageCount int `json:"usageCount"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + CreatedBy string `json:"createdBy"` + Tags []string `json:"tags"` +} + +// ----------------------------- +// Promotion Stats +// ----------------------------- + +type PromotionStats struct { + TotalPromotions int `json:"totalPromotions"` + ActivePromotions int `json:"activePromotions"` + TotalRevenue float64 `json:"totalRevenue"` + TotalOrders int `json:"totalOrders"` + AverageDiscount float64 `json:"averageDiscount"` +} + +// ----------------------------- +// Utility: Decode array of rules +// ----------------------------- + +func DecodePromotionRules(data []byte) ([]PromotionRule, error) { + var rules []PromotionRule + if err := json.Unmarshal(data, &rules); err != nil { + return nil, err + } + return rules, nil +} + +// Example helper to inspect conditions programmatically. +func WalkConditions(conds []Condition, fn func(c Condition) bool) { + for _, c := range conds { + if !fn(c) { + return + } + if grp, ok := c.(ConditionGroup); ok { + WalkConditions(grp.Conditions, fn) + } + } +}