131 lines
4.5 KiB
Go
131 lines
4.5 KiB
Go
package promotions
|
|
|
|
import (
|
|
"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 and the per-promotion breakdown (applied discounts plus pending
|
|
// "spend X more for ..." nudges).
|
|
type EvaluateResponse struct {
|
|
TotalPrice *cart.Price `json:"totalPrice"`
|
|
TotalDiscount *cart.Price `json:"totalDiscount"`
|
|
AppliedPromotions []cart.AppliedPromotion `json:"appliedPromotions,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)
|
|
g.UpdateTotals()
|
|
ctx := NewContextFromCart(g, req.contextOptions()...)
|
|
results, _ := s.EvaluateAll(rules, ctx)
|
|
s.ApplyResults(g, results, ctx)
|
|
return EvaluateResponse{
|
|
TotalPrice: g.TotalPrice,
|
|
TotalDiscount: g.TotalDiscount,
|
|
AppliedPromotions: g.AppliedPromotions,
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
qty := it.Quantity
|
|
if qty == 0 {
|
|
qty = 1
|
|
}
|
|
vat := it.VatRate
|
|
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)
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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
|
|
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
|
|
}
|