Files
mats 9f1eca07e9
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
Build and Publish / BuildAndDeployArm64 (push) Failing after 7s
parent item mutations
2026-07-08 11:39:42 +02:00

272 lines
11 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package promotions
import (
"math"
"strings"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
)
// ----------------------------
// Stateless evaluation
// ----------------------------
//
// Evaluate runs the promotion engine against a caller-supplied context instead
// of a real cart, so a storefront can ask "what would these items get?" without
// creating or mutating a cart. It builds a synthetic CartGrain from the request,
// runs the same EvaluateAll + ApplyResults path the live cart uses, and returns
// the resulting totals plus the applied/pending effect breakdown.
//
// The per-line breakdown (cart.EvaluatedItem) is computed via the canonical
// cart.MapEvaluatedItems so the preview and post-add responses share shape
// and rounding rules — see pkg/cart/evaluated_item.go for the type and the
// map function.
// EvalItem is one — possibly partial — cart line for a stateless evaluation.
// Missing fields fall back to sensible defaults (qty 1, 25% VAT) so the engine
// does the best it can with whatever the caller provides.
type EvalItem struct {
Sku string `json:"sku,omitempty"`
Category string `json:"category,omitempty"`
Quantity uint16 `json:"qty,omitempty"`
PriceIncVat int64 `json:"priceIncVat,omitempty"`
VatRate float32 `json:"vatRate,omitempty"`
}
// EvaluateRequest is a stateless promotion-evaluation context. Every field is
// optional. Items drive the cart total and the item-scoped conditions; when no
// items are given, CartTotalIncVat synthesises a single line so total-only rules
// (volume discounts, free-shipping thresholds) can still be previewed.
type EvaluateRequest struct {
Items []EvalItem `json:"items,omitempty"`
CartTotalIncVat int64 `json:"cartTotalIncVat,omitempty"`
CustomerSegment string `json:"customerSegment,omitempty"`
CustomerLifetimeValue float64 `json:"customerLifetimeValue,omitempty"`
OrderCount int `json:"orderCount,omitempty"`
Now *time.Time `json:"now,omitempty"`
}
// EvaluateResponse is the outcome of a stateless evaluation: the totals after
// 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.
//
// Items is the same []cart.EvaluatedItem shape the live cart exposes on
// CartGrain.EvaluatedItems — referenced from cart so the preview and the
// live response match byte-for-byte on the wire.
type EvaluateResponse struct {
TotalPrice *cart.Price `json:"totalPrice"`
TotalDiscount *cart.Price `json:"totalDiscount"`
AppliedPromotions []cart.AppliedPromotion `json:"appliedPromotions,omitempty"`
Items []cart.EvaluatedItem `json:"items,omitempty"`
}
// 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)
s.EvaluateAndApply(rules, g, req.ContextOptions()...)
return EvaluateResponse{
TotalPrice: g.TotalPrice,
TotalDiscount: g.TotalDiscount,
AppliedPromotions: g.AppliedPromotions,
Items: cart.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)
// Populate the per-line breakdown now that ApplyResults is done —
// every effect has distributed per-line Discounts onto the grain's
// items, so MapEvaluatedItems reads consistent state. Sits on the
// canonical pipeline so callers (live-cart processor, the cart-aware
// preview handler, internal/admin mutations) never have to remember
// to refresh it themselves; downstream readers (UCP cart response,
// legacy /cart HTTP handler, cart-mcp, AMQP mutation feed) see a
// grain that already carries the per-line breakdown on its own
// EvaluatedItems field.
g.EvaluatedItems = cart.MapEvaluatedItems(g)
}
// 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 {
now := time.Now()
if req.Now != nil {
now = *req.Now
}
g := cart.NewCartGrain(0, now)
for _, it := range req.Items {
g.Items = append(g.Items, it.ToCartItem(tp))
}
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, vat),
})
}
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 {
if tp == nil {
return 2500
}
return tp.DefaultTaxRate("")
}
// 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))
}
if req.CustomerSegment != "" {
opts = append(opts, WithCustomerSegment(req.CustomerSegment))
}
if req.CustomerLifetimeValue != 0 {
opts = append(opts, WithCustomerLifetimeValue(req.CustomerLifetimeValue))
}
if req.OrderCount != 0 {
opts = append(opts, WithOrderCount(req.OrderCount))
}
return opts
}