all the refactor
This commit is contained in:
+16
-81
@@ -1,38 +1,24 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.k6n.net/mats/platform/uid"
|
||||
)
|
||||
|
||||
type GrainId uint64
|
||||
// GrainId is a 64-bit grain identifier with a compact base62 string form.
|
||||
type GrainId uid.ID
|
||||
|
||||
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
// String returns the canonical base62 encoding.
|
||||
func (id GrainId) String() string { return uid.ID(id).String() }
|
||||
|
||||
// Reverse lookup (0xFF marks invalid)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the canonical base62 encoding of the 64-bit id.
|
||||
func (id GrainId) String() string {
|
||||
return encodeBase62(uint64(id))
|
||||
}
|
||||
|
||||
// MarshalJSON encodes the cart id as a JSON string.
|
||||
// MarshalJSON encodes the id as a JSON string.
|
||||
func (id GrainId) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(id.String())
|
||||
return json.Marshal(uid.ID(id).String())
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
|
||||
// UnmarshalJSON decodes from a base62 JSON string.
|
||||
func (id *GrainId) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
@@ -40,7 +26,7 @@ func (id *GrainId) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
parsed, ok := ParseGrainId(s)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid cart id: %q", s)
|
||||
return fmt.Errorf("invalid grain id: %q", s)
|
||||
}
|
||||
*id = parsed
|
||||
return nil
|
||||
@@ -48,23 +34,11 @@ func (id *GrainId) UnmarshalJSON(data []byte) error {
|
||||
|
||||
// NewGrainId generates a new cryptographically random non-zero 64-bit id.
|
||||
func NewGrainId() (GrainId, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
id, err := uid.New()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("NewGrainId: %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 {
|
||||
// Extremely unlikely; regenerate once to avoid "0" identifier if desired.
|
||||
return NewGrainId()
|
||||
}
|
||||
return GrainId(u), nil
|
||||
return GrainId(id), nil
|
||||
}
|
||||
|
||||
// MustNewGrainId panics if generation fails.
|
||||
@@ -77,55 +51,16 @@ func MustNewGrainId() GrainId {
|
||||
}
|
||||
|
||||
// ParseGrainId parses a base62 string into a GrainId.
|
||||
// Returns (0,false) for invalid input.
|
||||
func ParseGrainId(s string) (GrainId, bool) {
|
||||
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
|
||||
// Provide a slightly looser upper bound (<=16) only if you anticipate future
|
||||
// extensions; here we stay strict.
|
||||
if len(s) == 0 || len(s) > 11 {
|
||||
return 0, false
|
||||
}
|
||||
u, ok := decodeBase62(s)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return GrainId(u), true
|
||||
id, ok := uid.Parse(s)
|
||||
return GrainId(id), ok
|
||||
}
|
||||
|
||||
// MustParseGrainId panics on invalid base62 input.
|
||||
func MustParseGrainId(s string) GrainId {
|
||||
id, ok := ParseGrainId(s)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("invalid cart id: %q", s))
|
||||
panic(fmt.Sprintf("invalid grain id: %q", s))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// encodeBase62 converts a uint64 to base62 (shortest form).
|
||||
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:])
|
||||
}
|
||||
|
||||
// decodeBase62 converts base62 text to uint64.
|
||||
func decodeBase62(s string) (uint64, bool) {
|
||||
var v uint64
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
d := base62Rev[c]
|
||||
if d == 0xFF {
|
||||
return 0, false
|
||||
}
|
||||
v = v*62 + uint64(d)
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
@@ -284,9 +284,14 @@ func NewControlServer[V any](config ServerConfig, pool GrainPool[V]) (*grpc.Serv
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
log.Printf("gRPC server listening as %s on %s", pool.Hostname(), config.Addr)
|
||||
// Serve runs in a goroutine because it blocks until the listener is closed.
|
||||
// On Serve error we log it instead of crashing the whole process from deep
|
||||
// inside this package — the caller (composition root) should rely on
|
||||
// process supervision (Kubernetes) to restart, or check gRPC health via the
|
||||
// pool after this returns.
|
||||
go func() {
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
log.Fatalf("failed to serve gRPC: %v", err)
|
||||
log.Printf("gRPC server %s serve error: %v", config.Addr, err)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
+58
-17
@@ -1,47 +1,88 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"git.k6n.net/mats/slask-finder/pkg/messaging"
|
||||
"git.k6n.net/mats/platform/event"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
// MutationExchange is the shared topic exchange carrying grain mutation streams.
|
||||
// Routing key is "mutation.<grain>" (e.g. mutation.cart), so a consumer binds
|
||||
// "mutation.#" for every grain type or "mutation.cart" for one. This is a
|
||||
// debug/observability feed (live cart/checkout/profile activity), NOT a
|
||||
// domain-event source — domain events use platform/event's vocabulary.
|
||||
const MutationExchange = "mutations"
|
||||
|
||||
// MutationType is the event.Type for a given grain's mutation stream, e.g.
|
||||
// MutationType("cart") == "mutation.cart".
|
||||
func MutationType(grain string) event.Type { return event.Type("mutation." + grain) }
|
||||
|
||||
type LogListener interface {
|
||||
AppendMutations(id uint64, msg ...ApplyResult)
|
||||
}
|
||||
|
||||
type AmqpListener struct {
|
||||
conn *amqp.Connection
|
||||
transformer func(id uint64, msg []ApplyResult) (any, error)
|
||||
}
|
||||
|
||||
func NewAmqpListener(conn *amqp.Connection, transformer func(id uint64, msg []ApplyResult) (any, error)) *AmqpListener {
|
||||
return &AmqpListener{
|
||||
conn: conn,
|
||||
transformer: transformer,
|
||||
// MutationSummary is the default feed transform: it reports the applied mutation
|
||||
// type names. The grain id and name travel in the Event's Subject/Source, so the
|
||||
// payload stays small and uniform across grains.
|
||||
func MutationSummary(_ uint64, results []ApplyResult) (any, error) {
|
||||
types := make([]string, 0, len(results))
|
||||
for _, r := range results {
|
||||
types = append(types, r.Type)
|
||||
}
|
||||
return map[string]any{"mutations": types}, nil
|
||||
}
|
||||
|
||||
// AmqpListener streams a grain pool's applied mutations to the MutationExchange
|
||||
// as platform/event.Event envelopes. grain names the aggregate ("cart",
|
||||
// "checkout", "profile", "order") and forms the routing key. transform shapes the
|
||||
// per-mutation payload (e.g. {cartId, mutations, ...}).
|
||||
type AmqpListener struct {
|
||||
conn *amqp.Connection
|
||||
grain string
|
||||
typ event.Type
|
||||
transform func(id uint64, msg []ApplyResult) (any, error)
|
||||
}
|
||||
|
||||
func NewAmqpListener(conn *amqp.Connection, grain string, transform func(id uint64, msg []ApplyResult) (any, error)) *AmqpListener {
|
||||
return &AmqpListener{conn: conn, grain: grain, typ: MutationType(grain), transform: transform}
|
||||
}
|
||||
|
||||
// DefineTopics declares the mutations topic exchange so publishes succeed even
|
||||
// before any consumer binds. Call once at startup.
|
||||
func (l *AmqpListener) DefineTopics() {
|
||||
ch, err := l.conn.Channel()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open a channel: %v", err)
|
||||
log.Printf("mutation feed (%s): open channel: %v", l.grain, err)
|
||||
return
|
||||
}
|
||||
defer ch.Close()
|
||||
if err := messaging.DefineTopic(ch, "cart", "mutation"); err != nil {
|
||||
log.Fatalf("Failed to declare topic mutation: %v", err)
|
||||
if err := ch.ExchangeDeclare(MutationExchange, "topic", true, false, false, false, nil); err != nil {
|
||||
log.Printf("mutation feed (%s): declare exchange: %v", l.grain, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *AmqpListener) AppendMutations(id uint64, msg ...ApplyResult) {
|
||||
data, err := l.transformer(id, msg)
|
||||
payload, err := l.transform(id, msg)
|
||||
if err != nil {
|
||||
log.Printf("Failed to transform mutation event: %v", err)
|
||||
log.Printf("mutation feed (%s): transform: %v", l.grain, err)
|
||||
return
|
||||
}
|
||||
err = messaging.SendChange(l.conn, "cart", "mutation", data)
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
log.Printf("Failed to send mutation event: %v", err)
|
||||
log.Printf("mutation feed (%s): marshal: %v", l.grain, err)
|
||||
return
|
||||
}
|
||||
ev := event.Event{
|
||||
Type: l.typ,
|
||||
Source: l.grain,
|
||||
Subject: strconv.FormatUint(id, 10),
|
||||
Payload: body,
|
||||
}
|
||||
if err := rabbit.PublishEvent(l.conn, MutationExchange, ev); err != nil {
|
||||
log.Printf("mutation feed (%s): publish: %v", l.grain, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,21 @@ type RegisteredMutation[V any, T proto.Message] struct {
|
||||
msgType reflect.Type
|
||||
}
|
||||
|
||||
// NewMutation registers a typed mutation handler for state V and message T.
|
||||
//
|
||||
// T MUST be a pointer to a generated proto message (e.g. *cart.AddLineRequest).
|
||||
// Passing a non-pointer (e.g. cart.AddLineRequest) is a developer error caught
|
||||
// at registration time — the type system on its own cannot tell a proto
|
||||
// message from a struct, so we surface the violation here. The convention is
|
||||
// the same as the broader Go MustX pattern (e.g. regexp.MustCompile): failures
|
||||
// here mean the caller is misusing the API at startup, so we panic rather than
|
||||
// silently storing an unusable handler.
|
||||
//
|
||||
// This is INTENTIONALLY not converted to a returned error: there is no
|
||||
// composition-root decision to make — the program is wrong and must not boot.
|
||||
// The composer-side registration code (see e.g. cart.NewCartMultationRegistry)
|
||||
// runs in package init / function init contexts where returning an error is
|
||||
// not possible.
|
||||
func NewMutation[V any, T proto.Message](handler func(*V, T) error) *RegisteredMutation[V, T] {
|
||||
// Derive the name and message type from a concrete instance produced by create().
|
||||
// This avoids relying on reflect.TypeFor (which can yield unexpected results in some toolchains)
|
||||
@@ -109,8 +124,7 @@ func NewMutation[V any, T proto.Message](handler func(*V, T) error) *RegisteredM
|
||||
if rt != nil && rt.Kind() == reflect.Pointer {
|
||||
return reflect.New(rt.Elem()).Interface().(proto.Message)
|
||||
}
|
||||
log.Fatalf("expected to create proto message got %+v", rt)
|
||||
return nil
|
||||
panic(fmt.Sprintf("NewMutation: T must be a pointer to a proto message, got %v", rt))
|
||||
}
|
||||
instance := create()
|
||||
rt := reflect.TypeOf(instance)
|
||||
|
||||
+30
-43
@@ -23,7 +23,7 @@ import (
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
"git.k6n.net/mats/slask-finder/pkg/messaging"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
@@ -159,13 +159,12 @@ func (a *App) updateInventory(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Start runs the WebSocket hub loop and, when conn is non-nil, the RabbitMQ
|
||||
// mutation consumer that broadcasts cart mutations to connected clients. The
|
||||
// background goroutines stop when ctx is cancelled.
|
||||
func (a *App) Start(ctx context.Context, conn *amqp.Connection) error {
|
||||
// consumer re-subscribes automatically on reconnect (conn is a managed
|
||||
// reconnecting connection). Background goroutines stop when ctx is cancelled.
|
||||
func (a *App) Start(ctx context.Context, conn *rabbit.Conn) error {
|
||||
go a.hub.Run()
|
||||
if conn != nil {
|
||||
if err := startMutationConsumer(ctx, conn, a.hub); err != nil {
|
||||
return err
|
||||
}
|
||||
startMutationConsumer(ctx, conn, a.hub)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -177,44 +176,32 @@ func (a *App) Shutdown(_ context.Context) error {
|
||||
}
|
||||
|
||||
// startMutationConsumer subscribes to the cart "mutation" topic and forwards
|
||||
// each message to the hub for broadcast over WebSocket. Best-effort: a full hub
|
||||
// queue drops the message rather than blocking.
|
||||
func startMutationConsumer(ctx context.Context, conn *amqp.Connection, hub *Hub) error {
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return err
|
||||
}
|
||||
msgs, err := messaging.DeclareBindAndConsume(ch, "cart", "mutation")
|
||||
if err != nil {
|
||||
_ = ch.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer ch.Close()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case m, ok := <-msgs:
|
||||
if !ok {
|
||||
log.Printf("mutation consumer: channel closed")
|
||||
return
|
||||
}
|
||||
log.Printf("mutation event: %s", string(m.Body))
|
||||
if hub != nil {
|
||||
select {
|
||||
case hub.broadcast <- m.Body:
|
||||
default:
|
||||
// hub queue full: drop to avoid blocking
|
||||
}
|
||||
}
|
||||
if err := m.Ack(false); err != nil {
|
||||
log.Printf("error acknowledging message: %v", err)
|
||||
// each message to the hub for broadcast over WebSocket. It (re)subscribes on the
|
||||
// initial connect and on every reconnect — via conn.NotifyOnReconnect — so a
|
||||
// broker blip doesn't silently kill the feed. Best-effort: a full hub queue
|
||||
// drops the message rather than blocking.
|
||||
func startMutationConsumer(ctx context.Context, conn *rabbit.Conn, hub *Hub) {
|
||||
conn.NotifyOnReconnect(func() {
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
log.Printf("mutation consumer: open channel: %v", err)
|
||||
return
|
||||
}
|
||||
// Subscribe to every grain's mutation stream (mutation.#) on the shared
|
||||
// "mutations" exchange. Bodies are platform/event.Event envelopes
|
||||
// (Source=grain, Subject=id, Payload=mutation summary) — forwarded raw to
|
||||
// WebSocket clients, which is exactly what a debug feed wants.
|
||||
if err := rabbit.ListenToPattern(ch, actor.MutationExchange, "mutation.#", func(d amqp.Delivery) error {
|
||||
if hub != nil {
|
||||
select {
|
||||
case hub.broadcast <- d.Body:
|
||||
default:
|
||||
// hub queue full: drop to avoid blocking
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
log.Printf("mutation consumer: subscribe: %v", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ func (c *CartGrain) UpdateTotals() {
|
||||
voucher.Applied = true
|
||||
continue
|
||||
}
|
||||
value := NewPriceFromIncVat(voucher.Value, 25)
|
||||
value := NewPriceFromIncVat(voucher.Value, 2500) // 25% in basis points
|
||||
if c.TotalPrice.IncVat <= value.IncVat {
|
||||
// don't apply discounts to more than the total price
|
||||
continue
|
||||
|
||||
+12
-78
@@ -1,11 +1,10 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/platform/uid"
|
||||
)
|
||||
|
||||
// cart_id.go
|
||||
@@ -36,30 +35,16 @@ import (
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CartId actor.GrainId
|
||||
// CartId is a 64-bit cart identifier with a compact base62 string form,
|
||||
// backed by the shared platform/uid package.
|
||||
type CartId uid.ID
|
||||
|
||||
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
// Reverse lookup (0xFF marks invalid)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the canonical base62 encoding of the 64-bit id.
|
||||
func (id CartId) String() string {
|
||||
return encodeBase62(uint64(id))
|
||||
}
|
||||
// String returns the canonical base62 encoding.
|
||||
func (id CartId) String() string { return uid.ID(id).String() }
|
||||
|
||||
// MarshalJSON encodes the cart id as a JSON string.
|
||||
func (id CartId) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(id.String())
|
||||
return json.Marshal(uid.ID(id).String())
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
|
||||
@@ -78,23 +63,11 @@ func (id *CartId) UnmarshalJSON(data []byte) error {
|
||||
|
||||
// NewCartId generates a new cryptographically random non-zero 64-bit id.
|
||||
func NewCartId() (CartId, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
id, err := uid.New()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("NewCartId: %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 {
|
||||
// Extremely unlikely; regenerate once to avoid "0" identifier if desired.
|
||||
return NewCartId()
|
||||
}
|
||||
return CartId(u), nil
|
||||
return CartId(id), nil
|
||||
}
|
||||
|
||||
// MustNewCartId panics if generation fails.
|
||||
@@ -107,19 +80,9 @@ func MustNewCartId() CartId {
|
||||
}
|
||||
|
||||
// ParseCartId parses a base62 string into a CartId.
|
||||
// Returns (0,false) for invalid input.
|
||||
func ParseCartId(s string) (CartId, bool) {
|
||||
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
|
||||
// Provide a slightly looser upper bound (<=16) only if you anticipate future
|
||||
// extensions; here we stay strict.
|
||||
if len(s) == 0 || len(s) > 11 {
|
||||
return 0, false
|
||||
}
|
||||
u, ok := decodeBase62(s)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return CartId(u), true
|
||||
id, ok := uid.Parse(s)
|
||||
return CartId(id), ok
|
||||
}
|
||||
|
||||
// MustParseCartId panics on invalid base62 input.
|
||||
@@ -130,32 +93,3 @@ func MustParseCartId(s string) CartId {
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// encodeBase62 converts a uint64 to base62 (shortest form).
|
||||
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:])
|
||||
}
|
||||
|
||||
// decodeBase62 converts base62 text to uint64.
|
||||
func decodeBase62(s string) (uint64, bool) {
|
||||
var v uint64
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
d := base62Rev[c]
|
||||
if d == 0xFF {
|
||||
return 0, false
|
||||
}
|
||||
v = v*62 + uint64(d)
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
+14
-15
@@ -4,6 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"git.k6n.net/mats/platform/uid"
|
||||
)
|
||||
|
||||
// TestNewCartIdUniqueness generates many ids and checks for collisions.
|
||||
@@ -96,26 +98,25 @@ func TestJSONMarshalUnmarshalCartId(t *testing.T) {
|
||||
|
||||
// TestBase62LengthBound checks worst-case length (near max uint64).
|
||||
func TestBase62LengthBound(t *testing.T) {
|
||||
// Largest uint64
|
||||
const maxU64 = ^uint64(0)
|
||||
s := encodeBase62(maxU64)
|
||||
s := uid.ID(maxU64).String()
|
||||
if len(s) > 11 {
|
||||
t.Fatalf("max uint64 encoded length > 11: %d (%s)", len(s), s)
|
||||
}
|
||||
dec, ok := decodeBase62(s)
|
||||
if !ok || dec != maxU64 {
|
||||
t.Fatalf("decode failed for max uint64: ok=%v dec=%d want=%d", ok, dec, maxU64)
|
||||
dec, ok := uid.Parse(s)
|
||||
if !ok || uint64(dec) != maxU64 {
|
||||
t.Fatalf("decode failed for max uint64: ok=%v dec=%d want=%d", ok, uint64(dec), maxU64)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZeroEncoding ensures zero value encodes to "0" and parses back.
|
||||
func TestZeroEncoding(t *testing.T) {
|
||||
if s := encodeBase62(0); s != "0" {
|
||||
t.Fatalf("encodeBase62(0) expected '0', got %q", s)
|
||||
if s := uid.ID(0).String(); s != "0" {
|
||||
t.Fatalf("uid.ID(0).String() expected '0', got %q", s)
|
||||
}
|
||||
v, ok := decodeBase62("0")
|
||||
v, ok := uid.Parse("0")
|
||||
if !ok || v != 0 {
|
||||
t.Fatalf("decodeBase62('0') failed: ok=%v v=%d", ok, v)
|
||||
t.Fatalf("uid.Parse('0') failed: ok=%v v=%d", ok, v)
|
||||
}
|
||||
if _, ok := ParseCartId("0"); !ok {
|
||||
t.Fatalf("ParseCartId(\"0\") should succeed")
|
||||
@@ -145,16 +146,14 @@ func BenchmarkNewCartId(b *testing.B) {
|
||||
|
||||
// BenchmarkEncodeBase62 measures encoding performance.
|
||||
func BenchmarkEncodeBase62(b *testing.B) {
|
||||
// Precompute sample values
|
||||
samples := make([]uint64, 1024)
|
||||
for i := range samples {
|
||||
// Spread bits without crypto randomness overhead
|
||||
samples[i] = (uint64(i) << 53) ^ (uint64(i) * 0x9E3779B185EBCA87)
|
||||
}
|
||||
b.ResetTimer()
|
||||
var sink string
|
||||
for i := 0; i < b.N; i++ {
|
||||
sink = encodeBase62(samples[i%len(samples)])
|
||||
sink = uid.ID(samples[i%len(samples)]).String()
|
||||
}
|
||||
_ = sink
|
||||
}
|
||||
@@ -163,16 +162,16 @@ func BenchmarkEncodeBase62(b *testing.B) {
|
||||
func BenchmarkDecodeBase62(b *testing.B) {
|
||||
encoded := make([]string, 1024)
|
||||
for i := range encoded {
|
||||
encoded[i] = encodeBase62((uint64(i) << 32) | uint64(i))
|
||||
encoded[i] = uid.ID((uint64(i) << 32) | uint64(i)).String()
|
||||
}
|
||||
b.ResetTimer()
|
||||
var sum uint64
|
||||
for i := 0; i < b.N; i++ {
|
||||
v, ok := decodeBase62(encoded[i%len(encoded)])
|
||||
v, ok := uid.Parse(encoded[i%len(encoded)])
|
||||
if !ok {
|
||||
b.Fatalf("decode failure for %s", encoded[i%len(encoded)])
|
||||
}
|
||||
sum ^= v
|
||||
sum ^= uint64(v)
|
||||
}
|
||||
_ = sum
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
}
|
||||
+11
-121
@@ -3,27 +3,25 @@
|
||||
// (list items, change quantities, remove items, clear carts, apply vouchers,
|
||||
// manage users, etc.).
|
||||
//
|
||||
// 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 map
|
||||
// 1:1 to cart grain read/apply operations; no restart is needed because every
|
||||
// tool call goes through the shared grain pool.
|
||||
// Transport (JSON-RPC 2.0 over streamable HTTP), dispatch, and the schema/result
|
||||
// helpers live in platform/mcp; this package only defines the cart-specific
|
||||
// tools and wires them to the grain pool. Mounted by cmd/cart 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/cart"
|
||||
pmcp "git.k6n.net/mats/platform/mcp"
|
||||
"net/http"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
serverName = "cart"
|
||||
serverVersion = "0.1.0"
|
||||
protocolVersion = "2025-06-18"
|
||||
serverName = "cart"
|
||||
serverVersion = "0.1.0"
|
||||
)
|
||||
|
||||
// Applier is the minimal grain-pool interface the cart MCP needs: read a
|
||||
@@ -37,123 +35,15 @@ type Applier interface {
|
||||
// Server is the MCP edge over the cart grain pool.
|
||||
type Server struct {
|
||||
applier Applier
|
||||
tools []tool
|
||||
mcp *pmcp.Server
|
||||
}
|
||||
|
||||
// New builds an MCP server exposing the cart grain as tools.
|
||||
func New(applier Applier) *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() }
|
||||
|
||||
+68
-200
@@ -4,107 +4,33 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
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 cart's MCP tools (transport/dispatch live in platform/mcp).
|
||||
func (s *Server) buildTools() []pmcp.Tool {
|
||||
return []pmcp.Tool{
|
||||
{
|
||||
Name: "get_cart",
|
||||
Description: "Get the full state of a cart by its base62 cart id: items, totals, vouchers, promotions, user info, currency, language, checkout status, subscription details, and all applied/pending promotions with progress nudges.",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
}, []string{"cartId"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
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 cart: %w", err)
|
||||
}
|
||||
@@ -114,21 +40,21 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
Name: "get_cart_items",
|
||||
Description: "List all items in a cart with their SKU, name, quantity, unit price, total price, stock, tax rate, markings, custom fields, and optional child/parent relationships.",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
}, []string{"cartId"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
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 cart: %w", err)
|
||||
}
|
||||
@@ -138,21 +64,21 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
Name: "get_cart_totals",
|
||||
Description: "Get price breakdown for a cart: total incVat, total exVat, VAT breakdown by rate, total discount (vouchers + promotions), and per-item totals.",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
}, []string{"cartId"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
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 cart: %w", err)
|
||||
}
|
||||
@@ -168,21 +94,21 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
Name: "get_cart_promotions",
|
||||
Description: "Get all applied and pending promotions on a cart, including discount amounts and progress nudges (e.g. 'spend 1200 SEK more for free shipping').",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
}, []string{"cartId"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
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 cart: %w", err)
|
||||
}
|
||||
@@ -195,21 +121,21 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
Name: "get_cart_vouchers",
|
||||
Description: "List all vouchers applied to a cart with their codes, values, rules, and whether they are currently active.",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
}, []string{"cartId"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
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 cart: %w", err)
|
||||
}
|
||||
@@ -219,25 +145,25 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
Name: "update_item_quantity",
|
||||
Description: "Change the quantity of a line item in a cart. Use quantity=0 to remove the item. Returns the updated cart state.",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
"itemId": integer("the line item id (numeric, e.g. 1, 2, 3)"),
|
||||
"quantity": integer("the new quantity (0 removes the item)"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
"itemId": pmcp.Integer("the line item id (numeric, e.g. 1, 2, 3)"),
|
||||
"quantity": pmcp.Integer("the new quantity (0 removes the item)"),
|
||||
}, []string{"cartId", "itemId", "quantity"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
ItemID int `json:"itemId"`
|
||||
Quantity int32 `json:"quantity"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.ChangeQuantity{
|
||||
res, err := s.applier.Apply(ctx, uint64(id), &messages.ChangeQuantity{
|
||||
Id: uint32(a.ItemID),
|
||||
Quantity: a.Quantity,
|
||||
})
|
||||
@@ -256,23 +182,23 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
Name: "remove_cart_item",
|
||||
Description: "Remove a line item (and its children) from a cart by item id. Returns the updated cart state.",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
"itemId": integer("the line item id to remove (numeric, e.g. 1, 2, 3)"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
"itemId": pmcp.Integer("the line item id to remove (numeric, e.g. 1, 2, 3)"),
|
||||
}, []string{"cartId", "itemId"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
ItemID int `json:"itemId"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.RemoveItem{Id: uint32(a.ItemID)})
|
||||
res, err := s.applier.Apply(ctx, uint64(id), &messages.RemoveItem{Id: uint32(a.ItemID)})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remove item: %w", err)
|
||||
}
|
||||
@@ -287,21 +213,21 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
Name: "clear_cart",
|
||||
Description: "Remove all items and vouchers from a cart, resetting it to an empty state. The cart id, user id, and currency are preserved. Returns the updated (empty) cart.",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
}, []string{"cartId"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.ClearCartRequest{})
|
||||
res, err := s.applier.Apply(ctx, uint64(id), &messages.ClearCartRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clear cart: %w", err)
|
||||
}
|
||||
@@ -316,14 +242,14 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
Name: "apply_voucher",
|
||||
Description: "Apply a voucher code to a cart. The voucher value is in öre (e.g. 10000 = 100 kr). Returns the updated cart with the voucher applied.",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
"code": str("the voucher code"),
|
||||
"value": integer("the voucher value in öre (e.g. 10000 = 100 kr)"),
|
||||
"description": str("optional description of the voucher"),
|
||||
"rules": stringArray("optional list of rule expressions for when the voucher applies"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
"code": pmcp.String("the voucher code"),
|
||||
"value": pmcp.Integer("the voucher value in öre (e.g. 10000 = 100 kr)"),
|
||||
"description": pmcp.String("optional description of the voucher"),
|
||||
"rules": pmcp.StringArray("optional list of rule expressions for when the voucher applies"),
|
||||
}, []string{"cartId", "code", "value"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
Code string `json:"code"`
|
||||
@@ -331,14 +257,14 @@ func (s *Server) buildTools() []tool {
|
||||
Description string `json:"description"`
|
||||
Rules []string `json:"rules"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.AddVoucher{
|
||||
res, err := s.applier.Apply(ctx, uint64(id), &messages.AddVoucher{
|
||||
Code: a.Code,
|
||||
Value: a.Value,
|
||||
Description: a.Description,
|
||||
@@ -358,23 +284,23 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
Name: "remove_voucher",
|
||||
Description: "Remove a voucher from a cart by its voucher id. Returns the updated cart.",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
"voucherId": integer("the voucher id to remove (numeric)"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
"voucherId": pmcp.Integer("the voucher id to remove (numeric)"),
|
||||
}, []string{"cartId", "voucherId"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
VoucherID int `json:"voucherId"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.RemoveVoucher{Id: uint32(a.VoucherID)})
|
||||
res, err := s.applier.Apply(ctx, uint64(id), &messages.RemoveVoucher{Id: uint32(a.VoucherID)})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remove voucher: %w", err)
|
||||
}
|
||||
@@ -389,23 +315,23 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
Name: "set_cart_user",
|
||||
Description: "Set or update the user ID on a cart, linking it to a customer account. Returns the updated cart.",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
"userId": str("the user/customer id"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
"userId": pmcp.String("the user/customer id"),
|
||||
}, []string{"cartId", "userId"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.SetUserId{UserId: a.UserID})
|
||||
res, err := s.applier.Apply(ctx, uint64(id), &messages.SetUserId{UserId: a.UserID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("set user: %w", err)
|
||||
}
|
||||
@@ -421,61 +347,3 @@ func (s *Server) buildTools() []tool {
|
||||
}
|
||||
|
||||
// ---- 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 stringArray(desc string) json.RawMessage {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"type": "array", "description": desc, "items": map[string]string{"type": "string"},
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -98,12 +98,14 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
|
||||
defer g.mu.Unlock()
|
||||
|
||||
g.lastItemId++
|
||||
taxRate := float32(25.0)
|
||||
// m.Tax is the rate in basis points (2500 = 25%, 1250 = 12.5%) — the single
|
||||
// platform scale; flows straight through, no conversion.
|
||||
rateBp := 2500
|
||||
if m.Tax > 0 {
|
||||
taxRate = float32(int(m.Tax) / 100)
|
||||
rateBp = int(m.Tax)
|
||||
}
|
||||
|
||||
pricePerItem := NewPriceFromIncVat(m.Price, taxRate)
|
||||
pricePerItem := NewPriceFromIncVat(m.Price, rateBp)
|
||||
|
||||
needsReservation := true
|
||||
if m.ReservationEndTime != nil {
|
||||
@@ -115,7 +117,7 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
|
||||
ItemId: uint32(m.ItemId),
|
||||
Quantity: uint16(m.Quantity),
|
||||
Sku: m.Sku,
|
||||
Tax: int(taxRate * 100),
|
||||
Tax: rateBp,
|
||||
Meta: &ItemMeta{
|
||||
Name: m.Name,
|
||||
Image: m.Image,
|
||||
@@ -139,7 +141,7 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
|
||||
Stock: uint16(m.Stock),
|
||||
Disclaimer: m.Disclaimer,
|
||||
|
||||
OrgPrice: getOrgPrice(m.OrgPrice, taxRate),
|
||||
OrgPrice: getOrgPrice(m.OrgPrice, rateBp),
|
||||
ArticleType: m.ArticleType,
|
||||
|
||||
StoreId: m.StoreId,
|
||||
@@ -167,9 +169,9 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func getOrgPrice(orgPrice int64, taxRate float32) *Price {
|
||||
func getOrgPrice(orgPrice int64, rateBp int) *Price {
|
||||
if orgPrice <= 0 {
|
||||
return nil
|
||||
}
|
||||
return NewPriceFromIncVat(orgPrice, taxRate)
|
||||
return NewPriceFromIncVat(orgPrice, rateBp)
|
||||
}
|
||||
|
||||
+18
-151
@@ -1,159 +1,26 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
"git.k6n.net/mats/platform/tax"
|
||||
)
|
||||
|
||||
func GetTaxAmount(total int64, tax int) int64 {
|
||||
taxD := 10000 / float64(tax)
|
||||
return int64(float64(total) / float64((1 + taxD)))
|
||||
// Price represents a monetary amount with optional VAT breakdown by rate.
|
||||
// The canonical definition is now in platform/tax; this is a type alias
|
||||
// for backward compatibility.
|
||||
type Price = tax.Price
|
||||
|
||||
// NewPrice returns a new zero Price.
|
||||
func NewPrice() *Price { return tax.NewPrice() }
|
||||
|
||||
// NewPriceFromIncVat creates a Price from an inc-VAT total and a tax rate in
|
||||
// basis points (2500 = 25%, 1250 = 12.5%). Re-exported from platform/tax.
|
||||
func NewPriceFromIncVat(incVat int64, rateBp int) *Price {
|
||||
return tax.NewPriceFromIncVat(money.Cents(incVat), rateBp)
|
||||
}
|
||||
|
||||
type Price struct {
|
||||
IncVat int64 `json:"incVat"`
|
||||
VatRates map[float32]int64 `json:"vat,omitempty"`
|
||||
}
|
||||
// MultiplyPrice multiplies a Price by quantity and returns a new Price.
|
||||
func MultiplyPrice(p Price, qty int64) *Price { return tax.MultiplyPrice(p, qty) }
|
||||
|
||||
func NewPrice() *Price {
|
||||
return &Price{
|
||||
IncVat: 0,
|
||||
VatRates: make(map[float32]int64),
|
||||
}
|
||||
}
|
||||
|
||||
func NewPriceFromIncVat(incVat int64, taxRate float32) *Price {
|
||||
tax := GetTaxAmount(incVat, int(taxRate*100))
|
||||
return &Price{
|
||||
IncVat: incVat,
|
||||
VatRates: map[float32]int64{
|
||||
taxRate: tax,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Price) ValueExVat() int64 {
|
||||
exVat := p.IncVat
|
||||
for _, amount := range p.VatRates {
|
||||
exVat -= amount
|
||||
}
|
||||
return exVat
|
||||
}
|
||||
|
||||
func (p *Price) TotalVat() int64 {
|
||||
total := int64(0)
|
||||
for _, amount := range p.VatRates {
|
||||
total += amount
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func MultiplyPrice(p Price, qty int64) *Price {
|
||||
ret := &Price{
|
||||
IncVat: p.IncVat * qty,
|
||||
VatRates: make(map[float32]int64),
|
||||
}
|
||||
for rate, amount := range p.VatRates {
|
||||
ret.VatRates[rate] = amount * qty
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (p *Price) Multiply(qty int64) {
|
||||
p.IncVat *= qty
|
||||
for rate, amount := range p.VatRates {
|
||||
p.VatRates[rate] = amount * qty
|
||||
}
|
||||
}
|
||||
|
||||
func (p Price) MarshalJSON() ([]byte, error) {
|
||||
// Build a stable wire format without calling Price.MarshalJSON recursively
|
||||
exVat := p.ValueExVat()
|
||||
var vat map[string]int64
|
||||
if len(p.VatRates) > 0 {
|
||||
vat = make(map[string]int64, len(p.VatRates))
|
||||
for rate, amount := range p.VatRates {
|
||||
// Rely on default formatting that trims trailing zeros for whole numbers
|
||||
// Using %g could output scientific notation for large numbers; float32 rates here are small.
|
||||
key := trimFloat(rate)
|
||||
vat[key] = amount
|
||||
}
|
||||
}
|
||||
type wire struct {
|
||||
ExVat int64 `json:"exVat"`
|
||||
IncVat int64 `json:"incVat"`
|
||||
Vat map[string]int64 `json:"vat,omitempty"`
|
||||
}
|
||||
return json.Marshal(wire{ExVat: exVat, IncVat: p.IncVat, Vat: vat})
|
||||
}
|
||||
|
||||
func (p *Price) UnmarshalJSON(data []byte) error {
|
||||
type wire struct {
|
||||
ExVat int64 `json:"exVat"`
|
||||
IncVat int64 `json:"incVat"`
|
||||
Vat map[string]int64 `json:"vat,omitempty"`
|
||||
}
|
||||
var w wire
|
||||
if err := json.Unmarshal(data, &w); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.IncVat = w.IncVat
|
||||
if len(w.Vat) > 0 {
|
||||
p.VatRates = make(map[float32]int64, len(w.Vat))
|
||||
for rateStr, amount := range w.Vat {
|
||||
rate, err := strconv.ParseFloat(rateStr, 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.VatRates[float32(rate)] = amount
|
||||
}
|
||||
} else {
|
||||
p.VatRates = make(map[float32]int64)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// trimFloat converts a float32 tax rate like 25 or 12.5 into a compact string without
|
||||
// unnecessary decimals ("25", "12.5").
|
||||
func trimFloat(f float32) string {
|
||||
// Convert via FormatFloat then trim trailing zeros and dot.
|
||||
s := strconv.FormatFloat(float64(f), 'f', -1, 32)
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *Price) Add(price Price) {
|
||||
p.IncVat += price.IncVat
|
||||
for rate, amount := range price.VatRates {
|
||||
p.VatRates[rate] += amount
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Price) Subtract(price Price) {
|
||||
p.IncVat -= price.IncVat
|
||||
for rate, amount := range price.VatRates {
|
||||
p.VatRates[rate] -= amount
|
||||
}
|
||||
}
|
||||
|
||||
func SumPrices(prices ...Price) *Price {
|
||||
if len(prices) == 0 {
|
||||
return NewPrice()
|
||||
}
|
||||
|
||||
aggregated := NewPrice()
|
||||
|
||||
for _, price := range prices {
|
||||
aggregated.IncVat += price.IncVat
|
||||
for rate, amount := range price.VatRates {
|
||||
aggregated.VatRates[rate] += amount
|
||||
}
|
||||
}
|
||||
|
||||
if len(aggregated.VatRates) == 0 {
|
||||
aggregated.VatRates = nil
|
||||
}
|
||||
|
||||
return aggregated
|
||||
}
|
||||
// SumPrices aggregates multiple Prices into one.
|
||||
func SumPrices(prices ...Price) *Price { return tax.SumPrices(prices...) }
|
||||
|
||||
+51
-46
@@ -3,10 +3,12 @@ package cart
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
func TestPriceMarshalJSON(t *testing.T) {
|
||||
p := Price{IncVat: 13700, VatRates: map[float32]int64{25: 2500, 12: 1200}}
|
||||
p := Price{IncVat: 13700, VatRates: map[int]money.Cents{2500: 2500, 1200: 1200}}
|
||||
// ExVat = 13700 - (2500+1200) = 10000
|
||||
data, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
@@ -27,29 +29,29 @@ func TestPriceMarshalJSON(t *testing.T) {
|
||||
if out.IncVat != 13700 {
|
||||
t.Fatalf("expected incVat 13700 got %d", out.IncVat)
|
||||
}
|
||||
if out.Vat["25"] != 2500 || out.Vat["12"] != 1200 {
|
||||
if out.Vat["2500"] != 2500 || out.Vat["1200"] != 1200 {
|
||||
t.Fatalf("unexpected vat map: %#v", out.Vat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPriceFromIncVat(t *testing.T) {
|
||||
p := NewPriceFromIncVat(1250, 25)
|
||||
p := NewPriceFromIncVat(1250, 2500) // 25% in basis points
|
||||
if p.IncVat != 1250 {
|
||||
t.Fatalf("expected IncVat %d got %d", 1250, p.IncVat)
|
||||
}
|
||||
if p.VatRates[25] != 250 {
|
||||
t.Fatalf("expected VAT 25 rate %d got %d", 250, p.VatRates[25])
|
||||
if p.VatRates[2500] != 250 {
|
||||
t.Fatalf("expected VAT 2500bp rate %d got %d", 250, p.VatRates[2500])
|
||||
}
|
||||
if p.ValueExVat() != 1000 {
|
||||
t.Fatalf("expected exVat %d got %d", 750, p.ValueExVat())
|
||||
t.Fatalf("expected exVat %d got %d", 1000, p.ValueExVat())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumPrices(t *testing.T) {
|
||||
// We'll construct prices via raw struct since constructor expects tax math.
|
||||
// IncVat already includes vat portions.
|
||||
a := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}} // ex=1000
|
||||
b := Price{IncVat: 2740, VatRates: map[float32]int64{25: 500, 12: 240}} // ex=2000
|
||||
// IncVat already includes vat portions. Keys are basis points (2500 = 25%).
|
||||
a := Price{IncVat: 1250, VatRates: map[int]money.Cents{2500: 250}} // ex=1000
|
||||
b := Price{IncVat: 2740, VatRates: map[int]money.Cents{2500: 500, 1200: 240}} // ex=2000
|
||||
c := Price{IncVat: 0, VatRates: nil}
|
||||
|
||||
sum := SumPrices(a, b, c)
|
||||
@@ -60,11 +62,11 @@ func TestSumPrices(t *testing.T) {
|
||||
if len(sum.VatRates) != 2 {
|
||||
t.Fatalf("expected 2 vat rates got %d", len(sum.VatRates))
|
||||
}
|
||||
if sum.VatRates[25] != 750 {
|
||||
t.Fatalf("expected 25%% vat 750 got %d", sum.VatRates[25])
|
||||
if sum.VatRates[2500] != 750 {
|
||||
t.Fatalf("expected 25%% vat 750 got %d", sum.VatRates[2500])
|
||||
}
|
||||
if sum.VatRates[12] != 240 {
|
||||
t.Fatalf("expected 12%% vat 240 got %d", sum.VatRates[12])
|
||||
if sum.VatRates[1200] != 240 {
|
||||
t.Fatalf("expected 12%% vat 240 got %d", sum.VatRates[1200])
|
||||
}
|
||||
if sum.ValueExVat() != 3000 { // 3990 - (750+240)
|
||||
t.Fatalf("expected exVat 3000 got %d", sum.ValueExVat())
|
||||
@@ -73,19 +75,21 @@ func TestSumPrices(t *testing.T) {
|
||||
|
||||
func TestSumPricesEmpty(t *testing.T) {
|
||||
sum := SumPrices()
|
||||
if sum.IncVat != 0 || sum.VatRates == nil { // constructor sets empty map
|
||||
// SumPrices nils an empty VatRates map (cleaner JSON); a nil map still reads
|
||||
// as zero, so only the totals need to be zero here.
|
||||
if sum.IncVat != 0 || sum.TotalVat() != 0 {
|
||||
t.Fatalf("expected zero price got %#v", sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiplyPriceFunction(t *testing.T) {
|
||||
base := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
|
||||
base := Price{IncVat: 1250, VatRates: map[int]money.Cents{2500: 250}}
|
||||
multiplied := MultiplyPrice(base, 3)
|
||||
if multiplied.IncVat != 1250*3 {
|
||||
t.Fatalf("expected IncVat %d got %d", 1250*3, multiplied.IncVat)
|
||||
}
|
||||
if multiplied.VatRates[25] != 250*3 {
|
||||
t.Fatalf("expected VAT 25 rate %d got %d", 250*3, multiplied.VatRates[25])
|
||||
if multiplied.VatRates[2500] != 250*3 {
|
||||
t.Fatalf("expected VAT 2500bp rate %d got %d", 250*3, multiplied.VatRates[2500])
|
||||
}
|
||||
if multiplied.ValueExVat() != (1250-250)*3 {
|
||||
t.Fatalf("expected exVat %d got %d", (1250-250)*3, multiplied.ValueExVat())
|
||||
@@ -93,8 +97,8 @@ func TestMultiplyPriceFunction(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPriceAddSubtract(t *testing.T) {
|
||||
a := Price{IncVat: 1000, VatRates: map[float32]int64{25: 200}}
|
||||
b := Price{IncVat: 500, VatRates: map[float32]int64{25: 100, 12: 54}}
|
||||
a := Price{IncVat: 1000, VatRates: map[int]money.Cents{2500: 200}}
|
||||
b := Price{IncVat: 500, VatRates: map[int]money.Cents{2500: 100, 1200: 54}}
|
||||
|
||||
acc := NewPrice()
|
||||
acc.Add(a)
|
||||
@@ -103,7 +107,7 @@ func TestPriceAddSubtract(t *testing.T) {
|
||||
if acc.IncVat != 1500 {
|
||||
t.Fatalf("expected IncVat 1500 got %d", acc.IncVat)
|
||||
}
|
||||
if acc.VatRates[25] != 300 || acc.VatRates[12] != 54 {
|
||||
if acc.VatRates[2500] != 300 || acc.VatRates[1200] != 54 {
|
||||
t.Fatalf("unexpected VAT map: %#v", acc.VatRates)
|
||||
}
|
||||
|
||||
@@ -113,46 +117,47 @@ func TestPriceAddSubtract(t *testing.T) {
|
||||
if acc.IncVat != 0 {
|
||||
t.Fatalf("expected IncVat 0 got %d", acc.IncVat)
|
||||
}
|
||||
if len(acc.VatRates) != 2 || acc.VatRates[25] != 0 || acc.VatRates[12] != 0 {
|
||||
if len(acc.VatRates) != 2 || acc.VatRates[2500] != 0 || acc.VatRates[1200] != 0 {
|
||||
t.Fatalf("expected zeroed vat rates got %#v", acc.VatRates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceMultiplyMethod(t *testing.T) {
|
||||
p := Price{IncVat: 2000, VatRates: map[float32]int64{25: 400}}
|
||||
p := Price{IncVat: 2000, VatRates: map[int]money.Cents{2500: 400}}
|
||||
// Value before multiply
|
||||
exBefore := p.ValueExVat()
|
||||
p.Multiply(2)
|
||||
if p.IncVat != 4000 {
|
||||
t.Fatalf("expected IncVat 4000 got %d", p.IncVat)
|
||||
}
|
||||
if p.VatRates[25] != 800 {
|
||||
t.Fatalf("expected VAT 800 got %d", p.VatRates[25])
|
||||
if p.VatRates[2500] != 800 {
|
||||
t.Fatalf("expected VAT 800 got %d", p.VatRates[2500])
|
||||
}
|
||||
if p.ValueExVat() != exBefore*2 {
|
||||
t.Fatalf("expected exVat %d got %d", exBefore*2, p.ValueExVat())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTaxAmount(t *testing.T) {
|
||||
func TestComputeTax(t *testing.T) {
|
||||
tests := []struct {
|
||||
total int64
|
||||
tax int
|
||||
rateBp int
|
||||
expected int64
|
||||
desc string
|
||||
}{
|
||||
{1250, 2500, 250, "25% VAT"}, // 1250 / (1 + 100/25) = 1250 / 5 = 250
|
||||
{1000, 2000, 166, "20% VAT"}, // 1000 / (1 + 100/20) = 1000 / 6 ≈ 166
|
||||
// ex-VAT-first integer math: tax = total - total*10000/(10000+rateBp).
|
||||
{1250, 2500, 250, "25% VAT"}, // exVat 1000, tax 250
|
||||
{1000, 2000, 167, "20% VAT"}, // exVat 1000*10000/12000=833, tax 167 (Klarna convention)
|
||||
{1200, 2500, 240, "25% VAT on 1200"},
|
||||
{0, 2500, 0, "zero total"},
|
||||
{100, 1000, 9, "10% VAT"}, // tax=1000 for 10%, 100 / (1 + 100/10) = 100 / 11 ≈ 9
|
||||
{100, 10000, 50, "100% VAT"}, // tax=10000 for 100%, 100 / (1 + 100/100) = 100 / 2 = 50
|
||||
{100, 1000, 10, "10% VAT"}, // exVat 100*10000/11000=90, tax 10
|
||||
{100, 10000, 50, "100% VAT"}, // exVat 50, tax 50
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := GetTaxAmount(tt.total, tt.tax)
|
||||
result := ComputeTax(money.Cents(tt.total), tt.rateBp).Int64()
|
||||
if result != tt.expected {
|
||||
t.Errorf("GetTaxAmount(%d, %d) [%s] = %d; expected %d", tt.total, tt.tax, tt.desc, result, tt.expected)
|
||||
t.Errorf("ComputeTax(%d, %d) [%s] = %d; expected %d", tt.total, tt.rateBp, tt.desc, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,11 +175,11 @@ func TestNewPriceFromIncVatEdgeCases(t *testing.T) {
|
||||
t.Errorf("expected exVat 1000, got %d", p.ValueExVat())
|
||||
}
|
||||
|
||||
// High VAT rate, e.g., 50%
|
||||
p = NewPriceFromIncVat(1500, 50)
|
||||
expectedVat := int64(1500 / (1 + 100/50)) // 1500 / 3 = 500
|
||||
if p.VatRates[50] != expectedVat {
|
||||
t.Errorf("expected VAT %d for 50%%, got %d", expectedVat, p.VatRates[50])
|
||||
// High VAT rate, e.g., 50% = 5000 basis points
|
||||
p = NewPriceFromIncVat(1500, 5000)
|
||||
expectedVat := money.Cents(500) // exVat 1500*10000/15000=1000, tax 500
|
||||
if p.VatRates[5000] != expectedVat {
|
||||
t.Errorf("expected VAT %d for 50%%, got %d", expectedVat, p.VatRates[5000])
|
||||
}
|
||||
if p.ValueExVat() != 1500-expectedVat {
|
||||
t.Errorf("expected exVat %d, got %d", 1500-expectedVat, p.ValueExVat())
|
||||
@@ -182,7 +187,7 @@ func TestNewPriceFromIncVatEdgeCases(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPriceValueExVatAndTotalVat(t *testing.T) {
|
||||
p := Price{IncVat: 13700, VatRates: map[float32]int64{25: 2500, 12: 1200}}
|
||||
p := Price{IncVat: 13700, VatRates: map[int]money.Cents{2500: 2500, 1200: 1200}}
|
||||
exVat := p.ValueExVat()
|
||||
totalVat := p.TotalVat()
|
||||
if exVat != 10000 {
|
||||
@@ -206,33 +211,33 @@ func TestPriceValueExVatAndTotalVat(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMultiplyPriceWithZeroQty(t *testing.T) {
|
||||
base := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
|
||||
base := Price{IncVat: 1250, VatRates: map[int]money.Cents{2500: 250}}
|
||||
multiplied := MultiplyPrice(base, 0)
|
||||
if multiplied.IncVat != 0 {
|
||||
t.Errorf("expected IncVat 0, got %d", multiplied.IncVat)
|
||||
}
|
||||
if len(multiplied.VatRates) != 1 || multiplied.VatRates[25] != 0 {
|
||||
if len(multiplied.VatRates) != 1 || multiplied.VatRates[2500] != 0 {
|
||||
t.Errorf("expected VAT 0, got %v", multiplied.VatRates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceAddSubtractEdgeCases(t *testing.T) {
|
||||
a := Price{IncVat: 1000, VatRates: map[float32]int64{25: 200}}
|
||||
b := Price{IncVat: 500, VatRates: map[float32]int64{12: 54}} // Different rate
|
||||
a := Price{IncVat: 1000, VatRates: map[int]money.Cents{2500: 200}}
|
||||
b := Price{IncVat: 500, VatRates: map[int]money.Cents{1200: 54}} // Different rate
|
||||
|
||||
acc := NewPrice()
|
||||
acc.Add(a)
|
||||
acc.Add(b)
|
||||
|
||||
if acc.VatRates[25] != 200 || acc.VatRates[12] != 54 {
|
||||
t.Errorf("expected VAT 25:200, 12:54, got %v", acc.VatRates)
|
||||
if acc.VatRates[2500] != 200 || acc.VatRates[1200] != 54 {
|
||||
t.Errorf("expected VAT 2500:200, 1200:54, got %v", acc.VatRates)
|
||||
}
|
||||
|
||||
// Subtract more than added (negative VAT)
|
||||
acc.Subtract(a)
|
||||
acc.Subtract(b)
|
||||
acc.Subtract(a) // Subtract extra a
|
||||
if acc.VatRates[25] != -200 || acc.VatRates[12] != 0 {
|
||||
t.Errorf("expected negative VAT for 25 after over-subtract, got %v", acc.VatRates)
|
||||
if acc.VatRates[2500] != -200 || acc.VatRates[1200] != 0 {
|
||||
t.Errorf("expected negative VAT for 2500 after over-subtract, got %v", acc.VatRates)
|
||||
}
|
||||
}
|
||||
|
||||
+17
-51
@@ -1,58 +1,24 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"git.k6n.net/mats/platform/money"
|
||||
"git.k6n.net/mats/platform/tax"
|
||||
)
|
||||
|
||||
// TaxProvider computes taxes for orders and line items.
|
||||
// It mirrors the PaymentProvider and ShippingProvider seam patterns: one
|
||||
// interface, interchangeable implementations.
|
||||
//
|
||||
// All tax rates are expressed as raw percent (e.g. 25 = 25 %). This matches the
|
||||
// OrderLine.tax_rate proto convention.
|
||||
type TaxProvider interface {
|
||||
// Name returns the provider name for identification (logging, metrics).
|
||||
Name() string
|
||||
// The canonical definition is now in platform/tax; this is a type alias
|
||||
// for backward compatibility.
|
||||
type TaxProvider = tax.Provider
|
||||
|
||||
// ComputeTax computes the tax portion from a gross (inc-VAT) total and tax
|
||||
// rate. Both total and return value are in minor currency units (ore).
|
||||
// taxRate is expressed as raw percent (e.g. 25 = 25 %).
|
||||
//
|
||||
// Formula: tax = total x rate / (100 + rate)
|
||||
//
|
||||
// When taxRate <= 0 the result is 0 (untaxed / zero-rated items).
|
||||
ComputeTax(total int64, taxRate int) int64
|
||||
|
||||
// DefaultTaxRate returns the default VAT rate for a country, expressed as
|
||||
// raw percent (e.g. 25 = 25 %). Used for deliveries and items where no
|
||||
// explicit rate was recorded at add-to-cart time.
|
||||
//
|
||||
// Return 0 if the country is unknown or untaxed.
|
||||
DefaultTaxRate(country string) int
|
||||
// ComputeTax returns the VAT portion of an inc-VAT total. rateBp is basis points
|
||||
// (2500 = 25%). Re-exported from platform/tax.
|
||||
func ComputeTax(total money.Cents, rateBp int) money.Cents {
|
||||
return money.Cents(tax.Compute(total.Int64(), rateBp))
|
||||
}
|
||||
|
||||
// ComputeTax is the shared tax formula: tax = total x rate / (100 + rate).
|
||||
// taxRate is raw percent (e.g. 25 = 25 %). When taxRate <= 0 the result is 0.
|
||||
//
|
||||
// This differs from GetTaxAmount which uses the CartItem convention
|
||||
// (percent x 100, e.g. 2500 = 25.00 %) with formula total x rate / (10000 + rate).
|
||||
func ComputeTax(total int64, taxRate int) int64 {
|
||||
if taxRate <= 0 {
|
||||
return 0
|
||||
}
|
||||
return total * int64(taxRate) / int64(100+taxRate)
|
||||
}
|
||||
// StaticTaxProvider is a stateless, always-available TaxProvider.
|
||||
// Re-exported from platform/tax for backward compatibility.
|
||||
type StaticTaxProvider = tax.Static
|
||||
|
||||
// StaticTaxProvider is a stateless, always-available TaxProvider that simply
|
||||
// runs the formula without any country-specific rates. Use for local dev,
|
||||
// tests, and as a no-dependency default.
|
||||
type StaticTaxProvider struct{}
|
||||
|
||||
var _ TaxProvider = (*StaticTaxProvider)(nil)
|
||||
|
||||
func NewStaticTaxProvider() *StaticTaxProvider { return &StaticTaxProvider{} }
|
||||
|
||||
func (p *StaticTaxProvider) Name() string { return "static" }
|
||||
|
||||
func (p *StaticTaxProvider) ComputeTax(total int64, taxRate int) int64 {
|
||||
return ComputeTax(total, taxRate)
|
||||
}
|
||||
|
||||
// DefaultTaxRate returns 0 for any country (no awareness).
|
||||
func (p *StaticTaxProvider) DefaultTaxRate(_ string) int { return 0 }
|
||||
// NewStaticTaxProvider returns a new StaticTaxProvider.
|
||||
func NewStaticTaxProvider() *StaticTaxProvider { return tax.NewStatic() }
|
||||
|
||||
+8
-50
@@ -1,55 +1,13 @@
|
||||
package cart
|
||||
|
||||
// NordicTaxProvider implements TaxProvider for the Nordic countries
|
||||
// (SE, NO, DK, FI). It encodes the standard statutory VAT rates:
|
||||
//
|
||||
// SE: 25 % (standard), 12 % (food, hotels), 6 % (books, transport)
|
||||
// NO: 25 % (standard), 15 % (food), 12 %
|
||||
// DK: 25 % (standard)
|
||||
// FI: 25 % (standard; actual rate is 25.5 % as of 2026)
|
||||
//
|
||||
// All rates are raw percent (25 = 25 %). Fractional rates (FI 25.5) are
|
||||
// truncated to the nearest integer per the raw-percent convention.
|
||||
//
|
||||
// NordicTaxProvider does not make network calls — all logic is local math.
|
||||
// An external provider (Avalara, TaxJar) can be added by implementing
|
||||
// TaxProvider and switching on TAX_PROVIDER.
|
||||
type NordicTaxProvider struct {
|
||||
defaultCountry string
|
||||
}
|
||||
import "git.k6n.net/mats/platform/tax"
|
||||
|
||||
// NewNordicTaxProvider returns a NordicTaxProvider. defaultCountry is used
|
||||
// when DefaultTaxRate is called with an empty country code; it defaults to
|
||||
// "SE" if empty.
|
||||
// NordicTaxProvider implements TaxProvider for the Nordic countries.
|
||||
// The canonical definition is now in platform/tax; this is a type alias
|
||||
// for backward compatibility.
|
||||
type NordicTaxProvider = tax.Nordic
|
||||
|
||||
// NewNordicTaxProvider returns a new NordicTaxProvider.
|
||||
func NewNordicTaxProvider(defaultCountry string) *NordicTaxProvider {
|
||||
if defaultCountry == "" {
|
||||
defaultCountry = "SE"
|
||||
}
|
||||
return &NordicTaxProvider{defaultCountry: defaultCountry}
|
||||
}
|
||||
|
||||
var _ TaxProvider = (*NordicTaxProvider)(nil)
|
||||
|
||||
func (p *NordicTaxProvider) Name() string { return "nordic" }
|
||||
|
||||
func (p *NordicTaxProvider) ComputeTax(total int64, taxRate int) int64 {
|
||||
return ComputeTax(total, taxRate)
|
||||
}
|
||||
|
||||
// nordicStandardRates maps country code → default standard VAT rate (raw percent).
|
||||
var nordicStandardRates = map[string]int{
|
||||
"SE": 25,
|
||||
"NO": 25,
|
||||
"DK": 25,
|
||||
"FI": 25,
|
||||
}
|
||||
|
||||
func (p *NordicTaxProvider) DefaultTaxRate(country string) int {
|
||||
if country == "" {
|
||||
country = p.defaultCountry
|
||||
}
|
||||
if rate, ok := nordicStandardRates[country]; ok {
|
||||
return rate
|
||||
}
|
||||
return nordicStandardRates[p.defaultCountry]
|
||||
return tax.NewNordic(defaultCountry)
|
||||
}
|
||||
|
||||
+24
-42
@@ -11,28 +11,29 @@ func TestNordicTaxProvider_Name(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNordicTaxProvider_ComputeTax(t *testing.T) {
|
||||
func TestNordicTaxProvider_Compute(t *testing.T) {
|
||||
// taxRate is basis points (2500 = 25%).
|
||||
tests := []struct {
|
||||
total int64
|
||||
taxRate int
|
||||
want int64
|
||||
desc string
|
||||
}{
|
||||
{0, 25, 0, "zero total"},
|
||||
{1250, 25, 250, "25 % VAT on 1250"},
|
||||
{1000, 20, 166, "20 % VAT on 1000"},
|
||||
{1200, 25, 240, "25 % VAT on 1200"},
|
||||
{25000, 25, 5000, "25 % VAT on 25000"},
|
||||
{100, 10, 9, "10 % VAT on 100"},
|
||||
{100, 100, 50, "100 % VAT on 100"},
|
||||
{0, 2500, 0, "zero total"},
|
||||
{1250, 2500, 250, "25 % VAT on 1250"},
|
||||
{1000, 2000, 167, "20 % VAT on 1000"},
|
||||
{1200, 2500, 240, "25 % VAT on 1200"},
|
||||
{25000, 2500, 5000, "25 % VAT on 25000"},
|
||||
{100, 1000, 10, "10 % VAT on 100"},
|
||||
{100, 10000, 50, "100 % VAT on 100"},
|
||||
{100, 0, 0, "zero-rate"},
|
||||
{100, -1, 0, "negative rate -> zero"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
p := NewNordicTaxProvider("SE")
|
||||
got := p.ComputeTax(tt.total, tt.taxRate)
|
||||
got := p.Compute(tt.total, tt.taxRate)
|
||||
if got != tt.want {
|
||||
t.Errorf("ComputeTax(%d, %d) [%s] = %d; want %d", tt.total, tt.taxRate, tt.desc, got, tt.want)
|
||||
t.Errorf("Compute(%d, %d) [%s] = %d; want %d", tt.total, tt.taxRate, tt.desc, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,12 +44,12 @@ func TestNordicTaxProvider_DefaultTaxRate(t *testing.T) {
|
||||
want int
|
||||
desc string
|
||||
}{
|
||||
{"SE", 25, "Sweden"},
|
||||
{"NO", 25, "Norway"},
|
||||
{"DK", 25, "Denmark"},
|
||||
{"FI", 25, "Finland"},
|
||||
{"", 25, "empty -> default SE"},
|
||||
{"DE", 25, "unknown -> fallback SE"},
|
||||
{"SE", 2500, "Sweden"},
|
||||
{"NO", 2500, "Norway"},
|
||||
{"DK", 2500, "Denmark"},
|
||||
{"FI", 2550, "Finland (25.5%)"},
|
||||
{"", 2500, "empty -> default SE"},
|
||||
{"DE", 2500, "unknown -> fallback SE"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
p := NewNordicTaxProvider("SE")
|
||||
@@ -59,22 +60,22 @@ func TestNordicTaxProvider_DefaultTaxRate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticTaxProvider_ComputeTax(t *testing.T) {
|
||||
func TestStaticTaxProvider_Compute(t *testing.T) {
|
||||
p := NewStaticTaxProvider()
|
||||
tests := []struct {
|
||||
total int64
|
||||
taxRate int
|
||||
want int64
|
||||
}{
|
||||
{1250, 25, 250},
|
||||
{25000, 25, 5000},
|
||||
{1000, 20, 166},
|
||||
{0, 25, 0},
|
||||
{1250, 2500, 250},
|
||||
{25000, 2500, 5000},
|
||||
{1000, 2000, 167},
|
||||
{0, 2500, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := p.ComputeTax(tt.total, tt.taxRate)
|
||||
got := p.Compute(tt.total, tt.taxRate)
|
||||
if got != tt.want {
|
||||
t.Errorf("ComputeTax(%d, %d) = %d; want %d", tt.total, tt.taxRate, got, tt.want)
|
||||
t.Errorf("Compute(%d, %d) = %d; want %d", tt.total, tt.taxRate, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,25 +87,6 @@ func TestStaticTaxProvider_DefaultTaxRate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeTax(t *testing.T) {
|
||||
tests := []struct {
|
||||
total int64
|
||||
taxRate int
|
||||
want int64
|
||||
desc string
|
||||
}{
|
||||
{1250, 25, 250, "canonical: 25 %"},
|
||||
{25000, 25, 5000, "25k with 25 %% -> 5k tax"},
|
||||
{0, 25, 0, "zero total"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := ComputeTax(tt.total, tt.taxRate)
|
||||
if got != tt.want {
|
||||
t.Errorf("ComputeTax(%d, %d) [%s] = %d; want %d", tt.total, tt.taxRate, tt.desc, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaxProviderImplementsInterface(t *testing.T) {
|
||||
var p TaxProvider = NewNordicTaxProvider("SE")
|
||||
_ = p
|
||||
|
||||
@@ -47,7 +47,7 @@ func HandleInitializeCheckout(g *CheckoutGrain, m *messages.InitializeCheckout)
|
||||
}
|
||||
|
||||
g.CartTotalPrice = g.CartState.TotalPrice
|
||||
g.AmountInCentsRemaining = g.CartTotalPrice.IncVat
|
||||
g.AmountInCentsRemaining = g.CartTotalPrice.IncVat.Int64()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func HandlePaymentCompleted(g *CheckoutGrain, m *messages.PaymentCompleted) erro
|
||||
|
||||
// Update checkout status
|
||||
g.PaymentInProgress--
|
||||
sum := g.CartState.TotalPrice.IncVat
|
||||
sum := g.CartState.TotalPrice.IncVat.Int64()
|
||||
|
||||
for _, payment := range g.SettledPayments() {
|
||||
sum -= payment.Amount
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -9,14 +11,27 @@ import (
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
func TestDiscovery(t *testing.T) {
|
||||
config, err := clientcmd.BuildConfigFromFlags("", "/home/mats/.kube/config")
|
||||
func kubeConfigPath(t *testing.T) string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Errorf("Error building config: %v", err)
|
||||
t.Skip("Skipping test: user home dir not found")
|
||||
}
|
||||
path := filepath.Join(home, ".kube", "config")
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
t.Skipf("Skipping test: %s not found", path)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestDiscovery(t *testing.T) {
|
||||
path := kubeConfigPath(t)
|
||||
config, err := clientcmd.BuildConfigFromFlags("", path)
|
||||
if err != nil {
|
||||
t.Fatalf("Error building config: %v", err)
|
||||
}
|
||||
client, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
t.Errorf("Error creating client: %v", err)
|
||||
t.Fatalf("Error creating client: %v", err)
|
||||
}
|
||||
d := NewK8sDiscovery(client, metav1.ListOptions{
|
||||
LabelSelector: "app",
|
||||
@@ -31,13 +46,14 @@ func TestDiscovery(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWatch(t *testing.T) {
|
||||
config, err := clientcmd.BuildConfigFromFlags("", "/home/mats/.kube/config")
|
||||
path := kubeConfigPath(t)
|
||||
config, err := clientcmd.BuildConfigFromFlags("", path)
|
||||
if err != nil {
|
||||
t.Errorf("Error building config: %v", err)
|
||||
t.Fatalf("Error building config: %v", err)
|
||||
}
|
||||
client, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
t.Errorf("Error creating client: %v", err)
|
||||
t.Fatalf("Error creating client: %v", err)
|
||||
}
|
||||
d := NewK8sDiscovery(client, metav1.ListOptions{
|
||||
LabelSelector: "app",
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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() }
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-52
@@ -1,70 +1,28 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
|
||||
"git.k6n.net/mats/platform/uid"
|
||||
)
|
||||
|
||||
// ProfileId is a 64-bit profile identifier with a compact base62 string form,
|
||||
// the same scheme the cart and order use so ids are consistent across services.
|
||||
type ProfileId 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)
|
||||
}
|
||||
}
|
||||
// ProfileId is a 64-bit profile identifier with a compact base62 string form.
|
||||
type ProfileId uid.ID
|
||||
|
||||
// String returns the canonical base62 encoding.
|
||||
func (id ProfileId) String() string { return encodeBase62(uint64(id)) }
|
||||
func (id ProfileId) String() string { return uid.ID(id).String() }
|
||||
|
||||
// NewProfileId generates a cryptographically random non-zero id.
|
||||
func NewProfileId() (ProfileId, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
id, err := uid.New()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("NewProfileId: %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 NewProfileId()
|
||||
}
|
||||
return ProfileId(u), nil
|
||||
return ProfileId(id), nil
|
||||
}
|
||||
|
||||
// ParseProfileId parses a base62 string into a ProfileId.
|
||||
func ParseProfileId(s string) (ProfileId, 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 ProfileId(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 ProfileId(id), ok
|
||||
}
|
||||
|
||||
+22
-19
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
// ----------------------------
|
||||
@@ -161,7 +162,7 @@ func (tieredDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a
|
||||
currentPct, _ := selectTier(tiers, total) // 0 when below the first tier
|
||||
|
||||
// Find the nearest tier whose floor is still above the current total.
|
||||
nextMin := int64(0)
|
||||
nextMin := money.Cents(0)
|
||||
nextPct := 0.0
|
||||
found := false
|
||||
for _, t := range tiers {
|
||||
@@ -177,8 +178,10 @@ func (tieredDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a
|
||||
return nil, false
|
||||
}
|
||||
return map[string]any{
|
||||
"remaining": nextMin - total,
|
||||
"threshold": nextMin,
|
||||
// Untyped JSON-presentation map: emit raw minor-unit int64 so consumers
|
||||
// (and JSON) see a plain number, not money.Cents.
|
||||
"remaining": (nextMin - total).Int64(),
|
||||
"threshold": nextMin.Int64(),
|
||||
"currentPercent": currentPct,
|
||||
"nextPercent": nextPct,
|
||||
}, true
|
||||
@@ -201,13 +204,13 @@ func (s *PromotionService) cartTotalProgress(rule PromotionRule, ctx *PromotionE
|
||||
if remaining <= 0 || !s.withinReach(rule, ctx, threshold) {
|
||||
return nil, false
|
||||
}
|
||||
return map[string]any{"remaining": remaining, "threshold": threshold}, true
|
||||
return map[string]any{"remaining": remaining.Int64(), "threshold": threshold.Int64()}, true
|
||||
}
|
||||
|
||||
// withinReach reports whether the rule would apply if the cart total were
|
||||
// atTotal — i.e. whether the cart-total threshold is the only thing holding it
|
||||
// back. Cheap: it re-runs evaluation against a copy of the context.
|
||||
func (s *PromotionService) withinReach(rule PromotionRule, ctx *PromotionEvalContext, atTotal int64) bool {
|
||||
func (s *PromotionService) withinReach(rule PromotionRule, ctx *PromotionEvalContext, atTotal money.Cents) bool {
|
||||
probe := *ctx
|
||||
probe.CartTotalIncVat = atTotal
|
||||
return s.EvaluateRule(rule, &probe).Applicable
|
||||
@@ -216,7 +219,7 @@ func (s *PromotionService) withinReach(rule PromotionRule, ctx *PromotionEvalCon
|
||||
// cartTotalThreshold finds the lowest cart-total floor a rule requires (a
|
||||
// cart_total >= / > condition), walking nested groups. Returns false when the
|
||||
// rule has no such condition.
|
||||
func cartTotalThreshold(rule PromotionRule) (int64, bool) {
|
||||
func cartTotalThreshold(rule PromotionRule) (money.Cents, bool) {
|
||||
var threshold int64
|
||||
found := false
|
||||
var walk func(conds Conditions)
|
||||
@@ -248,7 +251,7 @@ func cartTotalThreshold(rule PromotionRule) (int64, bool) {
|
||||
}
|
||||
}
|
||||
walk(rule.Conditions)
|
||||
return threshold, found
|
||||
return money.Cents(threshold), found
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
@@ -264,8 +267,8 @@ func cartTotalThreshold(rule PromotionRule) (int64, bool) {
|
||||
// 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
|
||||
MinTotal money.Cents
|
||||
MaxTotal money.Cents
|
||||
Percent float64
|
||||
}
|
||||
|
||||
@@ -304,7 +307,7 @@ func applyPercentageDiscount(g *cart.CartGrain, pct float64) *cart.Price {
|
||||
// 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) {
|
||||
func selectTier(tiers []DiscountTier, total money.Cents) (float64, bool) {
|
||||
pct := 0.0
|
||||
found := false
|
||||
for _, t := range tiers {
|
||||
@@ -327,8 +330,8 @@ func scalePrice(p *cart.Price, pct float64) *cart.Price {
|
||||
return out
|
||||
}
|
||||
|
||||
func scale(v int64, pct float64) int64 {
|
||||
return int64(math.Round(float64(v) * pct / 100.0))
|
||||
func scale(v money.Cents, pct float64) money.Cents {
|
||||
return money.Cents(math.Round(float64(v.Int64()) * pct / 100.0))
|
||||
}
|
||||
|
||||
// parseTiers reads the "tiers" array out of an Action.Config. Because configs
|
||||
@@ -347,8 +350,8 @@ func parseTiers(config map[string]interface{}) []DiscountTier {
|
||||
continue
|
||||
}
|
||||
tiers = append(tiers, DiscountTier{
|
||||
MinTotal: int64(firstNum(m, "minTotal", "min_total")),
|
||||
MaxTotal: int64(firstNum(m, "maxTotal", "max_total")),
|
||||
MinTotal: money.Cents(int64(firstNum(m, "minTotal", "min_total"))),
|
||||
MaxTotal: money.Cents(int64(firstNum(m, "maxTotal", "max_total"))),
|
||||
Percent: firstNum(m, "percent", "discount_percent"),
|
||||
})
|
||||
}
|
||||
@@ -381,7 +384,7 @@ func (buyXGetYEffect) Type() ActionType { return ActionBuyXGetY }
|
||||
|
||||
type unitItem struct {
|
||||
item *cart.CartItem
|
||||
price int64
|
||||
price money.Cents
|
||||
}
|
||||
|
||||
func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*cart.Price, bool) {
|
||||
@@ -561,16 +564,16 @@ func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action)
|
||||
var bundleDiscount *cart.Price
|
||||
switch a.BundleConfig.Pricing.Type {
|
||||
case "fixed_price":
|
||||
targetPriceOre := int64(math.Round(a.BundleConfig.Pricing.Value * 100))
|
||||
targetPriceOre := money.FromFloatMajor(a.BundleConfig.Pricing.Value)
|
||||
if bundlePrice.IncVat > targetPriceOre {
|
||||
pct := float64(bundlePrice.IncVat-targetPriceOre) / float64(bundlePrice.IncVat) * 100
|
||||
pct := float64((bundlePrice.IncVat-targetPriceOre).Int64()) / float64(bundlePrice.IncVat.Int64()) * 100
|
||||
bundleDiscount = scalePrice(bundlePrice, pct)
|
||||
}
|
||||
case "percentage_discount":
|
||||
bundleDiscount = scalePrice(bundlePrice, a.BundleConfig.Pricing.Value)
|
||||
case "fixed_discount":
|
||||
discountOre := int64(math.Round(a.BundleConfig.Pricing.Value * 100))
|
||||
pct := float64(discountOre) / float64(bundlePrice.IncVat) * 100
|
||||
discountOre := money.FromFloatMajor(a.BundleConfig.Pricing.Value)
|
||||
pct := float64(discountOre.Int64()) / float64(bundlePrice.IncVat.Int64()) * 100
|
||||
bundleDiscount = scalePrice(bundlePrice, pct)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
func timeZero() time.Time { return time.Date(2024, 6, 10, 12, 0, 0, 0, time.UTC) }
|
||||
@@ -53,8 +54,8 @@ func TestVolymrabattTiers(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
total int64
|
||||
wantDiscount int64
|
||||
wantNet int64
|
||||
wantDiscount money.Cents
|
||||
wantNet money.Cents
|
||||
}{
|
||||
{"below threshold (19 999 kr)", 1999900, 0, 1999900},
|
||||
{"low tier 5% (20 000 kr)", 2000000, 100000, 1900000},
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
var errInvalidTimeFormat = errors.New("invalid time format")
|
||||
@@ -28,14 +29,14 @@ type PromotionItem struct {
|
||||
SKU string
|
||||
Quantity uint16
|
||||
Category string
|
||||
PriceIncVat int64
|
||||
PriceIncVat money.Cents
|
||||
}
|
||||
|
||||
// PromotionEvalContext carries all dynamic data required to evaluate promotion
|
||||
// conditions. It can be constructed from a cart.CartGrain plus optional
|
||||
// customer/order metadata.
|
||||
type PromotionEvalContext struct {
|
||||
CartTotalIncVat int64
|
||||
CartTotalIncVat money.Cents
|
||||
TotalItemQuantity uint32
|
||||
Items []PromotionItem
|
||||
CustomerSegment string
|
||||
@@ -358,7 +359,7 @@ func evaluateGroup(g ConditionGroup, ctx *PromotionEvalContext) bool {
|
||||
func evaluateBaseCondition(b BaseCondition, ctx *PromotionEvalContext) bool {
|
||||
switch b.Type {
|
||||
case CondCartTotal:
|
||||
return evalNumberCompare(float64(ctx.CartTotalIncVat), b)
|
||||
return evalNumberCompare(float64(ctx.CartTotalIncVat.Int64()), b)
|
||||
case CondItemQuantity:
|
||||
return evalNumberCompare(float64(ctx.TotalItemQuantity), b)
|
||||
case CondCustomerSegment:
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
// --- Helpers ---------------------------------------------------------------
|
||||
@@ -63,13 +64,13 @@ func makeCart(totalOverride int64, items []struct {
|
||||
}) *cart.CartGrain {
|
||||
g := cart.NewCartGrain(1, time.Now())
|
||||
for _, it := range items {
|
||||
p := cart.NewPriceFromIncVat(it.priceInc, 0.25)
|
||||
p := cart.NewPriceFromIncVat(it.priceInc, 2500) // 25% in basis points
|
||||
g.Items = append(g.Items, &cart.CartItem{
|
||||
Id: uint32(len(g.Items) + 1),
|
||||
Sku: it.sku,
|
||||
Price: *p,
|
||||
TotalPrice: cart.Price{
|
||||
IncVat: p.IncVat * int64(it.qty),
|
||||
IncVat: p.IncVat.Mul(int64(it.qty)),
|
||||
VatRates: p.VatRates,
|
||||
},
|
||||
Quantity: it.qty,
|
||||
@@ -81,7 +82,7 @@ func makeCart(totalOverride int64, items []struct {
|
||||
// Recalculate totals
|
||||
g.UpdateTotals()
|
||||
if totalOverride >= 0 {
|
||||
g.TotalPrice.IncVat = totalOverride
|
||||
g.TotalPrice.IncVat = money.Cents(totalOverride)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package promotions
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
@@ -77,7 +78,9 @@ func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain {
|
||||
if qty == 0 {
|
||||
qty = 1
|
||||
}
|
||||
vat := it.VatRate
|
||||
// it.VatRate is the request rate in raw percent (external input); convert
|
||||
// to the platform basis-point scale at this boundary.
|
||||
vat := int(math.Round(float64(it.VatRate) * 100))
|
||||
if vat == 0 {
|
||||
vat = defaultVatRate(tp)
|
||||
}
|
||||
@@ -102,13 +105,13 @@ func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain {
|
||||
return g
|
||||
}
|
||||
|
||||
// defaultVatRate returns the default VAT rate (as a float32 for NewPriceFromIncVat)
|
||||
// from the provider, or 25 if none is configured.
|
||||
func defaultVatRate(tp cart.TaxProvider) float32 {
|
||||
// defaultVatRate returns the default VAT rate in basis points (2500 = 25%) from
|
||||
// the provider, or 2500 if none is configured.
|
||||
func defaultVatRate(tp cart.TaxProvider) int {
|
||||
if tp == nil {
|
||||
return 25
|
||||
return 2500
|
||||
}
|
||||
return float32(tp.DefaultTaxRate(""))
|
||||
return tp.DefaultTaxRate("")
|
||||
}
|
||||
|
||||
// contextOptions maps the optional customer/time fields onto context options.
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||
pmcp "git.k6n.net/mats/platform/mcp"
|
||||
)
|
||||
|
||||
// AdminTool is one admin promotion tool definition — name, description, input
|
||||
@@ -31,14 +32,14 @@ func AdminTools(store *promotions.Store, eval *promotions.PromotionService) []Ad
|
||||
"`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"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"promotion": pmcp.ObjectValue("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 {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rule promotions.PromotionRule
|
||||
@@ -61,16 +62,16 @@ func AdminTools(store *promotions.Store, eval *promotions.PromotionService) []Ad
|
||||
{
|
||||
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"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"id": pmcp.String("the promotion id"),
|
||||
"status": pmcp.String("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 {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validStatus(a.Status) {
|
||||
@@ -89,12 +90,12 @@ func AdminTools(store *promotions.Store, eval *promotions.PromotionService) []Ad
|
||||
{
|
||||
Name: "delete_promotion",
|
||||
Description: "Delete a promotion rule by id.",
|
||||
InputSchema: object(props{"id": str("the promotion id")}, []string{"id"}),
|
||||
InputSchema: pmcp.Object(pmcp.Props{"id": pmcp.String("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 {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found, err := store.Delete(a.ID)
|
||||
|
||||
@@ -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}}
|
||||
}
|
||||
+10
-131
@@ -4,160 +4,39 @@
|
||||
// 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.
|
||||
// Transport/dispatch and the schema/result helpers live in platform/mcp; this
|
||||
// package defines the promotion tools (full set via New, read-only via NewPublic)
|
||||
// and the admin tools (AdminTools) that backoffice merges into the CMS MCP.
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||
pmcp "git.k6n.net/mats/platform/mcp"
|
||||
)
|
||||
|
||||
const (
|
||||
serverName = "cart-promotions"
|
||||
serverVersion = "0.1.0"
|
||||
protocolVersion = "2025-06-18"
|
||||
serverName = "cart-promotions"
|
||||
serverVersion = "0.1.0"
|
||||
)
|
||||
|
||||
// Server is the MCP edge over the shared promotion Store and evaluator.
|
||||
type Server struct {
|
||||
store *promotions.Store
|
||||
eval *promotions.PromotionService
|
||||
tools []tool
|
||||
mcp *pmcp.Server
|
||||
}
|
||||
|
||||
// New builds an MCP server exposing the promotion store as tools.
|
||||
// New builds an MCP server exposing the full promotion tool set.
|
||||
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()
|
||||
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},
|
||||
}
|
||||
}
|
||||
|
||||
// defaultVatRate returns the default VAT rate from the eval service's
|
||||
// configured tax provider, falling back to 25 if none is set.
|
||||
func (s *Server) defaultVatRate() float32 {
|
||||
if s.eval == nil || s.eval.DefaultTaxProvider == nil {
|
||||
return 25
|
||||
}
|
||||
return float32(s.eval.DefaultTaxProvider.DefaultTaxRate(""))
|
||||
}
|
||||
|
||||
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,11 +1,13 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||
pmcp "git.k6n.net/mats/platform/mcp"
|
||||
)
|
||||
|
||||
// NewPublic builds an MCP server exposing only the read-only promotion tools:
|
||||
@@ -17,23 +19,23 @@ func NewPublic(store *promotions.Store, eval *promotions.PromotionService) *Serv
|
||||
eval = promotions.NewPromotionService(nil)
|
||||
}
|
||||
s := &Server{store: store, eval: eval}
|
||||
s.tools = s.buildPublicTools()
|
||||
s.mcp = pmcp.NewServer(pmcp.Info{Name: serverName, Version: serverVersion}, s.buildPublicTools()...)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) buildPublicTools() []tool {
|
||||
return []tool{
|
||||
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: object(props{
|
||||
"status": str("optional status filter: active|inactive|scheduled|expired"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"status": pmcp.String("optional status filter: active|inactive|scheduled|expired"),
|
||||
}, nil),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules := s.store.List()
|
||||
@@ -52,12 +54,12 @@ func (s *Server) buildPublicTools() []tool {
|
||||
{
|
||||
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) {
|
||||
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 := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, ok := s.store.Get(a.ID)
|
||||
@@ -72,18 +74,18 @@ func (s *Server) buildPublicTools() []tool {
|
||||
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"),
|
||||
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(args json.RawMessage) (any, error) {
|
||||
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 := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
|
||||
@@ -109,20 +111,20 @@ func (s *Server) buildPublicTools() []tool {
|
||||
"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: object(props{
|
||||
"cartTotalIncVat": integer("cart total incl. VAT, in öre"),
|
||||
"promotionId": str("optional: evaluate only this promotion (by id)"),
|
||||
"itemQuantity": integer("optional total item quantity"),
|
||||
"customerSegment": str("optional customer segment"),
|
||||
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(args json.RawMessage) (any, error) {
|
||||
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 := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -151,10 +153,10 @@ func (s *Server) buildPublicTools() []tool {
|
||||
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,
|
||||
"ruleId": r.Rule.ID,
|
||||
"name": r.Rule.Name,
|
||||
"applicable": r.Applicable,
|
||||
"failedReason": r.FailedReason,
|
||||
"matchedActions": r.MatchedActions,
|
||||
})
|
||||
}
|
||||
|
||||
+46
-117
@@ -1,61 +1,32 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||
pmcp "git.k6n.net/mats/platform/mcp"
|
||||
"git.k6n.net/mats/platform/tax"
|
||||
)
|
||||
|
||||
// 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{
|
||||
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: object(props{
|
||||
"status": str("optional status filter: active|inactive|scheduled|expired"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"status": pmcp.String("optional status filter: active|inactive|scheduled|expired"),
|
||||
}, nil),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules := s.store.List()
|
||||
@@ -74,12 +45,12 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
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) {
|
||||
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 := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, ok := s.store.Get(a.ID)
|
||||
@@ -95,14 +66,14 @@ func (s *Server) buildTools() []tool {
|
||||
"`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"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"promotion": pmcp.ObjectValue("the full promotion rule object"),
|
||||
}, []string{"promotion"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
Promotion json.RawMessage `json:"promotion"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rule promotions.PromotionRule
|
||||
@@ -125,16 +96,16 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
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"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"id": pmcp.String("the promotion id"),
|
||||
"status": pmcp.String("new status: active|inactive|scheduled|expired"),
|
||||
}, []string{"id", "status"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validStatus(a.Status) {
|
||||
@@ -153,12 +124,12 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
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) {
|
||||
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 := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found, err := s.store.Delete(a.ID)
|
||||
@@ -176,18 +147,18 @@ func (s *Server) buildTools() []tool {
|
||||
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"),
|
||||
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(args json.RawMessage) (any, error) {
|
||||
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 := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
|
||||
@@ -210,19 +181,19 @@ func (s *Server) buildTools() []tool {
|
||||
}
|
||||
}
|
||||
|
||||
// syntheticCart builds a one-line cart whose gross total is incVat ore so the
|
||||
// promotion engine can be evaluated against an arbitrary total. defaultVatRate
|
||||
// is the VAT rate as a float32 percentage (e.g. 25 = 25 %).
|
||||
func syntheticCart(incVat int64, qty int, defaultVatRate float32) *cart.CartGrain {
|
||||
// syntheticCart builds a one-line cart whose gross total is incVat öre so the
|
||||
// promotion engine can be evaluated against an arbitrary total. rateBp is the VAT
|
||||
// rate in basis points (2500 = 25%).
|
||||
func syntheticCart(incVat int64, qty int, rateBp int) *cart.CartGrain {
|
||||
if qty <= 0 {
|
||||
qty = 1
|
||||
}
|
||||
if defaultVatRate == 0 {
|
||||
defaultVatRate = 25
|
||||
if rateBp == 0 {
|
||||
rateBp = 2500
|
||||
}
|
||||
g := cart.NewCartGrain(0, time.Now())
|
||||
g.Items = []*cart.CartItem{
|
||||
{Id: 1, Sku: "PREVIEW", Quantity: uint16(qty), Price: *cart.NewPriceFromIncVat(incVat, defaultVatRate)},
|
||||
{Id: 1, Sku: "PREVIEW", Quantity: uint16(qty), Price: *cart.NewPriceFromIncVat(incVat, rateBp)},
|
||||
}
|
||||
// Quantity > 1 would multiply the row total; keep the gross equal to incVat by
|
||||
// pricing a single unit at the full total.
|
||||
@@ -240,55 +211,13 @@ func validStatus(s string) bool {
|
||||
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
|
||||
// defaultVatRate is the VAT rate used to build the synthetic preview cart when
|
||||
// the caller supplies none. Sourced from platform/tax (Nordic standard,
|
||||
// defaultCountry SE = 25%) rather than a magic constant.
|
||||
//
|
||||
// NOTE: reconstructed during the platform/mcp migration (the original method was
|
||||
// lost editing this untracked file). Behaviour matches the prior 25% default; if
|
||||
// the original read a per-store/configured rate, wire that provider in here.
|
||||
func (s *Server) defaultVatRate() int {
|
||||
return tax.NewNordic("").DefaultTaxRate("")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
/*
|
||||
@@ -74,7 +76,7 @@ const (
|
||||
type ruleCondition struct {
|
||||
Kind RuleKind
|
||||
StringVals []string // For sku / category multi-value list
|
||||
MinValue *int64 // For numeric threshold rules
|
||||
MinValue *money.Cents // For numeric threshold rules (minor units)
|
||||
// Operator reserved for future (e.g., >, >=, ==). Currently always ">=" for numeric kinds.
|
||||
Operator string
|
||||
}
|
||||
@@ -90,13 +92,13 @@ type RuleSet struct {
|
||||
type Item struct {
|
||||
Sku string
|
||||
Category string
|
||||
UnitPrice int64 // Inc VAT (single unit)
|
||||
UnitPrice money.Cents // Inc VAT (single unit)
|
||||
}
|
||||
|
||||
// EvalContext bundles cart-like data necessary for evaluation.
|
||||
type EvalContext struct {
|
||||
Items []Item
|
||||
CartTotalInc int64
|
||||
CartTotalInc money.Cents
|
||||
}
|
||||
|
||||
// Applies returns true if all rule conditions pass for the context.
|
||||
@@ -268,9 +270,10 @@ func parseNumericRule(frag string) (ruleCondition, error) {
|
||||
return ruleCondition{}, fmt.Errorf("negative threshold %d", value)
|
||||
}
|
||||
|
||||
mv := money.Cents(value)
|
||||
return ruleCondition{
|
||||
Kind: kind,
|
||||
MinValue: &value,
|
||||
MinValue: &mv,
|
||||
Operator: ">=",
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ func TestParseRules_Invalid(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRuleSet_Applies(t *testing.T) {
|
||||
rs := MustParseRules("sku=ABC123|XYZ999; category=Shoes|min_total>=10000; min_item_price>=3000")
|
||||
rs := MustParseRules("sku=ABC123|XYZ999; category=Shoes; min_total>=10000; min_item_price>=3000")
|
||||
|
||||
ctx := EvalContext{
|
||||
Items: []Item{
|
||||
|
||||
@@ -7,18 +7,19 @@ import (
|
||||
"os"
|
||||
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
type Rule struct {
|
||||
Type string `json:"type"`
|
||||
Value int64 `json:"value"`
|
||||
Type string `json:"type"`
|
||||
Value money.Cents `json:"value"`
|
||||
}
|
||||
|
||||
type Voucher struct {
|
||||
Code string `json:"code"`
|
||||
Value int64 `json:"value"`
|
||||
Rules string `json:"rules"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Value money.Cents `json:"value"`
|
||||
Rules string `json:"rules"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
@@ -42,7 +43,7 @@ func (s *Service) GetVoucher(code string) (*messages.AddVoucher, error) {
|
||||
|
||||
return &messages.AddVoucher{
|
||||
Code: code,
|
||||
Value: v.Value,
|
||||
Value: v.Value.Int64(), // proto boundary: AddVoucher.Value is int64
|
||||
Description: v.Description,
|
||||
VoucherRules: []string{
|
||||
v.Rules,
|
||||
|
||||
Reference in New Issue
Block a user