promotions
This commit is contained in:
+251
-32
@@ -7,15 +7,249 @@ import (
|
||||
)
|
||||
|
||||
// ----------------------------
|
||||
// Action application
|
||||
// Effects: apply + progress
|
||||
// ----------------------------
|
||||
//
|
||||
// The evaluation engine (eval.go) decides *which* actions apply to a cart but
|
||||
// deliberately does not mutate the cart. Applying an action — turning a
|
||||
// "14% tiered discount" into actual öre off the total — happens here, because
|
||||
// it needs to touch cart.Price math. main.go calls ApplyActions after
|
||||
// UpdateTotals so the base totals (line items + vouchers) are settled before
|
||||
// promotions reduce them.
|
||||
// The evaluation engine (eval.go) decides *which* rules apply to a cart but
|
||||
// deliberately does not mutate it. Turning a matched action into actual öre off
|
||||
// the total — and describing how close an unmatched (or partially matched)
|
||||
// action is to its next reward — happens here, behind the Effect interface.
|
||||
//
|
||||
// Every action type is one Effect. ApplyResults walks the rules and, for each
|
||||
// action, asks its Effect to (a) apply itself if the rule qualifies and (b)
|
||||
// report progress. Both contributions land in cart.AppliedPromotion, so the
|
||||
// result exposes "you saved X" and "spend Y more for ..." through one list, for
|
||||
// any effect type — including progress on an already-applied effect.
|
||||
|
||||
// Effect is the behaviour for a single promotion action type.
|
||||
type Effect interface {
|
||||
// Type is the action type this effect handles.
|
||||
Type() ActionType
|
||||
// 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)
|
||||
// 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"
|
||||
// (pending) from "remaining to the next tier" (already applied). ok=false
|
||||
// means there is nothing worth surfacing.
|
||||
Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool)
|
||||
}
|
||||
|
||||
// defaultEffects returns the built-in action handlers.
|
||||
func defaultEffects() map[ActionType]Effect {
|
||||
return map[ActionType]Effect{
|
||||
ActionPercentageDiscount: percentageDiscountEffect{},
|
||||
ActionTieredDiscount: tieredDiscountEffect{},
|
||||
ActionFreeShipping: freeShippingEffect{},
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyResults applies the qualifying rules' actions to the cart in priority
|
||||
// order and records every effect — applied or pending — in g.AppliedPromotions.
|
||||
// An applied entry carries its Discount; a pending entry carries Pending=true.
|
||||
// Either kind may carry a Progress payload. Must be called after g.UpdateTotals().
|
||||
//
|
||||
// ctx is the same context used for EvaluateAll; progress is computed against the
|
||||
// pre-promotion totals it captured.
|
||||
func (s *PromotionService) ApplyResults(g *cart.CartGrain, results []EvaluationResult, ctx *PromotionEvalContext) {
|
||||
if g == nil || g.TotalPrice == nil {
|
||||
return
|
||||
}
|
||||
for _, res := range orderedByPriority(results) {
|
||||
for _, a := range res.Rule.Actions {
|
||||
eff, ok := s.effects[a.Type]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
entry := cart.AppliedPromotion{
|
||||
PromotionId: res.Rule.ID,
|
||||
Name: res.Rule.Name,
|
||||
ActionId: a.ID,
|
||||
Type: string(a.Type),
|
||||
Label: actionLabel(a),
|
||||
}
|
||||
recorded := false
|
||||
if res.Applicable {
|
||||
if discount, applied := eff.Apply(g, a); applied {
|
||||
entry.Discount = discount
|
||||
recorded = true
|
||||
}
|
||||
}
|
||||
if progress, ok := eff.Progress(s, res.Rule, a, ctx, res.Applicable); ok {
|
||||
entry.Progress = progress
|
||||
entry.Pending = !res.Applicable
|
||||
recorded = true
|
||||
}
|
||||
if recorded {
|
||||
g.AppliedPromotions = append(g.AppliedPromotions, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func actionLabel(a Action) string {
|
||||
if a.Label != nil {
|
||||
return *a.Label
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// Built-in effects
|
||||
// ----------------------------
|
||||
|
||||
// percentageDiscountEffect: a flat percentage off the whole cart. It can only be
|
||||
// on or off, so its only progress is "spend X more to reach the threshold".
|
||||
type percentageDiscountEffect struct{}
|
||||
|
||||
func (percentageDiscountEffect) Type() ActionType { return ActionPercentageDiscount }
|
||||
|
||||
func (percentageDiscountEffect) Apply(g *cart.CartGrain, a Action) (*cart.Price, bool) {
|
||||
d := applyPercentageDiscount(g, percentFromValue(a.Value))
|
||||
return d, d != nil
|
||||
}
|
||||
|
||||
func (percentageDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
|
||||
if qualified {
|
||||
return nil, false
|
||||
}
|
||||
return s.cartTotalProgress(rule, ctx)
|
||||
}
|
||||
|
||||
// freeShippingEffect: no cart-side amount (the fee lives in checkout). Applying
|
||||
// it just marks qualification; below the threshold it reports the remaining gap.
|
||||
type freeShippingEffect struct{}
|
||||
|
||||
func (freeShippingEffect) Type() ActionType { return ActionFreeShipping }
|
||||
|
||||
func (freeShippingEffect) Apply(_ *cart.CartGrain, _ Action) (*cart.Price, bool) {
|
||||
return nil, true
|
||||
}
|
||||
|
||||
func (freeShippingEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
|
||||
if qualified {
|
||||
return nil, false
|
||||
}
|
||||
return s.cartTotalProgress(rule, ctx)
|
||||
}
|
||||
|
||||
// tieredDiscountEffect: a volume discount whose percentage grows with the cart
|
||||
// total. Its progress points at the *next* tier, so it reports useful "spend X
|
||||
// more for Y%" data both before the first tier (pending) and between tiers
|
||||
// (already applied) — the canonical progress-on-applied-effect case.
|
||||
type tieredDiscountEffect struct{}
|
||||
|
||||
func (tieredDiscountEffect) Type() ActionType { return ActionTieredDiscount }
|
||||
|
||||
func (tieredDiscountEffect) Apply(g *cart.CartGrain, a Action) (*cart.Price, bool) {
|
||||
d := applyTieredDiscount(g, a)
|
||||
return d, d != nil
|
||||
}
|
||||
|
||||
func (tieredDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
|
||||
tiers := parseTiers(a.Config)
|
||||
if len(tiers) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
total := ctx.CartTotalIncVat
|
||||
currentPct, _ := selectTier(tiers, total) // 0 when below the first tier
|
||||
|
||||
// Find the nearest tier whose floor is still above the current total.
|
||||
nextMin := int64(0)
|
||||
nextPct := 0.0
|
||||
found := false
|
||||
for _, t := range tiers {
|
||||
if t.MinTotal > total && (!found || t.MinTotal < nextMin) {
|
||||
nextMin, nextPct, found = t.MinTotal, t.Percent, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, false // already at or above the top tier
|
||||
}
|
||||
// If not yet qualified, only nudge when the total is the sole blocker.
|
||||
if !qualified && !s.withinReach(rule, ctx, nextMin) {
|
||||
return nil, false
|
||||
}
|
||||
return map[string]any{
|
||||
"remaining": nextMin - total,
|
||||
"threshold": nextMin,
|
||||
"currentPercent": currentPct,
|
||||
"nextPercent": nextPct,
|
||||
}, true
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// Shared progress / threshold helpers
|
||||
// ----------------------------
|
||||
|
||||
// cartTotalProgress reports the gap to a rule's cart-total threshold, but only
|
||||
// when that threshold is the *sole* reason the rule isn't applying — so we never
|
||||
// tell a customer to spend more when a different condition (segment, date, ...)
|
||||
// is the real blocker.
|
||||
func (s *PromotionService) cartTotalProgress(rule PromotionRule, ctx *PromotionEvalContext) (map[string]any, bool) {
|
||||
threshold, ok := cartTotalThreshold(rule)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
remaining := threshold - ctx.CartTotalIncVat
|
||||
if remaining <= 0 || !s.withinReach(rule, ctx, threshold) {
|
||||
return nil, false
|
||||
}
|
||||
return map[string]any{"remaining": remaining, "threshold": threshold}, true
|
||||
}
|
||||
|
||||
// withinReach reports whether the rule would apply if the cart total were
|
||||
// atTotal — i.e. whether the cart-total threshold is the only thing holding it
|
||||
// back. Cheap: it re-runs evaluation against a copy of the context.
|
||||
func (s *PromotionService) withinReach(rule PromotionRule, ctx *PromotionEvalContext, atTotal int64) bool {
|
||||
probe := *ctx
|
||||
probe.CartTotalIncVat = atTotal
|
||||
return s.EvaluateRule(rule, &probe).Applicable
|
||||
}
|
||||
|
||||
// cartTotalThreshold finds the lowest cart-total floor a rule requires (a
|
||||
// cart_total >= / > condition), walking nested groups. Returns false when the
|
||||
// rule has no such condition.
|
||||
func cartTotalThreshold(rule PromotionRule) (int64, bool) {
|
||||
var threshold int64
|
||||
found := false
|
||||
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 != CondCartTotal {
|
||||
continue
|
||||
}
|
||||
op := normalizeOperator(string(v.Operator))
|
||||
if op != string(OpGreaterOrEqual) && op != string(OpGreaterThan) {
|
||||
continue
|
||||
}
|
||||
f, ok := v.Value.AsFloat64()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
t := int64(f)
|
||||
if op == string(OpGreaterThan) {
|
||||
t++ // strictly greater: need at least one öre past the floor
|
||||
}
|
||||
if !found || t < threshold {
|
||||
threshold, found = t, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(rule.Conditions)
|
||||
return threshold, found
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// Discount math
|
||||
// ----------------------------
|
||||
|
||||
// DiscountTier describes one band of a tiered (volume) discount. Bounds are the
|
||||
// cart total *including VAT*, in öre (the cart's native unit), matching
|
||||
@@ -31,44 +265,28 @@ type DiscountTier struct {
|
||||
Percent float64
|
||||
}
|
||||
|
||||
// ApplyActions applies the matched promotion actions to the cart grain,
|
||||
// reducing TotalPrice and growing TotalDiscount. It is safe to call with a nil
|
||||
// or empty action slice. Must be called after g.UpdateTotals().
|
||||
func ApplyActions(g *cart.CartGrain, actions []Action) {
|
||||
if g == nil || g.TotalPrice == nil {
|
||||
return
|
||||
}
|
||||
for _, a := range actions {
|
||||
switch a.Type {
|
||||
case ActionTieredDiscount:
|
||||
applyTieredDiscount(g, a)
|
||||
case ActionPercentageDiscount:
|
||||
applyPercentageDiscount(g, percentFromValue(a.Value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyTieredDiscount(g *cart.CartGrain, a Action) {
|
||||
func applyTieredDiscount(g *cart.CartGrain, a Action) *cart.Price {
|
||||
tiers := parseTiers(a.Config)
|
||||
if len(tiers) == 0 {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
pct, ok := selectTier(tiers, g.TotalPrice.IncVat)
|
||||
if !ok {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
applyPercentageDiscount(g, pct)
|
||||
return applyPercentageDiscount(g, pct)
|
||||
}
|
||||
|
||||
// applyPercentageDiscount removes pct% of the current cart total, preserving the
|
||||
// VAT-rate breakdown proportionally, and records it in TotalDiscount.
|
||||
func applyPercentageDiscount(g *cart.CartGrain, pct float64) {
|
||||
// VAT-rate breakdown proportionally, recording it in TotalDiscount and returning
|
||||
// the discount applied (nil if nothing was taken off).
|
||||
func applyPercentageDiscount(g *cart.CartGrain, pct float64) *cart.Price {
|
||||
if pct <= 0 || g.TotalPrice.IncVat <= 0 {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
discount := scalePrice(g.TotalPrice, pct)
|
||||
if discount.IncVat <= 0 {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
// Never discount below zero.
|
||||
if discount.IncVat > g.TotalPrice.IncVat {
|
||||
@@ -76,6 +294,7 @@ func applyPercentageDiscount(g *cart.CartGrain, pct float64) {
|
||||
}
|
||||
g.TotalDiscount.Add(*discount)
|
||||
g.TotalPrice.Subtract(*discount)
|
||||
return discount
|
||||
}
|
||||
|
||||
// selectTier returns the discount percent for the band the total falls into.
|
||||
|
||||
@@ -48,7 +48,6 @@ func cartWithTotal(incVat int64) *cart.CartGrain {
|
||||
}
|
||||
|
||||
func TestVolymrabattTiers(t *testing.T) {
|
||||
svc := NewPromotionService(nil)
|
||||
rules := []PromotionRule{volymrabattRule()}
|
||||
|
||||
cases := []struct {
|
||||
@@ -69,9 +68,11 @@ func TestVolymrabattTiers(t *testing.T) {
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
svc := NewPromotionService(nil)
|
||||
g := cartWithTotal(c.total)
|
||||
_, actions := svc.EvaluateAll(rules, NewContextFromCart(g, WithNow(timeZero())))
|
||||
ApplyActions(g, actions)
|
||||
results, _ := svc.EvaluateAll(rules, NewContextFromCart(g, WithNow(timeZero())))
|
||||
ctx := NewContextFromCart(g, WithNow(timeZero()))
|
||||
svc.ApplyResults(g, results, ctx)
|
||||
|
||||
if got := g.TotalDiscount.IncVat; got != c.wantDiscount {
|
||||
t.Errorf("discount = %d, want %d", got, c.wantDiscount)
|
||||
@@ -82,3 +83,66 @@ func TestVolymrabattTiers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// findApplied returns the first appliedPromotions entry for ruleID.
|
||||
func findApplied(g *cart.CartGrain, ruleID string) (cart.AppliedPromotion, bool) {
|
||||
for _, ap := range g.AppliedPromotions {
|
||||
if ap.PromotionId == ruleID {
|
||||
return ap, true
|
||||
}
|
||||
}
|
||||
return cart.AppliedPromotion{}, false
|
||||
}
|
||||
|
||||
// TestApplyResultsRecordsEffects covers the applied/pending breakdown the result
|
||||
// now exposes: an applied effect attributed to its rule, a pending nudge with
|
||||
// progress when below the threshold, and progress-toward-the-next-tier on an
|
||||
// already-applied tiered discount.
|
||||
func TestApplyResultsRecordsEffects(t *testing.T) {
|
||||
rules := []PromotionRule{volymrabattRule()}
|
||||
apply := func(total int64) *cart.CartGrain {
|
||||
svc := NewPromotionService(nil)
|
||||
g := cartWithTotal(total)
|
||||
results, _ := svc.EvaluateAll(rules, NewContextFromCart(g, WithNow(timeZero())))
|
||||
svc.ApplyResults(g, results, NewContextFromCart(g, WithNow(timeZero())))
|
||||
return g
|
||||
}
|
||||
|
||||
t.Run("applied effect records discount + next-tier progress", func(t *testing.T) {
|
||||
g := apply(5000000) // qualifies at the 9% tier
|
||||
ap, ok := findApplied(g, "volymrabatt")
|
||||
if !ok {
|
||||
t.Fatal("no volymrabatt entry recorded")
|
||||
}
|
||||
if ap.Pending {
|
||||
t.Error("applied effect should not be pending")
|
||||
}
|
||||
if ap.Discount == nil || ap.Discount.IncVat != 450000 {
|
||||
t.Errorf("discount = %v, want IncVat 450000", ap.Discount)
|
||||
}
|
||||
// 50 000 kr sits in the 9% tier; the next tier (14%) starts at 60 000 kr.
|
||||
if got := ap.Progress["remaining"]; got != int64(1000000) {
|
||||
t.Errorf("progress remaining = %v, want 1000000", got)
|
||||
}
|
||||
if got := ap.Progress["nextPercent"]; got != 14.0 {
|
||||
t.Errorf("progress nextPercent = %v, want 14", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pending nudge below threshold", func(t *testing.T) {
|
||||
g := apply(1500000) // below the 20 000 kr entry tier
|
||||
if g.TotalDiscount.IncVat != 0 {
|
||||
t.Errorf("nothing should be applied, discount = %d", g.TotalDiscount.IncVat)
|
||||
}
|
||||
ap, ok := findApplied(g, "volymrabatt")
|
||||
if !ok {
|
||||
t.Fatal("expected a pending volymrabatt nudge")
|
||||
}
|
||||
if !ap.Pending || ap.Discount != nil {
|
||||
t.Errorf("expected pending with no discount, got pending=%v discount=%v", ap.Pending, ap.Discount)
|
||||
}
|
||||
if got := ap.Progress["remaining"]; got != int64(500000) {
|
||||
t.Errorf("progress remaining = %v, want 500000", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+21
-2
@@ -100,14 +100,33 @@ func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEval
|
||||
// PromotionService evaluates PromotionRules against a PromotionEvalContext.
|
||||
type PromotionService struct {
|
||||
tracer Tracer
|
||||
// effects is the per-action-type behaviour registry. Each Effect both applies
|
||||
// its action to the cart (when the owning rule qualifies) and reports its own
|
||||
// progress, so any effect type can surface "spend X more for ..." nudges —
|
||||
// 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
|
||||
}
|
||||
|
||||
// NewPromotionService constructs a PromotionService with an optional tracer.
|
||||
// NewPromotionService constructs a PromotionService with an optional tracer and
|
||||
// the built-in effect handlers.
|
||||
func NewPromotionService(t Tracer) *PromotionService {
|
||||
if t == nil {
|
||||
t = NoopTracer{}
|
||||
}
|
||||
return &PromotionService{tracer: t}
|
||||
return &PromotionService{
|
||||
tracer: t,
|
||||
effects: defaultEffects(),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterEffect adds (or overrides) the handler for an action type, so new
|
||||
// applied/pending effects can be plugged in without touching the engine.
|
||||
func (s *PromotionService) RegisterEffect(e Effect) {
|
||||
if s.effects == nil {
|
||||
s.effects = map[ActionType]Effect{}
|
||||
}
|
||||
s.effects[e.Type()] = e
|
||||
}
|
||||
|
||||
// EvaluationResult holds the outcome of evaluating a single rule.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package promotions
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
// ----------------------------
|
||||
// Stateless evaluation
|
||||
// ----------------------------
|
||||
//
|
||||
// Evaluate runs the promotion engine against a caller-supplied context instead
|
||||
// of a real cart, so a storefront can ask "what would these items get?" without
|
||||
// creating or mutating a cart. It builds a synthetic CartGrain from the request,
|
||||
// runs the same EvaluateAll + ApplyResults path the live cart uses, and returns
|
||||
// the resulting totals plus the applied/pending effect breakdown.
|
||||
|
||||
// EvalItem is one — possibly partial — cart line for a stateless evaluation.
|
||||
// Missing fields fall back to sensible defaults (qty 1, 25% VAT) so the engine
|
||||
// does the best it can with whatever the caller provides.
|
||||
type EvalItem struct {
|
||||
Sku string `json:"sku,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Quantity uint16 `json:"qty,omitempty"`
|
||||
PriceIncVat int64 `json:"priceIncVat,omitempty"`
|
||||
VatRate float32 `json:"vatRate,omitempty"`
|
||||
}
|
||||
|
||||
// EvaluateRequest is a stateless promotion-evaluation context. Every field is
|
||||
// optional. Items drive the cart total and the item-scoped conditions; when no
|
||||
// items are given, CartTotalIncVat synthesises a single line so total-only rules
|
||||
// (volume discounts, free-shipping thresholds) can still be previewed.
|
||||
type EvaluateRequest struct {
|
||||
Items []EvalItem `json:"items,omitempty"`
|
||||
CartTotalIncVat int64 `json:"cartTotalIncVat,omitempty"`
|
||||
CustomerSegment string `json:"customerSegment,omitempty"`
|
||||
CustomerLifetimeValue float64 `json:"customerLifetimeValue,omitempty"`
|
||||
OrderCount int `json:"orderCount,omitempty"`
|
||||
Now *time.Time `json:"now,omitempty"`
|
||||
}
|
||||
|
||||
// EvaluateResponse is the outcome of a stateless evaluation: the totals after
|
||||
// promotions and the per-promotion breakdown (applied discounts plus pending
|
||||
// "spend X more for ..." nudges).
|
||||
type EvaluateResponse struct {
|
||||
TotalPrice *cart.Price `json:"totalPrice"`
|
||||
TotalDiscount *cart.Price `json:"totalDiscount"`
|
||||
AppliedPromotions []cart.AppliedPromotion `json:"appliedPromotions,omitempty"`
|
||||
}
|
||||
|
||||
// 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.UpdateTotals()
|
||||
ctx := NewContextFromCart(g, req.contextOptions()...)
|
||||
results, _ := s.EvaluateAll(rules, ctx)
|
||||
s.ApplyResults(g, results, ctx)
|
||||
return EvaluateResponse{
|
||||
TotalPrice: g.TotalPrice,
|
||||
TotalDiscount: g.TotalDiscount,
|
||||
AppliedPromotions: g.AppliedPromotions,
|
||||
}
|
||||
}
|
||||
|
||||
// toCart materialises the request into a throwaway CartGrain.
|
||||
func (req EvaluateRequest) toCart() *cart.CartGrain {
|
||||
now := time.Now()
|
||||
if req.Now != nil {
|
||||
now = *req.Now
|
||||
}
|
||||
g := cart.NewCartGrain(0, now)
|
||||
for _, it := range req.Items {
|
||||
qty := it.Quantity
|
||||
if qty == 0 {
|
||||
qty = 1
|
||||
}
|
||||
vat := it.VatRate
|
||||
if vat == 0 {
|
||||
vat = 25
|
||||
}
|
||||
item := &cart.CartItem{
|
||||
Sku: it.Sku,
|
||||
Quantity: qty,
|
||||
Price: *cart.NewPriceFromIncVat(it.PriceIncVat, vat),
|
||||
}
|
||||
if it.Category != "" {
|
||||
item.Meta = &cart.ItemMeta{Category: it.Category}
|
||||
}
|
||||
g.Items = append(g.Items, item)
|
||||
}
|
||||
if len(g.Items) == 0 && req.CartTotalIncVat > 0 {
|
||||
g.Items = append(g.Items, &cart.CartItem{
|
||||
Sku: "synthetic",
|
||||
Quantity: 1,
|
||||
Price: *cart.NewPriceFromIncVat(req.CartTotalIncVat, 25),
|
||||
})
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// contextOptions maps the optional customer/time fields onto context options.
|
||||
func (req EvaluateRequest) contextOptions() []ContextOption {
|
||||
var opts []ContextOption
|
||||
if req.Now != nil {
|
||||
opts = append(opts, WithNow(*req.Now))
|
||||
}
|
||||
if req.CustomerSegment != "" {
|
||||
opts = append(opts, WithCustomerSegment(req.CustomerSegment))
|
||||
}
|
||||
if req.CustomerLifetimeValue != 0 {
|
||||
opts = append(opts, WithCustomerLifetimeValue(req.CustomerLifetimeValue))
|
||||
}
|
||||
if req.OrderCount != 0 {
|
||||
opts = append(opts, WithOrderCount(req.OrderCount))
|
||||
}
|
||||
return opts
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package promotions
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestEvaluatePartialCart exercises the stateless evaluation path used by
|
||||
// POST /promotions/evaluate: items drive the total, qualifying effects apply,
|
||||
// and near-miss rules surface as pending nudges.
|
||||
func TestEvaluatePartialCart(t *testing.T) {
|
||||
svc := NewPromotionService(nil)
|
||||
rules := []PromotionRule{volymrabattRule()}
|
||||
now := timeZero()
|
||||
|
||||
t.Run("items qualify for a tier", func(t *testing.T) {
|
||||
// 2 × 25 000 kr (incl. VAT) = 50 000 kr -> 9% tier.
|
||||
resp := svc.Evaluate(rules, EvaluateRequest{
|
||||
Now: &now,
|
||||
Items: []EvalItem{
|
||||
{Sku: "A", Quantity: 2, PriceIncVat: 2500000},
|
||||
},
|
||||
})
|
||||
if resp.TotalDiscount.IncVat != 450000 {
|
||||
t.Errorf("discount = %d, want 450000", resp.TotalDiscount.IncVat)
|
||||
}
|
||||
if len(resp.AppliedPromotions) != 1 || resp.AppliedPromotions[0].Pending {
|
||||
t.Fatalf("want 1 applied (non-pending) effect, got %+v", resp.AppliedPromotions)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("total-only fallback surfaces a pending nudge", func(t *testing.T) {
|
||||
// No items, just a total below the entry tier.
|
||||
resp := svc.Evaluate(rules, EvaluateRequest{Now: &now, CartTotalIncVat: 1500000})
|
||||
if resp.TotalDiscount.IncVat != 0 {
|
||||
t.Errorf("nothing should apply, discount = %d", resp.TotalDiscount.IncVat)
|
||||
}
|
||||
if len(resp.AppliedPromotions) != 1 || !resp.AppliedPromotions[0].Pending {
|
||||
t.Fatalf("want 1 pending nudge, got %+v", resp.AppliedPromotions)
|
||||
}
|
||||
if got := resp.AppliedPromotions[0].Progress["remaining"]; got != int64(500000) {
|
||||
t.Errorf("remaining = %v, want 500000", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -196,13 +196,14 @@ func (s *Server) buildTools() []tool {
|
||||
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
|
||||
}
|
||||
ctx := promotions.NewContextFromCart(g, opts...)
|
||||
_, actions := s.eval.EvaluateAll(s.store.Snapshot(), ctx)
|
||||
promotions.ApplyActions(g, actions)
|
||||
results, actions := s.eval.EvaluateAll(s.store.Snapshot(), ctx)
|
||||
s.eval.ApplyResults(g, results, ctx)
|
||||
return map[string]any{
|
||||
"subtotalIncVat": a.CartTotalIncVat,
|
||||
"discountIncVat": g.TotalDiscount.IncVat,
|
||||
"netIncVat": g.TotalPrice.IncVat,
|
||||
"matchedActions": actions,
|
||||
"subtotalIncVat": a.CartTotalIncVat,
|
||||
"discountIncVat": g.TotalDiscount.IncVat,
|
||||
"netIncVat": g.TotalPrice.IncVat,
|
||||
"matchedActions": actions,
|
||||
"appliedPromotions": g.AppliedPromotions,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user