promotions
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled

This commit is contained in:
2026-06-18 11:32:19 +02:00
parent 2545be4315
commit 2443fd8b49
9 changed files with 583 additions and 55 deletions
+10 -2
View File
@@ -149,8 +149,11 @@ func main() {
// any matched actions (e.g. the Volymrabatt volume discount), which adjust
// TotalPrice/TotalDiscount on top of line items and vouchers.
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
_, actions := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
promotions.ApplyActions(g, actions)
results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
// ApplyResults applies qualifying actions in priority order and records
// every effect — both applied discounts and pending "spend X more for ..."
// nudges with their progress — in g.AppliedPromotions.
promotionService.ApplyResults(g, results, promotionCtx)
g.Version++
return nil
}),
@@ -275,6 +278,11 @@ func main() {
mux.Handle("/mcp", promotionMCP.Handler())
mux.Handle("/mcp/", promotionMCP.Handler())
// Stateless promotion evaluation: POST a (possibly partial) eval context and
// get back the resulting totals plus the applied/pending effect breakdown,
// without creating or mutating a real cart.
mux.HandleFunc("POST /promotions/evaluate", newPromotionEvaluateHandler(promotionStore, promotionService))
// only for local
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
pool.AddRemote(r.PathValue("host"))
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"encoding/json"
"errors"
"io"
"net/http"
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
)
// newPromotionEvaluateHandler serves POST /promotions/evaluate: it takes a
// (possibly partial) evaluation context and returns the totals plus the
// applied/pending promotion effects, without creating or mutating a real cart.
// An empty body is treated as an empty context (evaluates against no items).
func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.PromotionService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req promotions.EvaluateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
http.Error(w, "invalid JSON body: "+err.Error(), http.StatusBadRequest)
return
}
resp := svc.Evaluate(store.Snapshot(), req)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
+28 -1
View File
@@ -37,7 +37,7 @@ type CartItem struct {
SellerId string `json:"sellerId,omitempty"`
OrgPrice *Price `json:"orgPrice,omitempty"`
Cgm string `json:"cgm,omitempty"`
Tax int
Tax int `json:"tax"`
Stock uint16 `json:"stock"`
Quantity uint16 `json:"qty"`
Discount *Price `json:"discount,omitempty"`
@@ -97,6 +97,31 @@ type Marking struct {
Text string `json:"text"`
}
// AppliedPromotion records a single promotion action's effect on the cart. It is
// rebuilt every time promotions are (re-)applied so the API result exposes a
// per-promotion breakdown rather than only the aggregate TotalDiscount.
//
// An entry is one of two kinds, both living in the same list:
// - applied (Pending=false): the action took effect now. Discount holds the
// amount taken off (nil for non-monetary effects such as free_shipping).
// - pending (Pending=true): the action has NOT qualified yet but is within
// reach. Progress carries effect-specific data describing what's left to
// unlock it — e.g. {"remaining": 120000, "threshold": 500000} for a
// cart-total nudge. Keeping it an open map lets new effect types surface
// their own progress fields without changing this struct. This powers
// "spend X more for ..." nudges generically.
type AppliedPromotion struct {
PromotionId string `json:"promotionId,omitempty"`
Name string `json:"name,omitempty"`
ActionId string `json:"actionId,omitempty"`
Type string `json:"type,omitempty"`
Label string `json:"label,omitempty"`
Discount *Price `json:"discount,omitempty"`
Pending bool `json:"pending,omitempty"`
Progress map[string]interface{} `json:"progress,omitempty"`
}
type CartGrain struct {
mu sync.RWMutex
lastItemId uint32
@@ -117,6 +142,7 @@ type CartGrain struct {
OrderReference string `json:"orderReference,omitempty"`
Vouchers []*Voucher `json:"vouchers,omitempty"`
AppliedPromotions []AppliedPromotion `json:"appliedPromotions,omitempty"`
Notifications []CartNotification `json:"cartNotification,omitempty"`
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
@@ -245,6 +271,7 @@ func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
func (c *CartGrain) UpdateTotals() {
c.TotalPrice = NewPrice()
c.TotalDiscount = NewPrice()
c.AppliedPromotions = nil
for _, item := range c.Items {
rowTotal := MultiplyPrice(item.Price, int64(item.Quantity))
+251 -32
View File
@@ -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.
+67 -3
View File
@@ -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
View File
@@ -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.
+119
View File
@@ -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
}
+42
View File
@@ -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)
}
})
}
+3 -2
View File
@@ -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,
"appliedPromotions": g.AppliedPromotions,
}, nil
},
},