promotions
This commit is contained in:
+10
-2
@@ -149,8 +149,11 @@ func main() {
|
|||||||
// any matched actions (e.g. the Volymrabatt volume discount), which adjust
|
// any matched actions (e.g. the Volymrabatt volume discount), which adjust
|
||||||
// TotalPrice/TotalDiscount on top of line items and vouchers.
|
// TotalPrice/TotalDiscount on top of line items and vouchers.
|
||||||
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
||||||
_, actions := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
||||||
promotions.ApplyActions(g, actions)
|
// 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++
|
g.Version++
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
@@ -275,6 +278,11 @@ func main() {
|
|||||||
mux.Handle("/mcp", promotionMCP.Handler())
|
mux.Handle("/mcp", promotionMCP.Handler())
|
||||||
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
|
// only for local
|
||||||
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
pool.AddRemote(r.PathValue("host"))
|
pool.AddRemote(r.PathValue("host"))
|
||||||
|
|||||||
@@ -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
@@ -37,7 +37,7 @@ type CartItem struct {
|
|||||||
SellerId string `json:"sellerId,omitempty"`
|
SellerId string `json:"sellerId,omitempty"`
|
||||||
OrgPrice *Price `json:"orgPrice,omitempty"`
|
OrgPrice *Price `json:"orgPrice,omitempty"`
|
||||||
Cgm string `json:"cgm,omitempty"`
|
Cgm string `json:"cgm,omitempty"`
|
||||||
Tax int
|
Tax int `json:"tax"`
|
||||||
Stock uint16 `json:"stock"`
|
Stock uint16 `json:"stock"`
|
||||||
Quantity uint16 `json:"qty"`
|
Quantity uint16 `json:"qty"`
|
||||||
Discount *Price `json:"discount,omitempty"`
|
Discount *Price `json:"discount,omitempty"`
|
||||||
@@ -97,6 +97,31 @@ type Marking struct {
|
|||||||
Text string `json:"text"`
|
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 {
|
type CartGrain struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
lastItemId uint32
|
lastItemId uint32
|
||||||
@@ -117,6 +142,7 @@ type CartGrain struct {
|
|||||||
OrderReference string `json:"orderReference,omitempty"`
|
OrderReference string `json:"orderReference,omitempty"`
|
||||||
|
|
||||||
Vouchers []*Voucher `json:"vouchers,omitempty"`
|
Vouchers []*Voucher `json:"vouchers,omitempty"`
|
||||||
|
AppliedPromotions []AppliedPromotion `json:"appliedPromotions,omitempty"`
|
||||||
Notifications []CartNotification `json:"cartNotification,omitempty"`
|
Notifications []CartNotification `json:"cartNotification,omitempty"`
|
||||||
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
|
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
|
||||||
|
|
||||||
@@ -245,6 +271,7 @@ func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
|
|||||||
func (c *CartGrain) UpdateTotals() {
|
func (c *CartGrain) UpdateTotals() {
|
||||||
c.TotalPrice = NewPrice()
|
c.TotalPrice = NewPrice()
|
||||||
c.TotalDiscount = NewPrice()
|
c.TotalDiscount = NewPrice()
|
||||||
|
c.AppliedPromotions = nil
|
||||||
|
|
||||||
for _, item := range c.Items {
|
for _, item := range c.Items {
|
||||||
rowTotal := MultiplyPrice(item.Price, int64(item.Quantity))
|
rowTotal := MultiplyPrice(item.Price, int64(item.Quantity))
|
||||||
|
|||||||
+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
|
// The evaluation engine (eval.go) decides *which* rules apply to a cart but
|
||||||
// deliberately does not mutate the cart. Applying an action — turning a
|
// deliberately does not mutate it. Turning a matched action into actual öre off
|
||||||
// "14% tiered discount" into actual öre off the total — happens here, because
|
// the total — and describing how close an unmatched (or partially matched)
|
||||||
// it needs to touch cart.Price math. main.go calls ApplyActions after
|
// action is to its next reward — happens here, behind the Effect interface.
|
||||||
// UpdateTotals so the base totals (line items + vouchers) are settled before
|
//
|
||||||
// promotions reduce them.
|
// 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
|
// DiscountTier describes one band of a tiered (volume) discount. Bounds are the
|
||||||
// cart total *including VAT*, in öre (the cart's native unit), matching
|
// cart total *including VAT*, in öre (the cart's native unit), matching
|
||||||
@@ -31,44 +265,28 @@ type DiscountTier struct {
|
|||||||
Percent float64
|
Percent float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyActions applies the matched promotion actions to the cart grain,
|
func applyTieredDiscount(g *cart.CartGrain, a Action) *cart.Price {
|
||||||
// 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) {
|
|
||||||
tiers := parseTiers(a.Config)
|
tiers := parseTiers(a.Config)
|
||||||
if len(tiers) == 0 {
|
if len(tiers) == 0 {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
pct, ok := selectTier(tiers, g.TotalPrice.IncVat)
|
pct, ok := selectTier(tiers, g.TotalPrice.IncVat)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
applyPercentageDiscount(g, pct)
|
return applyPercentageDiscount(g, pct)
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyPercentageDiscount removes pct% of the current cart total, preserving the
|
// applyPercentageDiscount removes pct% of the current cart total, preserving the
|
||||||
// VAT-rate breakdown proportionally, and records it in TotalDiscount.
|
// VAT-rate breakdown proportionally, recording it in TotalDiscount and returning
|
||||||
func applyPercentageDiscount(g *cart.CartGrain, pct float64) {
|
// 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 {
|
if pct <= 0 || g.TotalPrice.IncVat <= 0 {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
discount := scalePrice(g.TotalPrice, pct)
|
discount := scalePrice(g.TotalPrice, pct)
|
||||||
if discount.IncVat <= 0 {
|
if discount.IncVat <= 0 {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
// Never discount below zero.
|
// Never discount below zero.
|
||||||
if discount.IncVat > g.TotalPrice.IncVat {
|
if discount.IncVat > g.TotalPrice.IncVat {
|
||||||
@@ -76,6 +294,7 @@ func applyPercentageDiscount(g *cart.CartGrain, pct float64) {
|
|||||||
}
|
}
|
||||||
g.TotalDiscount.Add(*discount)
|
g.TotalDiscount.Add(*discount)
|
||||||
g.TotalPrice.Subtract(*discount)
|
g.TotalPrice.Subtract(*discount)
|
||||||
|
return discount
|
||||||
}
|
}
|
||||||
|
|
||||||
// selectTier returns the discount percent for the band the total falls into.
|
// 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) {
|
func TestVolymrabattTiers(t *testing.T) {
|
||||||
svc := NewPromotionService(nil)
|
|
||||||
rules := []PromotionRule{volymrabattRule()}
|
rules := []PromotionRule{volymrabattRule()}
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
@@ -69,9 +68,11 @@ func TestVolymrabattTiers(t *testing.T) {
|
|||||||
|
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
svc := NewPromotionService(nil)
|
||||||
g := cartWithTotal(c.total)
|
g := cartWithTotal(c.total)
|
||||||
_, actions := svc.EvaluateAll(rules, NewContextFromCart(g, WithNow(timeZero())))
|
results, _ := svc.EvaluateAll(rules, NewContextFromCart(g, WithNow(timeZero())))
|
||||||
ApplyActions(g, actions)
|
ctx := NewContextFromCart(g, WithNow(timeZero()))
|
||||||
|
svc.ApplyResults(g, results, ctx)
|
||||||
|
|
||||||
if got := g.TotalDiscount.IncVat; got != c.wantDiscount {
|
if got := g.TotalDiscount.IncVat; got != c.wantDiscount {
|
||||||
t.Errorf("discount = %d, want %d", 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.
|
// PromotionService evaluates PromotionRules against a PromotionEvalContext.
|
||||||
type PromotionService struct {
|
type PromotionService struct {
|
||||||
tracer Tracer
|
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 {
|
func NewPromotionService(t Tracer) *PromotionService {
|
||||||
if t == nil {
|
if t == nil {
|
||||||
t = NoopTracer{}
|
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.
|
// 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))
|
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
|
||||||
}
|
}
|
||||||
ctx := promotions.NewContextFromCart(g, opts...)
|
ctx := promotions.NewContextFromCart(g, opts...)
|
||||||
_, actions := s.eval.EvaluateAll(s.store.Snapshot(), ctx)
|
results, actions := s.eval.EvaluateAll(s.store.Snapshot(), ctx)
|
||||||
promotions.ApplyActions(g, actions)
|
s.eval.ApplyResults(g, results, ctx)
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"subtotalIncVat": a.CartTotalIncVat,
|
"subtotalIncVat": a.CartTotalIncVat,
|
||||||
"discountIncVat": g.TotalDiscount.IncVat,
|
"discountIncVat": g.TotalDiscount.IncVat,
|
||||||
"netIncVat": g.TotalPrice.IncVat,
|
"netIncVat": g.TotalPrice.IncVat,
|
||||||
"matchedActions": actions,
|
"matchedActions": actions,
|
||||||
|
"appliedPromotions": g.AppliedPromotions,
|
||||||
}, nil
|
}, nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user