cart and checkout
This commit is contained in:
+367
-5
@@ -2,6 +2,8 @@ package promotions
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
)
|
||||
@@ -28,7 +30,7 @@ type Effect interface {
|
||||
// Apply mutates the cart for a qualifying action and returns the discount it
|
||||
// took (nil for non-monetary effects such as free shipping) plus whether
|
||||
// anything took effect worth recording.
|
||||
Apply(g *cart.CartGrain, a Action) (discount *cart.Price, applied bool)
|
||||
Apply(g *cart.CartGrain, rule PromotionRule, a Action) (discount *cart.Price, applied bool)
|
||||
// Progress reports how far the cart is from (further) unlocking this action,
|
||||
// as an open key/value payload. qualified says whether the owning rule
|
||||
// currently applies, letting the effect distinguish "remaining to unlock"
|
||||
@@ -43,6 +45,8 @@ func defaultEffects() map[ActionType]Effect {
|
||||
ActionPercentageDiscount: percentageDiscountEffect{},
|
||||
ActionTieredDiscount: tieredDiscountEffect{},
|
||||
ActionFreeShipping: freeShippingEffect{},
|
||||
ActionBuyXGetY: buyXGetYEffect{},
|
||||
ActionBundleDiscount: bundleDiscountEffect{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +76,7 @@ func (s *PromotionService) ApplyResults(g *cart.CartGrain, results []EvaluationR
|
||||
}
|
||||
recorded := false
|
||||
if res.Applicable {
|
||||
if discount, applied := eff.Apply(g, a); applied {
|
||||
if discount, applied := eff.Apply(g, res.Rule, a); applied {
|
||||
entry.Discount = discount
|
||||
recorded = true
|
||||
}
|
||||
@@ -106,7 +110,7 @@ type percentageDiscountEffect struct{}
|
||||
|
||||
func (percentageDiscountEffect) Type() ActionType { return ActionPercentageDiscount }
|
||||
|
||||
func (percentageDiscountEffect) Apply(g *cart.CartGrain, a Action) (*cart.Price, bool) {
|
||||
func (percentageDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
|
||||
d := applyPercentageDiscount(g, percentFromValue(a.Value))
|
||||
return d, d != nil
|
||||
}
|
||||
@@ -124,7 +128,7 @@ type freeShippingEffect struct{}
|
||||
|
||||
func (freeShippingEffect) Type() ActionType { return ActionFreeShipping }
|
||||
|
||||
func (freeShippingEffect) Apply(_ *cart.CartGrain, _ Action) (*cart.Price, bool) {
|
||||
func (freeShippingEffect) Apply(_ *cart.CartGrain, _ PromotionRule, _ Action) (*cart.Price, bool) {
|
||||
return nil, true
|
||||
}
|
||||
|
||||
@@ -143,7 +147,7 @@ type tieredDiscountEffect struct{}
|
||||
|
||||
func (tieredDiscountEffect) Type() ActionType { return ActionTieredDiscount }
|
||||
|
||||
func (tieredDiscountEffect) Apply(g *cart.CartGrain, a Action) (*cart.Price, bool) {
|
||||
func (tieredDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
|
||||
d := applyTieredDiscount(g, a)
|
||||
return d, d != nil
|
||||
}
|
||||
@@ -366,3 +370,361 @@ func firstNum(m map[string]interface{}, keys ...string) float64 {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// buyXGetYEffect implementation
|
||||
// ----------------------------
|
||||
|
||||
type buyXGetYEffect struct{}
|
||||
|
||||
func (buyXGetYEffect) Type() ActionType { return ActionBuyXGetY }
|
||||
|
||||
type unitItem struct {
|
||||
item *cart.CartItem
|
||||
price int64
|
||||
}
|
||||
|
||||
func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*cart.Price, bool) {
|
||||
config := a.Config
|
||||
buy := int(firstNum(config, "buy"))
|
||||
if buy <= 0 {
|
||||
buy = 1
|
||||
}
|
||||
get := int(firstNum(config, "get"))
|
||||
if get <= 0 {
|
||||
get = 1
|
||||
}
|
||||
discount := firstNum(config, "discount")
|
||||
if discount <= 0 {
|
||||
discount = 100.0 // default free
|
||||
}
|
||||
|
||||
eligible := eligibleItems(rule, g)
|
||||
var units []unitItem
|
||||
for _, item := range eligible {
|
||||
for k := uint16(0); k < item.Quantity; k++ {
|
||||
units = append(units, unitItem{
|
||||
item: item,
|
||||
price: item.Price.IncVat,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
n := len(units)
|
||||
numFree := (n / (buy + get)) * get
|
||||
if numFree <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Sort units by price ascending so we discount the cheapest ones
|
||||
slices.SortFunc(units, func(x, y unitItem) int {
|
||||
if x.price < y.price {
|
||||
return -1
|
||||
}
|
||||
if x.price > y.price {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
totalDiscount := cart.NewPrice()
|
||||
for i := 0; i < numFree; i++ {
|
||||
unitDiscount := scalePrice(&units[i].item.Price, discount)
|
||||
totalDiscount.Add(*unitDiscount)
|
||||
}
|
||||
|
||||
if totalDiscount.IncVat <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
// Never discount below zero.
|
||||
if totalDiscount.IncVat > g.TotalPrice.IncVat {
|
||||
totalDiscount = g.TotalPrice
|
||||
}
|
||||
g.TotalDiscount.Add(*totalDiscount)
|
||||
g.TotalPrice.Subtract(*totalDiscount)
|
||||
return totalDiscount, true
|
||||
}
|
||||
|
||||
func (buyXGetYEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// bundleDiscountEffect implementation
|
||||
// ----------------------------
|
||||
|
||||
type bundleDiscountEffect struct{}
|
||||
|
||||
func (bundleDiscountEffect) Type() ActionType { return ActionBundleDiscount }
|
||||
|
||||
func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
|
||||
if a.BundleConfig == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// 1. Represent all cart items as individual units
|
||||
type bundleUnit struct {
|
||||
item *cart.CartItem
|
||||
used bool
|
||||
}
|
||||
var units []*bundleUnit
|
||||
for _, item := range g.Items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
for k := uint16(0); k < item.Quantity; k++ {
|
||||
units = append(units, &bundleUnit{
|
||||
item: item,
|
||||
used: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Greedy match bundles
|
||||
var formedBundles [][]*cart.CartItem
|
||||
for {
|
||||
// Attempt to form one bundle
|
||||
var matchedUnits []*bundleUnit
|
||||
possible := true
|
||||
|
||||
for _, container := range a.BundleConfig.Containers {
|
||||
needed := container.Quantity
|
||||
found := 0
|
||||
var containerMatched []*bundleUnit
|
||||
|
||||
// Find unused units matching container's qualifyingRules
|
||||
for _, u := range units {
|
||||
if u.used {
|
||||
continue
|
||||
}
|
||||
// Check if this unit is already matched in this bundle instance to avoid double counting
|
||||
alreadyMatched := false
|
||||
for _, mu := range matchedUnits {
|
||||
if mu == u {
|
||||
alreadyMatched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if alreadyMatched {
|
||||
continue
|
||||
}
|
||||
|
||||
if matchQualifyingRule(container.QualifyingRules, u.item) {
|
||||
containerMatched = append(containerMatched, u)
|
||||
found++
|
||||
if found == needed {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if found < needed {
|
||||
if a.BundleConfig.RequireAllContainers {
|
||||
possible = false
|
||||
break
|
||||
}
|
||||
} else {
|
||||
matchedUnits = append(matchedUnits, containerMatched...)
|
||||
}
|
||||
}
|
||||
|
||||
if !possible || len(matchedUnits) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Mark matched units as used
|
||||
var bundleItems []*cart.CartItem
|
||||
for _, mu := range matchedUnits {
|
||||
mu.used = true
|
||||
bundleItems = append(bundleItems, mu.item)
|
||||
}
|
||||
formedBundles = append(formedBundles, bundleItems)
|
||||
}
|
||||
|
||||
if len(formedBundles) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
totalDiscount := cart.NewPrice()
|
||||
for _, bundle := range formedBundles {
|
||||
// Calculate the original bundle price
|
||||
bundlePrice := cart.NewPrice()
|
||||
for _, item := range bundle {
|
||||
// Add a single unit's price
|
||||
bundlePrice.Add(item.Price)
|
||||
}
|
||||
|
||||
if bundlePrice.IncVat <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var bundleDiscount *cart.Price
|
||||
switch a.BundleConfig.Pricing.Type {
|
||||
case "fixed_price":
|
||||
targetPriceOre := int64(math.Round(a.BundleConfig.Pricing.Value * 100))
|
||||
if bundlePrice.IncVat > targetPriceOre {
|
||||
pct := float64(bundlePrice.IncVat-targetPriceOre) / float64(bundlePrice.IncVat) * 100
|
||||
bundleDiscount = scalePrice(bundlePrice, pct)
|
||||
}
|
||||
case "percentage_discount":
|
||||
bundleDiscount = scalePrice(bundlePrice, a.BundleConfig.Pricing.Value)
|
||||
case "fixed_discount":
|
||||
discountOre := int64(math.Round(a.BundleConfig.Pricing.Value * 100))
|
||||
pct := float64(discountOre) / float64(bundlePrice.IncVat) * 100
|
||||
bundleDiscount = scalePrice(bundlePrice, pct)
|
||||
}
|
||||
|
||||
if bundleDiscount != nil && bundleDiscount.IncVat > 0 {
|
||||
totalDiscount.Add(*bundleDiscount)
|
||||
}
|
||||
}
|
||||
|
||||
if totalDiscount.IncVat <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
// Never discount below zero.
|
||||
if totalDiscount.IncVat > g.TotalPrice.IncVat {
|
||||
totalDiscount = g.TotalPrice
|
||||
}
|
||||
g.TotalDiscount.Add(*totalDiscount)
|
||||
g.TotalPrice.Subtract(*totalDiscount)
|
||||
return totalDiscount, true
|
||||
}
|
||||
|
||||
func (bundleDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// Helpers
|
||||
// ----------------------------
|
||||
|
||||
func eligibleItems(rule PromotionRule, g *cart.CartGrain) []*cart.CartItem {
|
||||
var categories []string
|
||||
var productIDs []string
|
||||
|
||||
var walk func(conds Conditions)
|
||||
walk = func(conds Conditions) {
|
||||
for _, c := range conds {
|
||||
switch v := c.(type) {
|
||||
case ConditionGroup:
|
||||
walk(v.Conditions)
|
||||
case BaseCondition:
|
||||
if v.Type == CondProductCategory {
|
||||
if s, ok := v.Value.AsString(); ok {
|
||||
categories = append(categories, strings.ToLower(s))
|
||||
} else if arr, ok := v.Value.AsStringSlice(); ok {
|
||||
for _, s := range arr {
|
||||
categories = append(categories, strings.ToLower(s))
|
||||
}
|
||||
}
|
||||
} else if v.Type == CondProductID {
|
||||
if s, ok := v.Value.AsString(); ok {
|
||||
productIDs = append(productIDs, strings.ToLower(s))
|
||||
} else if arr, ok := v.Value.AsStringSlice(); ok {
|
||||
for _, s := range arr {
|
||||
productIDs = append(productIDs, strings.ToLower(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(rule.Conditions)
|
||||
|
||||
var out []*cart.CartItem
|
||||
for _, item := range g.Items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
match := true
|
||||
if len(categories) > 0 {
|
||||
cat := ""
|
||||
if item.Meta != nil {
|
||||
cat = strings.ToLower(item.Meta.Category)
|
||||
}
|
||||
found := false
|
||||
for _, c := range categories {
|
||||
if c == cat {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
match = false
|
||||
}
|
||||
}
|
||||
if len(productIDs) > 0 {
|
||||
sku := strings.ToLower(item.Sku)
|
||||
found := false
|
||||
for _, p := range productIDs {
|
||||
if p == sku {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
match = false
|
||||
}
|
||||
}
|
||||
if match {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func matchQualifyingRule(rule BundleQualifyingRules, item *cart.CartItem) bool {
|
||||
if item == nil {
|
||||
return false
|
||||
}
|
||||
valStr, isStr := rule.Value.(string)
|
||||
var valSlice []string
|
||||
if !isStr {
|
||||
if rawSlice, ok := rule.Value.([]interface{}); ok {
|
||||
for _, val := range rawSlice {
|
||||
if s, ok := val.(string); ok {
|
||||
valSlice = append(valSlice, strings.ToLower(s))
|
||||
}
|
||||
}
|
||||
} else if strSlice, ok := rule.Value.([]string); ok {
|
||||
for _, s := range strSlice {
|
||||
valSlice = append(valSlice, strings.ToLower(s))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
valStr = strings.ToLower(valStr)
|
||||
}
|
||||
|
||||
switch rule.Type {
|
||||
case "category":
|
||||
if item.Meta == nil {
|
||||
return false
|
||||
}
|
||||
cat := strings.ToLower(item.Meta.Category)
|
||||
if isStr {
|
||||
return cat == valStr
|
||||
}
|
||||
for _, c := range valSlice {
|
||||
if cat == c {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "product_ids":
|
||||
sku := strings.ToLower(item.Sku)
|
||||
if isStr {
|
||||
return sku == valStr
|
||||
}
|
||||
for _, s := range valSlice {
|
||||
if sku == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "all":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,3 +146,104 @@ func TestApplyResultsRecordsEffects(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuyXGetYEffect(t *testing.T) {
|
||||
rule := PromotionRule{
|
||||
ID: "buy2get1",
|
||||
Name: "Buy 2 Get 1 Free",
|
||||
Status: StatusActive,
|
||||
Actions: []Action{
|
||||
{
|
||||
ID: "buy2get1-action",
|
||||
Type: ActionBuyXGetY,
|
||||
Config: map[string]interface{}{
|
||||
"buy": 2.0,
|
||||
"get": 1.0,
|
||||
"discount": 100.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewPromotionService(nil)
|
||||
|
||||
// Cart with 3 items of price 1000 each. Buy 2 get 1 free -> 1 is free -> 1000 discount.
|
||||
g := cart.NewCartGrain(1, timeZero())
|
||||
g.Items = []*cart.CartItem{
|
||||
{Id: 1, Sku: "SKU1", Quantity: 3, Price: *cart.NewPriceFromIncVat(1000, 25)},
|
||||
}
|
||||
g.UpdateTotals()
|
||||
|
||||
results, _ := svc.EvaluateAll([]PromotionRule{rule}, NewContextFromCart(g, WithNow(timeZero())))
|
||||
svc.ApplyResults(g, results, NewContextFromCart(g, WithNow(timeZero())))
|
||||
|
||||
if got := g.TotalDiscount.IncVat; got != 1000 {
|
||||
t.Errorf("discount = %d, want 1000", got)
|
||||
}
|
||||
if got := g.TotalPrice.IncVat; got != 2000 {
|
||||
t.Errorf("total price = %d, want 2000", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleDiscountEffect(t *testing.T) {
|
||||
rule := PromotionRule{
|
||||
ID: "bundle-deal",
|
||||
Name: "Bundle Deal",
|
||||
Status: StatusActive,
|
||||
Actions: []Action{
|
||||
{
|
||||
ID: "bundle-action",
|
||||
Type: ActionBundleDiscount,
|
||||
BundleConfig: &BundleConfig{
|
||||
Containers: []BundleContainer{
|
||||
{
|
||||
ID: "shoes",
|
||||
Name: "Shoes",
|
||||
Quantity: 1,
|
||||
SelectionType: "any",
|
||||
QualifyingRules: BundleQualifyingRules{
|
||||
Type: "category",
|
||||
Value: "shoes",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "socks",
|
||||
Name: "Socks",
|
||||
Quantity: 2,
|
||||
SelectionType: "any",
|
||||
QualifyingRules: BundleQualifyingRules{
|
||||
Type: "category",
|
||||
Value: "socks",
|
||||
},
|
||||
},
|
||||
},
|
||||
Pricing: BundlePricing{
|
||||
Type: "fixed_price",
|
||||
Value: 40.0, // 40 kr -> 4000 ore
|
||||
},
|
||||
RequireAllContainers: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewPromotionService(nil)
|
||||
|
||||
// Cart with 1 shoe (5000) and 2 socks (1000 each). Total original = 7000. Bundle fixed price is 4000. Discount = 3000.
|
||||
g := cart.NewCartGrain(1, timeZero())
|
||||
g.Items = []*cart.CartItem{
|
||||
{Id: 1, Sku: "shoe-1", Quantity: 1, Price: *cart.NewPriceFromIncVat(5000, 25), Meta: &cart.ItemMeta{Category: "shoes"}},
|
||||
{Id: 2, Sku: "socks-1", Quantity: 2, Price: *cart.NewPriceFromIncVat(1000, 25), Meta: &cart.ItemMeta{Category: "socks"}},
|
||||
}
|
||||
g.UpdateTotals()
|
||||
|
||||
results, _ := svc.EvaluateAll([]PromotionRule{rule}, NewContextFromCart(g, WithNow(timeZero())))
|
||||
svc.ApplyResults(g, results, NewContextFromCart(g, WithNow(timeZero())))
|
||||
|
||||
if got := g.TotalDiscount.IncVat; got != 3000 {
|
||||
t.Errorf("discount = %d, want 3000", got)
|
||||
}
|
||||
if got := g.TotalPrice.IncVat; got != 4000 {
|
||||
t.Errorf("total price = %d, want 4000", got)
|
||||
}
|
||||
}
|
||||
|
||||
+34
-2
@@ -42,6 +42,7 @@ type PromotionEvalContext struct {
|
||||
CustomerLifetimeValue float64
|
||||
OrderCount int
|
||||
Now time.Time
|
||||
CouponCodes []string
|
||||
}
|
||||
|
||||
// ContextOption allows customization of fields when building a PromotionEvalContext.
|
||||
@@ -67,6 +68,11 @@ func WithNow(t time.Time) ContextOption {
|
||||
return func(c *PromotionEvalContext) { c.Now = t }
|
||||
}
|
||||
|
||||
// WithCouponCodes sets active coupon codes.
|
||||
func WithCouponCodes(codes []string) ContextOption {
|
||||
return func(c *PromotionEvalContext) { c.CouponCodes = codes }
|
||||
}
|
||||
|
||||
// NewContextFromCart builds a PromotionEvalContext from a CartGrain and optional metadata.
|
||||
func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEvalContext {
|
||||
ctx := &PromotionEvalContext{
|
||||
@@ -74,6 +80,7 @@ func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEval
|
||||
CartTotalIncVat: 0,
|
||||
TotalItemQuantity: 0,
|
||||
Now: time.Now(),
|
||||
CouponCodes: make([]string, 0),
|
||||
}
|
||||
if g.TotalPrice != nil {
|
||||
ctx.CartTotalIncVat = g.TotalPrice.IncVat
|
||||
@@ -91,6 +98,9 @@ func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEval
|
||||
})
|
||||
ctx.TotalItemQuantity += uint32(it.Quantity)
|
||||
}
|
||||
for _, v := range g.Vouchers {
|
||||
ctx.CouponCodes = append(ctx.CouponCodes, v.Code)
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(ctx)
|
||||
}
|
||||
@@ -106,18 +116,29 @@ type PromotionService struct {
|
||||
// even one that has already applied (e.g. a tiered discount at 5% pointing at
|
||||
// the next 9% tier). Register a new Effect to add a new action type.
|
||||
effects map[ActionType]Effect
|
||||
// DefaultTaxProvider provides the VAT rate used when constructing synthetic
|
||||
// carts for stateless evaluation and preview. When nil, falls back to 25 %.
|
||||
DefaultTaxProvider cart.TaxProvider
|
||||
}
|
||||
|
||||
// NewPromotionService constructs a PromotionService with an optional tracer and
|
||||
// the built-in effect handlers.
|
||||
func NewPromotionService(t Tracer) *PromotionService {
|
||||
// NewPromotionService constructs a PromotionService with an optional tracer.
|
||||
// When tp is non-nil, it is used as DefaultTaxProvider for synthetic cart
|
||||
// construction (stateless evaluation, preview); otherwise the service falls
|
||||
// back to 25 % VAT.
|
||||
func NewPromotionService(t Tracer, tp ...cart.TaxProvider) *PromotionService {
|
||||
if t == nil {
|
||||
t = NoopTracer{}
|
||||
}
|
||||
return &PromotionService{
|
||||
s := &PromotionService{
|
||||
tracer: t,
|
||||
effects: defaultEffects(),
|
||||
}
|
||||
if len(tp) > 0 && tp[0] != nil {
|
||||
s.DefaultTaxProvider = tp[0]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// RegisterEffect adds (or overrides) the handler for an action type, so new
|
||||
@@ -360,11 +381,22 @@ func evaluateBaseCondition(b BaseCondition, ctx *PromotionEvalContext) bool {
|
||||
return evalDayOfWeek(ctx.Now, b)
|
||||
case CondTimeOfDay:
|
||||
return evalTimeOfDay(ctx.Now, b)
|
||||
case CondCouponCode:
|
||||
return evalAnyCouponMatch(ctx.CouponCodes, b)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func evalAnyCouponMatch(codes []string, b BaseCondition) bool {
|
||||
for _, code := range codes {
|
||||
if evalStringCompare(code, b) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func evalAnyItemMatch(pred func(PromotionItem) bool, b BaseCondition, ctx *PromotionEvalContext) bool {
|
||||
if slices.ContainsFunc(ctx.Items, pred) {
|
||||
return true
|
||||
|
||||
@@ -442,6 +442,45 @@ func TestDateRangeCondition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCouponCodeCondition(t *testing.T) {
|
||||
rule := PromotionRule{
|
||||
ID: "coupon-promo",
|
||||
Name: "Coupon Promo",
|
||||
Status: StatusActive,
|
||||
Conditions: Conditions{
|
||||
BaseCondition{
|
||||
ID: "c",
|
||||
Type: CondCouponCode,
|
||||
Operator: OpEquals,
|
||||
Value: cvString("SAVE50"),
|
||||
},
|
||||
},
|
||||
Actions: []Action{{ID: "a", Type: ActionFixedDiscount, Value: 500}},
|
||||
}
|
||||
|
||||
svc := NewPromotionService(nil)
|
||||
|
||||
// Context with matching coupon code
|
||||
ctxMatched := &PromotionEvalContext{
|
||||
CouponCodes: []string{"SAVE50"},
|
||||
Now: time.Now(),
|
||||
}
|
||||
resMatched := svc.EvaluateRule(rule, ctxMatched)
|
||||
if !resMatched.Applicable {
|
||||
t.Errorf("expected coupon code rule to apply with SAVE50")
|
||||
}
|
||||
|
||||
// Context without matching coupon code
|
||||
ctxUnmatched := &PromotionEvalContext{
|
||||
CouponCodes: []string{"SAVE20"},
|
||||
Now: time.Now(),
|
||||
}
|
||||
resUnmatched := svc.EvaluateRule(rule, ctxUnmatched)
|
||||
if resUnmatched.Applicable {
|
||||
t.Errorf("expected coupon code rule NOT to apply with SAVE20")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Utilities -------------------------------------------------------------
|
||||
|
||||
func ptr(s string) *string { return &s }
|
||||
|
||||
@@ -52,7 +52,7 @@ type EvaluateResponse struct {
|
||||
// Evaluate runs rules against a synthetic cart built from req and returns the
|
||||
// resulting totals and effects without touching any real cart state.
|
||||
func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest) EvaluateResponse {
|
||||
g := req.toCart()
|
||||
g := req.toCart(s.DefaultTaxProvider)
|
||||
g.UpdateTotals()
|
||||
ctx := NewContextFromCart(g, req.contextOptions()...)
|
||||
results, _ := s.EvaluateAll(rules, ctx)
|
||||
@@ -65,7 +65,8 @@ func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest)
|
||||
}
|
||||
|
||||
// toCart materialises the request into a throwaway CartGrain.
|
||||
func (req EvaluateRequest) toCart() *cart.CartGrain {
|
||||
// tp is an optional TaxProvider for the default VAT rate; when nil, falls back to 25 %.
|
||||
func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain {
|
||||
now := time.Now()
|
||||
if req.Now != nil {
|
||||
now = *req.Now
|
||||
@@ -78,7 +79,7 @@ func (req EvaluateRequest) toCart() *cart.CartGrain {
|
||||
}
|
||||
vat := it.VatRate
|
||||
if vat == 0 {
|
||||
vat = 25
|
||||
vat = defaultVatRate(tp)
|
||||
}
|
||||
item := &cart.CartItem{
|
||||
Sku: it.Sku,
|
||||
@@ -91,15 +92,25 @@ func (req EvaluateRequest) toCart() *cart.CartGrain {
|
||||
g.Items = append(g.Items, item)
|
||||
}
|
||||
if len(g.Items) == 0 && req.CartTotalIncVat > 0 {
|
||||
vat := defaultVatRate(tp)
|
||||
g.Items = append(g.Items, &cart.CartItem{
|
||||
Sku: "synthetic",
|
||||
Quantity: 1,
|
||||
Price: *cart.NewPriceFromIncVat(req.CartTotalIncVat, 25),
|
||||
Price: *cart.NewPriceFromIncVat(req.CartTotalIncVat, vat),
|
||||
})
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// defaultVatRate returns the default VAT rate (as a float32 for NewPriceFromIncVat)
|
||||
// from the provider, or 25 if none is configured.
|
||||
func defaultVatRate(tp cart.TaxProvider) float32 {
|
||||
if tp == nil {
|
||||
return 25
|
||||
}
|
||||
return float32(tp.DefaultTaxRate(""))
|
||||
}
|
||||
|
||||
// contextOptions maps the optional customer/time fields onto context options.
|
||||
func (req EvaluateRequest) contextOptions() []ContextOption {
|
||||
var opts []ContextOption
|
||||
|
||||
@@ -133,6 +133,15 @@ func (s *Server) initialize(params json.RawMessage) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// defaultVatRate returns the default VAT rate from the eval service's
|
||||
// configured tax provider, falling back to 25 if none is set.
|
||||
func (s *Server) defaultVatRate() float32 {
|
||||
if s.eval == nil || s.eval.DefaultTaxProvider == nil {
|
||||
return 25
|
||||
}
|
||||
return float32(s.eval.DefaultTaxProvider.DefaultTaxRate(""))
|
||||
}
|
||||
|
||||
func isBatch(body []byte) bool {
|
||||
for _, b := range body {
|
||||
switch b {
|
||||
|
||||
@@ -86,7 +86,7 @@ func (s *Server) buildPublicTools() []tool {
|
||||
if err := decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity)
|
||||
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
|
||||
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
|
||||
if a.CustomerSegment != "" {
|
||||
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
|
||||
@@ -141,7 +141,7 @@ func (s *Server) buildPublicTools() []tool {
|
||||
rules = filtered
|
||||
}
|
||||
|
||||
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity)
|
||||
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
|
||||
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
|
||||
if a.CustomerSegment != "" {
|
||||
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
|
||||
|
||||
@@ -190,7 +190,7 @@ func (s *Server) buildTools() []tool {
|
||||
if err := decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity)
|
||||
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
|
||||
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
|
||||
if a.CustomerSegment != "" {
|
||||
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
|
||||
@@ -210,15 +210,19 @@ func (s *Server) buildTools() []tool {
|
||||
}
|
||||
}
|
||||
|
||||
// syntheticCart builds a one-line cart whose gross total is incVat öre (25% VAT
|
||||
// assumed) so the promotion engine can be evaluated against an arbitrary total.
|
||||
func syntheticCart(incVat int64, qty int) *cart.CartGrain {
|
||||
// syntheticCart builds a one-line cart whose gross total is incVat ore so the
|
||||
// promotion engine can be evaluated against an arbitrary total. defaultVatRate
|
||||
// is the VAT rate as a float32 percentage (e.g. 25 = 25 %).
|
||||
func syntheticCart(incVat int64, qty int, defaultVatRate float32) *cart.CartGrain {
|
||||
if qty <= 0 {
|
||||
qty = 1
|
||||
}
|
||||
if defaultVatRate == 0 {
|
||||
defaultVatRate = 25
|
||||
}
|
||||
g := cart.NewCartGrain(0, time.Now())
|
||||
g.Items = []*cart.CartItem{
|
||||
{Id: 1, Sku: "PREVIEW", Quantity: uint16(qty), Price: *cart.NewPriceFromIncVat(incVat, 25)},
|
||||
{Id: 1, Sku: "PREVIEW", Quantity: uint16(qty), Price: *cart.NewPriceFromIncVat(incVat, defaultVatRate)},
|
||||
}
|
||||
// Quantity > 1 would multiply the row total; keep the gross equal to incVat by
|
||||
// pricing a single unit at the full total.
|
||||
|
||||
@@ -41,6 +41,7 @@ const (
|
||||
CondDateRange ConditionType = "date_range"
|
||||
CondDayOfWeek ConditionType = "day_of_week"
|
||||
CondTimeOfDay ConditionType = "time_of_day"
|
||||
CondCouponCode ConditionType = "coupon_code"
|
||||
CondGroup ConditionType = "group" // synthetic value for groups
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user