Files
go-cart-actor/pkg/promotions/evaluate.go
T
mats e20793a6b3
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
better results
2026-07-08 00:55:05 +02:00

330 lines
13 KiB
Go
Raw 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.
// 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.
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)
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 {
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
}