Files
go-cart-actor/cmd/cart/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

206 lines
8.9 KiB
Go

package main
import (
"encoding/json"
"errors"
"io"
"log"
"net/http"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
)
// newPromotionEvaluateHandler serves POST /promotions/evaluate: it takes a
// (possibly partial) evaluation context and returns the totals plus the
// applied/pending promotion effects, without creating or mutating a real cart.
// 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 {
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
}
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)
}
}
}
// 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
}