try new vouchers
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m14s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m57s

This commit is contained in:
matst80
2025-10-14 22:14:22 +02:00
parent f0b6a733f1
commit a7cbdcd0da
6 changed files with 632 additions and 18 deletions

347
pkg/voucher/parser.go Normal file
View File

@@ -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: <kind> >= 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 (>= <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 "<nil>"
}
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
}

179
pkg/voucher/parser_test.go Normal file
View File

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

View File

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