150 lines
4.4 KiB
Go
150 lines
4.4 KiB
Go
package promotions
|
|
|
|
import (
|
|
"math"
|
|
|
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
|
)
|
|
|
|
// ----------------------------
|
|
// Action application
|
|
// ----------------------------
|
|
//
|
|
// 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.
|
|
|
|
// DiscountTier describes one band of a tiered (volume) discount. Bounds are the
|
|
// cart total *including VAT*, in öre (the cart's native unit), matching
|
|
// PromotionEvalContext.CartTotalIncVat. MaxTotal == 0 means "no upper bound".
|
|
//
|
|
// A tier matches when MinTotal <= cartTotal && (MaxTotal == 0 || cartTotal <= MaxTotal).
|
|
// Bounds are allowed to touch (e.g. 40000 ends one tier and begins the next);
|
|
// when a total lands exactly on a shared boundary the higher tier wins, since
|
|
// selectTier keeps the last match while scanning in order.
|
|
type DiscountTier struct {
|
|
MinTotal int64
|
|
MaxTotal int64
|
|
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) {
|
|
tiers := parseTiers(a.Config)
|
|
if len(tiers) == 0 {
|
|
return
|
|
}
|
|
pct, ok := selectTier(tiers, g.TotalPrice.IncVat)
|
|
if !ok {
|
|
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) {
|
|
if pct <= 0 || g.TotalPrice.IncVat <= 0 {
|
|
return
|
|
}
|
|
discount := scalePrice(g.TotalPrice, pct)
|
|
if discount.IncVat <= 0 {
|
|
return
|
|
}
|
|
// Never discount below zero.
|
|
if discount.IncVat > g.TotalPrice.IncVat {
|
|
discount = g.TotalPrice
|
|
}
|
|
g.TotalDiscount.Add(*discount)
|
|
g.TotalPrice.Subtract(*discount)
|
|
}
|
|
|
|
// selectTier returns the discount percent for the band the total falls into.
|
|
// Scans in declared order and keeps the last match so shared boundaries favour
|
|
// the higher tier.
|
|
func selectTier(tiers []DiscountTier, total int64) (float64, bool) {
|
|
pct := 0.0
|
|
found := false
|
|
for _, t := range tiers {
|
|
if total >= t.MinTotal && (t.MaxTotal == 0 || total <= t.MaxTotal) {
|
|
pct = t.Percent
|
|
found = true
|
|
}
|
|
}
|
|
return pct, found
|
|
}
|
|
|
|
// scalePrice returns pct% of p, rounding each amount and the VAT breakdown
|
|
// independently so the parts stay consistent with the reported total.
|
|
func scalePrice(p *cart.Price, pct float64) *cart.Price {
|
|
out := cart.NewPrice()
|
|
out.IncVat = scale(p.IncVat, pct)
|
|
for rate, amount := range p.VatRates {
|
|
out.VatRates[rate] = scale(amount, pct)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func scale(v int64, pct float64) int64 {
|
|
return int64(math.Round(float64(v) * pct / 100.0))
|
|
}
|
|
|
|
// parseTiers reads the "tiers" array out of an Action.Config. Because configs
|
|
// are decoded via encoding/json into map[string]interface{}, every element is a
|
|
// map[string]interface{} and every number is a float64. Accepts both
|
|
// minTotal/maxTotal/percent and the snake_case min_total/max_total/discount_percent.
|
|
func parseTiers(config map[string]interface{}) []DiscountTier {
|
|
raw, ok := config["tiers"].([]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
tiers := make([]DiscountTier, 0, len(raw))
|
|
for _, r := range raw {
|
|
m, ok := r.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
tiers = append(tiers, DiscountTier{
|
|
MinTotal: int64(firstNum(m, "minTotal", "min_total")),
|
|
MaxTotal: int64(firstNum(m, "maxTotal", "max_total")),
|
|
Percent: firstNum(m, "percent", "discount_percent"),
|
|
})
|
|
}
|
|
return tiers
|
|
}
|
|
|
|
func percentFromValue(v interface{}) float64 {
|
|
if f, ok := v.(float64); ok {
|
|
return f
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func firstNum(m map[string]interface{}, keys ...string) float64 {
|
|
for _, k := range keys {
|
|
if f, ok := m[k].(float64); ok {
|
|
return f
|
|
}
|
|
}
|
|
return 0
|
|
}
|