diff --git a/cmd/cart/main.go b/cmd/cart/main.go index ef2f679..32acba0 100644 --- a/cmd/cart/main.go +++ b/cmd/cart/main.go @@ -15,6 +15,7 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/go-cart-actor/pkg/promotions" + promotionmcp "git.k6n.net/mats/go-cart-actor/pkg/promotions/mcp" "git.k6n.net/mats/go-cart-actor/pkg/proxy" "git.k6n.net/mats/go-cart-actor/pkg/voucher" "git.k6n.net/mats/go-redis-inventory/pkg/inventory" @@ -111,16 +112,18 @@ func main() { controlPlaneConfig := actor.DefaultServerConfig() - promotionData, err := promotions.LoadStateFile("data/promotions.json") + promotionStore, err := promotions.NewStore("data/promotions.json") if err != nil { - log.Printf("Error loading promotions: %v\n", err) + log.Fatalf("Error loading promotions: %v\n", err) } - log.Printf("loaded %d promotions", len(promotionData.State.Promotions)) + log.Printf("loaded %d promotions", len(promotionStore.List())) + + promotionService := promotions.NewPromotionService(nil) + promotionMCP := promotionmcp.New(promotionStore, promotionService) //inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]() - // promotionService := promotions.NewPromotionService(nil) // rdb := redis.NewClient(&redis.Options{ // Addr: redisAddress, // Password: redisPassword, @@ -142,15 +145,13 @@ func main() { _, span := tracer.Start(ctx, "Totals and promotions") defer span.End() g.UpdateTotals() + // Evaluate active promotions against the freshly-totalled cart and apply + // any matched actions (e.g. the Volymrabatt volume discount), which adjust + // TotalPrice/TotalDiscount on top of line items and vouchers. + promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now())) + _, actions := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx) + promotions.ApplyActions(g, actions) g.Version++ - // promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()), promotions.WithCustomerSegment("vip")) - // _, actions := promotionService.EvaluateAll(promotionData.State.Promotions, promotionCtx) - // for _, action := range actions { - - // log.Printf("apply: %+v", action) - - // g.UpdateTotals() - // } return nil }), ) @@ -267,6 +268,13 @@ func main() { } syncedServer.Serve(mux) + + // Promotion MCP edge: list/get/upsert/status/delete promotions and preview + // discounts. Mutations persist to data/promotions.json and are picked up by + // the cart's totals processor on the next mutation (it reads a fresh snapshot). + mux.Handle("/mcp", promotionMCP.Handler()) + mux.Handle("/mcp/", promotionMCP.Handler()) + // only for local mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) { pool.AddRemote(r.PathValue("host")) diff --git a/data/promotions.json b/data/promotions.json index 0967ef4..dc66ef5 100644 --- a/data/promotions.json +++ b/data/promotions.json @@ -1 +1,45 @@ -{} +{ + "version": 1, + "state": { + "promotions": [ + { + "id": "volymrabatt", + "name": "Volymrabatt", + "description": "Automatisk volymrabatt baserad på varukorgens totalsumma (inkl. moms). Belopp i öre: 20 000–40 000 kr → 5%, 40 000–60 000 kr → 9%, 60 000–90 000 kr → 14%.", + "status": "active", + "priority": 100, + "startDate": "2024-01-01", + "endDate": null, + "conditions": [ + { + "id": "min-cart-total", + "type": "cart_total", + "operator": ">=", + "value": 2000000, + "label": "Minst 20 000 kr i varukorgen" + } + ], + "actions": [ + { + "id": "volymrabatt-tiers", + "type": "tiered_discount", + "value": 0, + "config": { + "tiers": [ + { "minTotal": 2000000, "maxTotal": 4000000, "percent": 5 }, + { "minTotal": 4000000, "maxTotal": 6000000, "percent": 9 }, + { "minTotal": 6000000, "maxTotal": 9000000, "percent": 14 } + ] + }, + "label": "Volymrabatt 5–14%" + } + ], + "usageCount": 0, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "createdBy": "mats.tornberg@gmail.com", + "tags": ["volume", "volymrabatt", "test"] + } + ] + } +} diff --git a/pkg/promotions/apply.go b/pkg/promotions/apply.go new file mode 100644 index 0000000..062298a --- /dev/null +++ b/pkg/promotions/apply.go @@ -0,0 +1,149 @@ +package promotions + +import ( + "math" + + "git.k6n.net/mats/go-cart-actor/pkg/cart" +) + +// ---------------------------- +// Action application +// ---------------------------- +// +// The evaluation engine (eval.go) decides *which* actions apply to a cart but +// deliberately does not mutate the cart. Applying an action — turning a +// "14% tiered discount" into actual öre off the total — happens here, because +// it needs to touch cart.Price math. main.go calls ApplyActions after +// UpdateTotals so the base totals (line items + vouchers) are settled before +// promotions reduce them. + +// DiscountTier describes one band of a tiered (volume) discount. Bounds are the +// cart total *including VAT*, in öre (the cart's native unit), matching +// PromotionEvalContext.CartTotalIncVat. MaxTotal == 0 means "no upper bound". +// +// A tier matches when MinTotal <= cartTotal && (MaxTotal == 0 || cartTotal <= MaxTotal). +// Bounds are allowed to touch (e.g. 40000 ends one tier and begins the next); +// when a total lands exactly on a shared boundary the higher tier wins, since +// selectTier keeps the last match while scanning in order. +type DiscountTier struct { + MinTotal int64 + MaxTotal int64 + Percent float64 +} + +// ApplyActions applies the matched promotion actions to the cart grain, +// reducing TotalPrice and growing TotalDiscount. It is safe to call with a nil +// or empty action slice. Must be called after g.UpdateTotals(). +func ApplyActions(g *cart.CartGrain, actions []Action) { + if g == nil || g.TotalPrice == nil { + return + } + for _, a := range actions { + switch a.Type { + case ActionTieredDiscount: + applyTieredDiscount(g, a) + case ActionPercentageDiscount: + applyPercentageDiscount(g, percentFromValue(a.Value)) + } + } +} + +func applyTieredDiscount(g *cart.CartGrain, a Action) { + tiers := parseTiers(a.Config) + if len(tiers) == 0 { + return + } + pct, ok := selectTier(tiers, g.TotalPrice.IncVat) + if !ok { + return + } + applyPercentageDiscount(g, pct) +} + +// applyPercentageDiscount removes pct% of the current cart total, preserving the +// VAT-rate breakdown proportionally, and records it in TotalDiscount. +func applyPercentageDiscount(g *cart.CartGrain, pct float64) { + if pct <= 0 || g.TotalPrice.IncVat <= 0 { + return + } + discount := scalePrice(g.TotalPrice, pct) + if discount.IncVat <= 0 { + return + } + // Never discount below zero. + if discount.IncVat > g.TotalPrice.IncVat { + discount = g.TotalPrice + } + g.TotalDiscount.Add(*discount) + g.TotalPrice.Subtract(*discount) +} + +// selectTier returns the discount percent for the band the total falls into. +// Scans in declared order and keeps the last match so shared boundaries favour +// the higher tier. +func selectTier(tiers []DiscountTier, total int64) (float64, bool) { + pct := 0.0 + found := false + for _, t := range tiers { + if total >= t.MinTotal && (t.MaxTotal == 0 || total <= t.MaxTotal) { + pct = t.Percent + found = true + } + } + return pct, found +} + +// scalePrice returns pct% of p, rounding each amount and the VAT breakdown +// independently so the parts stay consistent with the reported total. +func scalePrice(p *cart.Price, pct float64) *cart.Price { + out := cart.NewPrice() + out.IncVat = scale(p.IncVat, pct) + for rate, amount := range p.VatRates { + out.VatRates[rate] = scale(amount, pct) + } + return out +} + +func scale(v int64, pct float64) int64 { + return int64(math.Round(float64(v) * pct / 100.0)) +} + +// parseTiers reads the "tiers" array out of an Action.Config. Because configs +// are decoded via encoding/json into map[string]interface{}, every element is a +// map[string]interface{} and every number is a float64. Accepts both +// minTotal/maxTotal/percent and the snake_case min_total/max_total/discount_percent. +func parseTiers(config map[string]interface{}) []DiscountTier { + raw, ok := config["tiers"].([]interface{}) + if !ok { + return nil + } + tiers := make([]DiscountTier, 0, len(raw)) + for _, r := range raw { + m, ok := r.(map[string]interface{}) + if !ok { + continue + } + tiers = append(tiers, DiscountTier{ + MinTotal: int64(firstNum(m, "minTotal", "min_total")), + MaxTotal: int64(firstNum(m, "maxTotal", "max_total")), + Percent: firstNum(m, "percent", "discount_percent"), + }) + } + return tiers +} + +func percentFromValue(v interface{}) float64 { + if f, ok := v.(float64); ok { + return f + } + return 0 +} + +func firstNum(m map[string]interface{}, keys ...string) float64 { + for _, k := range keys { + if f, ok := m[k].(float64); ok { + return f + } + } + return 0 +} diff --git a/pkg/promotions/apply_test.go b/pkg/promotions/apply_test.go new file mode 100644 index 0000000..ddedeb4 --- /dev/null +++ b/pkg/promotions/apply_test.go @@ -0,0 +1,84 @@ +package promotions + +import ( + "testing" + "time" + + "git.k6n.net/mats/go-cart-actor/pkg/cart" +) + +func timeZero() time.Time { return time.Date(2024, 6, 10, 12, 0, 0, 0, time.UTC) } + +// volymrabattRule mirrors the seeded data/promotions.json rule so the test +// exercises the same tier configuration shipped to the cart service. +func volymrabattRule() PromotionRule { + return PromotionRule{ + ID: "volymrabatt", + Name: "Volymrabatt", + Status: StatusActive, + Priority: 100, + Conditions: Conditions{ + BaseCondition{ID: "min", Type: CondCartTotal, Operator: OpGreaterOrEqual, Value: cvNum(2000000)}, + }, + Actions: []Action{ + { + ID: "tiers", + Type: ActionTieredDiscount, + Config: map[string]interface{}{ + "tiers": []interface{}{ + map[string]interface{}{"minTotal": 2000000.0, "maxTotal": 4000000.0, "percent": 5.0}, + map[string]interface{}{"minTotal": 4000000.0, "maxTotal": 6000000.0, "percent": 9.0}, + map[string]interface{}{"minTotal": 6000000.0, "maxTotal": 9000000.0, "percent": 14.0}, + }, + }, + }, + }, + } +} + +// cartWithTotal builds a grain whose single line item produces the requested +// gross (incl. 25% VAT) total in öre, then runs UpdateTotals. +func cartWithTotal(incVat int64) *cart.CartGrain { + g := cart.NewCartGrain(1, timeZero()) + g.Items = []*cart.CartItem{ + {Id: 1, Sku: "SKU", Quantity: 1, Price: *cart.NewPriceFromIncVat(incVat, 25)}, + } + g.UpdateTotals() + return g +} + +func TestVolymrabattTiers(t *testing.T) { + svc := NewPromotionService(nil) + rules := []PromotionRule{volymrabattRule()} + + cases := []struct { + name string + total int64 + wantDiscount int64 + wantNet int64 + }{ + {"below threshold (19 999 kr)", 1999900, 0, 1999900}, + {"low tier 5% (20 000 kr)", 2000000, 100000, 1900000}, + {"low tier boundary just under 40 000", 3999900, 199995, 3799905}, + {"mid tier 9% at 40 000 boundary", 4000000, 360000, 3640000}, + {"mid tier 9% (50 000 kr)", 5000000, 450000, 4550000}, + {"high tier 14% at 60 000 boundary", 6000000, 840000, 5160000}, + {"high tier 14% (90 000 kr)", 9000000, 1260000, 7740000}, + {"above top tier (100 000 kr) -> no discount", 10000000, 0, 10000000}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + g := cartWithTotal(c.total) + _, actions := svc.EvaluateAll(rules, NewContextFromCart(g, WithNow(timeZero()))) + ApplyActions(g, actions) + + if got := g.TotalDiscount.IncVat; got != c.wantDiscount { + t.Errorf("discount = %d, want %d", got, c.wantDiscount) + } + if got := g.TotalPrice.IncVat; got != c.wantNet { + t.Errorf("net total = %d, want %d", got, c.wantNet) + } + }) + } +} diff --git a/pkg/promotions/mcp/jsonrpc.go b/pkg/promotions/mcp/jsonrpc.go new file mode 100644 index 0000000..71f6263 --- /dev/null +++ b/pkg/promotions/mcp/jsonrpc.go @@ -0,0 +1,44 @@ +package mcp + +import "encoding/json" + +// JSON-RPC 2.0 envelope types (the wire format MCP rides on). Notifications are +// requests with no id and must not receive a response. + +type rpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +func (r *rpcRequest) isNotification() bool { return len(r.ID) == 0 } + +type rpcResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data,omitempty"` +} + +const ( + codeParseError = -32700 + codeInvalidRequest = -32600 + codeMethodNotFound = -32601 + codeInvalidParams = -32602 + codeInternalError = -32603 +) + +func result(id json.RawMessage, v any) *rpcResponse { + return &rpcResponse{JSONRPC: "2.0", ID: id, Result: v} +} + +func errorResponse(id json.RawMessage, code int, msg string) *rpcResponse { + return &rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}} +} diff --git a/pkg/promotions/mcp/mcp.go b/pkg/promotions/mcp/mcp.go new file mode 100644 index 0000000..d3dbd1d --- /dev/null +++ b/pkg/promotions/mcp/mcp.go @@ -0,0 +1,154 @@ +// Package mcp is the MCP edge for the cart's promotion engine: a Model Context +// Protocol server exposing the promotion Store and evaluator as tools so an +// agent can list, create, update, status-toggle and delete the promotions the +// cart actually applies (e.g. Volymrabatt), plus preview the discount a cart +// total would receive. +// +// Transport is streamable HTTP (JSON-RPC 2.0 over POST). It is mounted by +// cmd/cart under /mcp on the same HTTP server as the cart API. Tools mutate the +// shared promotions.Store and persist to data/promotions.json; the cart's +// mutation processor reads a fresh Store snapshot on every recompute, so edits +// take effect on the next cart change without a restart. +package mcp + +import ( + "encoding/json" + "io" + "net/http" + + "git.k6n.net/mats/go-cart-actor/pkg/promotions" +) + +const ( + serverName = "cart-promotions" + serverVersion = "0.1.0" + protocolVersion = "2025-06-18" +) + +// Server is the MCP edge over the shared promotion Store and evaluator. +type Server struct { + store *promotions.Store + eval *promotions.PromotionService + tools []tool +} + +// New builds an MCP server exposing the promotion store as tools. +func New(store *promotions.Store, eval *promotions.PromotionService) *Server { + if eval == nil { + eval = promotions.NewPromotionService(nil) + } + s := &Server{store: store, eval: eval} + s.tools = s.buildTools() + return s +} + +// Handler returns the streamable-HTTP handler to mount (e.g. at /mcp). +func (s *Server) Handler() http.Handler { + return http.HandlerFunc(s.serveHTTP) +} + +func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20)) + if err != nil { + writeRPC(w, errorResponse(nil, codeParseError, "read error")) + return + } + if isBatch(body) { + var reqs []rpcRequest + if err := json.Unmarshal(body, &reqs); err != nil { + writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON")) + return + } + var out []*rpcResponse + for i := range reqs { + if resp := s.dispatch(&reqs[i]); resp != nil { + out = append(out, resp) + } + } + if len(out) == 0 { + w.WriteHeader(http.StatusAccepted) + return + } + writeRPC(w, out) + return + } + var req rpcRequest + if err := json.Unmarshal(body, &req); err != nil { + writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON")) + return + } + resp := s.dispatch(&req) + if resp == nil { + w.WriteHeader(http.StatusAccepted) + return + } + writeRPC(w, resp) +} + +func (s *Server) dispatch(req *rpcRequest) *rpcResponse { + if req.JSONRPC != "2.0" { + if req.isNotification() { + return nil + } + return errorResponse(req.ID, codeInvalidRequest, "jsonrpc must be \"2.0\"") + } + switch req.Method { + case "initialize": + return result(req.ID, s.initialize(req.Params)) + case "ping": + return result(req.ID, struct{}{}) + case "tools/list": + return result(req.ID, map[string]any{"tools": s.tools}) + case "tools/call": + return s.callTool(req) + case "notifications/initialized", "notifications/cancelled": + return nil + default: + if req.isNotification() { + return nil + } + return errorResponse(req.ID, codeMethodNotFound, "unknown method: "+req.Method) + } +} + +func (s *Server) initialize(params json.RawMessage) map[string]any { + pv := protocolVersion + if len(params) > 0 { + var p struct { + ProtocolVersion string `json:"protocolVersion"` + } + if json.Unmarshal(params, &p) == nil && p.ProtocolVersion != "" { + pv = p.ProtocolVersion + } + } + return map[string]any{ + "protocolVersion": pv, + "capabilities": map[string]any{"tools": map[string]any{}}, + "serverInfo": map[string]any{"name": serverName, "version": serverVersion}, + } +} + +func isBatch(body []byte) bool { + for _, b := range body { + switch b { + case ' ', '\t', '\r', '\n': + continue + case '[': + return true + default: + return false + } + } + return false +} + +func writeRPC(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/pkg/promotions/mcp/mcp_test.go b/pkg/promotions/mcp/mcp_test.go new file mode 100644 index 0000000..2904479 --- /dev/null +++ b/pkg/promotions/mcp/mcp_test.go @@ -0,0 +1,130 @@ +package mcp + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "git.k6n.net/mats/go-cart-actor/pkg/promotions" +) + +// call issues a tools/call against the handler and returns the decoded text +// payload (the JSON the tool returned), failing on a tool/transport error. +func call(t *testing.T, h http.Handler, name string, args map[string]any) map[string]any { + t.Helper() + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]any{"name": name, "arguments": args}, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body)) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s: status %d", name, rec.Code) + } + var resp struct { + Result struct { + IsError bool `json:"isError"` + Content []struct { + Text string `json:"text"` + } `json:"content"` + } `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("%s: decode response: %v (%s)", name, err, rec.Body.String()) + } + if resp.Error != nil { + t.Fatalf("%s: rpc error: %s", name, resp.Error.Message) + } + if resp.Result.IsError { + t.Fatalf("%s: tool error: %s", name, resp.Result.Content[0].Text) + } + var out map[string]any + if len(resp.Result.Content) > 0 { + _ = json.Unmarshal([]byte(resp.Result.Content[0].Text), &out) + } + return out +} + +func TestPromotionMCPRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "promotions.json") + store, err := promotions.NewStore(path) + if err != nil { + t.Fatal(err) + } + h := New(store, nil).Handler() + + rule := map[string]any{ + "id": "volymrabatt", + "name": "Volymrabatt", + "status": "active", + "priority": 100, + "conditions": []any{ + map[string]any{"id": "min", "type": "cart_total", "operator": ">=", "value": 2000000}, + }, + "actions": []any{ + map[string]any{ + "id": "tiers", "type": "tiered_discount", "value": 0, + "config": map[string]any{ + "tiers": []any{ + map[string]any{"minTotal": 2000000, "maxTotal": 4000000, "percent": 5}, + map[string]any{"minTotal": 4000000, "maxTotal": 6000000, "percent": 9}, + map[string]any{"minTotal": 6000000, "maxTotal": 9000000, "percent": 14}, + }, + }, + }, + }, + } + + // Upsert (create). + got := call(t, h, "upsert_promotion", map[string]any{"promotion": rule}) + if got["replaced"] != false { + t.Errorf("first upsert should create, got replaced=%v", got["replaced"]) + } + + // It persisted to disk and a fresh store sees it. + reloaded, err := promotions.NewStore(path) + if err != nil { + t.Fatal(err) + } + if len(reloaded.List()) != 1 { + t.Fatalf("expected 1 persisted promotion, got %d", len(reloaded.List())) + } + + // List. + listed := call(t, h, "list_promotions", map[string]any{"status": "active"}) + if c, _ := listed["count"].(float64); c != 1 { + t.Errorf("list count = %v, want 1", listed["count"]) + } + + // Preview the 9% mid tier at 50 000 kr. + preview := call(t, h, "preview_cart_discount", map[string]any{"cartTotalIncVat": 5000000}) + if d, _ := preview["discountIncVat"].(float64); int64(d) != 450000 { + t.Errorf("discount = %v, want 450000", preview["discountIncVat"]) + } + if n, _ := preview["netIncVat"].(float64); int64(n) != 4550000 { + t.Errorf("net = %v, want 4550000", preview["netIncVat"]) + } + + // Toggle off → preview yields no discount. + call(t, h, "set_promotion_status", map[string]any{"id": "volymrabatt", "status": "inactive"}) + off := call(t, h, "preview_cart_discount", map[string]any{"cartTotalIncVat": 5000000}) + if d, _ := off["discountIncVat"].(float64); d != 0 { + t.Errorf("inactive promo should give 0 discount, got %v", off["discountIncVat"]) + } + + // Delete. + call(t, h, "delete_promotion", map[string]any{"id": "volymrabatt"}) + final := call(t, h, "list_promotions", nil) + if c, _ := final["count"].(float64); c != 0 { + t.Errorf("after delete count = %v, want 0", final["count"]) + } +} diff --git a/pkg/promotions/mcp/tools.go b/pkg/promotions/mcp/tools.go new file mode 100644 index 0000000..86b76ef --- /dev/null +++ b/pkg/promotions/mcp/tools.go @@ -0,0 +1,289 @@ +package mcp + +import ( + "encoding/json" + "fmt" + "time" + + "git.k6n.net/mats/go-cart-actor/pkg/cart" + "git.k6n.net/mats/go-cart-actor/pkg/promotions" +) + +// tool is one MCP tool: a name, a description, a JSON-Schema for its arguments, +// and the handler that runs it. +type tool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"inputSchema"` + invoke func(args json.RawMessage) (any, error) `json:"-"` +} + +func (s *Server) callTool(req *rpcRequest) *rpcResponse { + var p struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + } + if err := json.Unmarshal(req.Params, &p); err != nil { + return errorResponse(req.ID, codeInvalidParams, "invalid params") + } + var t *tool + for i := range s.tools { + if s.tools[i].Name == p.Name { + t = &s.tools[i] + break + } + } + if t == nil { + return errorResponse(req.ID, codeInvalidParams, "unknown tool: "+p.Name) + } + out, err := t.invoke(p.Arguments) + if err != nil { + return result(req.ID, toolError(err)) + } + return result(req.ID, toolText(out)) +} + +func (s *Server) buildTools() []tool { + return []tool{ + { + Name: "list_promotions", + Description: "List all promotion rules the cart engine evaluates, optionally filtered by status (active|inactive|scheduled|expired).", + InputSchema: object(props{ + "status": str("optional status filter: active|inactive|scheduled|expired"), + }, nil), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + Status string `json:"status"` + } + if err := 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: object(props{"id": str("the promotion id")}, []string{"id"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + ID string `json:"id"` + } + if err := 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: "upsert_promotion", + Description: "Create a promotion or replace the existing one with the same id. " + + "`promotion` is a full promotion-rule object (id, name, status, priority, conditions, actions). " + + "For a tiered volume discount (Volymrabatt) use a cart_total condition plus a tiered_discount action " + + "whose config.tiers each have minTotal/maxTotal (öre incl. VAT) and percent.", + InputSchema: object(props{ + "promotion": obj("the full promotion rule object"), + }, []string{"promotion"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + Promotion json.RawMessage `json:"promotion"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + var rule promotions.PromotionRule + if err := json.Unmarshal(a.Promotion, &rule); err != nil { + return nil, fmt.Errorf("invalid promotion: %w", err) + } + if rule.ID == "" { + return nil, fmt.Errorf("promotion.id is required") + } + if rule.Status == "" { + rule.Status = promotions.StatusActive + } + replaced, err := s.store.Upsert(rule) + if err != nil { + return nil, err + } + return map[string]any{"id": rule.ID, "replaced": replaced}, nil + }, + }, + { + Name: "set_promotion_status", + Description: "Set a promotion's status (active|inactive|scheduled|expired) to toggle it on or off.", + InputSchema: object(props{ + "id": str("the promotion id"), + "status": str("new status: active|inactive|scheduled|expired"), + }, []string{"id", "status"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + ID string `json:"id"` + Status string `json:"status"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + if !validStatus(a.Status) { + return nil, fmt.Errorf("invalid status %q", a.Status) + } + found, err := s.store.SetStatus(a.ID, promotions.PromotionStatus(a.Status)) + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("promotion %q not found", a.ID) + } + return map[string]any{"id": a.ID, "status": a.Status}, nil + }, + }, + { + Name: "delete_promotion", + Description: "Delete a promotion rule by id.", + InputSchema: object(props{"id": str("the promotion id")}, []string{"id"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + ID string `json:"id"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + found, err := s.store.Delete(a.ID) + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("promotion %q not found", a.ID) + } + 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: object(props{ + "cartTotalIncVat": integer("cart total incl. VAT, in öre"), + "itemQuantity": integer("optional total item quantity"), + "customerSegment": str("optional customer segment"), + }, []string{"cartTotalIncVat"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + CartTotalIncVat int64 `json:"cartTotalIncVat"` + ItemQuantity int `json:"itemQuantity"` + CustomerSegment string `json:"customerSegment"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity) + opts := []promotions.ContextOption{promotions.WithNow(time.Now())} + if a.CustomerSegment != "" { + opts = append(opts, promotions.WithCustomerSegment(a.CustomerSegment)) + } + ctx := promotions.NewContextFromCart(g, opts...) + _, actions := s.eval.EvaluateAll(s.store.Snapshot(), ctx) + promotions.ApplyActions(g, actions) + return map[string]any{ + "subtotalIncVat": a.CartTotalIncVat, + "discountIncVat": g.TotalDiscount.IncVat, + "netIncVat": g.TotalPrice.IncVat, + "matchedActions": actions, + }, nil + }, + }, + } +} + +// syntheticCart builds a one-line cart whose gross total is incVat öre (25% VAT +// assumed) so the promotion engine can be evaluated against an arbitrary total. +func syntheticCart(incVat int64, qty int) *cart.CartGrain { + if qty <= 0 { + qty = 1 + } + g := cart.NewCartGrain(0, time.Now()) + g.Items = []*cart.CartItem{ + {Id: 1, Sku: "PREVIEW", Quantity: uint16(qty), Price: *cart.NewPriceFromIncVat(incVat, 25)}, + } + // Quantity > 1 would multiply the row total; keep the gross equal to incVat by + // pricing a single unit at the full total. + g.Items[0].Quantity = 1 + g.UpdateTotals() + g.Items[0].Quantity = uint16(qty) + return g +} + +func validStatus(s string) bool { + switch promotions.PromotionStatus(s) { + case promotions.StatusActive, promotions.StatusInactive, promotions.StatusScheduled, promotions.StatusExpired: + return true + } + return false +} + +// ---- result + schema helpers (mirrors the graph-cms MCP edge) ------------- + +func toolText(v any) map[string]any { + b, err := json.Marshal(v) + if err != nil { + return toolError(err) + } + return map[string]any{ + "content": []map[string]any{{"type": "text", "text": string(b)}}, + } +} + +func toolError(err error) map[string]any { + return map[string]any{ + "isError": true, + "content": []map[string]any{{"type": "text", "text": err.Error()}}, + } +} + +// decode unmarshals tool arguments, tolerating empty/absent arguments. +func decode(args json.RawMessage, v any) error { + if len(args) == 0 || string(args) == "null" { + return nil + } + if err := json.Unmarshal(args, v); err != nil { + return fmt.Errorf("invalid arguments: %w", err) + } + return nil +} + +type props map[string]json.RawMessage + +func object(p props, required []string) json.RawMessage { + m := map[string]any{ + "type": "object", + "properties": p, + } + if len(required) > 0 { + m["required"] = required + } + b, _ := json.Marshal(m) + return b +} + +func str(desc string) json.RawMessage { return scalar("string", desc) } +func obj(desc string) json.RawMessage { return scalar("object", desc) } +func integer(d string) json.RawMessage { return scalar("integer", d) } + +func scalar(typ, desc string) json.RawMessage { + b, _ := json.Marshal(map[string]any{"type": typ, "description": desc}) + return b +} diff --git a/pkg/promotions/store.go b/pkg/promotions/store.go new file mode 100644 index 0000000..e2d0c17 --- /dev/null +++ b/pkg/promotions/store.go @@ -0,0 +1,139 @@ +package promotions + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" +) + +// Store is a thread-safe, file-backed collection of promotion rules. It is the +// single source of truth shared by the cart's mutation processor (which reads a +// snapshot on every recompute) and the promotion MCP tools (which mutate it and +// persist to disk). Because the cart re-reads the snapshot each time totals are +// recomputed, MCP edits take effect on the next cart mutation — no restart. +type Store struct { + mu sync.RWMutex + path string + version int + promotions []PromotionRule +} + +// NewStore loads the state file at path (an absent file yields an empty store) +// and returns a Store that persists back to the same path. +func NewStore(path string) (*Store, error) { + sf, err := LoadStateFile(path) + if err != nil { + return nil, err + } + version := sf.Version + if version == 0 { + version = 1 + } + return &Store{ + path: path, + version: version, + promotions: sf.State.Promotions, + }, nil +} + +// Snapshot returns a copy of the current rules, safe to evaluate without holding +// the lock. The PromotionRule values are passed by value to EvaluateAll, so a +// shallow copy of the slice is enough to avoid racing with concurrent mutations. +func (s *Store) Snapshot() []PromotionRule { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]PromotionRule, len(s.promotions)) + copy(out, s.promotions) + return out +} + +// List returns a copy of all rules (for the list_promotions tool). +func (s *Store) List() []PromotionRule { + return s.Snapshot() +} + +// Get returns the rule with the given id. +func (s *Store) Get(id string) (PromotionRule, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, r := range s.promotions { + if r.ID == id { + return r, true + } + } + return PromotionRule{}, false +} + +// Upsert inserts the rule or replaces the existing one with the same ID, then +// persists. Returns true if an existing rule was replaced. +func (s *Store) Upsert(rule PromotionRule) (replaced bool, err error) { + if rule.ID == "" { + return false, fmt.Errorf("promotion id is required") + } + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.promotions { + if s.promotions[i].ID == rule.ID { + s.promotions[i] = rule + return true, s.saveLocked() + } + } + s.promotions = append(s.promotions, rule) + return false, s.saveLocked() +} + +// SetStatus changes the status of a rule and persists. Returns false if no rule +// with the id exists. +func (s *Store) SetStatus(id string, status PromotionStatus) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.promotions { + if s.promotions[i].ID == id { + s.promotions[i].Status = status + return true, s.saveLocked() + } + } + return false, nil +} + +// Delete removes the rule with the id and persists. Returns false if not found. +func (s *Store) Delete(id string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.promotions { + if s.promotions[i].ID == id { + s.promotions = append(s.promotions[:i], s.promotions[i+1:]...) + return true, s.saveLocked() + } + } + return false, nil +} + +// saveLocked writes the current state to disk atomically. Caller holds s.mu. +func (s *Store) saveLocked() error { + sf := StateFile{ + Version: s.version, + State: PromotionState{Promotions: s.promotions}, + } + data, err := json.MarshalIndent(sf, "", " ") + if err != nil { + return err + } + dir := filepath.Dir(s.path) + tmp, err := os.CreateTemp(dir, ".promotions-*.json") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) // no-op once renamed + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, s.path) +}