better results
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s

This commit is contained in:
2026-07-08 00:55:05 +02:00
parent 781c1bd171
commit e20793a6b3
4 changed files with 545 additions and 91 deletions
+24 -53
View File
@@ -202,59 +202,17 @@ func main() {
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
_, span := tracer.Start(ctx, "Totals and promotions")
defer span.End()
// Clear bypass flags first
for _, v := range g.Vouchers {
if v != nil {
v.BypassedByPromotions = false
}
}
g.UpdateTotals()
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
// 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)
// Canonical promotion pipeline — same call site as the
// /promotions/evaluate-with-cart preview handler in
// promotions_evaluate.go. Going through the shared
// PromotionService.EvaluateAndApply means the bypass
// 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
// promotion logic here, add it to EvaluateAndApply
// instead — the preview must follow.
promotionService.EvaluateAndApply(promotionStore.Snapshot(), g, promotions.WithNow(time.Now()))
g.Version++
return nil
}),
@@ -490,6 +448,19 @@ func main() {
// without creating or mutating a real cart.
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
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
pool.AddRemote(r.PathValue("host"))
+176
View File
@@ -4,8 +4,11 @@ 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"
)
@@ -13,6 +16,7 @@ import (
// (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
@@ -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
}