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.
|
||||
|
||||
Reference in New Issue
Block a user