promotion types
All checks were successful
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m7s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m20s

This commit is contained in:
matst80
2025-10-17 09:23:05 +02:00
parent a1833d6685
commit 918aa7d265
4 changed files with 831 additions and 0 deletions

443
pkg/promotions/type_test.go Normal file
View File

@@ -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 }

371
pkg/promotions/types.go Normal file
View File

@@ -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)
}
}
}