cart and checkout
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s

This commit is contained in:
2026-06-27 19:49:00 +02:00
parent 492f54ff45
commit 528c59bfd3
67 changed files with 3618 additions and 1031 deletions
+367 -5
View File
@@ -2,6 +2,8 @@ package promotions
import (
"math"
"slices"
"strings"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
)
@@ -28,7 +30,7 @@ type Effect interface {
// 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)
Apply(g *cart.CartGrain, rule PromotionRule, 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"
@@ -43,6 +45,8 @@ func defaultEffects() map[ActionType]Effect {
ActionPercentageDiscount: percentageDiscountEffect{},
ActionTieredDiscount: tieredDiscountEffect{},
ActionFreeShipping: freeShippingEffect{},
ActionBuyXGetY: buyXGetYEffect{},
ActionBundleDiscount: bundleDiscountEffect{},
}
}
@@ -72,7 +76,7 @@ func (s *PromotionService) ApplyResults(g *cart.CartGrain, results []EvaluationR
}
recorded := false
if res.Applicable {
if discount, applied := eff.Apply(g, a); applied {
if discount, applied := eff.Apply(g, res.Rule, a); applied {
entry.Discount = discount
recorded = true
}
@@ -106,7 +110,7 @@ type percentageDiscountEffect struct{}
func (percentageDiscountEffect) Type() ActionType { return ActionPercentageDiscount }
func (percentageDiscountEffect) Apply(g *cart.CartGrain, a Action) (*cart.Price, bool) {
func (percentageDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
d := applyPercentageDiscount(g, percentFromValue(a.Value))
return d, d != nil
}
@@ -124,7 +128,7 @@ type freeShippingEffect struct{}
func (freeShippingEffect) Type() ActionType { return ActionFreeShipping }
func (freeShippingEffect) Apply(_ *cart.CartGrain, _ Action) (*cart.Price, bool) {
func (freeShippingEffect) Apply(_ *cart.CartGrain, _ PromotionRule, _ Action) (*cart.Price, bool) {
return nil, true
}
@@ -143,7 +147,7 @@ type tieredDiscountEffect struct{}
func (tieredDiscountEffect) Type() ActionType { return ActionTieredDiscount }
func (tieredDiscountEffect) Apply(g *cart.CartGrain, a Action) (*cart.Price, bool) {
func (tieredDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
d := applyTieredDiscount(g, a)
return d, d != nil
}
@@ -366,3 +370,361 @@ func firstNum(m map[string]interface{}, keys ...string) float64 {
}
return 0
}
// ----------------------------
// buyXGetYEffect implementation
// ----------------------------
type buyXGetYEffect struct{}
func (buyXGetYEffect) Type() ActionType { return ActionBuyXGetY }
type unitItem struct {
item *cart.CartItem
price int64
}
func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*cart.Price, bool) {
config := a.Config
buy := int(firstNum(config, "buy"))
if buy <= 0 {
buy = 1
}
get := int(firstNum(config, "get"))
if get <= 0 {
get = 1
}
discount := firstNum(config, "discount")
if discount <= 0 {
discount = 100.0 // default free
}
eligible := eligibleItems(rule, g)
var units []unitItem
for _, item := range eligible {
for k := uint16(0); k < item.Quantity; k++ {
units = append(units, unitItem{
item: item,
price: item.Price.IncVat,
})
}
}
n := len(units)
numFree := (n / (buy + get)) * get
if numFree <= 0 {
return nil, false
}
// Sort units by price ascending so we discount the cheapest ones
slices.SortFunc(units, func(x, y unitItem) int {
if x.price < y.price {
return -1
}
if x.price > y.price {
return 1
}
return 0
})
totalDiscount := cart.NewPrice()
for i := 0; i < numFree; i++ {
unitDiscount := scalePrice(&units[i].item.Price, discount)
totalDiscount.Add(*unitDiscount)
}
if totalDiscount.IncVat <= 0 {
return nil, false
}
// Never discount below zero.
if totalDiscount.IncVat > g.TotalPrice.IncVat {
totalDiscount = g.TotalPrice
}
g.TotalDiscount.Add(*totalDiscount)
g.TotalPrice.Subtract(*totalDiscount)
return totalDiscount, true
}
func (buyXGetYEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
return nil, false
}
// ----------------------------
// bundleDiscountEffect implementation
// ----------------------------
type bundleDiscountEffect struct{}
func (bundleDiscountEffect) Type() ActionType { return ActionBundleDiscount }
func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action) (*cart.Price, bool) {
if a.BundleConfig == nil {
return nil, false
}
// 1. Represent all cart items as individual units
type bundleUnit struct {
item *cart.CartItem
used bool
}
var units []*bundleUnit
for _, item := range g.Items {
if item == nil {
continue
}
for k := uint16(0); k < item.Quantity; k++ {
units = append(units, &bundleUnit{
item: item,
used: false,
})
}
}
// 2. Greedy match bundles
var formedBundles [][]*cart.CartItem
for {
// Attempt to form one bundle
var matchedUnits []*bundleUnit
possible := true
for _, container := range a.BundleConfig.Containers {
needed := container.Quantity
found := 0
var containerMatched []*bundleUnit
// Find unused units matching container's qualifyingRules
for _, u := range units {
if u.used {
continue
}
// Check if this unit is already matched in this bundle instance to avoid double counting
alreadyMatched := false
for _, mu := range matchedUnits {
if mu == u {
alreadyMatched = true
break
}
}
if alreadyMatched {
continue
}
if matchQualifyingRule(container.QualifyingRules, u.item) {
containerMatched = append(containerMatched, u)
found++
if found == needed {
break
}
}
}
if found < needed {
if a.BundleConfig.RequireAllContainers {
possible = false
break
}
} else {
matchedUnits = append(matchedUnits, containerMatched...)
}
}
if !possible || len(matchedUnits) == 0 {
break
}
// Mark matched units as used
var bundleItems []*cart.CartItem
for _, mu := range matchedUnits {
mu.used = true
bundleItems = append(bundleItems, mu.item)
}
formedBundles = append(formedBundles, bundleItems)
}
if len(formedBundles) == 0 {
return nil, false
}
totalDiscount := cart.NewPrice()
for _, bundle := range formedBundles {
// Calculate the original bundle price
bundlePrice := cart.NewPrice()
for _, item := range bundle {
// Add a single unit's price
bundlePrice.Add(item.Price)
}
if bundlePrice.IncVat <= 0 {
continue
}
var bundleDiscount *cart.Price
switch a.BundleConfig.Pricing.Type {
case "fixed_price":
targetPriceOre := int64(math.Round(a.BundleConfig.Pricing.Value * 100))
if bundlePrice.IncVat > targetPriceOre {
pct := float64(bundlePrice.IncVat-targetPriceOre) / float64(bundlePrice.IncVat) * 100
bundleDiscount = scalePrice(bundlePrice, pct)
}
case "percentage_discount":
bundleDiscount = scalePrice(bundlePrice, a.BundleConfig.Pricing.Value)
case "fixed_discount":
discountOre := int64(math.Round(a.BundleConfig.Pricing.Value * 100))
pct := float64(discountOre) / float64(bundlePrice.IncVat) * 100
bundleDiscount = scalePrice(bundlePrice, pct)
}
if bundleDiscount != nil && bundleDiscount.IncVat > 0 {
totalDiscount.Add(*bundleDiscount)
}
}
if totalDiscount.IncVat <= 0 {
return nil, false
}
// Never discount below zero.
if totalDiscount.IncVat > g.TotalPrice.IncVat {
totalDiscount = g.TotalPrice
}
g.TotalDiscount.Add(*totalDiscount)
g.TotalPrice.Subtract(*totalDiscount)
return totalDiscount, true
}
func (bundleDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a Action, ctx *PromotionEvalContext, qualified bool) (map[string]any, bool) {
return nil, false
}
// ----------------------------
// Helpers
// ----------------------------
func eligibleItems(rule PromotionRule, g *cart.CartGrain) []*cart.CartItem {
var categories []string
var productIDs []string
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 == CondProductCategory {
if s, ok := v.Value.AsString(); ok {
categories = append(categories, strings.ToLower(s))
} else if arr, ok := v.Value.AsStringSlice(); ok {
for _, s := range arr {
categories = append(categories, strings.ToLower(s))
}
}
} else if v.Type == CondProductID {
if s, ok := v.Value.AsString(); ok {
productIDs = append(productIDs, strings.ToLower(s))
} else if arr, ok := v.Value.AsStringSlice(); ok {
for _, s := range arr {
productIDs = append(productIDs, strings.ToLower(s))
}
}
}
}
}
}
walk(rule.Conditions)
var out []*cart.CartItem
for _, item := range g.Items {
if item == nil {
continue
}
match := true
if len(categories) > 0 {
cat := ""
if item.Meta != nil {
cat = strings.ToLower(item.Meta.Category)
}
found := false
for _, c := range categories {
if c == cat {
found = true
break
}
}
if !found {
match = false
}
}
if len(productIDs) > 0 {
sku := strings.ToLower(item.Sku)
found := false
for _, p := range productIDs {
if p == sku {
found = true
break
}
}
if !found {
match = false
}
}
if match {
out = append(out, item)
}
}
return out
}
func matchQualifyingRule(rule BundleQualifyingRules, item *cart.CartItem) bool {
if item == nil {
return false
}
valStr, isStr := rule.Value.(string)
var valSlice []string
if !isStr {
if rawSlice, ok := rule.Value.([]interface{}); ok {
for _, val := range rawSlice {
if s, ok := val.(string); ok {
valSlice = append(valSlice, strings.ToLower(s))
}
}
} else if strSlice, ok := rule.Value.([]string); ok {
for _, s := range strSlice {
valSlice = append(valSlice, strings.ToLower(s))
}
}
} else {
valStr = strings.ToLower(valStr)
}
switch rule.Type {
case "category":
if item.Meta == nil {
return false
}
cat := strings.ToLower(item.Meta.Category)
if isStr {
return cat == valStr
}
for _, c := range valSlice {
if cat == c {
return true
}
}
return false
case "product_ids":
sku := strings.ToLower(item.Sku)
if isStr {
return sku == valStr
}
for _, s := range valSlice {
if sku == s {
return true
}
}
return false
case "all":
return true
default:
return false
}
}
+101
View File
@@ -146,3 +146,104 @@ func TestApplyResultsRecordsEffects(t *testing.T) {
}
})
}
func TestBuyXGetYEffect(t *testing.T) {
rule := PromotionRule{
ID: "buy2get1",
Name: "Buy 2 Get 1 Free",
Status: StatusActive,
Actions: []Action{
{
ID: "buy2get1-action",
Type: ActionBuyXGetY,
Config: map[string]interface{}{
"buy": 2.0,
"get": 1.0,
"discount": 100.0,
},
},
},
}
svc := NewPromotionService(nil)
// Cart with 3 items of price 1000 each. Buy 2 get 1 free -> 1 is free -> 1000 discount.
g := cart.NewCartGrain(1, timeZero())
g.Items = []*cart.CartItem{
{Id: 1, Sku: "SKU1", Quantity: 3, Price: *cart.NewPriceFromIncVat(1000, 25)},
}
g.UpdateTotals()
results, _ := svc.EvaluateAll([]PromotionRule{rule}, NewContextFromCart(g, WithNow(timeZero())))
svc.ApplyResults(g, results, NewContextFromCart(g, WithNow(timeZero())))
if got := g.TotalDiscount.IncVat; got != 1000 {
t.Errorf("discount = %d, want 1000", got)
}
if got := g.TotalPrice.IncVat; got != 2000 {
t.Errorf("total price = %d, want 2000", got)
}
}
func TestBundleDiscountEffect(t *testing.T) {
rule := PromotionRule{
ID: "bundle-deal",
Name: "Bundle Deal",
Status: StatusActive,
Actions: []Action{
{
ID: "bundle-action",
Type: ActionBundleDiscount,
BundleConfig: &BundleConfig{
Containers: []BundleContainer{
{
ID: "shoes",
Name: "Shoes",
Quantity: 1,
SelectionType: "any",
QualifyingRules: BundleQualifyingRules{
Type: "category",
Value: "shoes",
},
},
{
ID: "socks",
Name: "Socks",
Quantity: 2,
SelectionType: "any",
QualifyingRules: BundleQualifyingRules{
Type: "category",
Value: "socks",
},
},
},
Pricing: BundlePricing{
Type: "fixed_price",
Value: 40.0, // 40 kr -> 4000 ore
},
RequireAllContainers: true,
},
},
},
}
svc := NewPromotionService(nil)
// Cart with 1 shoe (5000) and 2 socks (1000 each). Total original = 7000. Bundle fixed price is 4000. Discount = 3000.
g := cart.NewCartGrain(1, timeZero())
g.Items = []*cart.CartItem{
{Id: 1, Sku: "shoe-1", Quantity: 1, Price: *cart.NewPriceFromIncVat(5000, 25), Meta: &cart.ItemMeta{Category: "shoes"}},
{Id: 2, Sku: "socks-1", Quantity: 2, Price: *cart.NewPriceFromIncVat(1000, 25), Meta: &cart.ItemMeta{Category: "socks"}},
}
g.UpdateTotals()
results, _ := svc.EvaluateAll([]PromotionRule{rule}, NewContextFromCart(g, WithNow(timeZero())))
svc.ApplyResults(g, results, NewContextFromCart(g, WithNow(timeZero())))
if got := g.TotalDiscount.IncVat; got != 3000 {
t.Errorf("discount = %d, want 3000", got)
}
if got := g.TotalPrice.IncVat; got != 4000 {
t.Errorf("total price = %d, want 4000", got)
}
}
+34 -2
View File
@@ -42,6 +42,7 @@ type PromotionEvalContext struct {
CustomerLifetimeValue float64
OrderCount int
Now time.Time
CouponCodes []string
}
// ContextOption allows customization of fields when building a PromotionEvalContext.
@@ -67,6 +68,11 @@ func WithNow(t time.Time) ContextOption {
return func(c *PromotionEvalContext) { c.Now = t }
}
// WithCouponCodes sets active coupon codes.
func WithCouponCodes(codes []string) ContextOption {
return func(c *PromotionEvalContext) { c.CouponCodes = codes }
}
// NewContextFromCart builds a PromotionEvalContext from a CartGrain and optional metadata.
func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEvalContext {
ctx := &PromotionEvalContext{
@@ -74,6 +80,7 @@ func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEval
CartTotalIncVat: 0,
TotalItemQuantity: 0,
Now: time.Now(),
CouponCodes: make([]string, 0),
}
if g.TotalPrice != nil {
ctx.CartTotalIncVat = g.TotalPrice.IncVat
@@ -91,6 +98,9 @@ func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEval
})
ctx.TotalItemQuantity += uint32(it.Quantity)
}
for _, v := range g.Vouchers {
ctx.CouponCodes = append(ctx.CouponCodes, v.Code)
}
for _, o := range opts {
o(ctx)
}
@@ -106,18 +116,29 @@ type PromotionService struct {
// 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
// DefaultTaxProvider provides the VAT rate used when constructing synthetic
// carts for stateless evaluation and preview. When nil, falls back to 25 %.
DefaultTaxProvider cart.TaxProvider
}
// NewPromotionService constructs a PromotionService with an optional tracer and
// the built-in effect handlers.
func NewPromotionService(t Tracer) *PromotionService {
// NewPromotionService constructs a PromotionService with an optional tracer.
// When tp is non-nil, it is used as DefaultTaxProvider for synthetic cart
// construction (stateless evaluation, preview); otherwise the service falls
// back to 25 % VAT.
func NewPromotionService(t Tracer, tp ...cart.TaxProvider) *PromotionService {
if t == nil {
t = NoopTracer{}
}
return &PromotionService{
s := &PromotionService{
tracer: t,
effects: defaultEffects(),
}
if len(tp) > 0 && tp[0] != nil {
s.DefaultTaxProvider = tp[0]
}
return s
}
// RegisterEffect adds (or overrides) the handler for an action type, so new
@@ -360,11 +381,22 @@ func evaluateBaseCondition(b BaseCondition, ctx *PromotionEvalContext) bool {
return evalDayOfWeek(ctx.Now, b)
case CondTimeOfDay:
return evalTimeOfDay(ctx.Now, b)
case CondCouponCode:
return evalAnyCouponMatch(ctx.CouponCodes, b)
default:
return false
}
}
func evalAnyCouponMatch(codes []string, b BaseCondition) bool {
for _, code := range codes {
if evalStringCompare(code, b) {
return true
}
}
return false
}
func evalAnyItemMatch(pred func(PromotionItem) bool, b BaseCondition, ctx *PromotionEvalContext) bool {
if slices.ContainsFunc(ctx.Items, pred) {
return true
+39
View File
@@ -442,6 +442,45 @@ func TestDateRangeCondition(t *testing.T) {
}
}
func TestCouponCodeCondition(t *testing.T) {
rule := PromotionRule{
ID: "coupon-promo",
Name: "Coupon Promo",
Status: StatusActive,
Conditions: Conditions{
BaseCondition{
ID: "c",
Type: CondCouponCode,
Operator: OpEquals,
Value: cvString("SAVE50"),
},
},
Actions: []Action{{ID: "a", Type: ActionFixedDiscount, Value: 500}},
}
svc := NewPromotionService(nil)
// Context with matching coupon code
ctxMatched := &PromotionEvalContext{
CouponCodes: []string{"SAVE50"},
Now: time.Now(),
}
resMatched := svc.EvaluateRule(rule, ctxMatched)
if !resMatched.Applicable {
t.Errorf("expected coupon code rule to apply with SAVE50")
}
// Context without matching coupon code
ctxUnmatched := &PromotionEvalContext{
CouponCodes: []string{"SAVE20"},
Now: time.Now(),
}
resUnmatched := svc.EvaluateRule(rule, ctxUnmatched)
if resUnmatched.Applicable {
t.Errorf("expected coupon code rule NOT to apply with SAVE20")
}
}
// --- Utilities -------------------------------------------------------------
func ptr(s string) *string { return &s }
+15 -4
View File
@@ -52,7 +52,7 @@ type EvaluateResponse struct {
// 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 := req.toCart(s.DefaultTaxProvider)
g.UpdateTotals()
ctx := NewContextFromCart(g, req.contextOptions()...)
results, _ := s.EvaluateAll(rules, ctx)
@@ -65,7 +65,8 @@ func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest)
}
// toCart materialises the request into a throwaway CartGrain.
func (req EvaluateRequest) toCart() *cart.CartGrain {
// tp is an optional TaxProvider for the default VAT rate; when nil, falls back to 25 %.
func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain {
now := time.Now()
if req.Now != nil {
now = *req.Now
@@ -78,7 +79,7 @@ func (req EvaluateRequest) toCart() *cart.CartGrain {
}
vat := it.VatRate
if vat == 0 {
vat = 25
vat = defaultVatRate(tp)
}
item := &cart.CartItem{
Sku: it.Sku,
@@ -91,15 +92,25 @@ func (req EvaluateRequest) toCart() *cart.CartGrain {
g.Items = append(g.Items, item)
}
if len(g.Items) == 0 && req.CartTotalIncVat > 0 {
vat := defaultVatRate(tp)
g.Items = append(g.Items, &cart.CartItem{
Sku: "synthetic",
Quantity: 1,
Price: *cart.NewPriceFromIncVat(req.CartTotalIncVat, 25),
Price: *cart.NewPriceFromIncVat(req.CartTotalIncVat, vat),
})
}
return g
}
// defaultVatRate returns the default VAT rate (as a float32 for NewPriceFromIncVat)
// from the provider, or 25 if none is configured.
func defaultVatRate(tp cart.TaxProvider) float32 {
if tp == nil {
return 25
}
return float32(tp.DefaultTaxRate(""))
}
// contextOptions maps the optional customer/time fields onto context options.
func (req EvaluateRequest) contextOptions() []ContextOption {
var opts []ContextOption
+9
View File
@@ -133,6 +133,15 @@ func (s *Server) initialize(params json.RawMessage) map[string]any {
}
}
// defaultVatRate returns the default VAT rate from the eval service's
// configured tax provider, falling back to 25 if none is set.
func (s *Server) defaultVatRate() float32 {
if s.eval == nil || s.eval.DefaultTaxProvider == nil {
return 25
}
return float32(s.eval.DefaultTaxProvider.DefaultTaxRate(""))
}
func isBatch(body []byte) bool {
for _, b := range body {
switch b {
+2 -2
View File
@@ -86,7 +86,7 @@ func (s *Server) buildPublicTools() []tool {
if err := decode(args, &a); err != nil {
return nil, err
}
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity)
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
if a.CustomerSegment != "" {
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
@@ -141,7 +141,7 @@ func (s *Server) buildPublicTools() []tool {
rules = filtered
}
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity)
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
if a.CustomerSegment != "" {
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
+9 -5
View File
@@ -190,7 +190,7 @@ func (s *Server) buildTools() []tool {
if err := decode(args, &a); err != nil {
return nil, err
}
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity)
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
opts := []promotions.ContextOption{promotions.WithNow(time.Now())}
if a.CustomerSegment != "" {
opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment))
@@ -210,15 +210,19 @@ func (s *Server) buildTools() []tool {
}
}
// syntheticCart builds a one-line cart whose gross total is incVat öre (25% VAT
// assumed) so the promotion engine can be evaluated against an arbitrary total.
func syntheticCart(incVat int64, qty int) *cart.CartGrain {
// syntheticCart builds a one-line cart whose gross total is incVat ore so the
// promotion engine can be evaluated against an arbitrary total. defaultVatRate
// is the VAT rate as a float32 percentage (e.g. 25 = 25 %).
func syntheticCart(incVat int64, qty int, defaultVatRate float32) *cart.CartGrain {
if qty <= 0 {
qty = 1
}
if defaultVatRate == 0 {
defaultVatRate = 25
}
g := cart.NewCartGrain(0, time.Now())
g.Items = []*cart.CartItem{
{Id: 1, Sku: "PREVIEW", Quantity: uint16(qty), Price: *cart.NewPriceFromIncVat(incVat, 25)},
{Id: 1, Sku: "PREVIEW", Quantity: uint16(qty), Price: *cart.NewPriceFromIncVat(incVat, defaultVatRate)},
}
// Quantity > 1 would multiply the row total; keep the gross equal to incVat by
// pricing a single unit at the full total.
+1
View File
@@ -41,6 +41,7 @@ const (
CondDateRange ConditionType = "date_range"
CondDayOfWeek ConditionType = "day_of_week"
CondTimeOfDay ConditionType = "time_of_day"
CondCouponCode ConditionType = "coupon_code"
CondGroup ConditionType = "group" // synthetic value for groups
)