423 lines
15 KiB
Go
423 lines
15 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
|
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
|
pmcp "git.k6n.net/mats/platform/mcp"
|
|
)
|
|
|
|
// buildTools returns the order's MCP tools (transport/dispatch live in platform/mcp).
|
|
func (s *Server) buildTools() []pmcp.Tool {
|
|
return []pmcp.Tool{
|
|
{
|
|
Name: "get_order",
|
|
Description: "Get the full detail of an order by its base62 order id: status, line items, payments, fulfillments, returns, refunds, customer info, billing/shipping addresses, and amounts.",
|
|
InputSchema: pmcp.Object(pmcp.Props{
|
|
"orderId": pmcp.String("the base62 order id"),
|
|
}, []string{"orderId"}),
|
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
|
var a struct {
|
|
OrderID string `json:"orderId"`
|
|
}
|
|
if err := pmcp.Decode(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
id, ok := order.ParseOrderId(a.OrderID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
|
|
}
|
|
g, err := s.applier.Get(ctx, uint64(id))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get order: %w", err)
|
|
}
|
|
if g.Status == order.StatusNew {
|
|
return nil, fmt.Errorf("order %q not found", a.OrderID)
|
|
}
|
|
return g, nil
|
|
},
|
|
},
|
|
{
|
|
Name: "get_order_status",
|
|
Description: "Get the current status (new|pending|authorized|captured|partially_fulfilled|fulfilled|completed|cancelled|refunded) of an order and the legal next transitions.",
|
|
InputSchema: pmcp.Object(pmcp.Props{
|
|
"orderId": pmcp.String("the base62 order id"),
|
|
}, []string{"orderId"}),
|
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
|
var a struct {
|
|
OrderID string `json:"orderId"`
|
|
}
|
|
if err := pmcp.Decode(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
id, ok := order.ParseOrderId(a.OrderID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
|
|
}
|
|
g, err := s.applier.Get(ctx, uint64(id))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get order: %w", err)
|
|
}
|
|
if g.Status == order.StatusNew {
|
|
return nil, fmt.Errorf("order %q not found", a.OrderID)
|
|
}
|
|
return map[string]any{
|
|
"orderId": a.OrderID,
|
|
"status": string(g.Status),
|
|
"nextTransitions": validTransitions(g.Status),
|
|
}, nil
|
|
},
|
|
},
|
|
{
|
|
Name: "get_order_lines",
|
|
Description: "List all line items on an order with reference, SKU, name, quantity, unit price, tax rate, total amount, total tax, and fulfilled quantity.",
|
|
InputSchema: pmcp.Object(pmcp.Props{
|
|
"orderId": pmcp.String("the base62 order id"),
|
|
}, []string{"orderId"}),
|
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
|
var a struct {
|
|
OrderID string `json:"orderId"`
|
|
}
|
|
if err := pmcp.Decode(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
id, ok := order.ParseOrderId(a.OrderID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
|
|
}
|
|
g, err := s.applier.Get(ctx, uint64(id))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get order: %w", err)
|
|
}
|
|
if g.Status == order.StatusNew {
|
|
return nil, fmt.Errorf("order %q not found", a.OrderID)
|
|
}
|
|
return map[string]any{"lines": g.Lines, "count": len(g.Lines)}, nil
|
|
},
|
|
},
|
|
{
|
|
Name: "get_order_payments",
|
|
Description: "List all payment records on an order: provider, authorized/captured/refunded amounts, authorization and capture references.",
|
|
InputSchema: pmcp.Object(pmcp.Props{
|
|
"orderId": pmcp.String("the base62 order id"),
|
|
}, []string{"orderId"}),
|
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
|
var a struct {
|
|
OrderID string `json:"orderId"`
|
|
}
|
|
if err := pmcp.Decode(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
id, ok := order.ParseOrderId(a.OrderID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
|
|
}
|
|
g, err := s.applier.Get(ctx, uint64(id))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get order: %w", err)
|
|
}
|
|
if g.Status == order.StatusNew {
|
|
return nil, fmt.Errorf("order %q not found", a.OrderID)
|
|
}
|
|
return map[string]any{"payments": g.Payments, "capturedAmount": g.CapturedAmount, "refundedAmount": g.RefundedAmount}, nil
|
|
},
|
|
},
|
|
{
|
|
Name: "get_order_fulfillments",
|
|
Description: "List all fulfillments (shipments) on an order: carrier, tracking number/URI, shipped lines with quantities, and creation date.",
|
|
InputSchema: pmcp.Object(pmcp.Props{
|
|
"orderId": pmcp.String("the base62 order id"),
|
|
}, []string{"orderId"}),
|
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
|
var a struct {
|
|
OrderID string `json:"orderId"`
|
|
}
|
|
if err := pmcp.Decode(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
id, ok := order.ParseOrderId(a.OrderID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
|
|
}
|
|
g, err := s.applier.Get(ctx, uint64(id))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get order: %w", err)
|
|
}
|
|
if g.Status == order.StatusNew {
|
|
return nil, fmt.Errorf("order %q not found", a.OrderID)
|
|
}
|
|
return map[string]any{"fulfillments": g.Fulfillments, "count": len(g.Fulfillments)}, nil
|
|
},
|
|
},
|
|
{
|
|
Name: "get_order_returns",
|
|
Description: "List all return requests (RMAs) on an order: reason, returned lines, and request date.",
|
|
InputSchema: pmcp.Object(pmcp.Props{
|
|
"orderId": pmcp.String("the base62 order id"),
|
|
}, []string{"orderId"}),
|
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
|
var a struct {
|
|
OrderID string `json:"orderId"`
|
|
}
|
|
if err := pmcp.Decode(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
id, ok := order.ParseOrderId(a.OrderID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
|
|
}
|
|
g, err := s.applier.Get(ctx, uint64(id))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get order: %w", err)
|
|
}
|
|
if g.Status == order.StatusNew {
|
|
return nil, fmt.Errorf("order %q not found", a.OrderID)
|
|
}
|
|
return map[string]any{"returns": g.Returns, "count": len(g.Returns)}, nil
|
|
},
|
|
},
|
|
{
|
|
Name: "cancel_order",
|
|
Description: "Cancel an order that is still in a cancellable state (pending or authorized) with an optional reason. Returns the updated order.",
|
|
InputSchema: pmcp.Object(pmcp.Props{
|
|
"orderId": pmcp.String("the base62 order id"),
|
|
"reason": pmcp.String("optional reason for cancellation"),
|
|
}, []string{"orderId"}),
|
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
|
var a struct {
|
|
OrderID string `json:"orderId"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := pmcp.Decode(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
id, ok := order.ParseOrderId(a.OrderID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
|
|
}
|
|
res, err := s.applier.Apply(ctx, uint64(id), &messages.CancelOrder{
|
|
Reason: a.Reason,
|
|
AtMs: time.Now().UnixMilli(),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cancel order: %w", err)
|
|
}
|
|
for _, m := range res.Mutations {
|
|
if m.Error != nil {
|
|
return nil, m.Error
|
|
}
|
|
}
|
|
return map[string]any{"orderId": a.OrderID, "order": &res.Result}, nil
|
|
},
|
|
},
|
|
{
|
|
Name: "create_fulfillment",
|
|
Description: "Ship lines on a captured order. Provide carrier and optionally tracking info. Each line entry requires a reference (the line reference) and quantity to ship. Returns the updated order.",
|
|
InputSchema: pmcp.Object(pmcp.Props{
|
|
"orderId": pmcp.String("the base62 order id"),
|
|
"carrier": pmcp.String("the shipping carrier name (e.g. PostNord, DHL)"),
|
|
"trackingNumber": pmcp.String("optional tracking number"),
|
|
"trackingUri": pmcp.String("optional tracking URL"),
|
|
"lines": pmcp.ObjectArray("lines to fulfill, each with reference (string) and quantity (integer)"),
|
|
}, []string{"orderId", "lines"}),
|
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
|
var a struct {
|
|
OrderID string `json:"orderId"`
|
|
Carrier string `json:"carrier"`
|
|
TrackingNumber string `json:"trackingNumber"`
|
|
TrackingURI string `json:"trackingUri"`
|
|
Lines []struct {
|
|
Reference string `json:"reference"`
|
|
Quantity int32 `json:"quantity"`
|
|
} `json:"lines"`
|
|
}
|
|
if err := pmcp.Decode(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
id, ok := order.ParseOrderId(a.OrderID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
|
|
}
|
|
fid, _ := order.NewOrderId()
|
|
fulfillmentLines := make([]*messages.FulfillmentLine, len(a.Lines))
|
|
for i, l := range a.Lines {
|
|
fulfillmentLines[i] = &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity}
|
|
}
|
|
res, err := s.applier.Apply(ctx, uint64(id), &messages.CreateFulfillment{
|
|
Id: "f_" + fid.String(),
|
|
Carrier: a.Carrier,
|
|
TrackingNumber: a.TrackingNumber,
|
|
TrackingUri: a.TrackingURI,
|
|
Lines: fulfillmentLines,
|
|
AtMs: time.Now().UnixMilli(),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create fulfillment: %w", err)
|
|
}
|
|
for _, m := range res.Mutations {
|
|
if m.Error != nil {
|
|
return nil, m.Error
|
|
}
|
|
}
|
|
return map[string]any{"orderId": a.OrderID, "order": &res.Result}, nil
|
|
},
|
|
},
|
|
{
|
|
Name: "complete_order",
|
|
Description: "Mark a fulfilled order as completed. Only allowed when the order is in 'fulfilled' status. Returns the updated order.",
|
|
InputSchema: pmcp.Object(pmcp.Props{
|
|
"orderId": pmcp.String("the base62 order id"),
|
|
}, []string{"orderId"}),
|
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
|
var a struct {
|
|
OrderID string `json:"orderId"`
|
|
}
|
|
if err := pmcp.Decode(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
id, ok := order.ParseOrderId(a.OrderID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
|
|
}
|
|
res, err := s.applier.Apply(ctx, uint64(id), &messages.CompleteOrder{
|
|
AtMs: time.Now().UnixMilli(),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("complete order: %w", err)
|
|
}
|
|
for _, m := range res.Mutations {
|
|
if m.Error != nil {
|
|
return nil, m.Error
|
|
}
|
|
}
|
|
return map[string]any{"orderId": a.OrderID, "order": &res.Result}, nil
|
|
},
|
|
},
|
|
{
|
|
Name: "request_return",
|
|
Description: "Open a return request (RMA) against fulfilled lines on an order. Provide the lines being returned and a reason. Returns the updated order with the return recorded.",
|
|
InputSchema: pmcp.Object(pmcp.Props{
|
|
"orderId": pmcp.String("the base62 order id"),
|
|
"reason": pmcp.String("the reason for the return"),
|
|
"lines": pmcp.ObjectArray("lines being returned, each with reference (string) and quantity (integer)"),
|
|
}, []string{"orderId", "reason", "lines"}),
|
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
|
var a struct {
|
|
OrderID string `json:"orderId"`
|
|
Reason string `json:"reason"`
|
|
Lines []struct {
|
|
Reference string `json:"reference"`
|
|
Quantity int32 `json:"quantity"`
|
|
} `json:"lines"`
|
|
}
|
|
if err := pmcp.Decode(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
id, ok := order.ParseOrderId(a.OrderID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
|
|
}
|
|
rid, _ := order.NewOrderId()
|
|
returnLines := make([]*messages.FulfillmentLine, len(a.Lines))
|
|
for i, l := range a.Lines {
|
|
returnLines[i] = &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity}
|
|
}
|
|
res, err := s.applier.Apply(ctx, uint64(id), &messages.RequestReturn{
|
|
Id: "r_" + rid.String(),
|
|
Reason: a.Reason,
|
|
Lines: returnLines,
|
|
AtMs: time.Now().UnixMilli(),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request return: %w", err)
|
|
}
|
|
for _, m := range res.Mutations {
|
|
if m.Error != nil {
|
|
return nil, m.Error
|
|
}
|
|
}
|
|
return map[string]any{"orderId": a.OrderID, "order": &res.Result}, nil
|
|
},
|
|
},
|
|
{
|
|
Name: "issue_refund",
|
|
Description: "Issue a refund against a captured order. Amount is in minor units (öre); omit for the full remaining captured amount. Returns the updated order.",
|
|
InputSchema: pmcp.Object(pmcp.Props{
|
|
"orderId": pmcp.String("the base62 order id"),
|
|
"amount": pmcp.Integer("refund amount in minor units (öre); omit for full remaining captured amount"),
|
|
}, []string{"orderId"}),
|
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
|
var a struct {
|
|
OrderID string `json:"orderId"`
|
|
Amount int64 `json:"amount"`
|
|
}
|
|
if err := pmcp.Decode(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
id, ok := order.ParseOrderId(a.OrderID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
|
|
}
|
|
// If amount is 0 (omitted), default to full remaining captured amount.
|
|
if a.Amount <= 0 {
|
|
g, err := s.applier.Get(ctx, uint64(id))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get order for refund: %w", err)
|
|
}
|
|
if g.Status == order.StatusNew {
|
|
return nil, fmt.Errorf("order %q not found", a.OrderID)
|
|
}
|
|
a.Amount = (g.CapturedAmount - g.RefundedAmount).Int64()
|
|
}
|
|
if a.Amount <= 0 {
|
|
return nil, fmt.Errorf("refund amount must be positive")
|
|
}
|
|
res, err := s.applier.Apply(ctx, uint64(id), &messages.IssueRefund{
|
|
Provider: "mcp",
|
|
Amount: a.Amount,
|
|
Reference: "ref-mcp-" + a.OrderID,
|
|
AtMs: time.Now().UnixMilli(),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("issue refund: %w", err)
|
|
}
|
|
for _, m := range res.Mutations {
|
|
if m.Error != nil {
|
|
return nil, m.Error
|
|
}
|
|
}
|
|
return map[string]any{"orderId": a.OrderID, "order": &res.Result}, nil
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// validTransitions returns the list of legal next statuses for a given status,
|
|
// matching the transitions map in the order state machine.
|
|
func validTransitions(s order.Status) []string {
|
|
switch s {
|
|
case order.StatusNew:
|
|
return []string{"pending"}
|
|
case order.StatusPending:
|
|
return []string{"authorized", "cancelled"}
|
|
case order.StatusAuthorized:
|
|
return []string{"captured", "cancelled"}
|
|
case order.StatusCaptured:
|
|
return []string{"partially_fulfilled", "fulfilled", "refunded"}
|
|
case order.StatusPartiallyFulfilled:
|
|
return []string{"partially_fulfilled", "fulfilled", "refunded"}
|
|
case order.StatusFulfilled:
|
|
return []string{"completed", "refunded"}
|
|
case order.StatusCompleted:
|
|
return []string{"refunded"}
|
|
default:
|
|
return []string{}
|
|
}
|
|
}
|
|
|
|
// ---- result + schema helpers -----------------------------------------------
|