better results
This commit is contained in:
+24
-53
@@ -202,59 +202,17 @@ func main() {
|
|||||||
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
||||||
_, span := tracer.Start(ctx, "Totals and promotions")
|
_, span := tracer.Start(ctx, "Totals and promotions")
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
// Canonical promotion pipeline — same call site as the
|
||||||
// Clear bypass flags first
|
// /promotions/evaluate-with-cart preview handler in
|
||||||
for _, v := range g.Vouchers {
|
// promotions_evaluate.go. Going through the shared
|
||||||
if v != nil {
|
// PromotionService.EvaluateAndApply means the bypass
|
||||||
v.BypassedByPromotions = false
|
// loop, the re-eval trigger, and ApplyResults stay in
|
||||||
}
|
// lockstep with the preview so a what-if always matches
|
||||||
}
|
// post-add behavior (including the coupon+promotion-
|
||||||
|
// overlap edge case). If you find yourself adding
|
||||||
g.UpdateTotals()
|
// promotion logic here, add it to EvaluateAndApply
|
||||||
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
// instead — the preview must follow.
|
||||||
results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
promotionService.EvaluateAndApply(promotionStore.Snapshot(), g, promotions.WithNow(time.Now()))
|
||||||
|
|
||||||
// Check if any qualified promotion rule has a coupon_code condition matching our vouchers
|
|
||||||
hasBypassed := false
|
|
||||||
for _, res := range results {
|
|
||||||
if res.Applicable {
|
|
||||||
promotions.WalkConditions(res.Rule.Conditions, func(c promotions.Condition) bool {
|
|
||||||
if bc, ok := c.(promotions.BaseCondition); ok && bc.Type == promotions.CondCouponCode {
|
|
||||||
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 {
|
|
||||||
if !v.BypassedByPromotions {
|
|
||||||
v.BypassedByPromotions = true
|
|
||||||
hasBypassed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasBypassed {
|
|
||||||
// Re-evaluate with bypassed vouchers
|
|
||||||
g.UpdateTotals()
|
|
||||||
promotionCtx = promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
|
||||||
results, _ = promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyResults applies qualifying actions in priority order and records
|
|
||||||
// every effect — both applied discounts and pending "spend X more for ..."
|
|
||||||
// nudges with their progress — in g.AppliedPromotions.
|
|
||||||
promotionService.ApplyResults(g, results, promotionCtx)
|
|
||||||
g.Version++
|
g.Version++
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
@@ -490,6 +448,19 @@ func main() {
|
|||||||
// without creating or mutating a real cart.
|
// without creating or mutating a real cart.
|
||||||
mux.HandleFunc("POST /promotions/evaluate", newPromotionEvaluateHandler(promotionStore, promotionService))
|
mux.HandleFunc("POST /promotions/evaluate", newPromotionEvaluateHandler(promotionStore, promotionService))
|
||||||
|
|
||||||
|
// Cart-aware promotion evaluation: reads the cartid cookie the storefront
|
||||||
|
// already maintains, fetches the actual cart grain (cross-pod via
|
||||||
|
// GetAnywhere), merges with the request's items (the items the user is
|
||||||
|
// considering adding), and runs the engine on the merged line list. The
|
||||||
|
// cart is the source of truth (vouchers, customer segment, customer
|
||||||
|
// lifetime value, total) so a serialised copy from the browser can race
|
||||||
|
// the live cart — letting the backend read the cookie + fetch the grain
|
||||||
|
// means exactly one source of truth and no race. Missing cookie or fetch
|
||||||
|
// failure gracefully degrades to a stateless evaluation of the request
|
||||||
|
// items alone. See newPromotionEvaluateWithCartHandler in
|
||||||
|
// promotions_evaluate.go for the full contract.
|
||||||
|
mux.HandleFunc("POST /promotions/evaluate-with-cart", newPromotionEvaluateWithCartHandler(promotionStore, promotionService, syncedServer))
|
||||||
|
|
||||||
// only for local
|
// only for local
|
||||||
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
pool.AddRemote(r.PathValue("host"))
|
pool.AddRemote(r.PathValue("host"))
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,6 +16,7 @@ import (
|
|||||||
// (possibly partial) evaluation context and returns the totals plus the
|
// (possibly partial) evaluation context and returns the totals plus the
|
||||||
// applied/pending promotion effects, without creating or mutating a real cart.
|
// applied/pending promotion effects, without creating or mutating a real cart.
|
||||||
// An empty body is treated as an empty context (evaluates against no items).
|
// An empty body is treated as an empty context (evaluates against no items).
|
||||||
|
// Pure stateless — callers compose the line list themselves.
|
||||||
func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.PromotionService) http.HandlerFunc {
|
func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.PromotionService) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var req promotions.EvaluateRequest
|
var req promotions.EvaluateRequest
|
||||||
@@ -27,3 +31,175 @@ func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.Promot
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newPromotionEvaluateWithCartHandler serves POST /promotions/evaluate-with-cart.
|
||||||
|
// Reads the `cartid` cookie the storefront already maintains (set by
|
||||||
|
// GET /cart on first visit), fetches the actual cart from the grain pool
|
||||||
|
// (cross-pod via GetAnywhere so a cart owned by another pod is fetched
|
||||||
|
// transparently), deep-copies the grain, merges the PDP request items
|
||||||
|
// into the copy (replace-by-sku semantic so the PDP qty wins for any
|
||||||
|
// product already in the cart), and runs the canonical
|
||||||
|
// promotions.PromotionService.EvaluateAndApply pipeline the live cart
|
||||||
|
// processor uses — so the preview sees the cart's vouchers, customer
|
||||||
|
// segment, customer-lifetime-value, and computed total, not just the
|
||||||
|
// line list, and the coupon+promotion-overlap edge case is handled
|
||||||
|
// the same way on both sides. Returns the same EvaluateResponse shape
|
||||||
|
// as the stateless endpoint.
|
||||||
|
//
|
||||||
|
// Why this exists: the storefront's "I kundkorgen" preview needs the
|
||||||
|
// engine's verdict for "what would the cart look like if I add this
|
||||||
|
// product". The cart is the source of truth (it has vouchers, customer
|
||||||
|
// segment, customer-lifetime-value, total, etc. that the engine needs),
|
||||||
|
// and a serialised copy sent from the browser can race the live cart.
|
||||||
|
// Letting the backend read the cookie + fetch the grain means there is
|
||||||
|
// exactly one source of truth and no race. The grain is deep-copied via
|
||||||
|
// JSON marshal/unmarshal so the preview never mutates the live state —
|
||||||
|
// every read is purely a what-if computation.
|
||||||
|
//
|
||||||
|
// Missing/invalid cookie (new visitor, preview-only tab): the handler
|
||||||
|
// treats the request as a pure stateless evaluation of the request
|
||||||
|
// items alone — the "what would I get just for the product I'm
|
||||||
|
// viewing" answer is still useful on its own.
|
||||||
|
//
|
||||||
|
// Cart fetch failure (the grain is gone, the pool is degraded, etc.):
|
||||||
|
// the handler logs and falls through to the same stateless evaluation
|
||||||
|
// of the request items. The preview degrades gracefully rather than
|
||||||
|
// erroring the whole block.
|
||||||
|
//
|
||||||
|
// "Replace" merge semantic: if the same sku is in both the cart and
|
||||||
|
// the request items, the request item wins (with its qty). This
|
||||||
|
// matches the PDP's intent ("I am previewing adding this product
|
||||||
|
// at this qty") and avoids double-counting the same product.
|
||||||
|
func newPromotionEvaluateWithCartHandler(store *promotions.Store, svc *promotions.PromotionService, server *PoolServer) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req promotions.EvaluateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
http.Error(w, "invalid JSON body: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp promotions.EvaluateResponse
|
||||||
|
if g := cloneCartForPreview(server, r); g != nil {
|
||||||
|
mergeRequestItemsIntoGrain(g, req.Items, svc.DefaultTaxProvider)
|
||||||
|
// One call to the canonical pipeline — same code path
|
||||||
|
// the live cart processor's reg.RegisterProcessor in
|
||||||
|
// main.go uses. Going through the shared
|
||||||
|
// PromotionService.EvaluateAndApply means the bypass
|
||||||
|
// loop, the re-eval trigger, and ApplyResults stay
|
||||||
|
// in lockstep with post-add behavior.
|
||||||
|
svc.EvaluateAndApply(store.Snapshot(), g, previewContextOptions(req)...)
|
||||||
|
resp = promotions.EvaluateResponse{
|
||||||
|
TotalPrice: g.TotalPrice,
|
||||||
|
TotalDiscount: g.TotalDiscount,
|
||||||
|
AppliedPromotions: g.AppliedPromotions,
|
||||||
|
Items: promotions.MapEvaluatedItems(g),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No live cart (missing/invalid cookie, fetch failed, or
|
||||||
|
// deep-copy failed). Fall back to the pure stateless
|
||||||
|
// evaluation of the request items alone — the user still
|
||||||
|
// gets a useful preview of "what would the engine do for
|
||||||
|
// just the items I'm about to add".
|
||||||
|
resp = svc.Evaluate(store.Snapshot(), req)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneCartForPreview reads the cartid cookie, fetches the live grain
|
||||||
|
// (cross-pod via GetAnywhere), and returns a deep copy safe to mutate
|
||||||
|
// for the what-if preview. Returns nil (and logs) when the cookie is
|
||||||
|
// missing/invalid OR the grain fetch fails OR the deep-copy fails —
|
||||||
|
// the caller falls back to a stateless evaluation of the request items
|
||||||
|
// alone.
|
||||||
|
//
|
||||||
|
// The deep copy is via JSON marshal/unmarshal, which is intentional: it
|
||||||
|
// produces a fully independent *cart.CartGrain (re-allocating every
|
||||||
|
// pointer — Items, Vouchers, ItemMeta, Price, etc.) so the live grain
|
||||||
|
// is never touched, even if the live grain holds non-serializable
|
||||||
|
// unexported state like an in-memory event-log channel. Unexported
|
||||||
|
// fields are silently dropped by the json package, which is exactly
|
||||||
|
// what we want here — the engine only reads the grain's exported
|
||||||
|
// state (Items, Vouchers, TotalPrice, etc.), and the live grain's
|
||||||
|
// in-memory event log / channels must not be cloned or shared.
|
||||||
|
func cloneCartForPreview(server *PoolServer, r *http.Request) *cart.CartGrain {
|
||||||
|
cookie, err := r.Cookie("cartid")
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id, ok := cart.ParseCartId(cookie.Value)
|
||||||
|
if !ok {
|
||||||
|
log.Printf("promotions/evaluate-with-cart: invalid cartid cookie, falling back to stateless")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
live, err := server.GetAnywhere(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("promotions/evaluate-with-cart: cart fetch failed for %s, falling back to stateless: %v", id, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if live == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
buf, err := json.Marshal(live)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("promotions/evaluate-with-cart: cart marshal failed for %s, falling back to stateless: %v", id, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var clone *cart.CartGrain
|
||||||
|
if err := json.Unmarshal(buf, &clone); err != nil {
|
||||||
|
log.Printf("promotions/evaluate-with-cart: cart unmarshal failed for %s, falling back to stateless: %v", id, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeRequestItemsIntoGrain merges reqItems into the grain's items with
|
||||||
|
// "replace" semantic: any cart line whose sku also appears in the
|
||||||
|
// request is dropped (the request item wins, so the PDP qty replaces
|
||||||
|
// the cart qty for that product). Remaining cart lines are kept
|
||||||
|
// verbatim, then the converted request items are appended. The grain
|
||||||
|
// is mutated in place — caller is expected to be holding a deep copy
|
||||||
|
// from cloneCartForPreview. The EvalItem→CartItem conversion uses the
|
||||||
|
// shared promotions.EvalItem.ToCartItem helper so the math (VAT rate
|
||||||
|
// rounding, qty default, ItemMeta, Price) matches the synthetic-cart
|
||||||
|
// conversion in the stateless endpoint exactly.
|
||||||
|
func mergeRequestItemsIntoGrain(g *cart.CartGrain, reqItems []promotions.EvalItem, tp cart.TaxProvider) {
|
||||||
|
requestSkus := make(map[string]struct{}, len(reqItems))
|
||||||
|
for _, it := range reqItems {
|
||||||
|
if it.Sku != "" {
|
||||||
|
requestSkus[it.Sku] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged := make([]*cart.CartItem, 0, len(g.Items)+len(reqItems))
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if _, taken := requestSkus[it.Sku]; taken {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged = append(merged, it)
|
||||||
|
}
|
||||||
|
for _, it := range reqItems {
|
||||||
|
merged = append(merged, it.ToCartItem(tp))
|
||||||
|
}
|
||||||
|
g.Items = merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// previewContextOptions builds the ContextOption list for the
|
||||||
|
// cart-aware preview. The shared EvaluateRequest.ContextOptions
|
||||||
|
// already includes WithNow when req.Now is set, so we only prepend a
|
||||||
|
// default WithNow(time.Now()) when the request didn't specify one —
|
||||||
|
// avoiding a duplicate WithNow in the opts list (the engine's
|
||||||
|
// NewContextFromCart applies options in order, but the last-one-wins
|
||||||
|
// rule for duplicates is not a contract we want to rely on). The
|
||||||
|
// other context options (CustomerSegment, CLV, OrderCount) come from
|
||||||
|
// req.ContextOptions unchanged.
|
||||||
|
func previewContextOptions(req promotions.EvaluateRequest) []promotions.ContextOption {
|
||||||
|
opts := req.ContextOptions()
|
||||||
|
if req.Now == nil {
|
||||||
|
opts = append([]promotions.ContextOption{promotions.WithNow(time.Now())}, opts...)
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|||||||
+122
-11
@@ -285,24 +285,53 @@ func applyTieredDiscount(g *cart.CartGrain, a Action) *cart.Price {
|
|||||||
return applyPercentageDiscount(g, pct)
|
return applyPercentageDiscount(g, pct)
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyPercentageDiscount removes pct% of the current cart total, preserving the
|
// applyPercentageDiscount removes pct% of each line's row total, sums
|
||||||
// VAT-rate breakdown proportionally, recording it in TotalDiscount and returning
|
// the per-line amounts into the cart's TotalDiscount, and returns the
|
||||||
// the discount applied (nil if nothing was taken off).
|
// 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 {
|
func applyPercentageDiscount(g *cart.CartGrain, pct float64) *cart.Price {
|
||||||
if pct <= 0 || g.TotalPrice.IncVat <= 0 {
|
if pct <= 0 || g.TotalPrice.IncVat <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
discount := scalePrice(g.TotalPrice, pct)
|
totalDiscount := cart.NewPrice()
|
||||||
if discount.IncVat <= 0 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
// Never discount below zero.
|
// Safety net for any aggregate rounding overshoot.
|
||||||
if discount.IncVat > g.TotalPrice.IncVat {
|
if totalDiscount.IncVat > g.TotalPrice.IncVat {
|
||||||
discount = g.TotalPrice
|
totalDiscount = g.TotalPrice
|
||||||
}
|
}
|
||||||
g.TotalDiscount.Add(*discount)
|
g.TotalDiscount.Add(*totalDiscount)
|
||||||
g.TotalPrice.Subtract(*discount)
|
g.TotalPrice.Subtract(*totalDiscount)
|
||||||
return discount
|
return totalDiscount
|
||||||
}
|
}
|
||||||
|
|
||||||
// selectTier returns the discount percent for the band the total falls into.
|
// 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)
|
unitDiscount := scalePrice(&units[i].item.Price, discount)
|
||||||
totalDiscount.Add(*unitDiscount)
|
totalDiscount.Add(*unitDiscount)
|
||||||
affectedMap[units[i].item.Id] = true
|
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 {
|
if totalDiscount.IncVat <= 0 {
|
||||||
@@ -590,6 +630,15 @@ func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action)
|
|||||||
|
|
||||||
if bundleDiscount != nil && bundleDiscount.IncVat > 0 {
|
if bundleDiscount != nil && bundleDiscount.IncVat > 0 {
|
||||||
totalDiscount.Add(*bundleDiscount)
|
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 {
|
for _, item := range bundle {
|
||||||
affectedMap[item.Id] = true
|
affectedMap[item.Id] = true
|
||||||
}
|
}
|
||||||
@@ -623,6 +672,68 @@ func (bundleDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a
|
|||||||
// Helpers
|
// 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 {
|
func eligibleItems(rule PromotionRule, g *cart.CartGrain) []*cart.CartItem {
|
||||||
var categories []string
|
var categories []string
|
||||||
var productIDs []string
|
var productIDs []string
|
||||||
|
|||||||
+223
-27
@@ -2,6 +2,7 @@ package promotions
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"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
|
// EvaluateResponse is the outcome of a stateless evaluation: the totals after
|
||||||
// promotions and the per-promotion breakdown (applied discounts plus pending
|
// promotions, the per-promotion breakdown (applied discounts plus pending
|
||||||
// "spend X more for ..." nudges).
|
// "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 {
|
type EvaluateResponse struct {
|
||||||
TotalPrice *cart.Price `json:"totalPrice"`
|
TotalPrice *cart.Price `json:"totalPrice"`
|
||||||
TotalDiscount *cart.Price `json:"totalDiscount"`
|
TotalDiscount *cart.Price `json:"totalDiscount"`
|
||||||
AppliedPromotions []cart.AppliedPromotion `json:"appliedPromotions,omitempty"`
|
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
|
// Evaluate runs rules against a synthetic cart built from req and returns the
|
||||||
// resulting totals and effects without touching any real cart state.
|
// resulting totals and effects without touching any real cart state.
|
||||||
func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest) EvaluateResponse {
|
func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest) EvaluateResponse {
|
||||||
g := req.toCart(s.DefaultTaxProvider)
|
g := req.toCart(s.DefaultTaxProvider)
|
||||||
g.UpdateTotals()
|
s.EvaluateAndApply(rules, g, req.ContextOptions()...)
|
||||||
ctx := NewContextFromCart(g, req.contextOptions()...)
|
|
||||||
results, _ := s.EvaluateAll(rules, ctx)
|
|
||||||
s.ApplyResults(g, results, ctx)
|
|
||||||
return EvaluateResponse{
|
return EvaluateResponse{
|
||||||
TotalPrice: g.TotalPrice,
|
TotalPrice: g.TotalPrice,
|
||||||
TotalDiscount: g.TotalDiscount,
|
TotalDiscount: g.TotalDiscount,
|
||||||
AppliedPromotions: g.AppliedPromotions,
|
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.
|
// toCart materialises the request into a throwaway CartGrain.
|
||||||
// tp is an optional TaxProvider for the default VAT rate; when nil, falls back to 25 %.
|
// 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 {
|
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)
|
g := cart.NewCartGrain(0, now)
|
||||||
for _, it := range req.Items {
|
for _, it := range req.Items {
|
||||||
qty := it.Quantity
|
g.Items = append(g.Items, it.ToCartItem(tp))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
if len(g.Items) == 0 && req.CartTotalIncVat > 0 {
|
if len(g.Items) == 0 && req.CartTotalIncVat > 0 {
|
||||||
vat := defaultVatRate(tp)
|
vat := defaultVatRate(tp)
|
||||||
@@ -105,6 +260,41 @@ func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain {
|
|||||||
return g
|
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
|
// defaultVatRate returns the default VAT rate in basis points (2500 = 25%) from
|
||||||
// the provider, or 2500 if none is configured.
|
// the provider, or 2500 if none is configured.
|
||||||
func defaultVatRate(tp cart.TaxProvider) int {
|
func defaultVatRate(tp cart.TaxProvider) int {
|
||||||
@@ -114,8 +304,14 @@ func defaultVatRate(tp cart.TaxProvider) int {
|
|||||||
return tp.DefaultTaxRate("")
|
return tp.DefaultTaxRate("")
|
||||||
}
|
}
|
||||||
|
|
||||||
// contextOptions maps the optional customer/time fields onto context options.
|
// ContextOptions maps the optional customer/time fields onto context
|
||||||
func (req EvaluateRequest) contextOptions() []ContextOption {
|
// 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
|
var opts []ContextOption
|
||||||
if req.Now != nil {
|
if req.Now != nil {
|
||||||
opts = append(opts, WithNow(*req.Now))
|
opts = append(opts, WithNow(*req.Now))
|
||||||
|
|||||||
Reference in New Issue
Block a user