all the refactor
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s

This commit is contained in:
2026-06-28 17:51:52 +02:00
parent 7db0d236c7
commit aa8b2bdedc
84 changed files with 4328 additions and 2344 deletions
+3 -3
View File
@@ -85,7 +85,7 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
if err != nil {
return err
}
auth, err := provider.Authorize(ctx, o.OrderReference, o.TotalAmount, o.Currency)
auth, err := provider.Authorize(ctx, o.OrderReference, o.TotalAmount.Int64(), o.Currency)
if err != nil {
return err
}
@@ -108,7 +108,7 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
amount, _ := st.Vars["authAmount"].(int64)
if amount == 0 {
if o, err := app.Get(ctx, st.ID); err == nil {
amount = o.TotalAmount
amount = o.TotalAmount.Int64()
}
}
capture, err := provider.Capture(ctx, authRef, amount)
@@ -154,7 +154,7 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
if err != nil {
return err
}
amount := o.CapturedAmount - o.RefundedAmount // default: full remaining
amount := (o.CapturedAmount - o.RefundedAmount).Int64() // default: full remaining
if len(params) > 0 {
var p struct {
Amount int64 `json:"amount"`
+78
View File
@@ -0,0 +1,78 @@
package order
import (
"context"
"encoding/json"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/platform/event"
contract "git.k6n.net/mats/platform/order"
)
// RegisterOrderCreatedEmit registers the "emit_order_created" flow hook: it
// publishes an event.OrderCreated envelope (payload contract.Created) for the
// order, reading the grain via app and publishing durably via pub.
//
// It is a HOOK, not an action, on purpose: hook errors are logged, never abort
// the flow. The order is already placed and paid by the time this fires, so a
// transient publish hiccup must not roll it back — and pub is the durable outbox
// (a local fsync'd append), so "failure" here means disk error, then the relay
// retries delivery on its own. With a nil publisher (dev without AMQP) it is a
// no-op. Attach it to the "after" hooks of the final paid step, e.g. capture.
func RegisterOrderCreatedEmit(reg *flow.Registry, app Applier, pub Publisher, exchange, source string) {
reg.Hook("emit_order_created", func(ctx context.Context, st *flow.State, _ flow.HookInfo, _ json.RawMessage) error {
if pub == nil {
return nil // no broker configured (dev)
}
o, err := app.Get(ctx, st.ID)
if err != nil {
return err
}
created := buildOrderCreated(o)
if len(created.Lines) == 0 {
return nil // nothing for a reactor to act on
}
payload, err := created.Encode()
if err != nil {
return err
}
ev := event.Event{
ID: "order-created-" + created.OrderID,
Type: event.OrderCreated,
Source: source,
Subject: created.OrderID,
Payload: payload,
Time: time.Now(),
Meta: map[string]string{"country": created.Country},
}
body, err := json.Marshal(ev)
if err != nil {
return err
}
return pub.Publish(exchange, string(event.OrderCreated), body)
})
}
// buildOrderCreated projects the grain onto the exported order.created contract.
// Lines carry only SKU + quantity (what reactors need); inventory commits at the
// order's Country location.
func buildOrderCreated(o *OrderGrain) contract.Created {
lines := make([]contract.Line, 0, len(o.Lines))
for _, l := range o.Lines {
if l.Sku == "" || l.Quantity <= 0 {
continue
}
lines = append(lines, contract.Line{SKU: l.Sku, Quantity: l.Quantity, Location: l.Location})
}
return contract.Created{
OrderID: OrderId(o.Id).String(),
OrderReference: o.OrderReference,
CartID: o.CartId,
Country: o.Country,
Currency: o.Currency,
CustomerEmail: o.CustomerEmail,
Lines: lines,
PlacedAtMs: nowMs(),
}
}
+3
View File
@@ -41,6 +41,9 @@ func newFlowRegistry(pool Applier, provider PaymentProvider) *flow.Registry {
reg := flow.NewRegistry()
flow.RegisterBuiltinHooks(reg)
RegisterFlowActions(reg, pool, provider)
// place-and-pay references the emit_order_created hook; register it (nil
// publisher = no-op) so flow validation passes in tests too.
RegisterOrderCreatedEmit(reg, pool, nil, "order", "order")
return reg
}
+4 -1
View File
@@ -22,7 +22,10 @@
"name": "capture",
"action": "capture_payment",
"hooks": {
"after": [{ "type": "log", "params": { "message": "payment captured" } }]
"after": [
{ "type": "log", "params": { "message": "payment captured" } },
{ "type": "emit_order_created" }
]
}
}
]
+10 -53
View File
@@ -1,71 +1,28 @@
package order
import (
"crypto/rand"
"fmt"
"git.k6n.net/mats/platform/uid"
)
// OrderId is a 64-bit order identifier with a compact base62 string form, the
// same scheme the cart uses (cart.CartId) so ids are consistent across the
// commerce services. The grain is keyed by the raw uint64.
type OrderId uint64
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
var base62Rev [256]byte
func init() {
for i := range base62Rev {
base62Rev[i] = 0xFF
}
for i := 0; i < len(base62Alphabet); i++ {
base62Rev[base62Alphabet[i]] = byte(i)
}
}
// OrderId is a 64-bit order identifier with a compact base62 string form.
type OrderId uid.ID
// String returns the canonical base62 encoding.
func (id OrderId) String() string { return encodeBase62(uint64(id)) }
func (id OrderId) String() string { return uid.ID(id).String() }
// NewOrderId generates a cryptographically random non-zero id.
func NewOrderId() (OrderId, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
id, err := uid.New()
if err != nil {
return 0, fmt.Errorf("NewOrderId: %w", err)
}
u := uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 |
uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7])
if u == 0 {
return NewOrderId()
}
return OrderId(u), nil
return OrderId(id), nil
}
// ParseOrderId parses a base62 string into an OrderId.
func ParseOrderId(s string) (OrderId, bool) {
if len(s) == 0 || len(s) > 11 {
return 0, false
}
var v uint64
for i := 0; i < len(s); i++ {
d := base62Rev[s[i]]
if d == 0xFF {
return 0, false
}
v = v*62 + uint64(d)
}
return OrderId(v), true
}
func encodeBase62(u uint64) string {
if u == 0 {
return "0"
}
var buf [11]byte
i := len(buf)
for u > 0 {
i--
buf[i] = base62Alphabet[u%62]
u /= 62
}
return string(buf[i:])
id, ok := uid.Parse(s)
return OrderId(id), ok
}
-44
View File
@@ -1,44 +0,0 @@
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}}
}
+9 -120
View File
@@ -2,27 +2,24 @@
// exposing the order grain as tools so an agent can inspect order state and
// drive the order lifecycle (cancel, fulfill, complete, return, refund, etc.).
//
// Transport is streamable HTTP (JSON-RPC 2.0 over POST). It is mounted by
// cmd/order under /mcp on the same HTTP server as the order API. Tools map
// 1:1 to order grain read/apply operations; no restart is needed because every
// tool call goes through the shared grain pool.
// Transport/dispatch and the schema/result helpers live in platform/mcp; this
// package only defines the order-specific tools and wires them to the grain
// pool. Mounted by cmd/order under /mcp.
package mcp
import (
"context"
"encoding/json"
"io"
"net/http"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/order"
pmcp "git.k6n.net/mats/platform/mcp"
"google.golang.org/protobuf/proto"
)
const (
serverName = "orders"
serverVersion = "0.1.0"
protocolVersion = "2025-06-18"
serverName = "orders"
serverVersion = "0.1.0"
)
// OrderApplier is the minimal grain-pool interface the order MCP needs: read an
@@ -36,123 +33,15 @@ type OrderApplier interface {
// Server is the MCP edge over the order grain pool.
type Server struct {
applier OrderApplier
tools []tool
mcp *pmcp.Server
}
// New builds an MCP server exposing the order grain as tools.
func New(applier OrderApplier) *Server {
s := &Server{applier: applier}
s.tools = s.buildTools()
s.mcp = pmcp.NewServer(pmcp.Info{Name: serverName, Version: serverVersion}, 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)
}
func (s *Server) Handler() http.Handler { return s.mcp.Handler() }
+1 -1
View File
@@ -159,7 +159,7 @@ func callWithRaw(t *testing.T, h http.Handler, name string, args map[string]any)
t.Fatalf("%s: status %d", name, rec.Code)
}
var resp struct {
Result rawResult `json:"result"`
Result rawResult `json:"result"`
Error *struct{ Message string } `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+71 -210
View File
@@ -4,108 +4,34 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"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"
)
// 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")
}
// Extract UCP meta from arguments (UCP-Agent profile advertisement).
// Per the UCP spec, meta is a sibling key inside arguments:
// "arguments": { "meta": { "ucp-agent": { "profile": "..." } }, ... }
args := extractUCPAgentMeta(&p.Arguments)
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(args)
if err != nil {
return result(req.ID, toolError(err))
}
return result(req.ID, toolText(out))
}
// extractUCPAgentMeta checks if the JSON arguments contain a "meta" key with
// a nested "ucp-agent.profile" field. If found, it logs the profile (for
// observability) and strips the "meta" key before returning the cleaned
// arguments to the tool handler, so the meta object does not leak into the
// tool's argument namespace.
func extractUCPAgentMeta(arguments *json.RawMessage) json.RawMessage {
if arguments == nil || len(*arguments) == 0 {
return *arguments
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(*arguments, &obj); err != nil {
return *arguments
}
metaRaw, hasMeta := obj["meta"]
if !hasMeta {
return *arguments
}
// Try to extract the profile for observability.
var meta struct {
UCPAgent *struct {
Profile string `json:"profile"`
} `json:"ucp-agent"`
}
if err := json.Unmarshal(metaRaw, &meta); err == nil && meta.UCPAgent != nil && meta.UCPAgent.Profile != "" {
log.Printf("ucp-agent: profile=%s", meta.UCPAgent.Profile)
}
// Strip meta from arguments before passing to the tool handler.
delete(obj, "meta")
cleaned, err := json.Marshal(obj)
if err != nil {
return *arguments
}
return cleaned
}
func (s *Server) buildTools() []tool {
return []tool{
// 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: object(props{
"orderId": str("the base62 order id"),
InputSchema: pmcp.Object(pmcp.Props{
"orderId": pmcp.String("the base62 order id"),
}, []string{"orderId"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
}
if err := decode(args, &a); err != nil {
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(context.Background(), uint64(id))
g, err := s.applier.Get(ctx, uint64(id))
if err != nil {
return nil, fmt.Errorf("get order: %w", err)
}
@@ -118,21 +44,21 @@ func (s *Server) buildTools() []tool {
{
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: object(props{
"orderId": str("the base62 order id"),
InputSchema: pmcp.Object(pmcp.Props{
"orderId": pmcp.String("the base62 order id"),
}, []string{"orderId"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
}
if err := decode(args, &a); err != nil {
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(context.Background(), uint64(id))
g, err := s.applier.Get(ctx, uint64(id))
if err != nil {
return nil, fmt.Errorf("get order: %w", err)
}
@@ -140,8 +66,8 @@ func (s *Server) buildTools() []tool {
return nil, fmt.Errorf("order %q not found", a.OrderID)
}
return map[string]any{
"orderId": a.OrderID,
"status": string(g.Status),
"orderId": a.OrderID,
"status": string(g.Status),
"nextTransitions": validTransitions(g.Status),
}, nil
},
@@ -149,21 +75,21 @@ func (s *Server) buildTools() []tool {
{
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: object(props{
"orderId": str("the base62 order id"),
InputSchema: pmcp.Object(pmcp.Props{
"orderId": pmcp.String("the base62 order id"),
}, []string{"orderId"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
}
if err := decode(args, &a); err != nil {
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(context.Background(), uint64(id))
g, err := s.applier.Get(ctx, uint64(id))
if err != nil {
return nil, fmt.Errorf("get order: %w", err)
}
@@ -176,21 +102,21 @@ func (s *Server) buildTools() []tool {
{
Name: "get_order_payments",
Description: "List all payment records on an order: provider, authorized/captured/refunded amounts, authorization and capture references.",
InputSchema: object(props{
"orderId": str("the base62 order id"),
InputSchema: pmcp.Object(pmcp.Props{
"orderId": pmcp.String("the base62 order id"),
}, []string{"orderId"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
}
if err := decode(args, &a); err != nil {
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(context.Background(), uint64(id))
g, err := s.applier.Get(ctx, uint64(id))
if err != nil {
return nil, fmt.Errorf("get order: %w", err)
}
@@ -203,21 +129,21 @@ func (s *Server) buildTools() []tool {
{
Name: "get_order_fulfillments",
Description: "List all fulfillments (shipments) on an order: carrier, tracking number/URI, shipped lines with quantities, and creation date.",
InputSchema: object(props{
"orderId": str("the base62 order id"),
InputSchema: pmcp.Object(pmcp.Props{
"orderId": pmcp.String("the base62 order id"),
}, []string{"orderId"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
}
if err := decode(args, &a); err != nil {
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(context.Background(), uint64(id))
g, err := s.applier.Get(ctx, uint64(id))
if err != nil {
return nil, fmt.Errorf("get order: %w", err)
}
@@ -230,21 +156,21 @@ func (s *Server) buildTools() []tool {
{
Name: "get_order_returns",
Description: "List all return requests (RMAs) on an order: reason, returned lines, and request date.",
InputSchema: object(props{
"orderId": str("the base62 order id"),
InputSchema: pmcp.Object(pmcp.Props{
"orderId": pmcp.String("the base62 order id"),
}, []string{"orderId"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
}
if err := decode(args, &a); err != nil {
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(context.Background(), uint64(id))
g, err := s.applier.Get(ctx, uint64(id))
if err != nil {
return nil, fmt.Errorf("get order: %w", err)
}
@@ -257,23 +183,23 @@ func (s *Server) buildTools() []tool {
{
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: object(props{
"orderId": str("the base62 order id"),
"reason": str("optional reason for cancellation"),
InputSchema: pmcp.Object(pmcp.Props{
"orderId": pmcp.String("the base62 order id"),
"reason": pmcp.String("optional reason for cancellation"),
}, []string{"orderId"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
Reason string `json:"reason"`
}
if err := decode(args, &a); err != nil {
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(context.Background(), uint64(id), &messages.CancelOrder{
res, err := s.applier.Apply(ctx, uint64(id), &messages.CancelOrder{
Reason: a.Reason,
AtMs: time.Now().UnixMilli(),
})
@@ -291,14 +217,14 @@ func (s *Server) buildTools() []tool {
{
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: object(props{
"orderId": str("the base62 order id"),
"carrier": str("the shipping carrier name (e.g. PostNord, DHL)"),
"trackingNumber": str("optional tracking number"),
"trackingUri": str("optional tracking URL"),
"lines": objArray("lines to fulfill, each with reference (string) and quantity (integer)"),
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(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
Carrier string `json:"carrier"`
@@ -309,7 +235,7 @@ func (s *Server) buildTools() []tool {
Quantity int32 `json:"quantity"`
} `json:"lines"`
}
if err := decode(args, &a); err != nil {
if err := pmcp.Decode(args, &a); err != nil {
return nil, err
}
id, ok := order.ParseOrderId(a.OrderID)
@@ -321,7 +247,7 @@ func (s *Server) buildTools() []tool {
for i, l := range a.Lines {
fulfillmentLines[i] = &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity}
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.CreateFulfillment{
res, err := s.applier.Apply(ctx, uint64(id), &messages.CreateFulfillment{
Id: "f_" + fid.String(),
Carrier: a.Carrier,
TrackingNumber: a.TrackingNumber,
@@ -343,21 +269,21 @@ func (s *Server) buildTools() []tool {
{
Name: "complete_order",
Description: "Mark a fulfilled order as completed. Only allowed when the order is in 'fulfilled' status. Returns the updated order.",
InputSchema: object(props{
"orderId": str("the base62 order id"),
InputSchema: pmcp.Object(pmcp.Props{
"orderId": pmcp.String("the base62 order id"),
}, []string{"orderId"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
}
if err := decode(args, &a); err != nil {
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(context.Background(), uint64(id), &messages.CompleteOrder{
res, err := s.applier.Apply(ctx, uint64(id), &messages.CompleteOrder{
AtMs: time.Now().UnixMilli(),
})
if err != nil {
@@ -374,12 +300,12 @@ func (s *Server) buildTools() []tool {
{
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: object(props{
"orderId": str("the base62 order id"),
"reason": str("the reason for the return"),
"lines": objArray("lines being returned, each with reference (string) and quantity (integer)"),
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(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
Reason string `json:"reason"`
@@ -388,7 +314,7 @@ func (s *Server) buildTools() []tool {
Quantity int32 `json:"quantity"`
} `json:"lines"`
}
if err := decode(args, &a); err != nil {
if err := pmcp.Decode(args, &a); err != nil {
return nil, err
}
id, ok := order.ParseOrderId(a.OrderID)
@@ -400,7 +326,7 @@ func (s *Server) buildTools() []tool {
for i, l := range a.Lines {
returnLines[i] = &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity}
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.RequestReturn{
res, err := s.applier.Apply(ctx, uint64(id), &messages.RequestReturn{
Id: "r_" + rid.String(),
Reason: a.Reason,
Lines: returnLines,
@@ -420,16 +346,16 @@ func (s *Server) buildTools() []tool {
{
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: object(props{
"orderId": str("the base62 order id"),
"amount": integer("refund amount in minor units (öre); omit for full remaining captured amount"),
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(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
OrderID string `json:"orderId"`
Amount int64 `json:"amount"`
}
if err := decode(args, &a); err != nil {
if err := pmcp.Decode(args, &a); err != nil {
return nil, err
}
id, ok := order.ParseOrderId(a.OrderID)
@@ -438,19 +364,19 @@ func (s *Server) buildTools() []tool {
}
// If amount is 0 (omitted), default to full remaining captured amount.
if a.Amount <= 0 {
g, err := s.applier.Get(context.Background(), uint64(id))
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
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(context.Background(), uint64(id), &messages.IssueRefund{
res, err := s.applier.Apply(ctx, uint64(id), &messages.IssueRefund{
Provider: "mcp",
Amount: a.Amount,
Reference: "ref-mcp-" + a.OrderID,
@@ -494,68 +420,3 @@ func validTransitions(s order.Status) []string {
}
// ---- result + schema helpers -----------------------------------------------
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 integer(d string) json.RawMessage { return scalar("integer", d) }
func obj(desc string) json.RawMessage { return scalar("object", desc) }
func scalar(typ, desc string) json.RawMessage {
b, _ := json.Marshal(map[string]any{"type": typ, "description": desc})
return b
}
func objArray(desc string) json.RawMessage {
b, _ := json.Marshal(map[string]any{
"type": "array", "description": desc, "items": map[string]string{"type": "object"},
})
return b
}
func stringArray(desc string) json.RawMessage {
b, _ := json.Marshal(map[string]any{
"type": "array", "description": desc, "items": map[string]string{"type": "string"},
})
return b
}
+22 -20
View File
@@ -5,6 +5,7 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/actor"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
"git.k6n.net/mats/platform/money"
)
// This file holds the mutation handlers. Each handler is the *only* place a
@@ -37,8 +38,8 @@ func HandlePlaceOrder(o *OrderGrain, m *messages.PlaceOrder) error {
o.Currency = m.GetCurrency()
o.Locale = m.GetLocale()
o.Country = m.GetCountry()
o.TotalAmount = m.GetTotalAmount()
o.TotalTax = m.GetTotalTax()
o.TotalAmount = money.Cents(m.GetTotalAmount())
o.TotalTax = money.Cents(m.GetTotalTax())
o.CustomerEmail = m.GetCustomerEmail()
o.CustomerName = m.GetCustomerName()
if b := m.GetBillingAddress(); len(b) > 0 {
@@ -54,10 +55,11 @@ func HandlePlaceOrder(o *OrderGrain, m *messages.PlaceOrder) error {
Sku: l.GetSku(),
Name: l.GetName(),
Quantity: int(l.GetQuantity()),
UnitPrice: l.GetUnitPrice(),
UnitPrice: money.Cents(l.GetUnitPrice()),
TaxRate: int(l.GetTaxRate()),
TotalAmount: l.GetTotalAmount(),
TotalTax: l.GetTotalTax(),
TotalAmount: money.Cents(l.GetTotalAmount()),
TotalTax: money.Cents(l.GetTotalTax()),
Location: l.GetLocation(),
})
}
o.Status = StatusPending
@@ -74,7 +76,7 @@ func HandleAuthorizePayment(o *OrderGrain, m *messages.AuthorizePayment) error {
at := msToString(m.GetAtMs())
o.Payments = append(o.Payments, &Payment{
Provider: m.GetProvider(),
Authorized: m.GetAmount(),
Authorized: money.Cents(m.GetAmount()),
AuthRef: m.GetReference(),
AuthorizedAt: at,
})
@@ -93,10 +95,10 @@ func HandleCapturePayment(o *OrderGrain, m *messages.CapturePayment) error {
return fmt.Errorf("order: capture has no matching authorized payment")
}
at := msToString(m.GetAtMs())
p.Captured += m.GetAmount()
p.Captured += money.Cents(m.GetAmount())
p.CaptureRef = m.GetReference()
p.CapturedAt = at
o.CapturedAmount += m.GetAmount()
o.CapturedAmount += money.Cents(m.GetAmount())
o.Status = StatusCaptured
o.touch(at)
return nil
@@ -208,21 +210,21 @@ func HandleIssueRefund(o *OrderGrain, m *messages.IssueRefund) error {
if m.GetAmount() <= 0 {
return fmt.Errorf("order: refund amount must be positive")
}
if o.RefundedAmount+m.GetAmount() > o.CapturedAmount {
if o.RefundedAmount+money.Cents(m.GetAmount()) > o.CapturedAmount {
return fmt.Errorf("order: refund exceeds captured amount")
}
at := msToString(m.GetAtMs())
o.Refunds = append(o.Refunds, Refund{
Provider: m.GetProvider(),
Amount: m.GetAmount(),
Amount: money.Cents(m.GetAmount()),
Reference: m.GetReference(),
ReturnID: m.GetReturnId(),
IssuedAt: at,
})
o.RefundedAmount += m.GetAmount()
o.RefundedAmount += money.Cents(m.GetAmount())
for _, p := range o.Payments {
if p.Provider == m.GetProvider() {
p.Refunded += m.GetAmount()
p.Refunded += money.Cents(m.GetAmount())
break
}
}
@@ -264,10 +266,10 @@ func HandleRequestExchange(o *OrderGrain, m *messages.RequestExchange) error {
Sku: nl.GetSku(),
Name: nl.GetName(),
Quantity: int(nl.GetQuantity()),
UnitPrice: nl.GetUnitPrice(),
UnitPrice: money.Cents(nl.GetUnitPrice()),
TaxRate: int(nl.GetTaxRate()),
TotalAmount: nl.GetTotalAmount(),
TotalTax: nl.GetTotalTax(),
TotalAmount: money.Cents(nl.GetTotalAmount()),
TotalTax: money.Cents(nl.GetTotalTax()),
}
ex.NewLines = append(ex.NewLines, line)
@@ -296,14 +298,14 @@ func HandleEditOrderDetails(o *OrderGrain, m *messages.EditOrderDetails) error {
o.BillingAddress = append([]byte(nil), b...)
}
if sp := m.GetShippingPrice(); sp > 0 {
if sp := money.Cents(m.GetShippingPrice()); sp > 0 {
found := false
var oldTotal int64
var oldTotal money.Cents
for i := range o.Lines {
if o.Lines[i].Name == "Delivery" {
oldTotal = o.Lines[i].TotalAmount
o.Lines[i].UnitPrice = sp
o.Lines[i].TotalAmount = sp * int64(o.Lines[i].Quantity)
o.Lines[i].TotalAmount = sp.Mul(int64(o.Lines[i].Quantity))
o.Lines[i].TotalTax = ComputeTax(o.Lines[i].TotalAmount, o.Lines[i].TaxRate)
o.TotalAmount = o.TotalAmount - oldTotal + o.Lines[i].TotalAmount
found = true
@@ -317,9 +319,9 @@ func HandleEditOrderDetails(o *OrderGrain, m *messages.EditOrderDetails) error {
Name: "Delivery",
Quantity: 1,
UnitPrice: sp,
TaxRate: 25,
TaxRate: 2500, // 25% in basis points
TotalAmount: sp,
TotalTax: ComputeTax(sp, 25),
TotalTax: ComputeTax(sp, 2500),
}
o.Lines = append(o.Lines, line)
o.TotalAmount += sp
+22 -17
View File
@@ -9,6 +9,8 @@ import (
"encoding/json"
"sync"
"time"
"git.k6n.net/mats/platform/money"
)
// Status is the order lifecycle state.
@@ -57,21 +59,24 @@ type Line struct {
Sku string `json:"sku"`
Name string `json:"name"`
Quantity int `json:"quantity"`
UnitPrice int64 `json:"unitPrice"`
TaxRate int `json:"taxRate"`
TotalAmount int64 `json:"totalAmount"`
TotalTax int64 `json:"totalTax"`
UnitPrice money.Cents `json:"unitPrice"`
TaxRate int `json:"taxRate"`
TotalAmount money.Cents `json:"totalAmount"`
TotalTax money.Cents `json:"totalTax"`
// Location is the inventory location / store id to commit against (empty =
// order country). Carried through to the order.created event for commit.
Location string `json:"location,omitempty"`
// Fulfilled tracks how many units of this line have shipped.
Fulfilled int `json:"fulfilled"`
}
// Payment records one authorization/capture against a provider.
type Payment struct {
Provider string `json:"provider"`
Authorized int64 `json:"authorized"`
Captured int64 `json:"captured"`
Refunded int64 `json:"refunded"`
AuthRef string `json:"authRef,omitempty"`
Provider string `json:"provider"`
Authorized money.Cents `json:"authorized"`
Captured money.Cents `json:"captured"`
Refunded money.Cents `json:"refunded"`
AuthRef string `json:"authRef,omitempty"`
CaptureRef string `json:"captureRef,omitempty"`
AuthorizedAt string `json:"authorizedAt,omitempty"`
CapturedAt string `json:"capturedAt,omitempty"`
@@ -113,9 +118,9 @@ type Exchange struct {
// Refund records a refund issued against the order.
type Refund struct {
Provider string `json:"provider"`
Amount int64 `json:"amount"`
Reference string `json:"reference,omitempty"`
Provider string `json:"provider"`
Amount money.Cents `json:"amount"`
Reference string `json:"reference,omitempty"`
ReturnID string `json:"returnId,omitempty"`
IssuedAt string `json:"issuedAt,omitempty"`
}
@@ -135,9 +140,9 @@ type OrderGrain struct {
Locale string `json:"locale,omitempty"`
Country string `json:"country,omitempty"`
TotalAmount int64 `json:"totalAmount"`
TotalTax int64 `json:"totalTax"`
Lines []Line `json:"lines,omitempty"`
TotalAmount money.Cents `json:"totalAmount"`
TotalTax money.Cents `json:"totalTax"`
Lines []Line `json:"lines,omitempty"`
CustomerEmail string `json:"customerEmail,omitempty"`
CustomerName string `json:"customerName,omitempty"`
@@ -151,8 +156,8 @@ type OrderGrain struct {
Refunds []Refund `json:"refunds,omitempty"`
CapturedAmount int64 `json:"capturedAmount"`
RefundedAmount int64 `json:"refundedAmount"`
CapturedAmount money.Cents `json:"capturedAmount"`
RefundedAmount money.Cents `json:"refundedAmount"`
PlacedAt string `json:"placedAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
+19 -14
View File
@@ -1,30 +1,35 @@
package order
import "git.k6n.net/mats/go-cart-actor/pkg/cart"
import (
"git.k6n.net/mats/platform/money"
"git.k6n.net/mats/platform/tax"
)
// TaxProvider computes taxes for orders and line items.
// The canonical definition is in pkg/cart; this is a re-export for
// backward compatibility.
type TaxProvider = cart.TaxProvider
// The canonical definition is in platform/tax; this is a type alias
// for backward compatibility.
type TaxProvider = tax.Provider
// ComputeTax is the shared tax formula: tax = total x rate / (100 + rate).
// Re-exported from pkg/cart for backward compatibility.
func ComputeTax(total int64, taxRate int) int64 {
return cart.ComputeTax(total, taxRate)
// ComputeTax is the shared ex-VAT-first tax formula. rateBp is basis points
// (2500 = 25%). Re-exported from platform/tax for backward compatibility.
func ComputeTax(total money.Cents, rateBp int) money.Cents {
return money.Cents(tax.Compute(total.Int64(), rateBp))
}
// NordicTaxProvider implements TaxProvider for the Nordic countries.
// Re-exported from pkg/cart for backward compatibility.
type NordicTaxProvider = cart.NordicTaxProvider
// Re-exported from platform/tax for backward compatibility.
type NordicTaxProvider = tax.Nordic
// NewNordicTaxProvider returns a new NordicTaxProvider.
func NewNordicTaxProvider(defaultCountry string) *NordicTaxProvider {
return cart.NewNordicTaxProvider(defaultCountry)
return tax.NewNordic(defaultCountry)
}
// StaticTaxProvider is a stateless TaxProvider with no country awareness.
// Re-exported from pkg/cart for backward compatibility.
type StaticTaxProvider = cart.StaticTaxProvider
// Re-exported from platform/tax for backward compatibility.
type StaticTaxProvider = tax.Static
// NewStaticTaxProvider returns a new StaticTaxProvider.
func NewStaticTaxProvider() *StaticTaxProvider {
return cart.NewStaticTaxProvider()
return tax.NewStatic()
}
+4 -4
View File
@@ -16,11 +16,11 @@ func TestReExports(t *testing.T) {
if p.Name() != "nordic" {
t.Errorf("Name = %q", p.Name())
}
if got := p.DefaultTaxRate("SE"); got != 25 {
t.Errorf("DefaultTaxRate(SE) = %d, want 25", got)
if got := p.DefaultTaxRate("SE"); got != 2500 {
t.Errorf("DefaultTaxRate(SE) = %d, want 2500", got)
}
if got := ComputeTax(1250, 25); got != 250 {
t.Errorf("ComputeTax(1250, 25) = %d, want 250", got)
if got := ComputeTax(1250, 2500); got != 250 {
t.Errorf("ComputeTax(1250, 2500) = %d, want 250", got)
}
// StaticTaxProvider