272 lines
11 KiB
Go
272 lines
11 KiB
Go
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
|
||
}
|