diff --git a/cmd/cart/cart-grain.go b/cmd/cart/cart-grain.go index 7b0b911..846ba57 100644 --- a/cmd/cart/cart-grain.go +++ b/cmd/cart/cart-grain.go @@ -8,6 +8,7 @@ import ( "time" messages "git.tornberg.me/go-cart-actor/pkg/messages" + "git.tornberg.me/go-cart-actor/pkg/voucher" ) // Legacy padded [16]byte CartId and its helper methods removed. @@ -86,7 +87,55 @@ type Voucher struct { Code string `json:"code"` Rules []*messages.VoucherRule `json:"rules"` Id uint32 `json:"id"` - Value Price `json:"value"` + Value int64 `json:"value"` +} + +func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) { + // No rules -> applies to entire cart + if len(v.Rules) == 0 { + return cart.Items, true + } + + // Build evaluation context once + ctx := voucher.EvalContext{ + Items: make([]voucher.Item, 0, len(cart.Items)), + CartTotalInc: 0, + } + + if cart.TotalPrice != nil { + ctx.CartTotalInc = cart.TotalPrice.IncVat + } + + for _, it := range cart.Items { + category := "" + if it.Meta != nil { + category = it.Meta.Category + } + ctx.Items = append(ctx.Items, voucher.Item{ + Sku: it.Sku, + Category: category, + UnitPrice: it.Price.IncVat, + }) + } + + // All voucher rules must pass (logical AND) + for _, rule := range v.Rules { + expr := rule.GetCondition() + if expr == "" { + // Empty condition treated as pass (acts like a comment / placeholder) + continue + } + rs, err := voucher.ParseRules(expr) + if err != nil { + // Fail closed on parse error + return nil, false + } + if !rs.Applies(ctx) { + return nil, false + } + } + + return cart.Items, true } func (c *CartGrain) GetId() uint64 { @@ -208,9 +257,11 @@ func (c *CartGrain) UpdateTotals() { for _, delivery := range c.Deliveries { c.TotalPrice.Add(delivery.Price) } - // for _, voucher := range c.Vouchers { - // c.TotalPrice -= voucher.Value - // c.TotalTax -= voucher.TaxValue - // c.TotalDiscountTax += voucher.TaxValue - // } + for _, voucher := range c.Vouchers { + if _, ok := voucher.AppliesTo(c); ok { + value := NewPriceFromIncVat(voucher.Value, 25) + + c.TotalDiscount.Add(*value) + } + } } diff --git a/cmd/cart/mutation_add_voucher.go b/cmd/cart/mutation_add_voucher.go index cb80320..c29af7c 100644 --- a/cmd/cart/mutation_add_voucher.go +++ b/cmd/cart/mutation_add_voucher.go @@ -7,6 +7,32 @@ import ( "git.tornberg.me/go-cart-actor/pkg/messages" ) +func RemoveVoucher(g *CartGrain, m *messages.RemoveVoucher) error { + if m == nil { + return &actor.MutationError{ + Message: "RemoveVoucher: nil payload", + Code: 1003, + StatusCode: 400, + } + } + + if !slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool { + return v.Id == m.Id + }) { + return &actor.MutationError{ + Message: "voucher not applied", + Code: 1004, + StatusCode: 400, + } + } + + g.Vouchers = slices.DeleteFunc(g.Vouchers, func(v *Voucher) bool { + return v.Id == m.Id + }) + g.UpdateTotals() + return nil +} + func AddVoucher(g *CartGrain, m *messages.AddVoucher) error { if m == nil { return &actor.MutationError{ @@ -31,7 +57,7 @@ func AddVoucher(g *CartGrain, m *messages.AddVoucher) error { Id: g.lastVoucherId, Code: m.Code, Rules: m.VoucherRules, - Value: *NewPriceFromIncVat(m.Value, 25.0), + Value: m.Value, }) g.UpdateTotals() return nil diff --git a/cmd/cart/pool-server.go b/cmd/cart/pool-server.go index 3bd842d..db00529 100644 --- a/cmd/cart/pool-server.go +++ b/cmd/cart/pool-server.go @@ -12,6 +12,7 @@ import ( "git.tornberg.me/go-cart-actor/pkg/actor" messages "git.tornberg.me/go-cart-actor/pkg/messages" + "git.tornberg.me/go-cart-actor/pkg/voucher" "github.com/gogo/protobuf/proto" ) @@ -444,9 +445,20 @@ func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request } } +type AddVoucherRequest struct { + VoucherCode string `json:"code"` +} + func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, cartId CartId) error { - msg := &messages.AddVoucher{} - json.NewDecoder(r.Body).Decode(msg) + data := &AddVoucherRequest{} + json.NewDecoder(r.Body).Decode(data) + v := voucher.Service{} + msg, err := v.GetVoucher(data.VoucherCode) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return err + } reply, err := s.ApplyLocal(cartId, msg) if err != nil { w.WriteHeader(http.StatusInternalServerError) diff --git a/pkg/voucher/parser.go b/pkg/voucher/parser.go new file mode 100644 index 0000000..c76102f --- /dev/null +++ b/pkg/voucher/parser.go @@ -0,0 +1,347 @@ +package voucher + +import ( + "errors" + "fmt" + "strconv" + "strings" + "unicode" +) + +/* +Package voucher - rule parser + +A lightweight parser for voucher rule expressions. + +Supported rule kinds (case-insensitive keywords): + + sku=SKU1|SKU2|SKU3 + - At least one of the listed SKUs must be present in the cart. + + category=CatA|CatB|CatC + - At least one of the listed categories must be present. + + min_total>=12345 + - Cart total (Inc VAT) must be at least this value (int64). + + min_item_price>=5000 + - At least one individual item (Inc VAT single unit price) must be at least this value (int64). + +Rule list grammar (simplified): + rules := rule (sep rule)* + rule := (sku|category) '=' valueList + | (min_total|min_item_price) comparator number + valueList := value ('|' value)* + comparator := '>=' (only comparator currently supported for numeric rules) + sep := ';' | ',' | newline + +Whitespace is ignored around tokens. + +Example: + sku=ABC123|XYZ999; category=Shoes|Bags + min_total>=10000 + min_item_price>=2500, category=Accessories + +Parsing returns a RuleSet which can later be evaluated against a generic context. +The evaluation context uses simple Item abstractions to avoid tight coupling with +the cart implementation (which currently lives under cmd/cart and cannot be +imported due to being in package main). + +This is intentionally conservative and extensible: + * Adding new rule kinds: extend RuleKind constants, add parse + evaluate logic. + * Supporting new operators: extend numeric rule parsing & evaluation. +*/ + +var ( + // ErrEmptyExpression is returned when the input string has only whitespace. + ErrEmptyExpression = errors.New("voucher: empty rule expression") + // ErrInvalidRule indicates a syntactic or semantic issue with a single rule fragment. + ErrInvalidRule = errors.New("voucher: invalid rule") +) + +// RuleKind enumerates supported rule kinds. +type RuleKind string + +const ( + RuleSku RuleKind = "sku" + RuleCategory RuleKind = "category" + RuleMinTotal RuleKind = "min_total" + RuleMinItemPrice RuleKind = "min_item_price" +) + +// ruleCondition represents a single, parsed rule. +type ruleCondition struct { + Kind RuleKind + StringVals []string // For sku / category multi-value list + MinValue *int64 // For numeric threshold rules + // Operator reserved for future (e.g., >, >=, ==). Currently always ">=" for numeric kinds. + Operator string +} + +// RuleSet groups multiple rule conditions (logical AND). +// All conditions must pass for Applies() to return true. +type RuleSet struct { + Conditions []ruleCondition + Source string // original, trimmed source string +} + +// Item is a minimal abstraction for evaluation (decoupled from cart domain structs). +type Item struct { + Sku string + Category string + UnitPrice int64 // Inc VAT (single unit) +} + +// EvalContext bundles cart-like data necessary for evaluation. +type EvalContext struct { + Items []Item + CartTotalInc int64 +} + +// Applies returns true if all rule conditions pass for the context. +func (rs *RuleSet) Applies(ctx EvalContext) bool { + for _, c := range rs.Conditions { + switch c.Kind { + case RuleSku: + if !anyItem(ctx.Items, func(it Item) bool { + return containsFold(c.StringVals, it.Sku) + }) { + return false + } + case RuleCategory: + if !anyItem(ctx.Items, func(it Item) bool { + return containsFold(c.StringVals, it.Category) + }) { + return false + } + case RuleMinTotal: + if c.MinValue == nil || ctx.CartTotalInc < *c.MinValue { + return false + } + case RuleMinItemPrice: + if c.MinValue == nil { + return false + } + if !anyItem(ctx.Items, func(it Item) bool { + return it.UnitPrice >= *c.MinValue + }) { + return false + } + default: + // Unknown kinds fail closed to avoid granting unintended discounts. + return false + } + } + return true +} + +// 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 +} + +// ParseRules parses a rule expression into a RuleSet. +func ParseRules(input string) (*RuleSet, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return nil, ErrEmptyExpression + } + + fragments := splitRuleFragments(trimmed) + if len(fragments) == 0 { + return nil, ErrInvalidRule + } + + var conditions []ruleCondition + for _, frag := range fragments { + if frag == "" { + continue + } + c, err := parseFragment(frag) + if err != nil { + return nil, fmt.Errorf("%w: %s (%v)", ErrInvalidRule, frag, err) + } + conditions = append(conditions, c) + } + + if len(conditions) == 0 { + return nil, ErrInvalidRule + } + + return &RuleSet{ + Conditions: conditions, + Source: trimmed, + }, nil +} + +// splitRuleFragments splits on ; , or newline, while respecting basic structure. +func splitRuleFragments(s string) []string { + // Normalize line endings + s = strings.ReplaceAll(s, "\r\n", "\n") + + // We allow separators: newline, semicolon, comma. + seps := func(r rune) bool { + return r == ';' || r == '\n' || r == ',' + } + raw := strings.FieldsFunc(s, seps) + out := make([]string, 0, len(raw)) + for _, f := range raw { + t := strings.TrimSpace(f) + if t != "" { + out = append(out, t) + } + } + return out +} + +// parseFragment parses an individual rule fragment. +func parseFragment(frag string) (ruleCondition, error) { + lower := strings.ToLower(frag) + + // Numeric rules have form: >= number + if strings.HasPrefix(lower, string(RuleMinTotal)) || + strings.HasPrefix(lower, string(RuleMinItemPrice)) { + + return parseNumericRule(frag) + } + + // Key=Value list rules (sku / category). + if i := strings.Index(frag, "="); i > 0 { + key := strings.TrimSpace(frag[:i]) + valPart := strings.TrimSpace(frag[i+1:]) + if key == "" || valPart == "" { + return ruleCondition{}, errors.New("empty key/value") + } + kind := RuleKind(strings.ToLower(key)) + switch kind { + case RuleSku, RuleCategory: + values := splitAndClean(valPart, "|") + if len(values) == 0 { + return ruleCondition{}, errors.New("empty value list") + } + return ruleCondition{ + Kind: kind, + StringVals: values, + }, nil + default: + return ruleCondition{}, fmt.Errorf("unsupported key '%s'", key) + } + } + + return ruleCondition{}, fmt.Errorf("unrecognized fragment '%s'", frag) +} + +func parseNumericRule(frag string) (ruleCondition, error) { + // Support only '>=' for now. + var kind RuleKind + var rest string + + fragTrim := strings.TrimSpace(frag) + + switch { + case strings.HasPrefix(strings.ToLower(fragTrim), string(RuleMinTotal)): + kind = RuleMinTotal + rest = strings.TrimSpace(fragTrim[len(RuleMinTotal):]) + case strings.HasPrefix(strings.ToLower(fragTrim), string(RuleMinItemPrice)): + kind = RuleMinItemPrice + rest = strings.TrimSpace(fragTrim[len(RuleMinItemPrice):]) + default: + return ruleCondition{}, fmt.Errorf("unknown numeric rule '%s'", frag) + } + + // Expect operator and number (>= ) + rest = stripLeadingSpace(rest) + if !strings.HasPrefix(rest, ">=") { + return ruleCondition{}, fmt.Errorf("expected '>=' in '%s'", frag) + } + numStr := strings.TrimSpace(rest[2:]) + if numStr == "" { + return ruleCondition{}, fmt.Errorf("missing numeric value in '%s'", frag) + } + + value, err := strconv.ParseInt(numStr, 10, 64) + if err != nil { + return ruleCondition{}, fmt.Errorf("invalid number '%s': %v", numStr, err) + } + if value < 0 { + return ruleCondition{}, fmt.Errorf("negative threshold %d", value) + } + + return ruleCondition{ + Kind: kind, + MinValue: &value, + Operator: ">=", + }, nil +} + +func stripLeadingSpace(s string) string { + for len(s) > 0 && unicode.IsSpace(rune(s[0])) { + s = s[1:] + } + return s +} + +func splitAndClean(s string, sep string) []string { + raw := strings.Split(s, sep) + out := make([]string, 0, len(raw)) + for _, r := range raw { + t := strings.TrimSpace(r) + if t != "" { + out = append(out, t) + } + } + return out +} + +func containsFold(list []string, candidate string) bool { + for _, v := range list { + if strings.EqualFold(v, candidate) { + return true + } + } + return false +} + +// Describe returns a human-friendly summary of the parsed rule set. +func (rs *RuleSet) Describe() string { + if rs == nil { + return "" + } + var parts []string + for _, c := range rs.Conditions { + switch c.Kind { + case RuleSku, RuleCategory: + parts = append(parts, fmt.Sprintf("%s in (%s)", c.Kind, strings.Join(c.StringVals, "|"))) + case RuleMinTotal, RuleMinItemPrice: + if c.MinValue != nil { + parts = append(parts, fmt.Sprintf("%s %s %d", c.Kind, c.OperatorOr(">="), *c.MinValue)) + } + default: + parts = append(parts, fmt.Sprintf("unknown(%s)", c.Kind)) + } + } + return strings.Join(parts, " AND ") +} + +func (c ruleCondition) OperatorOr(def string) string { + if c.Operator == "" { + return def + } + return c.Operator +} + +// --- Convenience helpers for incremental adoption --- + +// MustParseRules panics on parse error (useful in tests or static initialization). +func MustParseRules(expr string) *RuleSet { + rs, err := ParseRules(expr) + if err != nil { + panic(err) + } + return rs +} diff --git a/pkg/voucher/parser_test.go b/pkg/voucher/parser_test.go new file mode 100644 index 0000000..c618e69 --- /dev/null +++ b/pkg/voucher/parser_test.go @@ -0,0 +1,179 @@ +package voucher + +import ( + "errors" + "testing" +) + +func TestParseRules_SimpleSku(t *testing.T) { + rs, err := ParseRules("sku=ABC123|XYZ999|def456") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(rs.Conditions) != 1 { + t.Fatalf("expected 1 condition got %d", len(rs.Conditions)) + } + c := rs.Conditions[0] + if c.Kind != RuleSku { + t.Fatalf("expected kind sku got %s", c.Kind) + } + if len(c.StringVals) != 3 { + t.Fatalf("expected 3 sku values got %d", len(c.StringVals)) + } + want := []string{"ABC123", "XYZ999", "def456"} + for i, v := range want { + if c.StringVals[i] != v { + t.Fatalf("expected sku[%d]=%s got %s", i, v, c.StringVals[i]) + } + } +} + +func TestParseRules_CategoryAndSkuMixedSeparators(t *testing.T) { + rs, err := ParseRules(" category=Shoes|Bags ; sku= A | B , min_total>=1000\nmin_item_price>=500") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(rs.Conditions) != 4 { + t.Fatalf("expected 4 conditions got %d", len(rs.Conditions)) + } + + kinds := []RuleKind{RuleCategory, RuleSku, RuleMinTotal, RuleMinItemPrice} + for i, k := range kinds { + if rs.Conditions[i].Kind != k { + t.Fatalf("expected condition[%d] kind %s got %s", i, k, rs.Conditions[i].Kind) + } + } + + // Validate numeric thresholds + if rs.Conditions[2].MinValue == nil || *rs.Conditions[2].MinValue != 1000 { + t.Fatalf("expected min_total>=1000 got %+v", rs.Conditions[2]) + } + if rs.Conditions[3].MinValue == nil || *rs.Conditions[3].MinValue != 500 { + t.Fatalf("expected min_item_price>=500 got %+v", rs.Conditions[3]) + } +} + +func TestParseRules_Empty(t *testing.T) { + _, err := ParseRules(" \n ") + if !errors.Is(err, ErrEmptyExpression) { + t.Fatalf("expected ErrEmptyExpression got %v", err) + } +} + +func TestParseRules_Invalid(t *testing.T) { + _, err := ParseRules("unknown=foo") + if err == nil { + t.Fatal("expected error for unknown key") + } + _, err = ParseRules("min_total>100") // wrong operator + if err == nil { + t.Fatal("expected error for wrong operator") + } + _, err = ParseRules("min_total>=") // missing value + if err == nil { + t.Fatal("expected error for missing numeric value") + } +} + +func TestRuleSet_Applies(t *testing.T) { + rs := MustParseRules("sku=ABC123|XYZ999; category=Shoes|min_total>=10000; min_item_price>=3000") + + ctx := EvalContext{ + Items: []Item{ + {Sku: "ABC123", Category: "Shoes", UnitPrice: 2500}, + {Sku: "FFF000", Category: "Accessories", UnitPrice: 3200}, + }, + CartTotalInc: 12000, + } + + if !rs.Applies(ctx) { + t.Fatalf("expected rules to apply") + } + + // Fail due to missing sku/category + ctx2 := EvalContext{ + Items: []Item{ + {Sku: "NOPE", Category: "Different", UnitPrice: 4000}, + }, + CartTotalInc: 20000, + } + if rs.Applies(ctx2) { + t.Fatalf("expected rules NOT to apply (sku/category mismatch)") + } + + // Fail due to min_total + ctx3 := EvalContext{ + Items: []Item{ + {Sku: "ABC123", Category: "Shoes", UnitPrice: 2500}, + {Sku: "FFF000", Category: "Accessories", UnitPrice: 3200}, + }, + CartTotalInc: 9000, + } + if rs.Applies(ctx3) { + t.Fatalf("expected rules NOT to apply (min_total not reached)") + } + + // Fail due to min_item_price (no item >=3000) + ctx4 := EvalContext{ + Items: []Item{ + {Sku: "ABC123", Category: "Shoes", UnitPrice: 2500}, + {Sku: "FFF000", Category: "Accessories", UnitPrice: 2800}, + }, + CartTotalInc: 15000, + } + if rs.Applies(ctx4) { + t.Fatalf("expected rules NOT to apply (min_item_price not satisfied)") + } +} + +func TestRuleSet_Applies_CaseInsensitive(t *testing.T) { + rs := MustParseRules("SKU=abc123|xyz999; CATEGORY=Shoes") + ctx := EvalContext{ + Items: []Item{ + {Sku: "AbC123", Category: "shoes", UnitPrice: 1000}, + }, + CartTotalInc: 1000, + } + if !rs.Applies(ctx) { + t.Fatalf("expected rules to apply (case-insensitive match)") + } +} + +func TestDescribe(t *testing.T) { + rs := MustParseRules("sku=A|B|min_total>=500") + desc := rs.Describe() + // Loose assertions to avoid over-specification + if desc == "" { + t.Fatalf("expected non-empty description") + } + if !(contains(desc, "sku") && contains(desc, "min_total")) { + t.Fatalf("description missing expected parts: %s", desc) + } +} + +func contains(haystack, needle string) bool { + return len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0 +} + +// Simple substring search (avoid importing strings to show intent explicitly here) +func indexOf(s, sub string) int { +outer: + for i := 0; i+len(sub) <= len(s); i++ { + for j := 0; j < len(sub); j++ { + if s[i+j] != sub[j] { + continue outer + } + } + return i + } + return -1 +} + +func TestMustParseRules_Panics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic for invalid expression") + } + }() + MustParseRules("~~ totally invalid ~~") +} diff --git a/pkg/voucher/service.go b/pkg/voucher/service.go index bc62adb..fb14ef4 100644 --- a/pkg/voucher/service.go +++ b/pkg/voucher/service.go @@ -2,7 +2,8 @@ package voucher import ( "errors" - "math" + + "git.tornberg.me/go-cart-actor/pkg/messages" ) type Rule struct { @@ -25,16 +26,14 @@ type Service struct { var ErrInvalidCode = errors.New("invalid vouchercode") -func (s *Service) GetVoucher(code string) (*Voucher, error) { - value := int64(math.Round(100 * math.Pow(10, 2))) +func (s *Service) GetVoucher(code string) (*messages.AddVoucher, error) { if code == "" { return nil, ErrInvalidCode } - return &Voucher{ - Code: code, - Value: value, - TaxValue: int64(float64(value) * 0.2), - TaxRate: 2500, - rules: nil, + value := int64(250_00) + return &messages.AddVoucher{ + Code: code, + Value: value, + VoucherRules: make([]*messages.VoucherRule, 0), }, nil }