diff --git a/cmd/backoffice/main.go b/cmd/backoffice/main.go index 7cc87eb..acefc24 100644 --- a/cmd/backoffice/main.go +++ b/cmd/backoffice/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "errors" "log" "net/http" "os" @@ -10,6 +9,8 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/backofficeadmin" "git.k6n.net/mats/platform/config" + "git.k6n.net/mats/platform/httpclient" + "git.k6n.net/mats/platform/httpserver" "git.k6n.net/mats/platform/rabbit" ) @@ -41,21 +42,14 @@ func main() { }) // Global CORS middleware allowing all origins and handling preflight. - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With") - w.Header().Set("Access-Control-Expose-Headers", "*") - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - mux.ServeHTTP(w, r) - }) - srv := &http.Server{ - Addr: addr, - Handler: handler, + Addr: addr, + Handler: httpclient.CORS( + httpclient.WithOrigin("*"), + httpclient.WithMethods("GET, POST, PUT, PATCH, DELETE, OPTIONS"), + httpclient.WithHeaders("Content-Type, Authorization, X-Requested-With"), + httpclient.WithExposeHeaders("*"), + )(mux), ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, @@ -70,6 +64,7 @@ func main() { if err != nil { log.Fatalf("failed to connect to RabbitMQ: %v", err) } + defer conn.Close() } if err := app.Start(ctx, conn); err != nil { log.Printf("AMQP listener disabled: %v", err) @@ -78,7 +73,9 @@ func main() { } log.Printf("backoffice HTTP listening on %s", addr) - if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("http server error: %v", err) + if err := httpserver.RunServerWithShutdown(srv, "cart-backoffice", 10*time.Second, 5*time.Second, + func(context.Context) error { cancel(); return nil }, + ); err != nil { + log.Fatalf("server: %v", err) } } diff --git a/cmd/cart/main.go b/cmd/cart/main.go index 502e5bc..0c91874 100644 --- a/cmd/cart/main.go +++ b/cmd/cart/main.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "log" - "net" "net/http" "net/http/pprof" "os" @@ -26,6 +25,7 @@ import ( "git.k6n.net/mats/platform/catalog" "git.k6n.net/mats/platform/config" "git.k6n.net/mats/platform/event" + "git.k6n.net/mats/platform/httpserver" "git.k6n.net/mats/platform/rabbit" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/redis/go-redis/v9" @@ -524,9 +524,11 @@ func main() { httpAddr := normalizeListenAddr(cartPort) debugAddr := normalizeListenAddr(config.EnvString("CART_DEBUG_PORT", "8081")) + // Debug mux on separate port (metrics + pprof). + go http.ListenAndServe(debugAddr, debugMux) + srv := &http.Server{ Addr: httpAddr, - BaseContext: func(net.Listener) context.Context { return ctx }, ReadTimeout: 10 * time.Second, // Close idle keep-alive connections so a load test with many short-lived // clients doesn't pin file handles open indefinitely. @@ -535,38 +537,15 @@ func main() { Handler: otelhttp.NewHandler(mux, "/"), } - defer func() { - - fmt.Println("Shutting down due to signal") - otelShutdown(context.Background()) - diskStorage.Close() - pool.Close() - - }() - - srvErr := make(chan error, 1) - go func() { - srvErr <- srv.ListenAndServe() - }() - - // Inventory change consumption used to live here over the bare Redis - // `inventory_changed` channel; it is now owned by the cart-inventory - // service, which translates quantity changes into inventory.level_changed - // bus crossings. The cart reads exact stock synchronously when it needs it. - // See docs/inventory-shape.md. - log.Printf("Server started at %s (debug %s)", httpAddr, debugAddr) - go http.ListenAndServe(debugAddr, debugMux) - - select { - case err = <-srvErr: - // Error when starting HTTP server. - log.Fatalf("Unable to start server: %v", err) - case <-ctx.Done(): - // Wait for first CTRL+C. - // Stop receiving signal notifications as soon as possible. - stop() + if err := httpserver.RunServerWithShutdown(srv, "cart", 15*time.Second, 5*time.Second, + func(context.Context) error { stop(); return nil }, + func(context.Context) error { otelShutdown(context.Background()); return nil }, + func(context.Context) error { diskStorage.Close(); return nil }, + func(context.Context) error { pool.Close(); return nil }, + ); err != nil { + log.Fatalf("server: %v", err) } } diff --git a/cmd/cart/utils.go b/cmd/cart/utils.go index fd63a97..7c6a6b8 100644 --- a/cmd/cart/utils.go +++ b/cmd/cart/utils.go @@ -55,14 +55,6 @@ func getOriginalHost(r *http.Request) string { return r.Host } -func getClientIp(r *http.Request) string { - ip := r.Header.Get("X-Forwarded-For") - if ip == "" { - ip = r.RemoteAddr - } - return ip -} - func CookieCartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/checkout/checkout_builder.go b/cmd/checkout/checkout_builder.go index 6c6b0e6..b7bf923 100644 --- a/cmd/checkout/checkout_builder.go +++ b/cmd/checkout/checkout_builder.go @@ -9,6 +9,7 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/go-cart-actor/pkg/checkout" + "git.k6n.net/mats/platform/httpclient" "git.k6n.net/mats/platform/tax" adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout" "github.com/adyen/adyen-go-api-library/v21/src/common" @@ -233,7 +234,7 @@ func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta { siteUrl = checkoutPublicURL } return &CheckoutMeta{ - ClientIp: getClientIp(r), + ClientIp: httpclient.ClientIP(r), SiteUrl: siteUrl, CallbackBaseUrl: checkoutCallbackBaseURL, Country: country, diff --git a/cmd/checkout/main.go b/cmd/checkout/main.go index 2c52129..8cf417e 100644 --- a/cmd/checkout/main.go +++ b/cmd/checkout/main.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log" - "net" "net/http" "os" "os/signal" @@ -17,6 +16,7 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/telemetry" redisinv "git.k6n.net/mats/go-redis-inventory/pkg/inventory" "git.k6n.net/mats/platform/config" + "git.k6n.net/mats/platform/httpserver" "git.k6n.net/mats/platform/rabbit" "git.k6n.net/mats/platform/tax" "github.com/adyen/adyen-go-api-library/v21/src/adyen" @@ -293,35 +293,25 @@ func main() { w.Write([]byte("1.0.0")) }) + // Debug mux on separate port (metrics + pprof). + go http.ListenAndServe(":8081", debugMux) + srv := &http.Server{ Addr: ":8080", - BaseContext: func(net.Listener) context.Context { return ctx }, ReadTimeout: 10 * time.Second, WriteTimeout: 20 * time.Second, Handler: otelhttp.NewHandler(mux, "/"), } - defer func() { - fmt.Println("Shutting down due to signal") - otelShutdown(context.Background()) - diskStorage.Close() - pool.Close() - }() - - srvErr := make(chan error, 1) - go func() { - srvErr <- srv.ListenAndServe() - }() - log.Print("Checkout server started at port 8080") - go http.ListenAndServe(":8081", debugMux) - - select { - case err = <-srvErr: - log.Fatalf("Unable to start server: %v", err) - case <-ctx.Done(): - stop() + if err := httpserver.RunServerWithShutdown(srv, "checkout", 15*time.Second, 5*time.Second, + func(context.Context) error { stop(); return nil }, + func(context.Context) error { otelShutdown(context.Background()); return nil }, + func(context.Context) error { diskStorage.Close(); return nil }, + func(context.Context) error { pool.Close(); return nil }, + ); err != nil { + log.Fatalf("server: %v", err) } } diff --git a/cmd/checkout/utils.go b/cmd/checkout/utils.go index 5ee9217..29421b9 100644 --- a/cmd/checkout/utils.go +++ b/cmd/checkout/utils.go @@ -36,14 +36,6 @@ func getScheme(r *http.Request) string { return "https" } -func getClientIp(r *http.Request) string { - ip := r.Header.Get("X-Forwarded-For") - if ip == "" { - ip = r.RemoteAddr - } - return ip -} - func getCurrency(country string) string { if country == "no" { return "NOK" diff --git a/cmd/profile/main.go b/cmd/profile/main.go index 4828e5b..ebdf5c7 100644 --- a/cmd/profile/main.go +++ b/cmd/profile/main.go @@ -4,9 +4,7 @@ import ( "context" "crypto/rand" "encoding/hex" - "fmt" "log" - "net" "net/http" "net/mail" "os" @@ -22,6 +20,7 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/proxy" "git.k6n.net/mats/go-cart-actor/pkg/telemetry" "git.k6n.net/mats/platform/config" + "git.k6n.net/mats/platform/httpserver" "git.k6n.net/mats/platform/rabbit" "github.com/redis/go-redis/v9" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" @@ -311,35 +310,25 @@ func main() { w.Write([]byte("1.0.0")) }) + // Debug mux on separate port (metrics + pprof). + go http.ListenAndServe(":8081", debugMux) + srv := &http.Server{ Addr: config.EnvString("PROFILE_ADDR", ":8080"), - BaseContext: func(net.Listener) context.Context { return ctx }, ReadTimeout: 10 * time.Second, WriteTimeout: 20 * time.Second, Handler: otelhttp.NewHandler(mux, "/"), } - defer func() { - fmt.Println("Shutting down profile service") - otelShutdown(context.Background()) - diskStorage.Close() - pool.Close() - }() - - srvErr := make(chan error, 1) - go func() { - srvErr <- srv.ListenAndServe() - }() - log.Print("Profile server started at port 8080") - go http.ListenAndServe(":8081", debugMux) - - select { - case err = <-srvErr: - log.Fatalf("Unable to start server: %v", err) - case <-ctx.Done(): - stop() + if err := httpserver.RunServerWithShutdown(srv, "profile", 15*time.Second, 5*time.Second, + func(context.Context) error { stop(); return nil }, + func(context.Context) error { otelShutdown(context.Background()); return nil }, + func(context.Context) error { diskStorage.Close(); return nil }, + func(context.Context) error { pool.Close(); return nil }, + ); err != nil { + log.Fatalf("server: %v", err) } } diff --git a/pkg/promotions/mcp/public.go b/pkg/promotions/mcp/public.go index ca6b789..bba8d06 100644 --- a/pkg/promotions/mcp/public.go +++ b/pkg/promotions/mcp/public.go @@ -25,151 +25,167 @@ func NewPublic(store *promotions.Store, eval *promotions.PromotionService) *Serv func (s *Server) buildPublicTools() []pmcp.Tool { return []pmcp.Tool{ - { - Name: "list_promotions", - Description: "List all promotion rules the cart engine evaluates, optionally filtered by status (active|inactive|scheduled|expired).", - InputSchema: pmcp.Object(pmcp.Props{ - "status": pmcp.String("optional status filter: active|inactive|scheduled|expired"), - }, nil), - Invoke: func(_ context.Context, args json.RawMessage) (any, error) { - var a struct { - Status string `json:"status"` - } - if err := pmcp.Decode(args, &a); err != nil { - return nil, err - } - rules := s.store.List() - if a.Status != "" { - filtered := rules[:0:0] - for _, r := range rules { - if string(r.Status) == a.Status { - filtered = append(filtered, r) - } - } - rules = filtered - } - return map[string]any{"promotions": rules, "count": len(rules)}, nil - }, - }, - { - Name: "get_promotion", - Description: "Fetch a single promotion rule by id.", - InputSchema: pmcp.Object(pmcp.Props{"id": pmcp.String("the promotion id")}, []string{"id"}), - Invoke: func(_ context.Context, args json.RawMessage) (any, error) { - var a struct { - ID string `json:"id"` - } - if err := pmcp.Decode(args, &a); err != nil { - return nil, err - } - r, ok := s.store.Get(a.ID) - if !ok { - return nil, fmt.Errorf("promotion %q not found", a.ID) - } - return r, nil - }, - }, - { - Name: "preview_cart_discount", - Description: "Evaluate the current active promotions against a hypothetical cart total and report the " + - "matched discount. cartTotalIncVat is in öre incl. VAT (e.g. 5 000 000 = 50 000 kr). Useful for " + - "verifying Volymrabatt tiers without touching a real cart.", - InputSchema: pmcp.Object(pmcp.Props{ - "cartTotalIncVat": pmcp.Integer("cart total incl. VAT, in öre"), - "itemQuantity": pmcp.Integer("optional total item quantity"), - "customerSegment": pmcp.String("optional customer segment"), - }, []string{"cartTotalIncVat"}), - Invoke: func(_ context.Context, args json.RawMessage) (any, error) { - var a struct { - CartTotalIncVat int64 `json:"cartTotalIncVat"` - ItemQuantity int `json:"itemQuantity"` - CustomerSegment string `json:"customerSegment"` - } - if err := pmcp.Decode(args, &a); err != nil { - return nil, err - } - g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate()) - opts := []promotions.ContextOption{promotions.WithNow(time.Now())} - if a.CustomerSegment != "" { - opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment)) - } - ctx := promotions.NewContextFromCart(g, opts...) - results, actions := s.eval.EvaluateAll(s.store.Snapshot(), ctx) - s.eval.ApplyResults(g, results, ctx) - return map[string]any{ - "subtotalIncVat": a.CartTotalIncVat, - "discountIncVat": g.TotalDiscount.IncVat, - "netIncVat": g.TotalPrice.IncVat, - "matchedActions": actions, - "appliedPromotions": g.AppliedPromotions, - }, nil - }, - }, - { - Name: "evaluate_promotion", - Description: "Evaluate promotions against a hypothetical cart and return the full evaluation result: " + - "which promotions match, what discount they apply, and any pending next-tier nudges (\"spend X more for Y%\"). " + - "When promotionId is set, only that specific promotion is evaluated (useful for testing a new rule). " + - "When omitted, all active promotions are evaluated. cartTotalIncVat is in öre incl. VAT.", - InputSchema: pmcp.Object(pmcp.Props{ - "cartTotalIncVat": pmcp.Integer("cart total incl. VAT, in öre"), - "promotionId": pmcp.String("optional: evaluate only this promotion (by id)"), - "itemQuantity": pmcp.Integer("optional total item quantity"), - "customerSegment": pmcp.String("optional customer segment"), - }, []string{"cartTotalIncVat"}), - Invoke: func(_ context.Context, args json.RawMessage) (any, error) { - var a struct { - CartTotalIncVat int64 `json:"cartTotalIncVat"` - PromotionId string `json:"promotionId"` - ItemQuantity int `json:"itemQuantity"` - CustomerSegment string `json:"customerSegment"` - } - if err := pmcp.Decode(args, &a); err != nil { - return nil, err - } + s.toolListPromotions(), + s.toolGetPromotion(), + s.toolPreviewCartDiscount(), + s.toolEvaluatePromotion(), + } +} - rules := s.store.Snapshot() - if a.PromotionId != "" { - filtered := rules[:0:0] - for _, r := range rules { - if r.ID == a.PromotionId { - filtered = append(filtered, r) - break - } +func (s *Server) toolListPromotions() pmcp.Tool { + return pmcp.Tool{ + Name: "list_promotions", + Description: "List all promotion rules the cart engine evaluates, optionally filtered by status (active|inactive|scheduled|expired).", + InputSchema: pmcp.Object(pmcp.Props{ + "status": pmcp.String("optional status filter: active|inactive|scheduled|expired"), + }, nil), + Invoke: func(_ context.Context, args json.RawMessage) (any, error) { + var a struct { + Status string `json:"status"` + } + if err := pmcp.Decode(args, &a); err != nil { + return nil, err + } + rules := s.store.List() + if a.Status != "" { + filtered := rules[:0:0] + for _, r := range rules { + if string(r.Status) == a.Status { + filtered = append(filtered, r) } - if len(filtered) == 0 { - return nil, fmt.Errorf("promotion %q not found", a.PromotionId) - } - rules = filtered } + rules = filtered + } + return map[string]any{"promotions": rules, "count": len(rules)}, nil + }, + } +} - g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate()) - opts := []promotions.ContextOption{promotions.WithNow(time.Now())} - if a.CustomerSegment != "" { - opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment)) +func (s *Server) toolGetPromotion() pmcp.Tool { + return pmcp.Tool{ + Name: "get_promotion", + Description: "Fetch a single promotion rule by id.", + InputSchema: pmcp.Object(pmcp.Props{"id": pmcp.String("the promotion id")}, []string{"id"}), + Invoke: func(_ context.Context, args json.RawMessage) (any, error) { + var a struct { + ID string `json:"id"` + } + if err := pmcp.Decode(args, &a); err != nil { + return nil, err + } + r, ok := s.store.Get(a.ID) + if !ok { + return nil, fmt.Errorf("promotion %q not found", a.ID) + } + return r, nil + }, + } +} + +func (s *Server) toolPreviewCartDiscount() pmcp.Tool { + return pmcp.Tool{ + Name: "preview_cart_discount", + Description: "Evaluate the current active promotions against a hypothetical cart total and report the " + + "matched discount. cartTotalIncVat is in öre incl. VAT (e.g. 5 000 000 = 50 000 kr). Useful for " + + "verifying Volymrabatt tiers without touching a real cart.", + InputSchema: pmcp.Object(pmcp.Props{ + "cartTotalIncVat": pmcp.Integer("cart total incl. VAT, in öre"), + "itemQuantity": pmcp.Integer("optional total item quantity"), + "customerSegment": pmcp.String("optional customer segment"), + }, []string{"cartTotalIncVat"}), + Invoke: func(_ context.Context, args json.RawMessage) (any, error) { + var a struct { + CartTotalIncVat int64 `json:"cartTotalIncVat"` + ItemQuantity int `json:"itemQuantity"` + CustomerSegment string `json:"customerSegment"` + } + if err := pmcp.Decode(args, &a); err != nil { + return nil, err + } + g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate()) + opts := []promotions.ContextOption{promotions.WithNow(time.Now())} + if a.CustomerSegment != "" { + opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment)) + } + ctx := promotions.NewContextFromCart(g, opts...) + results, actions := s.eval.EvaluateAll(s.store.Snapshot(), ctx) + s.eval.ApplyResults(g, results, ctx) + return map[string]any{ + "subtotalIncVat": a.CartTotalIncVat, + "discountIncVat": g.TotalDiscount.IncVat, + "netIncVat": g.TotalPrice.IncVat, + "matchedActions": actions, + "appliedPromotions": g.AppliedPromotions, + }, nil + }, + } +} + +func (s *Server) toolEvaluatePromotion() pmcp.Tool { + return pmcp.Tool{ + Name: "evaluate_promotion", + Description: "Evaluate promotions against a hypothetical cart and return the full evaluation result: " + + "which promotions match, what discount they apply, and any pending next-tier nudges (\"spend X more for Y%\"). " + + "When promotionId is set, only that specific promotion is evaluated (useful for testing a new rule). " + + "When omitted, all active promotions are evaluated. cartTotalIncVat is in öre incl. VAT.", + InputSchema: pmcp.Object(pmcp.Props{ + "cartTotalIncVat": pmcp.Integer("cart total incl. VAT, in öre"), + "promotionId": pmcp.String("optional: evaluate only this promotion (by id)"), + "itemQuantity": pmcp.Integer("optional total item quantity"), + "customerSegment": pmcp.String("optional customer segment"), + }, []string{"cartTotalIncVat"}), + Invoke: func(_ context.Context, args json.RawMessage) (any, error) { + var a struct { + CartTotalIncVat int64 `json:"cartTotalIncVat"` + PromotionId string `json:"promotionId"` + ItemQuantity int `json:"itemQuantity"` + CustomerSegment string `json:"customerSegment"` + } + if err := pmcp.Decode(args, &a); err != nil { + return nil, err + } + + rules := s.store.Snapshot() + if a.PromotionId != "" { + filtered := rules[:0:0] + for _, r := range rules { + if r.ID == a.PromotionId { + filtered = append(filtered, r) + break + } } - ctx := promotions.NewContextFromCart(g, opts...) - results, actions := s.eval.EvaluateAll(rules, ctx) - evalResults := make([]map[string]any, 0, len(results)) - for _, r := range results { - evalResults = append(evalResults, map[string]any{ - "ruleId": r.Rule.ID, - "name": r.Rule.Name, - "applicable": r.Applicable, - "failedReason": r.FailedReason, - "matchedActions": r.MatchedActions, - }) + if len(filtered) == 0 { + return nil, fmt.Errorf("promotion %q not found", a.PromotionId) } - s.eval.ApplyResults(g, results, ctx) - return map[string]any{ - "subtotalIncVat": a.CartTotalIncVat, - "discountIncVat": g.TotalDiscount.IncVat, - "netIncVat": g.TotalPrice.IncVat, - "matchedActions": actions, - "appliedPromotions": g.AppliedPromotions, - "evaluationResults": evalResults, - }, nil - }, + rules = filtered + } + + g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate()) + opts := []promotions.ContextOption{promotions.WithNow(time.Now())} + if a.CustomerSegment != "" { + opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment)) + } + ctx := promotions.NewContextFromCart(g, opts...) + results, actions := s.eval.EvaluateAll(rules, ctx) + evalResults := make([]map[string]any, 0, len(results)) + for _, r := range results { + evalResults = append(evalResults, map[string]any{ + "ruleId": r.Rule.ID, + "name": r.Rule.Name, + "applicable": r.Applicable, + "failedReason": r.FailedReason, + "matchedActions": r.MatchedActions, + }) + } + s.eval.ApplyResults(g, results, ctx) + return map[string]any{ + "subtotalIncVat": a.CartTotalIncVat, + "discountIncVat": g.TotalDiscount.IncVat, + "netIncVat": g.TotalPrice.IncVat, + "matchedActions": actions, + "appliedPromotions": g.AppliedPromotions, + "evaluationResults": evalResults, + }, nil }, } } diff --git a/pkg/promotions/mcp/tools.go b/pkg/promotions/mcp/tools.go index 069c294..c88e9c2 100644 --- a/pkg/promotions/mcp/tools.go +++ b/pkg/promotions/mcp/tools.go @@ -16,50 +16,8 @@ import ( // and the handler that runs it. func (s *Server) buildTools() []pmcp.Tool { return []pmcp.Tool{ - { - Name: "list_promotions", - Description: "List all promotion rules the cart engine evaluates, optionally filtered by status (active|inactive|scheduled|expired).", - InputSchema: pmcp.Object(pmcp.Props{ - "status": pmcp.String("optional status filter: active|inactive|scheduled|expired"), - }, nil), - Invoke: func(_ context.Context, args json.RawMessage) (any, error) { - var a struct { - Status string `json:"status"` - } - if err := pmcp.Decode(args, &a); err != nil { - return nil, err - } - rules := s.store.List() - if a.Status != "" { - filtered := rules[:0:0] - for _, r := range rules { - if string(r.Status) == a.Status { - filtered = append(filtered, r) - } - } - rules = filtered - } - return map[string]any{"promotions": rules, "count": len(rules)}, nil - }, - }, - { - Name: "get_promotion", - Description: "Fetch a single promotion rule by id.", - InputSchema: pmcp.Object(pmcp.Props{"id": pmcp.String("the promotion id")}, []string{"id"}), - Invoke: func(_ context.Context, args json.RawMessage) (any, error) { - var a struct { - ID string `json:"id"` - } - if err := pmcp.Decode(args, &a); err != nil { - return nil, err - } - r, ok := s.store.Get(a.ID) - if !ok { - return nil, fmt.Errorf("promotion %q not found", a.ID) - } - return r, nil - }, - }, + s.toolListPromotions(), + s.toolGetPromotion(), { Name: "upsert_promotion", Description: "Create a promotion or replace the existing one with the same id. " + @@ -142,42 +100,7 @@ func (s *Server) buildTools() []pmcp.Tool { return map[string]any{"id": a.ID, "deleted": true}, nil }, }, - { - Name: "preview_cart_discount", - Description: "Evaluate the current active promotions against a hypothetical cart total and report the " + - "matched discount. cartTotalIncVat is in öre incl. VAT (e.g. 5 000 000 = 50 000 kr). Useful for " + - "verifying Volymrabatt tiers without touching a real cart.", - InputSchema: pmcp.Object(pmcp.Props{ - "cartTotalIncVat": pmcp.Integer("cart total incl. VAT, in öre"), - "itemQuantity": pmcp.Integer("optional total item quantity"), - "customerSegment": pmcp.String("optional customer segment"), - }, []string{"cartTotalIncVat"}), - Invoke: func(_ context.Context, args json.RawMessage) (any, error) { - var a struct { - CartTotalIncVat int64 `json:"cartTotalIncVat"` - ItemQuantity int `json:"itemQuantity"` - CustomerSegment string `json:"customerSegment"` - } - if err := pmcp.Decode(args, &a); err != nil { - return nil, err - } - g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate()) - opts := []promotions.ContextOption{promotions.WithNow(time.Now())} - if a.CustomerSegment != "" { - opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment)) - } - ctx := promotions.NewContextFromCart(g, opts...) - results, actions := s.eval.EvaluateAll(s.store.Snapshot(), ctx) - s.eval.ApplyResults(g, results, ctx) - return map[string]any{ - "subtotalIncVat": a.CartTotalIncVat, - "discountIncVat": g.TotalDiscount.IncVat, - "netIncVat": g.TotalPrice.IncVat, - "matchedActions": actions, - "appliedPromotions": g.AppliedPromotions, - }, nil - }, - }, + s.toolPreviewCartDiscount(), } }