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. EvaluateAndApply // populates g.EvaluatedItems via cart.MapEvaluatedItems // on its final step, so the preview response reuses the // same per-line projection the live cart renders — math // and rounding stay in lockstep across both paths. svc.EvaluateAndApply(store.Snapshot(), g, previewContextOptions(req)...) resp = promotions.EvaluateResponse{ TotalPrice: g.TotalPrice, TotalDiscount: g.TotalDiscount, AppliedPromotions: g.AppliedPromotions, Items: g.EvaluatedItems, } } 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 }