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

This commit is contained in:
2026-06-17 21:43:38 +02:00
parent ad410d217c
commit 74dc252279
9 changed files with 1054 additions and 13 deletions
+149
View File
@@ -0,0 +1,149 @@
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
}