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
+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.