better results
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s

This commit is contained in:
2026-07-08 00:55:05 +02:00
parent 781c1bd171
commit e20793a6b3
4 changed files with 545 additions and 91 deletions
+122 -11
View File
@@ -285,24 +285,53 @@ func applyTieredDiscount(g *cart.CartGrain, a Action) *cart.Price {
return applyPercentageDiscount(g, pct)
}
// applyPercentageDiscount removes pct% of the current cart total, preserving the
// VAT-rate breakdown proportionally, recording it in TotalDiscount and returning
// the discount applied (nil if nothing was taken off).
// applyPercentageDiscount removes pct% of each line's row total, sums
// the per-line amounts into the cart's TotalDiscount, and returns the
// total (nil if nothing was taken off). Per-line first then sum
// (instead of total first then split) avoids penny-level rounding
// gaps where the per-line amounts no longer sum to the reported
// total. The per-line amount is also added to each line's Discount
// field on top of the OrgPrice-based discount that UpdateTotals
// already set, so consumers (the /promotions/evaluate-with-cart
// preview, the checkout payload mapper) can see the effective unit
// price per item.
func applyPercentageDiscount(g *cart.CartGrain, pct float64) *cart.Price {
if pct <= 0 || g.TotalPrice.IncVat <= 0 {
return nil
}
discount := scalePrice(g.TotalPrice, pct)
if discount.IncVat <= 0 {
totalDiscount := cart.NewPrice()
for _, item := range g.Items {
if item == nil || item.TotalPrice.IncVat <= 0 {
continue
}
itemDisc := scalePrice(&item.TotalPrice, pct)
if itemDisc.IncVat <= 0 {
continue
}
// Clamp to this line's row total so a single item can't go
// negative when its per-line discount (post-rounding) ends
// up larger than its row. itemDisc is *cart.Price; assign
// through the pointer to overwrite the rounded value with
// the raw row total when needed.
if itemDisc.IncVat > item.TotalPrice.IncVat {
*itemDisc = item.TotalPrice
}
if item.Discount == nil {
item.Discount = cart.NewPrice()
}
item.Discount.Add(*itemDisc)
totalDiscount.Add(*itemDisc)
}
if totalDiscount.IncVat <= 0 {
return nil
}
// Never discount below zero.
if discount.IncVat > g.TotalPrice.IncVat {
discount = g.TotalPrice
// Safety net for any aggregate rounding overshoot.
if totalDiscount.IncVat > g.TotalPrice.IncVat {
totalDiscount = g.TotalPrice
}
g.TotalDiscount.Add(*discount)
g.TotalPrice.Subtract(*discount)
return discount
g.TotalDiscount.Add(*totalDiscount)
g.TotalPrice.Subtract(*totalDiscount)
return totalDiscount
}
// selectTier returns the discount percent for the band the total falls into.
@@ -437,6 +466,17 @@ func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*c
unitDiscount := scalePrice(&units[i].item.Price, discount)
totalDiscount.Add(*unitDiscount)
affectedMap[units[i].item.Id] = true
// Per-line distribution: add the per-unit discount to the
// item's Discount field on top of the OrgPrice-based
// discount that UpdateTotals already set. If the same item
// is the free unit multiple times (e.g. qty 3 with
// buy-2-get-1 and a second buy-2-get-1), Add is called
// per free unit so the cumulative amount lands on the
// right line.
if units[i].item.Discount == nil {
units[i].item.Discount = cart.NewPrice()
}
units[i].item.Discount.Add(*unitDiscount)
}
if totalDiscount.IncVat <= 0 {
@@ -590,6 +630,15 @@ func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action)
if bundleDiscount != nil && bundleDiscount.IncVat > 0 {
totalDiscount.Add(*bundleDiscount)
// Per-line distribution: split the bundle discount
// across the entries in `bundle` proportionally to
// each entry's per-unit price (same item can appear
// multiple times if it's in the bundle multiple
// times). distributeBundleDiscount walks the entries
// and adds the share to each item's Discount field,
// with the last entry absorbing any rounding remainder
// so the per-line sum matches bundleDiscount exactly.
distributeBundleDiscount(bundle, bundleDiscount)
for _, item := range bundle {
affectedMap[item.Id] = true
}
@@ -623,6 +672,68 @@ func (bundleDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a
// Helpers
// ----------------------------
// distributeBundleDiscount splits bundleDiscount across the entries
// in bundleItems proportionally to each entry's per-unit price, and
// adds the share to each item's Discount field. The same item may
// appear multiple times in bundleItems (when it's in the bundle
// multiple times); each entry gets its own share, and Add on the
// item's Discount field is cumulative. The last entry absorbs any
// rounding remainder on IncVat (and on each VAT rate) so the
// per-line sum matches bundleDiscount exactly. No-op when
// bundleDiscount is nil/zero or bundleItems is empty.
func distributeBundleDiscount(bundleItems []*cart.CartItem, bundleDiscount *cart.Price) {
if bundleDiscount == nil || bundleDiscount.IncVat <= 0 || len(bundleItems) == 0 {
return
}
total := int64(0)
prices := make([]int64, len(bundleItems))
for i, item := range bundleItems {
if item == nil {
continue
}
prices[i] = item.Price.IncVat.Int64()
total += prices[i]
}
if total <= 0 {
return
}
var allocated int64
for i, item := range bundleItems {
if item == nil {
continue
}
var shareIncVat int64
if i == len(bundleItems)-1 {
shareIncVat = bundleDiscount.IncVat.Int64() - allocated
} else {
shareIncVat = bundleDiscount.IncVat.Int64() * prices[i] / total
}
if shareIncVat <= 0 {
continue
}
sharePrice := &cart.Price{
IncVat: money.Cents(shareIncVat),
VatRates: make(map[int]money.Cents, len(bundleDiscount.VatRates)),
}
// Pro-rate each VAT rate by the same share fraction so the
// VAT breakdown on the per-line discount matches the
// proportional split. Guard against the (degenerate) case
// where bundleDiscount.IncVat is somehow 0 here — the
// outer guard at the top should prevent this, but be
// safe.
if bundleDiscount.IncVat.Int64() > 0 {
for rate, amount := range bundleDiscount.VatRates {
sharePrice.VatRates[rate] = money.Cents(amount.Int64() * shareIncVat / bundleDiscount.IncVat.Int64())
}
}
if item.Discount == nil {
item.Discount = cart.NewPrice()
}
item.Discount.Add(*sharePrice)
allocated += shareIncVat
}
}
func eligibleItems(rule PromotionRule, g *cart.CartGrain) []*cart.CartItem {
var categories []string
var productIDs []string
+223 -27
View File
@@ -2,6 +2,7 @@ package promotions
import (
"math"
"strings"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
@@ -42,29 +43,201 @@ type EvaluateRequest struct {
}
// EvaluateResponse is the outcome of a stateless evaluation: the totals after
// promotions and the per-promotion breakdown (applied discounts plus pending
// "spend X more for ..." nudges).
// promotions, the per-promotion breakdown (applied discounts plus pending
// "spend X more for ..." nudges), and the per-line breakdown so the
// storefront can render the real (post-discount) unit price for each item.
type EvaluateResponse struct {
TotalPrice *cart.Price `json:"totalPrice"`
TotalDiscount *cart.Price `json:"totalDiscount"`
AppliedPromotions []cart.AppliedPromotion `json:"appliedPromotions,omitempty"`
Items []EvaluatedItem `json:"items,omitempty"`
}
// EvaluatedItem is one line in the EvaluateResponse.Items list. It mirrors
// the line that was evaluated (sku, quantity, unit list price, optional
// OrgPrice for the strikethrough) and adds the per-line discount the
// engine applied (orgPrice-based + promotion-based, additive) plus the
// resulting effective per-unit and per-line totals. Distributing the
// total discount down to the line level lets the storefront render
// "Item X: 100 kr → 80 kr" without re-doing the math on the client.
type EvaluatedItem struct {
Sku string `json:"sku"`
Name string `json:"name,omitempty"`
Quantity uint16 `json:"qty"`
PriceIncVat int64 `json:"priceIncVat"` // per-unit list price
OrgPriceIncVat int64 `json:"orgPriceIncVat,omitempty"` // per-unit pre-discount list price (for strikethrough)
DiscountIncVat int64 `json:"discountIncVat"` // per-line TOTAL discount (orgPrice + promotion), incVat in öre
EffectivePriceIncVat int64 `json:"effectivePriceIncVat"` // per-unit, after discount, incVat in öre (rounded)
EffectiveTotalIncVat int64 `json:"effectiveTotalIncVat"` // per-line, after discount, incVat in öre (exact)
}
// MapEvaluatedItems walks the grain's items after ApplyResults and
// produces the per-line EvaluatedItem list. The grain's item.Discount
// carries the TOTAL per-line discount (orgPrice + promotion — additive,
// set by UpdateTotals and the effects' per-line distribution). The
// effective totals are derived as (price * qty - discount), clamped to
// 0 (a promotion that over-discounted a line shows as free, not
// negative). The effective per-unit price is the per-line total
// divided by quantity, rounded to the nearest öre so the per-unit
// display matches the per-line total when multiplied back.
// Exported so the /promotions/evaluate-with-cart preview handler in
// cmd/cart can produce the same per-line shape the stateless
// Evaluate path produces.
func MapEvaluatedItems(g *cart.CartGrain) []EvaluatedItem {
if g == nil {
return nil
}
out := make([]EvaluatedItem, 0, len(g.Items))
for _, it := range g.Items {
if it == nil {
continue
}
var name string
if it.Meta != nil {
name = it.Meta.Name
}
var orgPrice int64
if it.OrgPrice != nil {
orgPrice = it.OrgPrice.IncVat.Int64()
}
var discount int64
if it.Discount != nil {
discount = it.Discount.IncVat.Int64()
}
priceRow := it.Price.IncVat.Int64() * int64(it.Quantity)
effTotal := priceRow - discount
if effTotal < 0 {
effTotal = 0
}
var effUnit int64
if it.Quantity > 0 {
// Nearest-öre rounding so per-unit * qty matches the
// per-line total (within 1 öre either way). Half-up:
// (effTotal + qty/2) / qty.
effUnit = (effTotal + int64(it.Quantity)/2) / int64(it.Quantity)
}
out = append(out, EvaluatedItem{
Sku: it.Sku,
Name: name,
Quantity: it.Quantity,
PriceIncVat: it.Price.IncVat.Int64(),
OrgPriceIncVat: orgPrice,
DiscountIncVat: discount,
EffectivePriceIncVat: effUnit,
EffectiveTotalIncVat: effTotal,
})
}
return out
}
// 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(s.DefaultTaxProvider)
g.UpdateTotals()
ctx := NewContextFromCart(g, req.contextOptions()...)
results, _ := s.EvaluateAll(rules, ctx)
s.ApplyResults(g, results, ctx)
s.EvaluateAndApply(rules, g, req.ContextOptions()...)
return EvaluateResponse{
TotalPrice: g.TotalPrice,
TotalDiscount: g.TotalDiscount,
AppliedPromotions: g.AppliedPromotions,
Items: MapEvaluatedItems(g),
}
}
// EvaluateAndApply runs the canonical promotion-evaluation pipeline on
// an existing grain. It is the single entry point every caller that
// needs "what would the engine do for this cart" MUST go through —
// the live cart processor in cmd/cart/main.go's reg.RegisterProcessor
// and the /promotions/evaluate-with-cart preview handler in
// cmd/cart/promotions_evaluate.go both call this method, so the
// preview and post-add behavior stay in lockstep (especially in the
// coupon+promotion-overlap edge case where a promotion rule with a
// coupon_code condition would otherwise stomp a voucher the customer
// has applied). If a new caller needs the same pipeline, add it here
// — do not duplicate the steps.
//
// The pipeline is:
//
//  1. Clear Vouchers.BypassedByPromotions on every voucher so the
//     re-eval trigger (step 5) works correctly on each invocation —
//     otherwise a coupon that was already bypassed on a previous
//     pass would not be re-marked and step 5 would not fire.
//  2. UpdateTotals on the grain (recomputes TotalPrice, TotalDiscount
//     from the current items + vouchers).
//  3. Build a context from the grain (via NewContextFromCart with the
//     supplied opts; defaults to WithNow(time.Now()) only when no
//     opts are passed at all — callers that pass non-empty opts
//     without WithNow get the engine's built-in default).
//  4. EvaluateAll against the rules.
//  5. markCouponsBypassed scans the results for applicable rules
//     with a coupon_code condition matching a voucher in the grain;
//     matching vouchers get BypassedByPromotions set. If any voucher
//     was newly marked, re-run steps 2-4 (the live cart has done this
//     since the bypass pass; the preview must match exactly).
//  6. ApplyResults records every effect (applied discounts + pending
//     "spend X more for ..." nudges) on the grain.
//
// The grain is mutated in place; for a read-only what-if, deep-copy
// the grain first (see cmd/cart/promotions_evaluate.go's
// cloneCartForPreview).
func (s *PromotionService) EvaluateAndApply(rules []PromotionRule, g *cart.CartGrain, opts ...ContextOption) {
for _, v := range g.Vouchers {
if v != nil {
v.BypassedByPromotions = false
}
}
if len(opts) == 0 {
opts = []ContextOption{WithNow(time.Now())}
}
g.UpdateTotals()
ctx := NewContextFromCart(g, opts...)
results, _ := s.EvaluateAll(rules, ctx)
if markCouponsBypassed(g, results) {
g.UpdateTotals()
ctx = NewContextFromCart(g, opts...)
results, _ = s.EvaluateAll(rules, ctx)
}
s.ApplyResults(g, results, ctx)
}
// markCouponsBypassed scans the evaluation results for any applicable
// rule with a coupon_code condition whose value matches a voucher in
// the grain; marks matching vouchers' BypassedByPromotions flag and
// returns true if any were newly marked. Called by EvaluateAndApply
// between the first and second EvaluateAll pass — see that method's
// doc for why the re-eval is needed.
func markCouponsBypassed(g *cart.CartGrain, results []EvaluationResult) bool {
hasBypassed := false
for _, res := range results {
if !res.Applicable {
continue
}
WalkConditions(res.Rule.Conditions, func(c Condition) bool {
bc, ok := c.(BaseCondition)
if !ok || bc.Type != CondCouponCode {
return true
}
var codes []string
if s, ok := bc.Value.AsString(); ok {
codes = append(codes, strings.ToLower(s))
} else if arr, ok := bc.Value.AsStringSlice(); ok {
for _, s := range arr {
codes = append(codes, strings.ToLower(s))
}
}
for _, code := range codes {
for _, v := range g.Vouchers {
if v != nil && strings.ToLower(v.Code) == code && !v.BypassedByPromotions {
v.BypassedByPromotions = true
hasBypassed = true
}
}
}
return true
})
}
return hasBypassed
}
// toCart materialises the request into a throwaway 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 {
@@ -74,25 +247,7 @@ func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain {
}
g := cart.NewCartGrain(0, now)
for _, it := range req.Items {
qty := it.Quantity
if qty == 0 {
qty = 1
}
// it.VatRate is the request rate in raw percent (external input); convert
// to the platform basis-point scale at this boundary.
vat := int(math.Round(float64(it.VatRate) * 100))
if vat == 0 {
vat = defaultVatRate(tp)
}
item := &cart.CartItem{
Sku: it.Sku,
Quantity: qty,
Price: *cart.NewPriceFromIncVat(it.PriceIncVat, vat),
}
if it.Category != "" {
item.Meta = &cart.ItemMeta{Category: it.Category}
}
g.Items = append(g.Items, item)
g.Items = append(g.Items, it.ToCartItem(tp))
}
if len(g.Items) == 0 && req.CartTotalIncVat > 0 {
vat := defaultVatRate(tp)
@@ -105,6 +260,41 @@ func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain {
return g
}
// ToCartItem converts this EvalItem into a CartItem. tp is an optional
// TaxProvider for the default VAT rate; when nil, falls back to 25 %.
// This is the canonical EvalItem→CartItem conversion — shared by the
// stateless evaluator (req.toCart above) and any caller that needs to
// materialise an EvalItem into a real cart grain (e.g. the
// /promotions/evaluate-with-cart preview handler, which merges PDP
// request items into a deep-copied live grain so the engine sees the
// user's "what-if" line list on top of the cart's actual state). Keep
// the math identical to the inline conversion that used to live in
// req.toCart so the two paths can never drift.
//
// VatRate is the request rate in raw percent (external input); convert
// to the platform basis-point scale (× 100, rounded) at this boundary.
// Missing qty defaults to 1; missing VAT rate falls back to
// defaultVatRate(tp). Optional Category lands in ItemMeta.
func (it EvalItem) ToCartItem(tp cart.TaxProvider) *cart.CartItem {
qty := it.Quantity
if qty == 0 {
qty = 1
}
vat := int(math.Round(float64(it.VatRate) * 100))
if vat == 0 {
vat = defaultVatRate(tp)
}
out := &cart.CartItem{
Sku: it.Sku,
Quantity: qty,
Price: *cart.NewPriceFromIncVat(it.PriceIncVat, vat),
}
if it.Category != "" {
out.Meta = &cart.ItemMeta{Category: it.Category}
}
return out
}
// defaultVatRate returns the default VAT rate in basis points (2500 = 25%) from
// the provider, or 2500 if none is configured.
func defaultVatRate(tp cart.TaxProvider) int {
@@ -114,8 +304,14 @@ func defaultVatRate(tp cart.TaxProvider) int {
return tp.DefaultTaxRate("")
}
// contextOptions maps the optional customer/time fields onto context options.
func (req EvaluateRequest) contextOptions() []ContextOption {
// ContextOptions maps the optional customer/time fields onto context
// options. WithNow is only added when req.Now is explicitly set —
// callers that need a default "now" should append WithNow(time.Now())
// themselves (or pass no opts and let EvaluateAndApply default it).
// Exported so callers that build a context from a request (e.g. the
// /promotions/evaluate-with-cart preview handler) can use the same
// mapping the stateless Evaluate path uses, keeping the two in sync.
func (req EvaluateRequest) ContextOptions() []ContextOption {
var opts []ContextOption
if req.Now != nil {
opts = append(opts, WithNow(*req.Now))