From aa8b2bdedc971497cef44a5041eaa7295b0e0eb3 Mon Sep 17 00:00:00 2001 From: matst80 Date: Sun, 28 Jun 2026 17:51:52 +0200 Subject: [PATCH] all the refactor --- api-tests/test_ucp.sh | 163 +- cmd/backoffice/main.go | 6 +- cmd/cart/main.go | 211 +- cmd/cart/pool-server.go | 59 +- cmd/cart/product-fetcher.go | 3 + cmd/cart/product-fetcher_integration_test.go | 4 +- cmd/cart/projection_cache_test.go | 170 ++ cmd/cart/projection_overlay.go | 97 + cmd/cart/projection_overlay_test.go | 148 ++ cmd/checkout/adyen-handlers.go | 25 +- cmd/checkout/checkout_builder.go | 32 +- cmd/checkout/klarna-handlers.go | 40 +- cmd/checkout/main.go | 29 +- cmd/checkout/order_client.go | 1 + cmd/checkout/order_create.go | 22 +- cmd/checkout/pool-server.go | 3 +- cmd/inventory/commit.go | 69 + cmd/inventory/level.go | 52 + cmd/inventory/main.go | 145 +- cmd/inventory/stockhandler.go | 25 +- cmd/order/amqp.go | 79 +- cmd/order/handlers.go | 90 +- cmd/order/handlers_checkout.go | 4 +- cmd/order/idempotency_lifecycle_test.go | 129 ++ cmd/order/main.go | 49 +- cmd/profile/main.go | 15 + cmd/ucp-artifacts/main.go | 1800 ++++++++++++++++++ go.mod | 1 + internal/ucp/cart_test.go | 5 +- internal/ucp/checkout.go | 26 +- internal/ucp/order.go | 2 +- internal/ucp/order_test.go | 5 +- internal/ucp/signing.go | 85 +- internal/ucp/signing_test.go | 9 +- internal/ucp/types.go | 168 +- pkg/actor/base62-id.go | 97 +- pkg/actor/grpc_server.go | 7 +- pkg/actor/log_listerner.go | 75 +- pkg/actor/mutation_registry.go | 18 +- pkg/backofficeadmin/app.go | 73 +- pkg/cart/cart-grain.go | 2 +- pkg/cart/cart_id.go | 90 +- pkg/cart/cart_id_test.go | 29 +- pkg/cart/mcp/jsonrpc.go | 44 - pkg/cart/mcp/mcp.go | 132 +- pkg/cart/mcp/tools.go | 268 +-- pkg/cart/mutation_add_item.go | 16 +- pkg/cart/price.go | 169 +- pkg/cart/price_test.go | 97 +- pkg/cart/tax.go | 68 +- pkg/cart/tax_nordic.go | 58 +- pkg/cart/tax_test.go | 66 +- pkg/checkout/mutation_initialize_checkout.go | 2 +- pkg/checkout/mutation_payment_completed.go | 2 +- pkg/discovery/discovery_test.go | 30 +- pkg/order/actions.go | 6 +- pkg/order/emit_created.go | 78 + pkg/order/flow_test.go | 3 + pkg/order/flows/place-and-pay.json | 5 +- pkg/order/id.go | 63 +- pkg/order/mcp/jsonrpc.go | 44 - pkg/order/mcp/mcp.go | 129 +- pkg/order/mcp/mcp_test.go | 2 +- pkg/order/mcp/tools.go | 281 +-- pkg/order/mutations.go | 42 +- pkg/order/order-grain.go | 39 +- pkg/order/tax.go | 33 +- pkg/order/tax_test.go | 8 +- pkg/profile/id.go | 62 +- pkg/promotions/apply.go | 41 +- pkg/promotions/apply_test.go | 5 +- pkg/promotions/eval.go | 7 +- pkg/promotions/eval_test.go | 7 +- pkg/promotions/evaluate.go | 15 +- pkg/promotions/mcp/admin.go | 19 +- pkg/promotions/mcp/jsonrpc.go | 44 - pkg/promotions/mcp/mcp.go | 141 +- pkg/promotions/mcp/public.go | 56 +- pkg/promotions/mcp/tools.go | 163 +- pkg/voucher/parser.go | 11 +- pkg/voucher/parser_test.go | 2 +- pkg/voucher/service.go | 15 +- proto/order.proto | 1 + proto/order/order.pb.go | 236 +-- 84 files changed, 4328 insertions(+), 2344 deletions(-) create mode 100644 cmd/cart/projection_cache_test.go create mode 100644 cmd/cart/projection_overlay.go create mode 100644 cmd/cart/projection_overlay_test.go create mode 100644 cmd/inventory/commit.go create mode 100644 cmd/inventory/level.go create mode 100644 cmd/order/idempotency_lifecycle_test.go create mode 100644 cmd/ucp-artifacts/main.go delete mode 100644 pkg/cart/mcp/jsonrpc.go create mode 100644 pkg/order/emit_created.go delete mode 100644 pkg/order/mcp/jsonrpc.go delete mode 100644 pkg/promotions/mcp/jsonrpc.go diff --git a/api-tests/test_ucp.sh b/api-tests/test_ucp.sh index 6f801e2..5d9d226 100755 --- a/api-tests/test_ucp.sh +++ b/api-tests/test_ucp.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # ────────────────────────────────────────────────────────────────────────────── # UCP API test suite -# Tests the Universal Commerce Protocol REST endpoints (cart, order) via curl. +# Tests the Universal Commerce Protocol REST endpoints (cart, checkout, order) +# via curl. # # Prerequisites: # make up-infra (infra compose up — order :8092, cart :8090) @@ -17,6 +18,7 @@ # # Single service: # bash api-tests/test_ucp.sh --order # bash api-tests/test_ucp.sh --cart +# bash api-tests/test_ucp.sh --checkout # # # Verify signatures (requires signing key mounted): # bash api-tests/test_ucp.sh --verify-sig @@ -36,16 +38,20 @@ elif [ "$MODE" = "--order" ]; then ORDER_URL="http://localhost:8092" elif [ "$MODE" = "--cart" ]; then CART_URL="http://localhost:8090" +elif [ "$MODE" = "--checkout" ]; then + CART_URL="http://localhost:8090" + CHECKOUT_URL="http://localhost:8094" elif [ "$MODE" = "--verify-sig" ]; then # If signing is enabled (key mounted), you need the full stack or direct with # env var — try both common ports. ORDER_URL="${ORDER_URL:-http://localhost:8092}" CART_URL="${CART_URL:-http://localhost:8090}" + CHECKOUT_URL="${CHECKOUT_URL:-http://localhost:8094}" else # Default: infra stack, direct to Docker ports CART_URL="${CART_URL:-http://localhost:8090}" ORDER_URL="${ORDER_URL:-http://localhost:8092}" - CHECKOUT_URL="${CHECKOUT_URL:-}" + CHECKOUT_URL="${CHECKOUT_URL:-http://localhost:8094}" fi echo "═══════════════════════════════════════════════════════════════" @@ -210,20 +216,25 @@ if [ -n "${CART_URL:-}" ]; then echo " response: $(echo "$cart_body" | head -c 400)" fi - # ── Add item to cart ──────────────────────────────────────────────────────── - echo "--- 2.3 POST /ucp/v1/carts/{id}/items ---" - add_resp=$(curl -s -w '\n%{http_code}' -X POST "$CART_URL/ucp/v1/carts/$CART_ID/items" \ + # ── Replace cart contents via UCP ─────────────────────────────────────────── + echo "--- 2.3 PUT /ucp/v1/carts/{id} ---" + add_resp=$(curl -s -w '\n%{http_code}' -X PUT "$CART_URL/ucp/v1/carts/$CART_ID" \ -H 'Content-Type: application/json' \ -d '{ - "sku": "TEST-SKU-1", - "name": "Test Product", - "quantity": 1, - "unitPrice": 19900, - "taxRate": 2500 + "country": "se", + "items": [ + { + "sku": "TEST-SKU-1", + "name": "Test Product", + "quantity": 1, + "price": 19900, + "taxRate": 25 + } + ] }') add_code=$(echo "$add_resp" | tail -1) add_body=$(echo "$add_resp" | sed '$d') - assert_status "Add item" 201 "$add_code" "$add_body" + assert_status "Update cart items" 200 "$add_code" "$add_body" # ── Get cart ──────────────────────────────────────────────────────────────── echo "--- 2.4 GET /ucp/v1/carts/{id} ---" @@ -231,41 +242,143 @@ if [ -n "${CART_URL:-}" ]; then get_cart_code=$(echo "$get_cart_resp" | tail -1) get_cart_body=$(echo "$get_cart_resp" | sed '$d') assert_status "Get cart" 200 "$get_cart_code" "$get_cart_body" - echo " total: $(echo "$get_cart_body" | sed -n 's/.*"total"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p')" + echo " totalIncVat: $(echo "$get_cart_body" | sed -n 's/.*"totalIncVat"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p')" echo "" fi # ══════════════════════════════════════════════════════════════════════════════ -# SECTION 3: Full flow — cart → checkout → order (via edge/nginx only) +# SECTION 3: Checkout service tests # ══════════════════════════════════════════════════════════════════════════════ -if [ "$MODE" = "--edge" ] && [ -n "${CHECKOUT_URL:-}" ]; then - header "3. Full Flow (via nginx)" +if [ -n "${CHECKOUT_URL:-}" ] && [ -n "${CART_ID:-}" ]; then + header "3. Checkout Service → $CHECKOUT_URL" - # ── This section needs the full compose stack with nginx on :8080, - # and checkout must be reachable. Skip for infra/direct modes. - echo "--- 3.1 Create checkout session ---" - checkout_resp=$(curl -s -w '\n%{http_code}' -X POST "$CHECKOUT_URL/ucp/v1/checkout-sessions" \ + # ── Health ────────────────────────────────────────────────────────────────── + echo "--- 3.1 Health check ---" + resp=$(curl -sf "$CHECKOUT_URL/healthz" 2>&1) && pass "/healthz" || fail "/healthz: $resp" + + # ── Create checkout session ──────────────────────────────────────────────── + echo "--- 3.2 POST /ucp/v1/checkout-sessions/ ---" + checkout_resp=$(curl -s -w '\n%{http_code}' -X POST "$CHECKOUT_URL/ucp/v1/checkout-sessions/" \ -H 'Content-Type: application/json' \ -d '{ - "cartId": "'"$CART_ID"'", - "currency": "SEK", - "country": "se" + "cartId": "'"$CART_ID"'", + "currency": "SEK", + "country": "se" }') checkout_code=$(echo "$checkout_resp" | tail -1) checkout_body=$(echo "$checkout_resp" | sed '$d') assert_status "Create checkout session" 201 "$checkout_code" "$checkout_body" + CHECKOUT_ID=$(echo "$checkout_body" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + if [ -n "$CHECKOUT_ID" ]; then + pass "Checkout ID: $CHECKOUT_ID" + else + fail "Could not extract checkout ID" + echo " response: $(echo "$checkout_body" | head -c 400)" + fi + + if [ -n "${CHECKOUT_ID:-}" ]; then + # ── Read checkout session ──────────────────────────────────────────────── + echo "--- 3.3 GET /ucp/v1/checkout-sessions/{id} ---" + get_checkout_resp=$(curl -s -w '\n%{http_code}' "$CHECKOUT_URL/ucp/v1/checkout-sessions/$CHECKOUT_ID") + get_checkout_code=$(echo "$get_checkout_resp" | tail -1) + get_checkout_body=$(echo "$get_checkout_resp" | sed '$d') + assert_status "Get checkout session" 200 "$get_checkout_code" "$get_checkout_body" + echo " checkout: $(maybe_jq "$get_checkout_body" '.id + " status=" + .status')" + + # ── Complete checkout session ──────────────────────────────────────────── + echo "--- 3.4 POST /ucp/v1/checkout-sessions/{id}/complete ---" + complete_resp=$(curl -s -w '\n%{http_code}' -X POST "$CHECKOUT_URL/ucp/v1/checkout-sessions/$CHECKOUT_ID/complete" \ + -H 'Content-Type: application/json' \ + -d '{ + "provider": "test", + "paymentToken": "ucp-test-payment-token", + "currency": "SEK", + "country": "se" + }') + complete_code=$(echo "$complete_resp" | tail -1) + complete_body=$(echo "$complete_resp" | sed '$d') + assert_status "Complete checkout session" 200 "$complete_code" "$complete_body" + COMPLETE_ORDER_ID=$(echo "$complete_body" | sed -n 's/.*"orderId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + if [ -n "$COMPLETE_ORDER_ID" ]; then + pass "Checkout produced order ID: $COMPLETE_ORDER_ID" + else + fail "Checkout completion did not return an orderId" + echo " response: $(echo "$complete_body" | head -c 400)" + fi + + # ── Signature headers (if enabled) ────────────────────────────────────── + echo "--- 3.5 Check signature headers ---" + checkout_sig_resp=$(curl -s -i "$CHECKOUT_URL/ucp/v1/checkout-sessions/$CHECKOUT_ID" 2>/dev/null | head -30) + if echo "$checkout_sig_resp" | grep -qi "signature-input"; then + pass "Checkout Signature-Input header present" + echo " $(echo "$checkout_sig_resp" | grep -i "signature-input" | head -1)" + else + echo " ℹ️ No Signature-Input header" + fi + if echo "$checkout_sig_resp" | grep -qi "^signature:"; then + pass "Checkout Signature header present" + else + echo " ℹ️ No Signature header" + fi + fi fi # ══════════════════════════════════════════════════════════════════════════════ -# SECTION 4: Signature verification (standalone) +# SECTION 4: Edge-only discovery artifact tests +# ══════════════════════════════════════════════════════════════════════════════ +if [ "$MODE" = "--edge" ]; then + header "4. Edge Discovery → $BASE" + + echo "--- 4.1 GET /.well-known/ucp ---" + profile_resp=$(curl -s -w '\n%{http_code}' "$BASE/.well-known/ucp") + profile_code=$(echo "$profile_resp" | tail -1) + profile_body=$(echo "$profile_resp" | sed '$d') + assert_status "Get UCP profile" 200 "$profile_code" "$profile_body" + if echo "$profile_body" | grep -q '/.well-known/ucp/openapi/shopping-rest.openapi.json'; then + pass "Profile advertises self-hosted REST OpenAPI" + else + fail "Profile is missing self-hosted REST OpenAPI URL" + fi + + echo "--- 4.2 GET /.well-known/ucp/openapi/shopping-rest.openapi.json ---" + openapi_resp=$(curl -s -w '\n%{http_code}' "$BASE/.well-known/ucp/openapi/shopping-rest.openapi.json") + openapi_code=$(echo "$openapi_resp" | tail -1) + openapi_body=$(echo "$openapi_resp" | sed '$d') + assert_status "Get UCP REST OpenAPI" 200 "$openapi_code" "$openapi_body" + if echo "$openapi_body" | grep -q '"openapi"'; then + pass "OpenAPI document returned" + else + fail "OpenAPI document body missing openapi field" + fi + + echo "--- 4.3 GET self-hosted UCP schemas ---" + for schema_path in \ + "/.well-known/ucp/schemas/shopping/customer.json" \ + "/.well-known/ucp/schemas/shopping/authentication.json" \ + "/.well-known/ucp/schemas/payments/processor_tokenizer.json" + do + schema_resp=$(curl -s -w '\n%{http_code}' "$BASE$schema_path") + schema_code=$(echo "$schema_resp" | tail -1) + schema_body=$(echo "$schema_resp" | sed '$d') + assert_status "Get $schema_path" 200 "$schema_code" "$schema_body" + if echo "$schema_body" | grep -q '"$schema"'; then + pass "$schema_path is valid schema JSON" + else + fail "$schema_path body missing \$schema" + fi + done +fi + +# ══════════════════════════════════════════════════════════════════════════════ +# SECTION 5: Signature verification (standalone) # ══════════════════════════════════════════════════════════════════════════════ if [ "$MODE" = "--verify-sig" ]; then - header "4. Signature Verification" + header "5. Signature Verification" # Verify that the response includes RFC 9421 HTTP Message Signatures. # Requires the service to have UCP_SIGNING_KEY_PATH set and the key mounted. - echo "--- 4.1 Check Signed Response ---" + echo "--- 5.1 Check Signed Response ---" sig_output=$(curl -s -D - "$ORDER_URL/ucp/v1/orders/$ORDER_ID" 2>/dev/null | head -40) echo "$sig_output" | while IFS= read -r line; do diff --git a/cmd/backoffice/main.go b/cmd/backoffice/main.go index ecf97bd..4982e24 100644 --- a/cmd/backoffice/main.go +++ b/cmd/backoffice/main.go @@ -10,7 +10,7 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/backofficeadmin" "git.k6n.net/mats/platform/config" - amqp "github.com/rabbitmq/amqp091-go" + "git.k6n.net/mats/platform/rabbit" ) func main() { @@ -66,9 +66,9 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - var conn *amqp.Connection + var conn *rabbit.Conn if amqpURL != "" { - conn, err = amqp.Dial(amqpURL) + conn, err = rabbit.Dial(amqpURL, "cart-backoffice") if err != nil { log.Fatalf("failed to connect to RabbitMQ: %v", err) } diff --git a/cmd/cart/main.go b/cmd/cart/main.go index f8afc9e..a8ae601 100644 --- a/cmd/cart/main.go +++ b/cmd/cart/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" "log" "net" @@ -10,6 +11,7 @@ import ( "os" "os/signal" "strings" + "sync" "time" "git.k6n.net/mats/go-cart-actor/internal/ucp" @@ -21,8 +23,10 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/telemetry" "git.k6n.net/mats/go-cart-actor/pkg/voucher" "git.k6n.net/mats/go-redis-inventory/pkg/inventory" + "git.k6n.net/mats/platform/catalog" "git.k6n.net/mats/platform/config" - "git.k6n.net/mats/slask-finder/pkg/messaging" + "git.k6n.net/mats/platform/event" + "git.k6n.net/mats/platform/rabbit" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -116,6 +120,123 @@ func matchesSkuAndLocation(update inventory.InventoryResult, item cart.CartItem) return false } +// catalogProjectionCache is the cart's in-process map of SKU → catalog.Projection, +// updated by reading catalog.projection_published events off the bus. It is the +// cart-side source of truth for catalog facts (price, tax_class, inventory_tracked +// flag, currency, image, brand) used at add-to-cart / checkout-reserve decisions. +// Stock state is NOT cached here — exact qty is still queried synchronously from +// the inventory service at decision points per docs/inventory-shape.md. +// +// Concurrency: read-mostly workload justifies sync.RWMutex. +// +// Single-tier storage. The bus is the only writer — the cart does not separately +// seed from a snapshot file at boot, because the access pattern is point lookup +// of cart-active SKUs only, and coupling two sources (mmap snapshot + bus-fed +// map) created duplicated state with subtle drift risk and an mmap-SIGBUS hazard. +// Cold-grace: the cache may be incomplete for an SKU the cart sees before the +// first projection_published arrives for it; the cart falls back to the existing +// PRODUCT_BASE_URL HTTP fetch and overlays the cache fields on top of it. +// +// Deletes carry a tombstone in the map (not a plain delete) so a bus delete of +// an SKU that already fits the cache rules correctly shadows it for Get / +// IsDeleted lookups — preventing an oversight from re-fetching a deleted SKU +// before the cache rebuilds. Tombstones have a per-pod lifetime; bus-driven, +// so the next sweep on the same SKU flips the entry back to live. +type catalogProjectionCache struct { + mu sync.RWMutex + items map[string]projectionEntry +} + +// projectionEntry is a cache slot: a live projection, or a tombstone (deleted) +// that shadows the live fallback. +type projectionEntry struct { + proj catalog.Projection + deleted bool +} + +func newCatalogProjectionCache() *catalogProjectionCache { + return &catalogProjectionCache{items: make(map[string]projectionEntry, 8192)} +} + +func (c *catalogProjectionCache) Apply(updates []catalog.ProjectionUpdate) (upserts, deletes int) { + c.mu.Lock() + defer c.mu.Unlock() + for _, u := range updates { + if u.Deleted { + c.items[u.SKU] = projectionEntry{deleted: true} + deletes++ + continue + } + if u.SKU == "" { + continue + } + c.items[u.SKU] = projectionEntry{proj: u.Projection} + upserts++ + } + return +} + +func (c *catalogProjectionCache) Get(sku string) (catalog.Projection, bool) { + c.mu.RLock() + e, ok := c.items[sku] + c.mu.RUnlock() + if !ok { + return catalog.Projection{}, false + } + if e.deleted { + return catalog.Projection{}, false + } + return e.proj, true +} + +// IsDeleted reports whether a tombstone exists for sku — used by HTTP-fetch +// call sites to skip the fallback for an SKU the bus has explicitly deleted, +// avoiding a wasteful round-trip on items the writer has just removed. +func (c *catalogProjectionCache) IsDeleted(sku string) bool { + c.mu.RLock() + e, ok := c.items[sku] + c.mu.RUnlock() + return ok && e.deleted +} + +// Len reports the number of map entries (bus-fed upserts + tombstones). Used +// for log gauges only. +func (c *catalogProjectionCache) Len() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.items) +} + +// projectionDeliveryHandler is the AMQP delivery handler for +// `catalog.projection_published` on the `catalog` topic exchange. Decodes the +// []ProjectionUpdate batch and merges it into cache. Returns nil on a batch +// that doesn't apply locally so the AMQP delivery is acked; returns an error +// on a decode failure so the broker knows to redeliver / quarantine. +// +// Recovery: any panic in a single event is caught so the goroutine keeps +// draining the queue; the bus stays live across malformed events. +func projectionDeliveryHandler(cache *catalogProjectionCache) func(amqp.Delivery) error { + return func(d amqp.Delivery) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("projection handler panic recovered: %v", r) + } + }() + var ev event.Event + if err := json.Unmarshal(d.Body, &ev); err != nil { + return fmt.Errorf("decode event envelope: %w", err) + } + updates, err := catalog.DecodeUpdates(ev.Payload) + if err != nil { + return fmt.Errorf("decode projection updates: %w", err) + } + upserts, deletes := cache.Apply(updates) + log.Printf("cart: catalog projection batch applied: upserts=%d deletes=%d total=%d eventID=%s source=%q", + upserts, deletes, cache.Len(), ev.ID, ev.Source) + return nil + } +} + func main() { // cartPort is the bare HTTP port. It drives both the local listener and the @@ -282,29 +403,48 @@ func main() { cartMCP := cartmcp.New(pool) - // Publish each applied mutation to the "cart"/"mutation" RabbitMQ topic so the - // backoffice /commerce live feed (and other consumers) see cart activity. - // Best-effort: if AMQP is unreachable the cart still serves; the feed stays empty. + // Stream each applied mutation to the shared "mutations" exchange (routing key + // mutation.cart) so the backoffice /commerce live feed (and other consumers) + // see cart activity. Best-effort: if AMQP is unreachable the cart still serves. if amqpUrl != "" { - if conn, derr := amqp.Dial(amqpUrl); derr != nil { + if conn, derr := rabbit.Dial(amqpUrl, "cart"); derr != nil { log.Printf("cart: AMQP connect failed, mutation feed disabled: %v", derr) } else { - if ch, cerr := conn.Channel(); cerr == nil { - _ = messaging.DefineTopic(ch, "cart", "mutation") // idempotent; non-fatal - _ = ch.Close() - } - pool.AddListener(actor.NewAmqpListener(conn, func(id uint64, results []actor.ApplyResult) (any, error) { - types := make([]string, 0, len(results)) - for _, r := range results { - types = append(types, r.Type) - } - return map[string]any{"cartId": id, "mutations": types}, nil - })) - log.Printf("cart: mutation feed enabled (cart/mutation)") + listener := actor.NewAmqpListener(conn.Connection(), "cart", actor.MutationSummary) + listener.DefineTopics() + pool.AddListener(listener) + log.Printf("cart: mutation feed enabled (mutation.cart)") } } - syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp)) //inventoryService, inventoryReservationService) + // catalog.projection_published consumer (the cart is the canonical + // consumer of the projection wire per docs/inventory-shape.md; inventory + // emits level crossings back to finder only, never the reverse). Own + // connection keeps the cart's mutation-feed lifecycle decoupled from a + // read-write-consume connection that could grow event handlers later + // (e.g. promotions reacting to a price drop on a watched SKU). + projectionCache := newCatalogProjectionCache() + if amqpUrl != "" { + if pConn, perr := rabbit.Dial(amqpUrl, "cart-catalog-projection"); perr != nil { + log.Printf("cart: catalog projection consumer DISABLED (dial failed): %v", perr) + } else { + pConn.NotifyOnReconnect(func() { + ch, err := pConn.Channel() + if err != nil { + log.Printf("cart: channel on projection reconnect: %v", err) + return + } + if err := rabbit.ListenToPattern(ch, "catalog", string(event.CatalogProjectionPublished), projectionDeliveryHandler(projectionCache)); err != nil { + log.Printf("cart: bind catalog.projection_published on reconnect: %v", err) + _ = ch.Close() + } + }) + defer func() { _ = pConn.Close() }() + log.Printf("cart: catalog projection consumer ENABLED (catalog.projection_published on 'catalog' exchange)") + } + } + + syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), projectionCache) //inventoryService, inventoryReservationService) app := &App{ pool: pool, @@ -374,6 +514,23 @@ func main() { debugMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) debugMux.HandleFunc("/debug/pprof/trace", pprof.Trace) debugMux.Handle("/metrics", promhttp.Handler()) + + // Projection cache probe (dev/verification). GET /debug/projection/{sku} → + // the resolved projection + the bus-fed map size. map_entries counts both + // live projections and tombstones; a found=true response confirms the bus + // consumer delivered the SKU to this pod. + debugMux.HandleFunc("/debug/projection/", func(w http.ResponseWriter, r *http.Request) { + sku := strings.TrimPrefix(r.URL.Path, "/debug/projection/") + p, found := projectionCache.Get(sku) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "sku": sku, + "found": found, + "projection": p, + "map_entries": projectionCache.Len(), + "deleted": projectionCache.IsDeleted(sku), + }) + }) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { // Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy) grainCount, capacity := app.pool.LocalUsage() @@ -434,19 +591,11 @@ func main() { srvErr <- srv.ListenAndServe() }() - // listener := inventory.NewInventoryChangeListener(rdb, context.Background(), func(changes []inventory.InventoryChange) { - // for _, change := range changes { - // log.Printf("inventory change: %v", change) - // inventoryPubSub.Publish(change) - // } - // }) - - // go func() { - // err := listener.Start() - // if err != nil { - // log.Fatalf("Unable to start inventory listener: %v", err) - // } - // }() + // Inventory change consumption used to live here over the bare Redis + // `inventory_changed` channel; it is now owned by the cart-inventory + // service, which translates quantity changes into inventory.level_changed + // bus crossings. The cart reads exact stock synchronously when it needs it. + // See docs/inventory-shape.md. log.Printf("Server started at %s (debug %s)", httpAddr, debugAddr) diff --git a/cmd/cart/pool-server.go b/cmd/cart/pool-server.go index 2a51661..2cf6223 100644 --- a/cmd/cart/pool-server.go +++ b/cmd/cart/pool-server.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "fmt" "io" "log" "net/http" @@ -41,12 +42,19 @@ var ( type PoolServer struct { actor.GrainPool[cart.CartGrain] pod_name string + // idx is the bus-fed catalog projection cache, consulted at HTTP-fetch + // sites (AddSkuToCartHandler, buildItemGroups) to overlay authoritative + // price / tax_class / display fields onto AddItem before mutation. nil is + // tolerated so handlers still serve if the bus consumer is unavailable + // (orphan / quarantine / CI), with a plain cache-miss fall-through. + idx *catalogProjectionCache } -func NewPoolServer(pool actor.GrainPool[cart.CartGrain], pod_name string) *PoolServer { +func NewPoolServer(pool actor.GrainPool[cart.CartGrain], pod_name string, idx *catalogProjectionCache) *PoolServer { srv := &PoolServer{ GrainPool: pool, pod_name: pod_name, + idx: idx, } return srv @@ -67,10 +75,26 @@ func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id c func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error { sku := r.PathValue("sku") + if s.idx != nil && s.idx.IsDeleted(sku) { + // Bus-deleted SKU — skip the HTTP fetch and reject the add. Mirrors the + // product-fetcher's "product service returned %d for sku %s" shape so + // upstream code that pattern-matches that string still works, AND we + // publish StatusNotFound so the HTTP response carries the right code + // (the handler's error-return path otherwise renders as 500). + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(fmt.Sprintf(`{"error":"product service returned %d for sku %s (bus-deleted)","sku":%q}`, 404, sku, sku))) + return nil + } msg, err := GetItemAddMessage(r.Context(), sku, 1, getCountryFromHost(r.Host), nil) if err != nil { return err } + if s.idx != nil { + if p, ok := s.idx.Get(sku); ok { + ApplyProjectionOverlay(msg, p) + } + } data, err := s.ApplyLocal(r.Context(), id, msg) if err != nil { @@ -154,16 +178,32 @@ type itemGroup struct { // drive child pricing, then children are fetched concurrently. Input order is // preserved. Items that fail to fetch are skipped (logged), matching the prior // best-effort behavior. -func buildItemGroups(ctx context.Context, items []Item, country string) []itemGroup { +// +// idx is the bus-fed projection cache; when non-nil, every AddItem built here +// has its cache-covered fields (price/tax_class/display/inventory_tracked/ +// drop_ship) overlaid onto the HTTP-fetched answer. Cache-hit calls naturally +// become authoritative for what the projection carries; HTTP stays for +// dimensions (parent width/height for child pricing), seller/orgPrice and the +// dynamic ExtraJson product data. Skipped cleanly when idx is nil. +func buildItemGroups(ctx context.Context, items []Item, country string, idx *catalogProjectionCache) []itemGroup { groups := make([]itemGroup, len(items)) wg := sync.WaitGroup{} for i, itm := range items { wg.Go(func() { + if idx != nil && idx.IsDeleted(itm.Sku) { + log.Printf("error adding item %s: bus-deleted (skipping)", itm.Sku) + return + } parentMsg, parentProduct, err := BuildItemMessage(ctx, itm.Sku, itm.Quantity, country, itm.StoreId, nil) if err != nil { log.Printf("error adding item %s: %v", itm.Sku, err) return } + if idx != nil { + if p, ok := idx.Get(itm.Sku); ok { + ApplyProjectionOverlay(parentMsg, p) + } + } parentMsg.CustomFields = itm.CustomFields groups[i].parent = parentMsg @@ -174,11 +214,20 @@ func buildItemGroups(ctx context.Context, items []Item, country string) []itemGr cwg := sync.WaitGroup{} for j, child := range itm.Children { cwg.Go(func() { + if idx != nil && idx.IsDeleted(child.Sku) { + log.Printf("error adding child %s of %s: bus-deleted (skipping)", child.Sku, itm.Sku) + return + } childMsg, _, err := BuildItemMessage(ctx, child.Sku, child.Quantity, country, child.StoreId, parentProduct) if err != nil { log.Printf("error adding child %s of %s: %v", child.Sku, itm.Sku, err) return } + if idx != nil { + if p, ok := idx.Get(child.Sku); ok { + ApplyProjectionOverlay(childMsg, p) + } + } childMsg.CustomFields = child.CustomFields children[j] = childMsg }) @@ -263,7 +312,7 @@ func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request, if _, err := s.ApplyLocal(r.Context(), id, &messages.ClearCartRequest{}); err != nil { return err } - groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country) + groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country, s.idx) reply, err := s.applyItemGroups(r.Context(), id, groups) if err != nil { return err @@ -286,7 +335,7 @@ func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Reque return err } - groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country) + groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country, s.idx) reply, err := s.applyItemGroups(r.Context(), id, groups) if err != nil { return err @@ -326,7 +375,7 @@ func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request Children: addRequest.Children, CustomFields: addRequest.CustomFields, } - groups := buildItemGroups(r.Context(), []Item{item}, addRequest.Country) + groups := buildItemGroups(r.Context(), []Item{item}, addRequest.Country, s.idx) reply, err := s.applyItemGroups(r.Context(), id, groups) if err != nil { return err diff --git a/cmd/cart/product-fetcher.go b/cmd/cart/product-fetcher.go index 7b05fe1..05a37a3 100644 --- a/cmd/cart/product-fetcher.go +++ b/cmd/cart/product-fetcher.go @@ -201,6 +201,9 @@ func ToItemAddMessage(item *ProductItem, parent *ProductItem, storeId *string, q Name: item.Title, Image: item.Img, Stock: stock, + // item.Vat is the product's VAT as a raw integer percent (e.g. 25); + // ×100 converts to the platform basis-point scale (2500). This is the + // single conversion boundary — everything downstream is basis points. Tax: int32(item.Vat * 100), SellerId: strconv.Itoa(item.SupplierId), SellerName: item.SupplierName, diff --git a/cmd/cart/product-fetcher_integration_test.go b/cmd/cart/product-fetcher_integration_test.go index 28cf01c..71c9858 100644 --- a/cmd/cart/product-fetcher_integration_test.go +++ b/cmd/cart/product-fetcher_integration_test.go @@ -56,7 +56,7 @@ func newTestPoolServer(t *testing.T) *PoolServer { if err != nil { t.Fatalf("new pool: %v", err) } - return NewPoolServer(pool, "test") + return NewPoolServer(pool, "test", nil) } // exampleId is the catalog item the fetcher rework was validated against. @@ -338,7 +338,7 @@ func TestChildren_ParentLinkage(t *testing.T) { Children: []Item{{Sku: exampleId, Quantity: 1, StoreId: &childStore}}, }} - groups := buildItemGroups(ctx, items, "se") + groups := buildItemGroups(ctx, items, "se", nil) if len(groups) != 1 { t.Fatalf("groups = %d, want 1", len(groups)) } diff --git a/cmd/cart/projection_cache_test.go b/cmd/cart/projection_cache_test.go new file mode 100644 index 0000000..b5428f4 --- /dev/null +++ b/cmd/cart/projection_cache_test.go @@ -0,0 +1,170 @@ +package main + +import ( + "testing" + + "git.k6n.net/mats/platform/catalog" + "git.k6n.net/mats/platform/money" +) + +// TestProjectionCache_ApplyUpserts covers the happy path: a batch of N +// projections reaches the cache via Apply, all are visible via Get. +func TestProjectionCache_ApplyUpserts(t *testing.T) { + c := newCatalogProjectionCache() + updates := []catalog.ProjectionUpdate{ + {Projection: catalog.Projection{ID: "id-A", SKU: "A", PriceIncVat: money.Cents(100_00), TaxClass: "standard"}}, + {Projection: catalog.Projection{ID: "id-B", SKU: "B", PriceIncVat: money.Cents(150_00), TaxClass: "reduced"}}, + {Projection: catalog.Projection{ID: "id-C", SKU: "C", PriceIncVat: money.Cents(0), TaxClass: ""}}, + } + upserts, deletes := c.Apply(updates) + if upserts != 3 || deletes != 0 { + t.Fatalf("counts: upserts=%d deletes=%d, want 3/0", upserts, deletes) + } + for _, want := range []struct { + sku string + price money.Cents + taxCls string + }{ + {"A", money.Cents(100_00), "standard"}, + {"B", money.Cents(150_00), "reduced"}, + {"C", money.Cents(0), ""}, + } { + got, ok := c.Get(want.sku) + if !ok { + t.Fatalf("Get(%q): missing", want.sku) + } + if got.PriceIncVat != want.price || got.TaxClass != want.taxCls { + t.Fatalf("Get(%q) = %+v, want price=%d taxCls=%q", want.sku, got, want.price, want.taxCls) + } + } + if c.Len() != 3 { + t.Fatalf("Len = %d, want 3", c.Len()) + } +} + +// TestProjectionCache_DeleteTombstone verifies that a delete update sets a +// tombstone (not removes the entry), so: +// - Get returns (_, false) on a deleted SKU +// - IsDeleted returns true +// - the same entry continues to count toward Len (size accounting) +func TestProjectionCache_DeleteTombstone(t *testing.T) { + c := newCatalogProjectionCache() + c.Apply([]catalog.ProjectionUpdate{ + {Projection: catalog.Projection{ID: "id-A", SKU: "A", PriceIncVat: money.Cents(100_00)}}, + }) + if _, ok := c.Get("A"); !ok { + t.Fatalf("A: expected hit before delete") + } + + upserts, deletes := c.Apply([]catalog.ProjectionUpdate{ + {Projection: catalog.Projection{SKU: "A"}, Deleted: true}, + }) + if upserts != 0 || deletes != 1 { + t.Fatalf("delete counts: upserts=%d deletes=%d, want 0/1", upserts, deletes) + } + + if got, ok := c.Get("A"); ok { + t.Fatalf("A after delete: got %+v ok=true, want miss", got) + } + if !c.IsDeleted("A") { + t.Fatalf("A after delete: IsDeleted false") + } + if c.IsDeleted("UNKNOWN") { + t.Fatalf("UNKNOWN: IsDeleted should be false (entry doesn't exist)") + } + if c.Len() != 1 { + t.Fatalf("Len after tombstone: %d, want 1 (tombstone still in map)", c.Len()) + } +} + +// TestProjectionCache_BusResurrect verifies that a fresh upsert after a delete +// clears the tombstone (live entry replaces it): Get returns the projection, +// IsDeleted flips to false. Mirrors how the live catalog keeps flipping state. +func TestProjectionCache_BusResurrect(t *testing.T) { + c := newCatalogProjectionCache() + c.Apply([]catalog.ProjectionUpdate{ + {Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(50_00)}}, + {Projection: catalog.Projection{SKU: "A"}, Deleted: true}, + }) + if !c.IsDeleted("A") { + t.Fatalf("A: not tombstoned after delete") + } + + c.Apply([]catalog.ProjectionUpdate{ + {Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(75_00)}}, + }) + + got, ok := c.Get("A") + if !ok { + t.Fatalf("A: expected live after re-upsert") + } + if got.PriceIncVat != money.Cents(75_00) { + t.Fatalf("A re-upsert price = %d, want 7500", got.PriceIncVat) + } + if c.IsDeleted("A") { + t.Fatalf("A: still tombstoned after re-upsert") + } +} + +// TestProjectionCache_EmptySKUIgnored validates that an upsert with an empty +// SKU is a no-op (defensive — bus should never publish "" but cheaper to log +// + drop than to populate an ambiguous cache slot). +func TestProjectionCache_EmptySKUIgnored(t *testing.T) { + c := newCatalogProjectionCache() + upserts, _ := c.Apply([]catalog.ProjectionUpdate{ + {Projection: catalog.Projection{SKU: "", PriceIncVat: money.Cents(100_00)}}, + }) + if upserts != 0 { + t.Fatalf("empty-SKU upsert: %d, want 0", upserts) + } + if c.Len() != 0 { + t.Fatalf("Len after empty-SKU: %d, want 0", c.Len()) + } +} + +// TestProjectionCache_BusRaceSafe runs Apply + Get concurrently under -race +// to lock down the lock order. Run via `go test -race ./cmd/cart/...`. +func TestProjectionCache_BusRaceSafe(t *testing.T) { + c := newCatalogProjectionCache() + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 500; i++ { + c.Apply([]catalog.ProjectionUpdate{ + {Projection: catalog.Projection{SKU: "X", PriceIncVat: money.Cents(int64(i * 100))}}, + }) + } + }() + for i := 0; i < 2000; i++ { + _, _ = c.Get("X") + _ = c.IsDeleted("X") + _ = c.Len() + } + <-done + if _, ok := c.Get("X"); !ok { + t.Fatalf("final Get(X): miss after concurrent writes") + } +} + +// TestTaxResolveBp covers the static TaxClass→bp mapping used by +// ApplyProjectionOverlay. Adds a regression net for the common Nordic rates. +func TestTaxResolveBp(t *testing.T) { + cases := []struct { + class string + want int + }{ + {"standard", 2500}, + {"reduced", 1200}, + {"lowered", 600}, + {"zero", 0}, + {"exempt", 0}, + {"", 2500}, // missing class defaults to standard (matches mutation registry) + {"unknown-class", 2500}, + } + for _, tc := range cases { + got := resolveTaxBp(tc.class) + if got != tc.want { + t.Errorf("resolveTaxBp(%q) = %d, want %d", tc.class, got, tc.want) + } + } +} diff --git a/cmd/cart/projection_overlay.go b/cmd/cart/projection_overlay.go new file mode 100644 index 0000000..09786d6 --- /dev/null +++ b/cmd/cart/projection_overlay.go @@ -0,0 +1,97 @@ +package main + +import ( + messages "git.k6n.net/mats/go-cart-actor/proto/cart" + "git.k6n.net/mats/platform/catalog" +) + +// taxClassToBp maps the canonical TaxClass string identifiers published on +// catalog.Projection to basis-points-of-a-percent (rate × 100) — the single +// platform-wide rate scale used by AddItem.Tax (int32 basis points), the +// mutation registry, and the tax.Provider Compute formula. +// +// Values reflect the standard tariff names used by the Nordic configuration. +// Per-country / per-tenant numeric resolution (e.g. Finland 25.5%, Ireland +// 13.5%) is delegated to platform/tax and is a follow-up: this static map +// gets the common case right (2500 / 1200 / 600 / 0) and lets the cache path +// ship while the platform/tax lookup is wired into the cart pool server. +var taxClassToBp = map[string]int{ + "standard": 2500, + "reduced": 1200, // common reduced rate (e.g. SE/NO food) + "lowered": 600, // super-reduced + "zero": 0, // zero-rated (export, healthcare, ...) + "exempt": 0, // similarly untaxed, separate nominal class +} + +// resolveTaxBp returns the basis-point rate for a TaxClass string, falling +// back to platform "standard" (25%) when the class is unrecognised. The +// default matches the grain's existing behaviour (AddItem falls back to 2500 +// when m.Tax == 0 — see pkg/cart/mutation_add_item.go). +func resolveTaxBp(class string) int { + if bp, ok := taxClassToBp[class]; ok { + return bp + } + return 2500 +} + +// ApplyProjectionOverlay overlays the cache's authoritative catalog facts onto +// an AddItem message that was just produced by a PRODUCT_BASE_URL HTTP fetch. +// +// Authoritative-from-cache fields (these WIN over the HTTP-fetched value when +// both are present, because the bus is the live stream): +// +// Price (bus has post-class-overrides; product service has static) +// Tax (TaxClass → bp via resolveTaxBp; only when TaxClass is set — +// an empty TaxClass means the producer didn't classify, +// so we leave the HTTP-fetched rate intact rather than +// quietly swapping in a default 2500 and miscategorising) +// Title (canonical, post-trim) +// Image (canonical) +// InventoryTracked (only flips to true; false is the zero value in proto +// and may be unset on the wire, so we don't second-guess) +// DropShip (only flips to true; same reasoning) +// +// The HTTP-fetched values remain authoritative for fields the cache does not +// carry: SellerId / SellerName (marketplace split), Stock (per docs/inventory- +// shape.md stock is queried synchronously from inventory, not cached), +// OrgPrice / Discount (not yet on the projection schema), the dynamic +// ExtraJson product data (configurator options — width/height used by +// accessory child-pricing), and the catalog uint32 ItemId (the product +// service's integer id which messages.AddItem.ItemId dedupes on — Projection +// only carries the opaque string ID, so a cache-only build path is blocked +// and the HTTP fetch is preserved). +// +// Tombstoned SKUs (catalog.projection_published with deleted=true) → the +// caller checks idx.IsDeleted(sku) before this helper and short-circuits. +func ApplyProjectionOverlay(msg *messages.AddItem, p catalog.Projection) { + if msg == nil { + return + } + // Price: positive bus values override HTTP. A zero PriceIncVat is treated + // as "no value" rather than "free". + if p.PriceIncVat.Int64() > 0 { + msg.Price = p.PriceIncVat.Int64() + } + // Tax: only override when the bus has classified the SKU. An empty TaxClass + // means the producer had nothing to say about VAT; the HTTP rate is more + // trustworthy in that case than a guessed 2500. + if p.TaxClass != "" { + msg.Tax = int32(resolveTaxBp(p.TaxClass)) + } + // Display fields: only override if non-empty so the HTTP-fanned value + // remains in place when the cache hasn't populated them yet. + if p.Title != "" { + msg.Name = p.Title + } + if p.Image != "" { + msg.Image = p.Image + } + // Flags: only flip to true (positive boolean signals). A false is the + // proto-zero value and not worth overriding. + if p.InventoryTracked { + msg.InventoryTracked = true + } + if p.DropShip { + msg.DropShip = true + } +} diff --git a/cmd/cart/projection_overlay_test.go b/cmd/cart/projection_overlay_test.go new file mode 100644 index 0000000..a913b19 --- /dev/null +++ b/cmd/cart/projection_overlay_test.go @@ -0,0 +1,148 @@ +package main + +import ( + "testing" + + messages "git.k6n.net/mats/go-cart-actor/proto/cart" + "git.k6n.net/mats/platform/catalog" + "git.k6n.net/mats/platform/money" +) + +// TestApplyProjectionOverlay_PricePositiveOnly covers the contract that a cache +// hit with a positive PriceIncVat overrides HTTP-fetched price, but a zero +// (unbroadcast) PriceIncVat must NOT overwrite a valid HTTP price — it would +// be a silent corruption. +func TestApplyProjectionOverlay_PricePositiveOnly(t *testing.T) { + startPrice := int64(95_00) + base := func() *messages.AddItem { + return &messages.AddItem{Sku: "X", Price: startPrice, Name: "http-name", Image: "http.jpg", Tax: 2500} + } + + // Positive cache value wins. + m := base() + ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", Title: "cache-name", Image: "cache.jpg", PriceIncVat: money.Cents(120_00), TaxClass: "reduced"}) + if m.Price != 120_00 { + t.Errorf("positive cache price: got %d, want 12000", m.Price) + } + + // Zero cache value must NOT overwrite (treat as "no value"). + m = base() + ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", PriceIncVat: money.Cents(0)}) + if m.Price != startPrice { + t.Errorf("zero cache price overwrote HTTP price: got %d, want %d (HTTP)", m.Price, startPrice) + } +} + +// TestApplyProjectionOverlay_TaxOnlyWhenClassSet verifies the TaxClass-skip on +// an unclassified cache entry: HTTP rate is more trustworthy than a guessed +// 2500 default, so msg.Tax stays untouched. +func TestApplyProjectionOverlay_TaxOnlyWhenClassSet(t *testing.T) { + base := func() *messages.AddItem { + return &messages.AddItem{Sku: "X", Tax: 600} // HTTP-fetched lowered 6% + } + + // Empty TaxClass: HTTP rate preserved. + m := base() + ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", TaxClass: ""}) + if m.Tax != 600 { + t.Errorf("empty TaxClass overwrote HTTP tax: got %d, want 600", m.Tax) + } + + // Non-empty: cache rate wins. + m = base() + ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", TaxClass: "reduced"}) + if m.Tax != 1200 { + t.Errorf("TaxClass=reduced: got %d, want 1200", m.Tax) + } + + m = base() + ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", TaxClass: "zero"}) + if m.Tax != 0 { + t.Errorf("TaxClass=zero: got %d, want 0", m.Tax) + } +} + +// TestApplyProjectionOverlay_DisplayFields verifies Title/Image override only +// when the cache actually carries a value (an unbus-fed "" isn't propagated +// over a valid HTTP value). +func TestApplyProjectionOverlay_DisplayFields(t *testing.T) { + base := func() *messages.AddItem { + return &messages.AddItem{Sku: "X", Name: "http-name", Image: "http.jpg"} + } + + m := base() + ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", Title: "cache-name", Image: "cache.jpg"}) + if m.Name != "cache-name" { + t.Errorf("Title overlay: got %q, want %q", m.Name, "cache-name") + } + if m.Image != "cache.jpg" { + t.Errorf("Image overlay: got %q, want %q", m.Image, "cache.jpg") + } + + // Empty cache Title/Image must NOT overwrite. + m = base() + ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", Title: "", Image: ""}) + if m.Name != "http-name" { + t.Errorf("empty cache Title wiped HTTP name: got %q", m.Name) + } + if m.Image != "http.jpg" { + t.Errorf("empty cache Image wiped HTTP image: got %q", m.Image) + } +} + +// TestApplyProjectionOverlay_FlagFlipToTrueOnly locks down the +// InventoryTracked / DropShip positive-flip semantics. False in the cache +// is the proto zero value and may be unset on the wire; we don't second-guess. +func TestApplyProjectionOverlay_FlagFlipToTrueOnly(t *testing.T) { + // HTTP set both true (e.g. live SKU). Cache also has them true. Should + // stay true. + m := &messages.AddItem{InventoryTracked: true, DropShip: true} + ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", InventoryTracked: true, DropShip: true}) + if !m.InventoryTracked || !m.DropShip { + t.Fatalf("true-from-cache over a true-from-HTTP: %+v", m) + } + + // HTTP set false (default), cache says true → upgrade to true. + m = &messages.AddItem{InventoryTracked: false, DropShip: false} + ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", InventoryTracked: true, DropShip: true}) + if !m.InventoryTracked || !m.DropShip { + t.Errorf("true-from-cache should flip false-from-HTTP: %+v", m) + } + + // HTTP already true, cache says false → must NOT downgrade (false would + // be the proto zero value; trust the live HTTP signal here). + m = &messages.AddItem{InventoryTracked: true, DropShip: true} + ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", InventoryTracked: false, DropShip: false}) + if !m.InventoryTracked || !m.DropShip { + t.Errorf("false-from-cache must NOT overwrite true-from-HTTP: %+v", m) + } +} + +// TestApplyProjectionOverlay_NilMsgSafe confirms the helper tolerates a nil +// pointer (defensive — caller already validates, but a future caller may not). +func TestApplyProjectionOverlay_NilMsgSafe(t *testing.T) { + // Must not panic. + ApplyProjectionOverlay(nil, catalog.Projection{SKU: "X"}) +} + +// TestIsDeletedVsGet asserts the two methods don't conflict: a tombstoned SKU +// is reported by both IsDeleted (true) and Get (miss). +func TestIsDeletedVsGet(t *testing.T) { + c := newCatalogProjectionCache() + c.Apply([]catalog.ProjectionUpdate{ + {Projection: catalog.Projection{SKU: "DL", PriceIncVat: money.Cents(10_00)}}, + {Projection: catalog.Projection{SKU: "DL"}, Deleted: true}, + }) + if _, ok := c.Get("DL"); ok { + t.Fatalf("Get on tombstone: ok=true") + } + if !c.IsDeleted("DL") { + t.Fatalf("IsDeleted on tombstone: false") + } + if c.IsDeleted("DL-fresh") { + t.Fatalf("IsDeleted on absent entry: true") + } + if _, ok := c.Get("DL-fresh"); ok { + t.Fatalf("Get on absent entry: ok=true") + } +} diff --git a/cmd/checkout/adyen-handlers.go b/cmd/checkout/adyen-handlers.go index b0e1d2e..50f7ada 100644 --- a/cmd/checkout/adyen-handlers.go +++ b/cmd/checkout/adyen-handlers.go @@ -12,7 +12,6 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/cart" messages "git.k6n.net/mats/go-cart-actor/proto/checkout" - "git.k6n.net/mats/go-redis-inventory/pkg/inventory" adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout" "github.com/adyen/adyen-go-api-library/v21/src/common" "github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator" @@ -232,27 +231,9 @@ func (s *CheckoutPoolServer) createAdyenOrder(ctx context.Context, checkoutId ca return } - if s.inventoryService != nil && grain.CartState != nil && !grain.InventoryReserved { - if rerr := s.inventoryService.ReserveInventory(ctx, getInventoryRequests(grain.CartState.Items)...); rerr != nil { - log.Printf("from-checkout: inventory reservation failed for %s: %v", checkoutId.String(), rerr) - } else { - // Commit step: release cart reservation since we permanently decremented stock - if s.reservationService != nil { - for _, item := range grain.CartState.Items { - if item == nil || !shouldTrackInventory(item) { - continue - } - if err := s.reservationService.ReleaseForCart(ctx, inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil { - log.Printf("from-checkout: failed to release cart reservation for %s: %v", item.Sku, err) - } - } - } - s.Apply(ctx, uint64(grain.Id), &messages.InventoryReserved{ - Id: grain.Id.String(), - Status: "success", - }) - } - } + // Inventory is NOT committed here — see the note in KlarnaPushHandler. The + // order saga emits order.created and the inventory service reacts + // (commit + release + level crossing), idempotently and off the revenue path. country := getCountryFromCurrency(item.Amount.Currency) if _, oerr := createOrderFromSettledCheckout(ctx, s, grain, settledPayment{ diff --git a/cmd/checkout/checkout_builder.go b/cmd/checkout/checkout_builder.go index 240e145..26cbda3 100644 --- a/cmd/checkout/checkout_builder.go +++ b/cmd/checkout/checkout_builder.go @@ -9,6 +9,7 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/go-cart-actor/pkg/checkout" + "git.k6n.net/mats/platform/tax" adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout" "github.com/adyen/adyen-go-api-library/v21/src/common" ) @@ -66,7 +67,7 @@ type CheckoutMeta struct { // CartItem / Delivery to expose that data and propagate it here. // tp is an optional TaxProvider; when non-nil, its DefaultTaxRate is used for // delivery lines instead of the hardcoded 2500 (25 % as CartItem format). -func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp cart.TaxProvider) ([]byte, *CheckoutOrder, error) { +func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp tax.Provider) ([]byte, *CheckoutOrder, error) { if grain == nil { return nil, nil, fmt.Errorf("nil grain") } @@ -177,19 +178,22 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta } // defaultKlarnaTaxRate returns the default tax rate for the purchase country, in Klarna -// format (percent x 100, e.g. 2500 = 25.00 %). When tp is nil, falls back to 2500 (25 %). -func defaultKlarnaTaxRate(tp cart.TaxProvider, country string) int { +// format (percent x 100, e.g. 2500 = 25.00 %). This matches the platform basis-point +// scale exactly, so DefaultTaxRate is passed through. When tp is nil, falls back to 2500. +func defaultKlarnaTaxRate(tp tax.Provider, country string) int { if tp == nil { return 2500 } - return tp.DefaultTaxRate(country) * 100 + return tp.DefaultTaxRate(country) } // defaultAdyenTaxRate returns the default tax rate for the purchase country, in Adyen -// format (raw percent, e.g. 25 = 25 %). When tp is nil, falls back to 25. -func defaultAdyenTaxRate(tp cart.TaxProvider, country string) int64 { +// format (taxPercentage in basis points, e.g. 2500 = 25 %). This matches the platform +// basis-point scale exactly, so DefaultTaxRate is passed through. When tp is nil, falls +// back to 2500. +func defaultAdyenTaxRate(tp tax.Provider, country string) int64 { if tp == nil { - return 25 + return 2500 } return int64(tp.DefaultTaxRate(country)) } @@ -211,7 +215,7 @@ func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta { } } -func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp cart.TaxProvider) (*adyenCheckout.CreateCheckoutSessionRequest, error) { +func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp tax.Provider) (*adyenCheckout.CreateCheckoutSessionRequest, error) { if grain == nil { return nil, fmt.Errorf("nil grain") } @@ -237,10 +241,10 @@ func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta } lineItems = append(lineItems, adyenCheckout.LineItem{ Quantity: common.PtrInt64(int64(it.Quantity)), - AmountIncludingTax: common.PtrInt64(it.TotalPrice.IncVat), + AmountIncludingTax: common.PtrInt64(it.TotalPrice.IncVat.Int64()), Description: common.PtrString(it.Meta.Name), - AmountExcludingTax: common.PtrInt64(it.TotalPrice.ValueExVat()), - TaxAmount: common.PtrInt64(it.TotalPrice.TotalVat()), + AmountExcludingTax: common.PtrInt64(it.TotalPrice.ValueExVat().Int64()), + TaxAmount: common.PtrInt64(it.TotalPrice.TotalVat().Int64()), TaxPercentage: common.PtrInt64(int64(it.Tax)), }) } @@ -263,8 +267,8 @@ func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta if d == nil { continue } - amountIncTax := d.Price.IncVat - amountExTax := d.Price.ValueExVat() + amountIncTax := d.Price.IncVat.Int64() + amountExTax := d.Price.ValueExVat().Int64() if hasFreeShipping { amountIncTax = 0 amountExTax = 0 @@ -284,7 +288,7 @@ func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta return &adyenCheckout.CreateCheckoutSessionRequest{ Reference: grain.Id.String(), Amount: adyenCheckout.Amount{ - Value: total.IncVat, + Value: total.IncVat.Int64(), Currency: currency, }, CountryCode: common.PtrString(country), diff --git a/cmd/checkout/klarna-handlers.go b/cmd/checkout/klarna-handlers.go index f2ddbb5..7848e7d 100644 --- a/cmd/checkout/klarna-handlers.go +++ b/cmd/checkout/klarna-handlers.go @@ -181,36 +181,12 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re return } - if s.inventoryService != nil { - inventoryRequests := getInventoryRequests(grain.CartState.Items) - invStatus := "success" - if err = s.inventoryService.ReserveInventory(r.Context(), inventoryRequests...); err != nil { - // The payment is already settled at Klarna (checkout_complete), so the - // order MUST be created. Inventory is best-effort at this point — a - // reservation failure (unstocked / drop-ship / non-tracked items) - // must not block order creation. Log it and flag the grain so - // fulfillment can reconcile. - logger.WarnContext(r.Context(), "klarna push: inventory reservation failed; creating order anyway", - "err", err, "checkoutId", grain.Id.String()) - invStatus = "failed" - } else { - // Commit step: successfully decremented stock permanently, now release the temporary cart reservations - if s.reservationService != nil { - for _, item := range grain.CartState.Items { - if item == nil || !shouldTrackInventory(item) { - continue - } - if err := s.reservationService.ReleaseForCart(r.Context(), inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil { - logger.WarnContext(r.Context(), "klarna push: failed to release cart reservation", "sku", item.Sku, "err", err) - } - } - } - } - s.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{ - Id: grain.Id.String(), - Status: invStatus, - }) - } + // Inventory is NOT committed here. Checkout announces a completed sale; the + // order saga emits order.created and the inventory service reacts (commit + + // release the cart hold + emit a level crossing), idempotently. This keeps a + // down inventory service from blocking the revenue path — the payment is + // already settled at Klarna, so the sale is a fact, not a request. See + // docs/inventory-shape.md. s.ApplyAnywhere(r.Context(), grain.Id, &messages.PaymentCompleted{ PaymentId: orderId, @@ -266,8 +242,6 @@ func createOrderFromCheckout(ctx context.Context, s *CheckoutPoolServer, grain * }) } - - func getLocationId(item *cart.CartItem) inventory.LocationID { if item.StoreId == nil || *item.StoreId == "" { return "se" @@ -282,7 +256,7 @@ func shouldTrackInventory(item *cart.CartItem) bool { if item.InventoryTracked { return true } - return item.Cgm == "55010" + return false } func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest { diff --git a/cmd/checkout/main.go b/cmd/checkout/main.go index d393c77..f85769e 100644 --- a/cmd/checkout/main.go +++ b/cmd/checkout/main.go @@ -12,17 +12,17 @@ import ( "git.k6n.net/mats/go-cart-actor/internal/ucp" "git.k6n.net/mats/go-cart-actor/pkg/actor" - "git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/go-cart-actor/pkg/checkout" "git.k6n.net/mats/go-cart-actor/pkg/proxy" "git.k6n.net/mats/go-cart-actor/pkg/telemetry" "git.k6n.net/mats/go-redis-inventory/pkg/inventory" "git.k6n.net/mats/platform/config" + "git.k6n.net/mats/platform/rabbit" + "git.k6n.net/mats/platform/tax" "github.com/adyen/adyen-go-api-library/v21/src/adyen" "github.com/adyen/adyen-go-api-library/v21/src/common" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" - amqp "github.com/rabbitmq/amqp091-go" "github.com/redis/go-redis/v9" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) @@ -55,14 +55,14 @@ var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-servic // selectTaxProvider picks the tax provider from the environment. // Default is NordicTaxProvider with SE as the default country. // Set TAX_PROVIDER=static for dev and tests (no country awareness). -func selectTaxProvider() cart.TaxProvider { +func selectTaxProvider() tax.Provider { switch os.Getenv("TAX_PROVIDER") { case "static": - return cart.NewStaticTaxProvider() + return tax.NewStatic() case "nordic": - return cart.NewNordicTaxProvider(config.EnvString("TAX_DEFAULT_COUNTRY", "SE")) + return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE")) default: - return cart.NewNordicTaxProvider(config.EnvString("TAX_DEFAULT_COUNTRY", "SE")) + return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE")) } } @@ -171,15 +171,28 @@ func main() { } var orderHandler *AmqpOrderHandler - conn, err := amqp.Dial(amqpUrl) + conn, err := rabbit.Dial(amqpUrl, "cart-checkout") if err != nil { log.Fatalf("failed to connect to RabbitMQ: %v", err) } - orderHandler = NewAmqpOrderHandler(conn) + orderHandler = NewAmqpOrderHandler(conn.Connection()) if err := orderHandler.DefineQueue(); err != nil { log.Fatalf("failed to declare order queue: %v", err) } + // Reconnecting order queue consumer: re-define queue and re-consume on reconnect. + conn.NotifyOnReconnect(func() { + if err := orderHandler.DefineQueue(); err != nil { + log.Printf("checkout: define queue on reconnect: %v", err) + } + }) + + // Stream applied checkout mutations to the shared mutations exchange + // (routing key mutation.checkout) for the backoffice live feed. + checkoutFeed := actor.NewAmqpListener(conn.Connection(), "checkout", actor.MutationSummary) + checkoutFeed.DefineTopics() + pool.AddListener(checkoutFeed) + syncedServer = NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient, orderClient, orderHandler) syncedServer.inventoryService = inventoryService syncedServer.reservationService = reservationService diff --git a/cmd/checkout/order_client.go b/cmd/checkout/order_client.go index b8accfc..312c298 100644 --- a/cmd/checkout/order_client.go +++ b/cmd/checkout/order_client.go @@ -66,6 +66,7 @@ type OrderLine struct { Quantity int32 `json:"quantity"` UnitPrice int64 `json:"unitPrice"` TaxRate int32 `json:"taxRate"` + Location string `json:"location,omitempty"` } // CreateOrderResult is the success response from POST /api/orders/from-checkout. diff --git a/cmd/checkout/order_create.go b/cmd/checkout/order_create.go index a31c0d7..b695585 100644 --- a/cmd/checkout/order_create.go +++ b/cmd/checkout/order_create.go @@ -43,21 +43,29 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine { if it == nil { continue } + // Carry the per-item store as the inventory commit location; empty when + // no store (commit then falls back to the order country / central stock). + location := "" + if it.StoreId != nil { + location = *it.StoreId + } lines = append(lines, OrderLine{ Reference: it.Sku, Sku: it.Sku, Name: it.Meta.Name, Quantity: int32(it.Quantity), - UnitPrice: it.Price.IncVat, - // CartItem.Tax is percentage x 100 (e.g. 2500 = 25.00%). Convert to raw percent (25). - TaxRate: int32(it.Tax / 100), + UnitPrice: it.Price.IncVat.Int64(), + // CartItem.Tax and OrderLine.TaxRate share the platform basis-point scale + // (2500 = 25%), so the rate passes through unchanged. + TaxRate: int32(it.Tax), + Location: location, }) } for _, d := range grain.Deliveries { if d == nil { continue } - unitPrice := d.Price.IncVat + unitPrice := d.Price.IncVat.Int64() if hasFreeShipping { unitPrice = 0 } @@ -70,7 +78,7 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine { Name: "Delivery", Quantity: 1, UnitPrice: unitPrice, - TaxRate: 25, + TaxRate: 2500, // 25% in basis points }) } @@ -82,7 +90,7 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine { } discountVal := int64(0) if ap.Discount != nil { - discountVal = ap.Discount.IncVat + discountVal = ap.Discount.IncVat.Int64() } if discountVal <= 0 { continue @@ -93,7 +101,7 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine { Name: "Promotion: " + ap.Name, Quantity: 1, UnitPrice: -discountVal, - TaxRate: 25, // default standard tax rate for promotion discount + TaxRate: 2500, // default standard tax rate (25%) in basis points }) } } diff --git a/cmd/checkout/pool-server.go b/cmd/checkout/pool-server.go index 72c9624..5d31e30 100644 --- a/cmd/checkout/pool-server.go +++ b/cmd/checkout/pool-server.go @@ -13,6 +13,7 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/go-cart-actor/pkg/checkout" + "git.k6n.net/mats/platform/tax" messages "git.k6n.net/mats/go-cart-actor/proto/checkout" adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen" @@ -53,7 +54,7 @@ type CheckoutPoolServer struct { orderHandler *AmqpOrderHandler inventoryService *inventory.RedisInventoryService reservationService *inventory.RedisCartReservationService - taxProvider cart.TaxProvider + taxProvider tax.Provider } func NewCheckoutPoolServer(pool actor.GrainPool[checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient, orderClient *OrderClient, orderHandler *AmqpOrderHandler) *CheckoutPoolServer { diff --git a/cmd/inventory/commit.go b/cmd/inventory/commit.go new file mode 100644 index 0000000..ea0854f --- /dev/null +++ b/cmd/inventory/commit.go @@ -0,0 +1,69 @@ +package main + +import ( + "context" + "log" + "time" + + "git.k6n.net/mats/go-redis-inventory/pkg/inventory" + contract "git.k6n.net/mats/platform/order" + "git.k6n.net/mats/platform/rabbit" + "github.com/redis/go-redis/v9" +) + +// commitOrder applies an order.created event to inventory. The sale already +// happened, so this is an unconditional permanent decrement (it may drive stock +// negative — visible oversell, reconciled downstream as backorder), NOT a +// check-and-reserve that could refuse. It then releases the cart's TTL hold and +// emits the resulting level crossing. Idempotent per order via a Redis marker, +// since the bus is at-least-once. +func commitOrder(ctx context.Context, rdb *redis.Client, svc *inventory.RedisInventoryService, resv *inventory.RedisCartReservationService, conn *rabbit.Conn, country string, low int64, c contract.Created) { + if c.OrderID == "" { + return + } + // Idempotency: first writer wins; redelivery is a no-op. + fresh, err := rdb.SetNX(ctx, "invcommit:"+c.OrderID, "1", 30*24*time.Hour).Result() + if err != nil { + log.Printf("inventory: commit idempotency order=%s: %v", c.OrderID, err) + return + } + if !fresh { + return // already committed + } + // Default location for lines that don't carry their own: the order country + // (central stock), falling back to the service's configured country. + defaultLoc := c.Country + if defaultLoc == "" { + defaultLoc = country + } + for _, line := range c.Lines { + if line.SKU == "" || line.Quantity <= 0 { + continue + } + loc := inventory.LocationID(defaultLoc) + if line.Location != "" { + loc = inventory.LocationID(line.Location) + } + sku := inventory.SKU(line.SKU) + pipe := rdb.Pipeline() + if err := svc.DecrementInventory(ctx, pipe, sku, loc, int64(line.Quantity)); err != nil { + log.Printf("inventory: commit decrement sku=%s order=%s: %v", sku, c.OrderID, err) + continue + } + if _, err := pipe.Exec(ctx); err != nil { + log.Printf("inventory: commit exec sku=%s order=%s: %v", sku, c.OrderID, err) + continue + } + // Release the cart's TTL hold now the sale is committed (best-effort — + // it would expire on its own anyway). + if c.CartID != "" && resv != nil { + if err := resv.ReleaseForCart(ctx, sku, loc, inventory.CartID(c.CartID)); err != nil { + log.Printf("inventory: commit release sku=%s cart=%s: %v", sku, c.CartID, err) + } + } + // Emit the level crossing from the new on-hand. + if newQty, err := svc.GetInventory(ctx, sku, loc); err == nil { + emitLevelIfChanged(ctx, rdb, conn, country, low, string(sku), string(loc), newQty) + } + } +} diff --git a/cmd/inventory/level.go b/cmd/inventory/level.go new file mode 100644 index 0000000..32d142d --- /dev/null +++ b/cmd/inventory/level.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "log" + "strconv" + "time" + + "git.k6n.net/mats/platform/event" + invlevel "git.k6n.net/mats/platform/inventory" + "git.k6n.net/mats/platform/rabbit" + "github.com/redis/go-redis/v9" +) + +// emitLevelIfChanged classifies qty into a Level and emits an +// inventory.level_changed bus event only when it differs from the level last +// emitted for this SKU+location (the levelKey marker). Exact quantity never +// leaves the service; only the coarse crossing does. Shared by every write path +// (catalog feed, order.created commit). A nil conn (no bus) makes it a no-op. +func emitLevelIfChanged(ctx context.Context, rdb *redis.Client, conn *rabbit.Conn, country string, low int64, sku, loc string, qty int64) { + if conn == nil { + return + } + lvl := invlevel.LevelFor(qty, low) + // Atomic read-old + write-new of the level marker; redis.Nil means the + // marker didn't exist yet (first observation), which we treat as a crossing. + prev, err := rdb.SetArgs(ctx, levelKey(sku, loc), string(lvl), redis.SetArgs{Get: true}).Result() + if err != nil && err != redis.Nil { + log.Printf("inventory: level marker sku=%s: %v", sku, err) + return + } + if prev == string(lvl) { + return // no threshold crossing — stay off the bus + } + payload, err := invlevel.LevelChanged{SKU: sku, Location: loc, Level: lvl}.Encode() + if err != nil { + log.Printf("inventory: encode level payload sku=%s: %v", sku, err) + return + } + ev := event.Event{ + ID: "invlvl-" + sku + "-" + loc + "-" + strconv.FormatInt(time.Now().UnixNano(), 36), + Type: event.InventoryLevelChanged, + Source: "inventory", + Subject: sku, + Payload: payload, + Time: time.Now(), + Meta: map[string]string{"country": country}, + } + if err := rabbit.PublishEvent(conn.Connection(), "inventory", ev); err != nil { + log.Printf("inventory: publish level event sku=%s: %v", sku, err) + } +} diff --git a/cmd/inventory/main.go b/cmd/inventory/main.go index bb82b6b..99a6f86 100644 --- a/cmd/inventory/main.go +++ b/cmd/inventory/main.go @@ -6,14 +6,18 @@ import ( "log" "net/http" "os" + "strconv" + "strings" "sync" "git.k6n.net/mats/go-redis-inventory/pkg/inventory" + "git.k6n.net/mats/platform/event" + orderevent "git.k6n.net/mats/platform/order" "git.k6n.net/mats/slask-finder/pkg/index" - "git.k6n.net/mats/slask-finder/pkg/messaging" "github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9/maintnotifications" + "git.k6n.net/mats/platform/rabbit" amqp "github.com/rabbitmq/amqp091-go" ) @@ -66,6 +70,12 @@ var country = "se" var redisAddress = "10.10.3.18:6379" var redisPassword = "slaskredis" +// lowWatermark is the cutoff between "low" and "high" stock for the level +// crossings emitted on the bus (0 < qty <= lowWatermark ⇒ low). Single global +// number for now; per-SKU / per-tenant thresholds can come later behind the +// same inventory.LevelChanged payload. +var lowWatermark int64 = 5 + func init() { // Override redis config from environment variables if set if addr, ok := os.LookupEnv("REDIS_ADDRESS"); ok { @@ -77,6 +87,19 @@ func init() { if ctry, ok := os.LookupEnv("COUNTRY"); ok { country = ctry } + if v, ok := os.LookupEnv("INVENTORY_LOW_WATERMARK"); ok { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + lowWatermark = n + } + } +} + +// levelKey is the Redis key holding the last-emitted Level for a SKU+location. +// It is the crossing-detection marker: the level listener only emits when the +// freshly computed level differs from this stored value, so the bus carries +// crossings, not every quantity tick. +func levelKey(sku, loc string) string { + return "invlevel:" + sku + ":" + loc } func main() { @@ -114,39 +137,121 @@ func main() { rdb: rdb, ctx: ctx, svc: *s, + country: country, + low: lowWatermark, } amqpUrl, ok := os.LookupEnv("RABBIT_HOST") if ok { log.Printf("Connecting to rabbitmq") - conn, err := amqp.DialConfig(amqpUrl, amqp.Config{ - Properties: amqp.NewConnectionProperties(), - }) - //a.conn = conn + conn, err := rabbit.Dial(amqpUrl, "cart-inventory") if err != nil { log.Fatalf("Failed to connect to RabbitMQ: %v", err) } - ch, err := conn.Channel() - if err != nil { - log.Fatalf("Failed to open a channel: %v", err) - } - // items listener - err = messaging.ListenToTopic(ch, country, "item_added", func(d amqp.Delivery) error { - wg := &sync.WaitGroup{} - err = index.ForEachRawDataItemInJSONArray(d.Body, func(item *index.RawDataItem) error { - stockhandler.HandleItem(item, wg) + // The catalog-feed handler emits inventory.level_changed crossings + // directly on this connection as it writes stock. + stockhandler.conn = conn + // Reconnecting consumer: re-listen on reconnect. + conn.NotifyOnReconnect(func() { + ch, err := conn.Channel() + if err != nil { + log.Printf("inventory: channel on reconnect: %v", err) + return + } + + // Producer side: declare the "inventory" topic exchange we publish + // inventory.level_changed crossings to. Durable + idempotent, so + // re-declaring on each reconnect is safe. + if err := ch.ExchangeDeclare("inventory", "topic", true, false, false, false, nil); err != nil { + log.Printf("inventory: declare inventory exchange: %v", err) + } + + // 1) LEGACY raw `catalog.item_changed` wire — payload is a raw item + // JSON array. Consumed by the historical pricewatcher + + // writeradmin item-changed paths. Carries RawDataItem bodies + // with full finder-style metadata (including the stock + // field that the new projection wire does not). + // + // This listener is the one that mutates Redis stock state via + // StockHandler.HandleItem. + if err := rabbit.ListenToPattern(ch, "catalog", string(event.CatalogItemChanged), func(d amqp.Delivery) error { + var ev event.Event + if err := json.Unmarshal(d.Body, &ev); err != nil { + log.Printf("inventory: decode catalog event: %v", err) + return nil + } + if ev.Meta["country"] != "" && ev.Meta["country"] != country { + return nil // not for our country + } + wg := &sync.WaitGroup{} + if err := index.ForEachRawDataItemInJSONArray(ev.Payload, func(item *index.RawDataItem) error { + stockhandler.HandleItem(item, wg) + return nil + }); err != nil { + log.Printf("inventory: apply items: %v", err) + } wg.Wait() log.Print("Batch done...") - return err - }) - return nil + return nil + }); err != nil { + log.Printf("inventory: listen on reconnect: %v", err) + } + + // 2) (REMOVED) `catalog.projection_published` listener — moved to + // the cart service. Per docs/inventory-shape.md, inventory's + // bus role is emit-only (inventory.level_changed crossings back + // to finder on the inventory exchange). The catalog projection + // is read by the cart at checkout-time lookups via its own + // in-memory CatalogCache, not by inventory. Stock state stays + // sourced from the legacy raw wire (block 1 below) until + // inventory itself emits level_changed on a stock mutation. + + // 3) order.created → commit stock (permanent decrement), release the + // cart hold, emit the level crossing. This is the async commit + // path: checkout no longer decrements synchronously; it announces + // a completed sale and inventory reacts. Idempotent per order. + if err := rabbit.ListenToPattern(ch, "order", string(event.OrderCreated), func(d amqp.Delivery) error { + var ev event.Event + if err := json.Unmarshal(d.Body, &ev); err != nil { + log.Printf("inventory: decode order event: %v", err) + return nil + } + if target := countryForEvent(&ev); target != "" && target != country { + return nil // not for our country/tenant + } + created, err := orderevent.Decode(ev.Payload) + if err != nil { + log.Printf("inventory: decode order.created: %v", err) + return nil + } + commitOrder(ctx, rdb, s, r, conn, country, lowWatermark, created) + return nil + }); err != nil { + log.Printf("inventory: listen order.created on reconnect: %v", err) + } }) - if err != nil { - log.Fatalf("Failed to listen to item_added topic: %v", err) - } } // Start HTTP server log.Println("Starting HTTP server on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) } + +// countryForEvent extracts the best-available country/tenant identifier +// from an envelope. Lookup order: Meta["country"], Meta["tenant"], then +// the Source prefix "catalog:". An empty string means "no decoration" — +// callers treat that as "for everyone" (same as the legacy listener). +func countryForEvent(ev *event.Event) string { + if t := strings.TrimSpace(ev.Meta["country"]); t != "" { + return t + } + if t := strings.TrimSpace(ev.Meta["tenant"]); t != "" { + return t + } + if strings.HasPrefix(ev.Source, "catalog:") { + if t := strings.TrimSpace(strings.TrimPrefix(ev.Source, "catalog:")); t != "" { + return t + } + } + return "" +} diff --git a/cmd/inventory/stockhandler.go b/cmd/inventory/stockhandler.go index 1c069bb..7610272 100644 --- a/cmd/inventory/stockhandler.go +++ b/cmd/inventory/stockhandler.go @@ -6,6 +6,7 @@ import ( "sync" "git.k6n.net/mats/go-redis-inventory/pkg/inventory" + "git.k6n.net/mats/platform/rabbit" "git.k6n.net/mats/slask-finder/pkg/types" "github.com/redis/go-redis/v9" ) @@ -15,12 +16,17 @@ type StockHandler struct { ctx context.Context svc inventory.RedisInventoryService MainStockLocationID inventory.LocationID + + // Bus producer config. conn is nil when RABBIT_HOST is unset (no bus); the + // handler still updates Redis and just skips emitting. + conn *rabbit.Conn + country string + low int64 } func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) { wg.Go(func() { ctx := s.ctx - pipe := s.rdb.Pipeline() centralStock, ok := item.GetNumberFieldValue("inStock") if !ok { centralStock = 0 @@ -28,16 +34,17 @@ func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) { sku, ok := item.GetStringFieldValue("sku") if !ok { log.Printf("unable to parse central stock for item: sku missing") - centralStock = 0 - } else { - s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), s.MainStockLocationID, int64(centralStock)) + return } - // for id, value := range item.GetStock() { - // s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), inventory.LocationID(id), int64(value)) - // } - _, err := pipe.Exec(ctx) - if err != nil { + // One linear pass: write the authoritative quantity, then derive and + // emit the level crossing from the same value. No Redis pub/sub + // round-trip — Redis stays the internal store, the bus carries levels. + pipe := s.rdb.Pipeline() + s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), s.MainStockLocationID, int64(centralStock)) + if _, err := pipe.Exec(ctx); err != nil { log.Printf("unable to update stock: %v", err) + return } + emitLevelIfChanged(ctx, s.rdb, s.conn, s.country, s.low, sku, string(s.MainStockLocationID), int64(centralStock)) }) } diff --git a/cmd/order/amqp.go b/cmd/order/amqp.go index 5abb358..1472873 100644 --- a/cmd/order/amqp.go +++ b/cmd/order/amqp.go @@ -6,6 +6,7 @@ import ( "sync" "time" + "git.k6n.net/mats/platform/rabbit" amqp "github.com/rabbitmq/amqp091-go" ) @@ -25,7 +26,7 @@ func newRabbitPublisher(url string) *rabbitPublisher { } func (p *rabbitPublisher) connect() error { - conn, err := amqp.Dial(p.url) + conn, err := rabbit.Connect(p.url, "cart-order-publisher") if err != nil { return err } @@ -91,50 +92,50 @@ func ingestOrderFromQueue(ctx context.Context, s *server, body []byte) error { } // startOrderIngest connects to AMQP and consumes "order-queue" until ctx is -// done. +// done. The connection is reconnecting — on broker blips the caller re-declares +// the queue and re-consumes automatically. func startOrderIngest(ctx context.Context, amqpURL string, s *server) { - conn, err := amqp.Dial(amqpURL) + conn, err := rabbit.Dial(amqpURL, "cart-order-ingest") if err != nil { s.logger.Warn("order ingest disabled: amqp dial failed", "err", err) return } - ch, err := conn.Channel() - if err != nil { - s.logger.Warn("order ingest disabled: channel failed", "err", err) - _ = conn.Close() - return - } - q, err := ch.QueueDeclare("order-queue", false, false, false, false, nil) - if err != nil { - s.logger.Warn("order ingest disabled: queue declare failed", "err", err) - _ = ch.Close() - _ = conn.Close() - return - } - msgs, err := ch.Consume(q.Name, "order-service", true, false, false, false, nil) - if err != nil { - s.logger.Warn("order ingest disabled: consume failed", "err", err) - _ = ch.Close() - _ = conn.Close() - return - } - s.logger.Info("ingesting orders from order-queue") - go func() { - defer conn.Close() - defer ch.Close() - for { - select { - case <-ctx.Done(): - return - case d, ok := <-msgs: - if !ok { - s.logger.Warn("order ingest: channel closed") + // Reconnecting consumer: re-declare queue and re-consume on reconnect. + conn.NotifyOnReconnect(func() { + ch, err := conn.Channel() + if err != nil { + s.logger.Warn("order ingest: channel on reconnect", "err", err) + return + } + q, err := ch.QueueDeclare("order-queue", false, false, false, false, nil) + if err != nil { + s.logger.Warn("order ingest: queue declare on reconnect", "err", err) + _ = ch.Close() + return + } + msgs, err := ch.Consume(q.Name, "order-service", true, false, false, false, nil) + if err != nil { + s.logger.Warn("order ingest: consume on reconnect", "err", err) + _ = ch.Close() + return + } + s.logger.Info("order ingest: consuming from order-queue (reconnect)") + go func() { + defer ch.Close() + for { + select { + case <-ctx.Done(): return - } - if err := ingestOrderFromQueue(ctx, s, d.Body); err != nil { - s.logger.Error("order queue ingest failed", "err", err) + case d, ok := <-msgs: + if !ok { + s.logger.Warn("order ingest: channel closed (will reconnect)") + return + } + if err := ingestOrderFromQueue(ctx, s, d.Body); err != nil { + s.logger.Error("order queue ingest failed", "err", err) + } } } - } - }() + }() + }) } diff --git a/cmd/order/handlers.go b/cmd/order/handlers.go index 3136384..09cc476 100644 --- a/cmd/order/handlers.go +++ b/cmd/order/handlers.go @@ -66,9 +66,33 @@ func (s *server) loadOrder(w http.ResponseWriter, r *http.Request) (order.OrderI return id, g, true } +// checkIdempotency checks if the request is an idempotent retry. +// It returns (idemKey, exists, unlockFn). +// If exists is true, the caller should write the current order grain status and return immediately. +// If exists is false, the caller must defer unlockFn() and continue. +func (s *server) checkIdempotency(w http.ResponseWriter, r *http.Request, id order.OrderId) (string, bool, func()) { + idemKey := r.Header.Get("Idempotency-Key") + if idemKey == "" { + return "", false, func() {} + } + unlock := s.idem.Lock(idemKey) + if _, ok := s.idem.Get(idemKey); ok { + unlock() // Release lock immediately since we are returning cached response + s.logger.Info("idempotency hit for lifecycle endpoint", "key", idemKey, "orderId", id.String()) + grain, err := s.applier.Get(r.Context(), uint64(id)) + if err != nil { + writeErr(w, http.StatusInternalServerError, err) + return idemKey, true, nil + } + writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "order": grain}) + return idemKey, true, nil + } + return idemKey, false, unlock +} + // applyAndRespond applies a mutation and returns the updated order. A rejected // state transition (handler error) maps to 409 Conflict. -func (s *server) applyAndRespond(w http.ResponseWriter, r *http.Request, id order.OrderId, msg proto.Message) { +func (s *server) applyAndRespond(w http.ResponseWriter, r *http.Request, id order.OrderId, msg proto.Message, idemKey string) { res, err := s.applier.Apply(r.Context(), uint64(id), msg) if err != nil { writeErr(w, http.StatusInternalServerError, err) @@ -80,6 +104,11 @@ func (s *server) applyAndRespond(w http.ResponseWriter, r *http.Request, id orde return } } + if idemKey != "" { + if err := s.idem.Put(idemKey, uint64(id)); err != nil { + s.logger.Error("idempotency record failed", "key", idemKey, "orderId", id.String(), "err", err) + } + } grain, err := s.applier.Get(r.Context(), uint64(id)) if err != nil { writeErr(w, http.StatusInternalServerError, err) @@ -105,6 +134,12 @@ func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) { if !ok { return } + idemKey, exists, unlock := s.checkIdempotency(w, r, id) + if exists { + return + } + defer unlock() + var req fulfillReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, err) @@ -182,7 +217,7 @@ func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) { for _, l := range req.Lines { msg.Lines = append(msg.Lines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity}) } - s.applyAndRespond(w, r, id, msg) + s.applyAndRespond(w, r, id, msg, idemKey) } type exchangeReq struct { @@ -208,6 +243,12 @@ func (s *server) handleExchange(w http.ResponseWriter, r *http.Request) { if !ok { return } + idemKey, exists, unlock := s.checkIdempotency(w, r, id) + if exists { + return + } + defer unlock() + var req exchangeReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, err) @@ -236,7 +277,7 @@ func (s *server) handleExchange(w http.ResponseWriter, r *http.Request) { TotalTax: l.TotalTax, }) } - s.applyAndRespond(w, r, id, msg) + s.applyAndRespond(w, r, id, msg, idemKey) } type editDetailsReq struct { @@ -250,6 +291,12 @@ func (s *server) handleEditDetails(w http.ResponseWriter, r *http.Request) { if !ok { return } + idemKey, exists, unlock := s.checkIdempotency(w, r, id) + if exists { + return + } + defer unlock() + var req editDetailsReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, err) @@ -261,16 +308,21 @@ func (s *server) handleEditDetails(w http.ResponseWriter, r *http.Request) { ShippingPrice: req.ShippingPrice, AtMs: nowMs(), } - s.applyAndRespond(w, r, id, msg) + s.applyAndRespond(w, r, id, msg, idemKey) } - func (s *server) handleComplete(w http.ResponseWriter, r *http.Request) { id, _, ok := s.loadOrder(w, r) if !ok { return } - s.applyAndRespond(w, r, id, &messages.CompleteOrder{AtMs: nowMs()}) + idemKey, exists, unlock := s.checkIdempotency(w, r, id) + if exists { + return + } + defer unlock() + + s.applyAndRespond(w, r, id, &messages.CompleteOrder{AtMs: nowMs()}, idemKey) } func (s *server) handleCancel(w http.ResponseWriter, r *http.Request) { @@ -278,11 +330,17 @@ func (s *server) handleCancel(w http.ResponseWriter, r *http.Request) { if !ok { return } + idemKey, exists, unlock := s.checkIdempotency(w, r, id) + if exists { + return + } + defer unlock() + var req struct { Reason string `json:"reason"` } _ = json.NewDecoder(r.Body).Decode(&req) - s.applyAndRespond(w, r, id, &messages.CancelOrder{Reason: req.Reason, AtMs: nowMs()}) + s.applyAndRespond(w, r, id, &messages.CancelOrder{Reason: req.Reason, AtMs: nowMs()}, idemKey) } type returnReq struct { @@ -298,6 +356,12 @@ func (s *server) handleReturn(w http.ResponseWriter, r *http.Request) { if !ok { return } + idemKey, exists, unlock := s.checkIdempotency(w, r, id) + if exists { + return + } + defer unlock() + var req returnReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, err) @@ -308,7 +372,7 @@ func (s *server) handleReturn(w http.ResponseWriter, r *http.Request) { for _, l := range req.Lines { msg.Lines = append(msg.Lines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity}) } - s.applyAndRespond(w, r, id, msg) + s.applyAndRespond(w, r, id, msg, idemKey) } func (s *server) handleRefund(w http.ResponseWriter, r *http.Request) { @@ -316,13 +380,19 @@ func (s *server) handleRefund(w http.ResponseWriter, r *http.Request) { if !ok { return } + idemKey, exists, unlock := s.checkIdempotency(w, r, id) + if exists { + return + } + defer unlock() + var req struct { Amount int64 `json:"amount"` } _ = json.NewDecoder(r.Body).Decode(&req) amount := req.Amount if amount <= 0 { - amount = g.CapturedAmount - g.RefundedAmount // default: full remaining + amount = (g.CapturedAmount - g.RefundedAmount).Int64() // default: full remaining } captureRef := capturedRef(g) ref, err := s.provider.Refund(r.Context(), captureRef, amount) @@ -335,7 +405,7 @@ func (s *server) handleRefund(w http.ResponseWriter, r *http.Request) { Amount: ref.Amount, Reference: ref.Reference, AtMs: nowMs(), - }) + }, idemKey) } // --- saga / flow admin (a) ------------------------------------------------ diff --git a/cmd/order/handlers_checkout.go b/cmd/order/handlers_checkout.go index 5f5f4e0..b6ab39e 100644 --- a/cmd/order/handlers_checkout.go +++ b/cmd/order/handlers_checkout.go @@ -10,6 +10,7 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/flow" "git.k6n.net/mats/go-cart-actor/pkg/order" messages "git.k6n.net/mats/go-cart-actor/proto/order" + "git.k6n.net/mats/platform/money" ) // fromCheckoutReq is the payload the checkout service sends to create an order @@ -164,7 +165,7 @@ func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messag var total, totalTax int64 for _, l := range req.Lines { lineTotal := l.UnitPrice * int64(l.Quantity) - lineTax := order.ComputeTax(lineTotal, int(l.TaxRate)) + lineTax := order.ComputeTax(money.Cents(lineTotal), int(l.TaxRate)).Int64() total += lineTotal totalTax += lineTax po.Lines = append(po.Lines, &messages.OrderLine{ @@ -176,6 +177,7 @@ func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messag TaxRate: l.TaxRate, TotalAmount: lineTotal, TotalTax: lineTax, + Location: l.Location, }) } po.TotalAmount = total diff --git a/cmd/order/idempotency_lifecycle_test.go b/cmd/order/idempotency_lifecycle_test.go new file mode 100644 index 0000000..2df38e3 --- /dev/null +++ b/cmd/order/idempotency_lifecycle_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + + "git.k6n.net/mats/go-cart-actor/pkg/order" +) + +func TestLifecycleIdempotencyRefund(t *testing.T) { + s := testServer(t) + s.provider = order.NewMockProvider() + ctx := context.Background() + + // Ingest an order to get it into Captured status + body := []byte(`{ + "checkoutId":"K-1","idempotencyKey":"checkout-K-1","cartId":"cart-1", + "currency":"SEK","country":"SE","customerEmail":"customer@example.com", + "lines":[{"reference":"l1","sku":"l1","name":"Widget","quantity":1,"unitPrice":15000,"taxRate":25}], + "payment":{"provider":"klarna","reference":"ref-123","amount":15000} + }`) + if err := ingestOrderFromQueue(ctx, s, body); err != nil { + t.Fatalf("ingest: %v", err) + } + + orderID, ok := s.idem.Get("checkout-K-1") + if !ok { + t.Fatalf("expected order ID to be stored in idempotency cache") + } + + // ── First refund request (with idempotency key) ──────────────────────── + reqBody := []byte(`{"amount": 5000}`) + req1 := httptest.NewRequest("POST", "/api/orders/"+order.OrderId(orderID).String()+"/refunds", bytes.NewReader(reqBody)) + req1.Header.Set("Idempotency-Key", "refund-key-123") + req1.SetPathValue("id", order.OrderId(orderID).String()) + w1 := httptest.NewRecorder() + + s.handleRefund(w1, req1) + + if w1.Code != http.StatusOK { + t.Fatalf("first refund status = %d, want 200", w1.Code) + } + + g1, err := s.applier.Get(ctx, orderID) + if err != nil { + t.Fatal(err) + } + if g1.RefundedAmount != 5000 { + t.Fatalf("refunded amount after first call = %d, want 5000", g1.RefundedAmount) + } + + // ── Second refund request (with the same idempotency key) ────────────── + req2 := httptest.NewRequest("POST", "/api/orders/"+order.OrderId(orderID).String()+"/refunds", bytes.NewReader(reqBody)) + req2.Header.Set("Idempotency-Key", "refund-key-123") + req2.SetPathValue("id", order.OrderId(orderID).String()) + w2 := httptest.NewRecorder() + + s.handleRefund(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("second refund status = %d, want 200", w2.Code) + } + + g2, err := s.applier.Get(ctx, orderID) + if err != nil { + t.Fatal(err) + } + // It should NOT have refunded another 5000 (total refunded remains 5000) + if g2.RefundedAmount != 5000 { + t.Fatalf("refunded amount after second call = %d, want 5000 (idempotency hit failed, did double refund)", g2.RefundedAmount) + } +} + +func TestLifecycleWithoutIdempotencyRefund(t *testing.T) { + s := testServer(t) + s.provider = order.NewMockProvider() + ctx := context.Background() + + // Ingest an order to get it into Captured status + body := []byte(`{ + "checkoutId":"K-1","idempotencyKey":"checkout-K-1","cartId":"cart-1", + "currency":"SEK","country":"SE","customerEmail":"customer@example.com", + "lines":[{"reference":"l1","sku":"l1","name":"Widget","quantity":1,"unitPrice":15000,"taxRate":25}], + "payment":{"provider":"klarna","reference":"ref-123","amount":15000} + }`) + if err := ingestOrderFromQueue(ctx, s, body); err != nil { + t.Fatalf("ingest: %v", err) + } + + orderID, ok := s.idem.Get("checkout-K-1") + if !ok { + t.Fatalf("expected order ID to be stored in idempotency cache") + } + + // First refund request (no idempotency key) + reqBody := []byte(`{"amount": 5000}`) + req1 := httptest.NewRequest("POST", "/api/orders/"+order.OrderId(orderID).String()+"/refunds", bytes.NewReader(reqBody)) + req1.SetPathValue("id", order.OrderId(orderID).String()) + w1 := httptest.NewRecorder() + + s.handleRefund(w1, req1) + + if w1.Code != http.StatusOK { + t.Fatalf("first refund status = %d, want 200", w1.Code) + } + + // Second refund request (no idempotency key) + req2 := httptest.NewRequest("POST", "/api/orders/"+order.OrderId(orderID).String()+"/refunds", bytes.NewReader(reqBody)) + req2.SetPathValue("id", order.OrderId(orderID).String()) + w2 := httptest.NewRecorder() + + s.handleRefund(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("second refund status = %d, want 200", w2.Code) + } + + g2, err := s.applier.Get(ctx, orderID) + if err != nil { + t.Fatal(err) + } + // Without idempotency, it SHOULD have refunded twice (total 10000) + if g2.RefundedAmount != 10000 { + t.Fatalf("refunded amount after second call without idempotency = %d, want 10000", g2.RefundedAmount) + } +} diff --git a/cmd/order/main.go b/cmd/order/main.go index 2b9caeb..d9ba9da 100644 --- a/cmd/order/main.go +++ b/cmd/order/main.go @@ -28,6 +28,8 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/outbox" messages "git.k6n.net/mats/go-cart-actor/proto/order" "git.k6n.net/mats/platform/config" + "git.k6n.net/mats/platform/rabbit" + "git.k6n.net/mats/platform/tax" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" @@ -42,7 +44,7 @@ type server struct { freg *flow.Registry flows *flowStore provider order.PaymentProvider - taxProvider order.TaxProvider + taxProvider tax.Provider defaultFlow string dataDir string idem *idempotency.Store @@ -112,11 +114,37 @@ func main() { } go box.Run(context.Background(), newRabbitPublisher(amqpURL), 5*time.Second, logger) emitPub = box + + // Stream applied order mutations to the shared mutations exchange (routing + // key mutation.order) for the backoffice live feed. Best-effort, separate + // from the durable outbox above. + if conn, derr := rabbit.Dial(amqpURL, "order"); derr != nil { + logger.Warn("order: mutation feed disabled", "err", derr) + } else { + feed := actor.NewAmqpListener(conn.Connection(), "order", actor.MutationSummary) + feed.DefineTopics() + pool.AddListener(feed) + + // Declare the "order" topic exchange the outbox relay publishes + // order.created to, so a publish can't hit a missing exchange before + // a consumer declares it. Durable + idempotent. + if ch, cerr := conn.Channel(); cerr != nil { + logger.Warn("order: declare order exchange: channel", "err", cerr) + } else { + if eerr := ch.ExchangeDeclare("order", "topic", true, false, false, false, nil); eerr != nil { + logger.Warn("order: declare order exchange", "err", eerr) + } + _ = ch.Close() + } + } } freg := flow.NewRegistry() flow.RegisterBuiltinHooks(freg) order.RegisterFlowActions(freg, applier, provider) order.RegisterEmitHook(freg, emitPub) + // order.created domain event → durable outbox → "order" topic exchange. + // Reactors: inventory commit, fulfillment allocation. + order.RegisterOrderCreatedEmit(freg, applier, emitPub, "order", "order") engine := flow.NewEngine(freg, logger) def, err := order.EmbeddedFlow("place-and-pay") @@ -248,8 +276,9 @@ type lineReq struct { Sku string `json:"sku"` Name string `json:"name"` Quantity int32 `json:"quantity"` - UnitPrice int64 `json:"unitPrice"` // inc-vat, minor units - TaxRate int32 `json:"taxRate"` // percent + UnitPrice int64 `json:"unitPrice"` // inc-vat, minor units + TaxRate int32 `json:"taxRate"` // basis points (2500 = 25%) + Location string `json:"location,omitempty"` // inventory commit location / store id } type checkoutReq struct { @@ -343,7 +372,7 @@ func (s *server) buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.P var total, totalTax int64 for _, l := range req.Lines { lineTotal := l.UnitPrice * int64(l.Quantity) - lineTax := s.taxProvider.ComputeTax(lineTotal, int(l.TaxRate)) + lineTax := s.taxProvider.Compute(lineTotal, int(l.TaxRate)) total += lineTotal totalTax += lineTax po.Lines = append(po.Lines, &messages.OrderLine{ @@ -442,8 +471,8 @@ func (s *server) handleList(w http.ResponseWriter, r *http.Request) { OrderId: order.OrderId(raw).String(), Reference: g.OrderReference, Status: g.Status, - TotalAmount: g.TotalAmount, - CapturedAmount: g.CapturedAmount, + TotalAmount: g.TotalAmount.Int64(), + CapturedAmount: g.CapturedAmount.Int64(), Currency: g.Currency, PlacedAt: g.PlacedAt, }) @@ -457,15 +486,15 @@ func (s *server) handleList(w http.ResponseWriter, r *http.Request) { // NordicTaxProvider with SE as the default country (matching the current // Nordic-only footprint). Set TAX_PROVIDER=static for dev and tests // (no country awareness). -func selectTaxProvider() order.TaxProvider { +func selectTaxProvider() tax.Provider { provider := os.Getenv("TAX_PROVIDER") switch provider { case "static": - return order.NewStaticTaxProvider() + return tax.NewStatic() case "nordic": - return order.NewNordicTaxProvider(config.EnvString("TAX_DEFAULT_COUNTRY", "SE")) + return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE")) default: - return order.NewNordicTaxProvider(config.EnvString("TAX_DEFAULT_COUNTRY", "SE")) + return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE")) } } diff --git a/cmd/profile/main.go b/cmd/profile/main.go index 69722a1..f20d8a5 100644 --- a/cmd/profile/main.go +++ b/cmd/profile/main.go @@ -20,6 +20,7 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/proxy" "git.k6n.net/mats/go-cart-actor/pkg/telemetry" "git.k6n.net/mats/platform/config" + "git.k6n.net/mats/platform/rabbit" "github.com/redis/go-redis/v9" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) @@ -126,6 +127,20 @@ func main() { log.Fatalf("Error creating profile pool: %v\n", err) } + // Stream applied profile mutations to the shared mutations exchange (routing + // key mutation.profile) for the backoffice live feed. Best-effort: disabled + // when AMQP_URL is unset or the broker is unreachable. + if amqpURL := os.Getenv("AMQP_URL"); amqpURL != "" { + if conn, derr := rabbit.Dial(amqpURL, "cart-profile"); derr != nil { + log.Printf("profile: AMQP connect failed, mutation feed disabled: %v", derr) + } else { + feed := actor.NewAmqpListener(conn.Connection(), "profile", actor.MutationSummary) + feed.DefineTopics() + pool.AddListener(feed) + log.Printf("profile: mutation feed enabled (mutation.profile)") + } + } + // Clustering control plane: gRPC server on :1337 serves peer pools' // ownership announcements and remote Apply/Get calls; UseDiscovery watches // for sibling profile pods (label actor-pool=profile) and wires them into the diff --git a/cmd/ucp-artifacts/main.go b/cmd/ucp-artifacts/main.go new file mode 100644 index 0000000..9e14ca0 --- /dev/null +++ b/cmd/ucp-artifacts/main.go @@ -0,0 +1,1800 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "git.k6n.net/mats/go-cart-actor/internal/ucp" +) + +const ( + ucpVersion = "2026-04-08" + publicHost = "https://shop.tornberg.me" + localEdge = "http://localhost:8080" + keyID = "k6n-ecdsa-2026" + customerURL = publicHost + "/.well-known/ucp/schemas/shopping/customer.json" + authURL = publicHost + "/.well-known/ucp/schemas/shopping/authentication.json" + paymentURL = publicHost + "/.well-known/ucp/schemas/payments/processor_tokenizer.json" + openapiURL = publicHost + "/.well-known/ucp/openapi/shopping-rest.openapi.json" +) + +func main() { + repoRoot, err := filepath.Abs("..") + if err != nil { + failf("resolve repo root: %v", err) + } + + profileJSON := mustMarshalJSON(buildProfile()) + customerJSON := mustValidateJSON(customerSchemaJSON) + authJSON := mustValidateJSON(authenticationSchemaJSON) + paymentJSON := mustValidateJSON(processorTokenizerSchemaJSON) + openapiJSON := mustValidateJSON(shoppingRESTOpenAPIJSON) + + mustWrite(filepath.Join(repoRoot, "docs", "ucp-profile.json"), profileJSON) + mustWrite(filepath.Join(repoRoot, "docs", "ucp", "schemas", "shopping", "customer.json"), customerJSON) + mustWrite(filepath.Join(repoRoot, "docs", "ucp", "schemas", "shopping", "authentication.json"), authJSON) + mustWrite(filepath.Join(repoRoot, "docs", "ucp", "schemas", "payments", "processor_tokenizer.json"), paymentJSON) + mustWrite(filepath.Join(repoRoot, "docs", "ucp", "openapi", "shopping-rest.openapi.json"), openapiJSON) + mustWrite(filepath.Join(repoRoot, "backoffice", "deploy", "k8s", "38-ucp-profile.generated.yaml"), + buildConfigMapYAML(profileJSON, customerJSON, authJSON, paymentJSON, openapiJSON)) +} + +func failf(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} + +func mustMarshalJSON(v any) []byte { + out, err := json.MarshalIndent(v, "", " ") + if err != nil { + failf("marshal json: %v", err) + } + out = append(out, '\n') + return out +} + +func mustValidateJSON(raw string) []byte { + data := []byte(strings.TrimSpace(raw) + "\n") + var v any + if err := json.Unmarshal(data, &v); err != nil { + failf("invalid generated json: %v", err) + } + out, err := json.MarshalIndent(v, "", " ") + if err != nil { + failf("re-marshal generated json: %v", err) + } + out = append(out, '\n') + return out +} + +func mustWrite(path string, data []byte) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + failf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + failf("write %s: %v", path, err) + } +} + +func buildProfile() ucp.ProfileData { + return ucp.ProfileData{ + UCP: ucp.Profile{ + Version: ucpVersion, + Business: ucp.BusinessInfo{ + Name: "K6N E-Commerce", + Description: "Swedish multi-country e-commerce platform — cart, checkout, order, catalog, and fulfillment services", + Domain: "shop.tornberg.me", + }, + Services: map[string][]ucp.ServiceBinding{ + "dev.ucp.shopping": { + { + Version: ucpVersion, + Spec: "https://ucp.dev/2026-04-08/specification/overview", + Transport: "rest", + Schema: openapiURL, + Endpoint: publicHost + "/ucp/v1", + }, + { + Version: ucpVersion, + Spec: "https://ucp.dev/2026-04-08/specification/overview", + Transport: "mcp", + Schema: "https://ucp.dev/2026-04-08/services/shopping/mcp.openrpc.json", + Endpoint: publicHost + "/cart-mcp", + }, + { + Version: ucpVersion, + Spec: "https://ucp.dev/2026-04-08/specification/overview", + Transport: "mcp", + Schema: "https://ucp.dev/2026-04-08/services/shopping/mcp.openrpc.json", + Endpoint: publicHost + "/order-mcp", + }, + { + Version: ucpVersion, + Spec: "https://ucp.dev/2026-04-08/specification/overview", + Transport: "mcp", + Schema: "https://ucp.dev/2026-04-08/services/shopping/mcp.openrpc.json", + Endpoint: publicHost + "/promotions-mcp", + }, + }, + }, + Capabilities: map[string][]ucp.CapabilityDecl{ + "dev.ucp.shopping.cart": {{ + Version: ucpVersion, + Spec: "https://ucp.dev/2026-04-08/specification/cart", + Schema: "https://ucp.dev/2026-04-08/schemas/shopping/cart.json", + Description: "Pre-checkout cart management — create, read, update cart contents, manage items, vouchers, and user identity", + Operations: []ucp.OpDef{ + {Method: "POST", Path: "/carts"}, + {Method: "GET", Path: "/carts/{id}"}, + {Method: "PUT", Path: "/carts/{id}"}, + {Method: "POST", Path: "/carts/{id}/cancel"}, + }, + }}, + "dev.ucp.shopping.checkout": {{ + Version: ucpVersion, + Spec: "https://ucp.dev/2026-04-08/specification/checkout", + Schema: "https://ucp.dev/2026-04-08/schemas/shopping/checkout.json", + Description: "Purchase session orchestration — initiate, update, complete, and cancel checkout sessions with Adyen and Klarna payment integration", + Operations: []ucp.OpDef{ + {Method: "POST", Path: "/checkout-sessions"}, + {Method: "GET", Path: "/checkout-sessions/{id}"}, + {Method: "PUT", Path: "/checkout-sessions/{id}"}, + {Method: "POST", Path: "/checkout-sessions/{id}/complete"}, + {Method: "POST", Path: "/checkout-sessions/{id}/cancel"}, + }, + }}, + "dev.ucp.shopping.order": {{ + Version: ucpVersion, + Spec: "https://ucp.dev/2026-04-08/specification/order", + Schema: "https://ucp.dev/2026-04-08/schemas/shopping/order.json", + Description: "Order lifecycle — view order state, cancel, fulfill, complete, request returns, and issue refunds", + Operations: []ucp.OpDef{ + {Method: "GET", Path: "/orders/{id}"}, + {Method: "POST", Path: "/orders/{id}/cancel"}, + {Method: "POST", Path: "/orders/{id}/fulfillments"}, + {Method: "POST", Path: "/orders/{id}/returns"}, + {Method: "POST", Path: "/orders/{id}/refunds"}, + }, + }}, + "dev.ucp.shopping.catalog.search": {{ + Version: ucpVersion, + Spec: "https://ucp.dev/2026-04-08/specification/catalog", + Schema: "https://ucp.dev/2026-04-08/schemas/shopping/catalog_search.json", + Description: "Full-text product search with BM25 and ColBERT relevance ranking, facet filtering, and autocomplete suggestions", + Operations: []ucp.OpDef{ + {Method: "GET", Path: "/catalog/feed"}, + {Method: "POST", Path: "/catalog/search"}, + }, + }}, + "dev.ucp.shopping.catalog.lookup": {{ + Version: ucpVersion, + Spec: "https://ucp.dev/2026-04-08/specification/catalog", + Schema: "https://ucp.dev/2026-04-08/schemas/shopping/catalog_lookup.json", + Description: "Retrieve products by SKU or id, including full PDP details and related metadata", + Operations: []ucp.OpDef{ + {Method: "POST", Path: "/catalog/lookup"}, + {Method: "POST", Path: "/catalog/product"}, + }, + }}, + "dev.ucp.shopping.customer": {{ + Version: ucpVersion, + Spec: "https://ucp.dev/2026-04-08/specification/identity-linking", + Schema: customerURL, + Description: "Customer profile management — view and update profile information, manage stored addresses with default shipping/billing designations, and identity linking across carts, checkouts, and orders", + Operations: []ucp.OpDef{ + {Method: "POST", Path: "/customers"}, + {Method: "GET", Path: "/customers/{id}"}, + {Method: "PUT", Path: "/customers/{id}"}, + {Method: "DELETE", Path: "/customers/{id}"}, + {Method: "POST", Path: "/customers/{id}/addresses"}, + {Method: "PUT", Path: "/customers/{id}/addresses/{addressId}"}, + {Method: "DELETE", Path: "/customers/{id}/addresses/{addressId}"}, + }, + }}, + "dev.ucp.shopping.authentication": {{ + Version: ucpVersion, + Spec: "https://ucp.dev/2026-04-08/specification/authentication", + Schema: authURL, + Description: "Customer authentication — email+password registration and login with HMAC-signed session cookies, logout, current-customer lookup, email verification, and password reset. Failed logins and reset requests are rate-limited.", + Operations: []ucp.OpDef{ + {Method: "POST", Path: "/auth/register"}, + {Method: "POST", Path: "/auth/login"}, + {Method: "POST", Path: "/auth/logout"}, + {Method: "GET", Path: "/auth/me"}, + {Method: "POST", Path: "/auth/verify-request"}, + {Method: "POST", Path: "/auth/verify"}, + {Method: "POST", Path: "/auth/reset-request"}, + {Method: "POST", Path: "/auth/reset"}, + }, + }}, + "dev.ucp.common.identity_linking": {{ + Version: ucpVersion, + Spec: "https://ucp.dev/2026-04-08/specification/identity-linking", + Schema: "https://ucp.dev/2026-04-08/schemas/common/identity_linking.json", + Description: "Identity linking — automatically links carts, checkouts, and orders to customer profiles via the /customers/{id} REST endpoints. Carts are linked when userId is set; checkouts are linked when customerId is provided; orders are linked on checkout completion.", + }}, + "dev.ucp.shopping.discount": {{ + Version: ucpVersion, + Extends: "dev.ucp.shopping.checkout", + Spec: "https://ucp.dev/2026-04-08/specification/discount", + Schema: "https://ucp.dev/2026-04-08/schemas/shopping/discount.json", + Description: "Promotion and discount code evaluation — percentage discounts, fixed amounts, volume discounts with progress nudges", + }}, + "dev.ucp.shopping.fulfillment": {{ + Version: ucpVersion, + Extends: "dev.ucp.shopping.checkout", + Spec: "https://ucp.dev/2026-04-08/specification/fulfillment", + Schema: "https://ucp.dev/2026-04-08/schemas/shopping/fulfillment.json", + Description: "Shipping and delivery options — carrier selection, pickup points, delivery pricing", + }}, + "dev.ucp.shopping.buyer_consent": {{ + Version: ucpVersion, + Extends: "dev.ucp.shopping.checkout", + Spec: "https://ucp.dev/2026-04-08/specification/buyer-consent", + Schema: "https://ucp.dev/2026-04-08/schemas/shopping/buyer_consent.json", + Description: "Explicit consent capture — order confirmation viewed, terms acceptance tracking", + }}, + }, + PaymentHandlers: map[string][]ucp.PaymentHandlerDecl{ + "com.adyen.checkout": {{ + ID: "adyen", + Version: ucpVersion, + Spec: "https://docs.adyen.com/api-explorer", + Schema: paymentURL, + Description: "Adyen payment gateway — credit/debit card, PayPal, Swish, and local payment methods via Adyen Web Drop-in", + }}, + "com.klarna.payments": {{ + ID: "klarna", + Version: ucpVersion, + Spec: "https://docs.klarna.com/api/payments", + Schema: paymentURL, + Description: "Klarna Payments — Pay Now, Pay Later, Slice It (installments) via Klarna API", + }}, + }, + }, + SigningKeys: []ucp.SigningKey{{ + Kid: keyID, + Kty: "EC", + Crv: "P-256", + X: "pnPksDSSMcNgUYT4--Z65LRXanpM0xKNYGyBc66-ajk", + Y: "Esoux-Cmy-C84ty5VUAdEpoDyNfWMeaAlHKGo-FjiQ", + Alg: "ES256", + }}, + } +} + +func buildConfigMapYAML(profile, customer, auth, payment, openapi []byte) []byte { + var b strings.Builder + b.WriteString("# Code generated by go run ./cmd/ucp-artifacts; DO NOT EDIT.\n") + b.WriteString("apiVersion: v1\n") + b.WriteString("kind: ConfigMap\n") + b.WriteString("metadata:\n") + b.WriteString(" name: ucp-profile-generated\n") + b.WriteString(" namespace: ecommerce\n") + b.WriteString("data:\n") + writeBlock(&b, "profile.json", profile) + writeBlock(&b, "customer.json", customer) + writeBlock(&b, "authentication.json", auth) + writeBlock(&b, "processor_tokenizer.json", payment) + writeBlock(&b, "shopping-rest.openapi.json", openapi) + return []byte(b.String()) +} + +func writeBlock(b *strings.Builder, key string, data []byte) { + b.WriteString(" " + key + ": |\n") + for _, line := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") { + b.WriteString(" " + line + "\n") + } +} + +const customerSchemaJSON = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://shop.tornberg.me/.well-known/ucp/schemas/shopping/customer.json", + "name": "dev.ucp.shopping.customer", + "title": "Customer Profile", + "description": "Customer profile with stored addresses and identity linking across carts, checkouts, and orders.", + "version": "2026-04-08", + "type": "object", + "required": ["id", "addresses", "orders", "emailVerified"], + "properties": { + "id": { "type": "string", "description": "Base62-encoded profile identifier." }, + "name": { "type": "string" }, + "email": { "type": "string", "format": "email" }, + "phone": { "type": "string" }, + "language": { "type": "string", "description": "BCP-47 language tag." }, + "currency": { "type": "string", "description": "ISO 4217 currency code." }, + "avatarUrl": { "type": "string", "format": "uri" }, + "emailVerified": { "type": "boolean" }, + "addresses": { + "type": "array", + "items": { "$ref": "#/$defs/address" } + }, + "orders": { + "type": "array", + "items": { "$ref": "#/$defs/orderRef" } + } + }, + "$defs": { + "address": { + "type": "object", + "required": ["id", "addressLine1", "city", "zip", "country"], + "properties": { + "id": { "type": "integer", "minimum": 0 }, + "label": { "type": "string" }, + "fullName": { "type": "string" }, + "addressLine1": { "type": "string" }, + "addressLine2": { "type": "string" }, + "city": { "type": "string" }, + "state": { "type": "string" }, + "zip": { "type": "string" }, + "country": { "type": "string", "description": "ISO 3166-1 alpha-2 country code." }, + "phone": { "type": "string" }, + "isDefaultShipping": { "type": "boolean" }, + "isDefaultBilling": { "type": "boolean" } + } + }, + "orderRef": { + "type": "object", + "required": ["orderReference"], + "properties": { + "orderReference": { "type": "string" }, + "status": { "type": "string" } + } + } + } +} +` + +const authenticationSchemaJSON = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://shop.tornberg.me/.well-known/ucp/schemas/shopping/authentication.json", + "name": "dev.ucp.shopping.authentication", + "title": "Customer Authentication", + "description": "Password-based customer authentication capability with signed session cookies, email verification, password reset, and identity-linking helpers.", + "version": "2026-04-08", + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["ok", "sent", "verified"] + }, + "error": { + "type": "string" + }, + "customer": { + "$ref": "https://shop.tornberg.me/.well-known/ucp/schemas/shopping/customer.json" + }, + "emailVerified": { + "type": "boolean" + } + }, + "additionalProperties": true, + "$defs": { + "registerRequest": { + "type": "object", + "required": ["email", "password"], + "properties": { + "email": { "type": "string", "format": "email" }, + "password": { "type": "string", "minLength": 8 }, + "name": { "type": "string" }, + "phone": { "type": "string" } + } + }, + "loginRequest": { + "type": "object", + "required": ["email", "password"], + "properties": { + "email": { "type": "string", "format": "email" }, + "password": { "type": "string", "minLength": 8 } + } + }, + "tokenRequest": { + "type": "object", + "required": ["token"], + "properties": { + "token": { "type": "string" } + } + }, + "resetRequest": { + "type": "object", + "required": ["email"], + "properties": { + "email": { "type": "string", "format": "email" } + } + }, + "resetCompleteRequest": { + "type": "object", + "required": ["token", "password"], + "properties": { + "token": { "type": "string" }, + "password": { "type": "string", "minLength": 8 } + } + } + } +} +` + +const processorTokenizerSchemaJSON = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://shop.tornberg.me/.well-known/ucp/schemas/payments/processor_tokenizer.json", + "name": "dev.ucp.processor_tokenizer", + "title": "Processor Tokenizer Payment Handler", + "description": "Payment handler that tokenizes a buyer-supplied instrument through a payment processor (Adyen, Klarna) and returns a processor token used to authorize the checkout.", + "version": "2026-04-08", + "type": "object", + "required": ["id", "instrument"], + "properties": { + "id": { "type": "string", "description": "Handler instance id distinguishing multiple processor configurations." }, + "instrument": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["card", "wallet", "bank_transfer", "invoice", "installment"], + "description": "Instrument class presented by the buyer." + }, + "scheme": { "type": "string", "description": "Card or wallet scheme, e.g. visa, mastercard, swish, klarna." } + } + }, + "paymentToken": { + "type": "string", + "description": "Opaque processor token issued after tokenizing the instrument; authorizes the checkout session." + }, + "processor": { + "type": "string", + "enum": ["adyen", "klarna"], + "description": "Backing payment processor that issued the token." + } + } +} +` + +const shoppingRESTOpenAPIJSON = ` +{ + "openapi": "3.1.0", + "info": { + "title": "K6N UCP REST API", + "version": "2026-04-08", + "description": "Authoritative REST contract for the K6N UCP surface served from /.well-known/ucp." + }, + "servers": [ + { "url": "https://shop.tornberg.me", "description": "Production edge" }, + { "url": "http://localhost:8080", "description": "Local compose edge" } + ], + "paths": { + "/.well-known/ucp": { + "get": { + "summary": "Fetch the UCP business profile", + "responses": { + "200": { + "description": "UCP profile JSON", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UCPProfile" } + } + } + } + } + } + }, + "/ucp/v1/carts/": { + "post": { + "summary": "Create a cart", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateCartRequest" } + } + } + }, + "responses": { + "201": { + "description": "Cart created", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CartResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "500": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/carts/{id}": { + "get": { + "summary": "Get a cart", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "responses": { + "200": { + "description": "Cart state", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CartResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "404": { "$ref": "#/components/responses/Error" } + } + }, + "put": { + "summary": "Replace cart contents", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateCartRequest" } + } + } + }, + "responses": { + "200": { + "description": "Updated cart", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CartResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "500": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/carts/{id}/cancel": { + "post": { + "summary": "Cancel a cart", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "responses": { + "200": { + "description": "Cleared cart", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CartResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "500": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/checkout-sessions/": { + "post": { + "summary": "Create a checkout session", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateCheckoutRequest" } + } + } + }, + "responses": { + "201": { + "description": "Checkout session created", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CheckoutResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "500": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/checkout-sessions/{id}": { + "get": { + "summary": "Get a checkout session", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "responses": { + "200": { + "description": "Checkout session state", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CheckoutResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "404": { "$ref": "#/components/responses/Error" } + } + }, + "put": { + "summary": "Update a checkout session", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateCheckoutRequest" } + } + } + }, + "responses": { + "200": { + "description": "Updated checkout session", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CheckoutResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "500": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/checkout-sessions/{id}/complete": { + "post": { + "summary": "Complete a checkout session", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CompleteCheckoutRequest" } + } + } + }, + "responses": { + "200": { + "description": "Completed checkout session", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CheckoutResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "404": { "$ref": "#/components/responses/Error" }, + "500": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/checkout-sessions/{id}/cancel": { + "post": { + "summary": "Cancel a checkout session", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "responses": { + "200": { + "description": "Cancelled checkout session", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CheckoutResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "404": { "$ref": "#/components/responses/Error" }, + "500": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/orders/{id}": { + "get": { + "summary": "Get an order", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "responses": { + "200": { + "description": "Order state", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/OrderResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "404": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/orders/{id}/cancel": { + "post": { + "summary": "Cancel an order", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reason": { "type": "string" } + } + } + } + } + }, + "responses": { + "200": { + "description": "Cancelled order", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/OrderResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "404": { "$ref": "#/components/responses/Error" }, + "422": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/orders/{id}/fulfillments": { + "post": { + "summary": "Create a fulfillment", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "carrier": { "type": "string" }, + "trackingNumber": { "type": "string" }, + "trackingUri": { "type": "string" }, + "lines": { + "type": "array", + "items": { + "type": "object", + "required": ["reference", "quantity"], + "properties": { + "reference": { "type": "string" }, + "quantity": { "type": "integer" } + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated order", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/OrderResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "404": { "$ref": "#/components/responses/Error" }, + "422": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/orders/{id}/returns": { + "post": { + "summary": "Request a return", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reason": { "type": "string" }, + "lines": { + "type": "array", + "items": { + "type": "object", + "required": ["reference", "quantity"], + "properties": { + "reference": { "type": "string" }, + "quantity": { "type": "integer" } + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated order", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/OrderResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "404": { "$ref": "#/components/responses/Error" }, + "422": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/orders/{id}/refunds": { + "post": { + "summary": "Issue a refund", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "amount": { "type": "integer" }, + "reason": { "type": "string" } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated order", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/OrderResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "404": { "$ref": "#/components/responses/Error" }, + "422": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/customers": { + "post": { + "summary": "Create a customer profile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateCustomerRequest" } + } + } + }, + "responses": { + "201": { + "description": "Created customer profile", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CustomerResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "500": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/customers/{id}": { + "get": { + "summary": "Get a customer profile", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "responses": { + "200": { + "description": "Customer profile", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CustomerResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "404": { "$ref": "#/components/responses/Error" } + } + }, + "put": { + "summary": "Update a customer profile", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateCustomerRequest" } + } + } + }, + "responses": { + "200": { + "description": "Updated customer profile", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CustomerResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "404": { "$ref": "#/components/responses/Error" } + } + }, + "delete": { + "summary": "Anonymize a customer profile", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "responses": { + "200": { + "description": "Anonymized customer profile", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CustomerResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "404": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/customers/{id}/addresses": { + "post": { + "summary": "Add a customer address", + "parameters": [{ "$ref": "#/components/parameters/Id" }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/AddressInput" } + } + } + }, + "responses": { + "200": { + "description": "Updated customer profile", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CustomerResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "404": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/customers/{id}/addresses/{addressId}": { + "put": { + "summary": "Update a customer address", + "parameters": [ + { "$ref": "#/components/parameters/Id" }, + { "$ref": "#/components/parameters/AddressId" } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/AddressInput" } + } + } + }, + "responses": { + "200": { + "description": "Updated customer profile", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CustomerResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "404": { "$ref": "#/components/responses/Error" } + } + }, + "delete": { + "summary": "Delete a customer address", + "parameters": [ + { "$ref": "#/components/parameters/Id" }, + { "$ref": "#/components/parameters/AddressId" } + ], + "responses": { + "200": { + "description": "Updated customer profile", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CustomerResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "404": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/auth/register": { + "post": { + "summary": "Register a customer account", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RegisterRequest" } + } + } + }, + "responses": { + "201": { + "description": "Registered customer", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CustomerResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "409": { "$ref": "#/components/responses/Error" }, + "500": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/auth/login": { + "post": { + "summary": "Log in a customer", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/LoginRequest" } + } + } + }, + "responses": { + "200": { + "description": "Authenticated customer", + "headers": { + "Set-Cookie": { + "schema": { "type": "string" }, + "description": "HTTP-only session cookie." + } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CustomerResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "401": { "$ref": "#/components/responses/Error" }, + "403": { "$ref": "#/components/responses/Error" }, + "429": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/auth/logout": { + "post": { + "summary": "Log out the current customer", + "responses": { + "200": { + "description": "Logout acknowledged", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/StatusResponse" } + } + } + } + } + } + }, + "/ucp/v1/auth/me": { + "get": { + "summary": "Get the current authenticated customer", + "responses": { + "200": { + "description": "Authenticated customer", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CustomerResponse" } + } + } + }, + "401": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/auth/verify-request": { + "post": { + "summary": "Request a verification email", + "responses": { + "200": { + "description": "Verification email requested", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/StatusResponse" } + } + } + }, + "401": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/auth/verify": { + "post": { + "summary": "Verify an email address", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/TokenRequest" } + } + } + }, + "responses": { + "200": { + "description": "Email verified", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/StatusResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/auth/reset-request": { + "post": { + "summary": "Request a password reset", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ResetRequest" } + } + } + }, + "responses": { + "200": { + "description": "Password reset requested", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/StatusResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "429": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/auth/reset": { + "post": { + "summary": "Complete a password reset", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ResetCompleteRequest" } + } + } + }, + "responses": { + "200": { + "description": "Password reset complete", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/StatusResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" } + } + } + }, + "/ucp/v1/catalog/feed": { + "get": { + "summary": "Fetch the catalog feed", + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 40 } + }, + { + "name": "cursor", + "in": "query", + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "Catalog feed page", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CatalogFeedResponse" } + } + } + } + } + } + }, + "/ucp/v1/catalog/lookup": { + "post": { + "summary": "Lookup products by id or SKU", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { "type": "string" } + }, + "skus": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Lookup result", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CatalogListResponse" } + } + } + } + } + } + }, + "/ucp/v1/catalog/product": { + "post": { + "summary": "Fetch a single product", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "sku": { "type": "string" } + } + } + } + } + }, + "responses": { + "200": { + "description": "Product result", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CatalogObjectResponse" } + } + } + } + } + } + }, + "/ucp/v1/catalog/search": { + "post": { + "summary": "Search the catalog", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "query": { "type": "string" }, + "filters": { "type": "object", "additionalProperties": true }, + "cursor": { "type": "string" }, + "limit": { "type": "integer", "minimum": 1, "maximum": 200 } + } + } + } + } + }, + "responses": { + "200": { + "description": "Search result", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CatalogFeedResponse" } + } + } + } + } + } + } + }, + "components": { + "parameters": { + "Id": { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + "AddressId": { + "name": "addressId", + "in": "path", + "required": true, + "schema": { "type": "integer", "minimum": 0 } + } + }, + "responses": { + "Error": { + "description": "Error response", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + }, + "schemas": { + "UCPProfile": { + "type": "object", + "properties": { + "ucp": { "type": "object", "additionalProperties": true }, + "signing_keys": { + "type": "array", + "items": { "type": "object", "additionalProperties": true } + } + } + }, + "Error": { + "type": "object", + "required": ["error"], + "properties": { + "error": { "type": "string" } + } + }, + "StatusResponse": { + "type": "object", + "required": ["status"], + "properties": { + "status": { "type": "string" } + } + }, + "VoucherInput": { + "type": "object", + "required": ["code"], + "properties": { + "code": { "type": "string" }, + "value": { "type": "integer" } + } + }, + "CartItemInput": { + "type": "object", + "required": ["sku"], + "properties": { + "sku": { "type": "string" }, + "quantity": { "type": "integer", "minimum": 0 }, + "name": { "type": "string" }, + "price": { "type": "integer" }, + "taxRate": { "type": "number" }, + "image": { "type": "string" }, + "storeId": { "type": "string" }, + "customFields": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "children": { + "type": "array", + "items": { "$ref": "#/components/schemas/CartItemInput" } + } + } + }, + "CreateCartRequest": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { "$ref": "#/components/schemas/CartItemInput" } + } + } + }, + "UpdateCartRequest": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { "$ref": "#/components/schemas/CartItemInput" } + }, + "vouchers": { + "type": "array", + "items": { "$ref": "#/components/schemas/VoucherInput" } + }, + "userId": { "type": "string" }, + "country": { "type": "string" } + } + }, + "Totals": { + "type": "object", + "required": ["totalIncVat", "totalExVat", "totalVat", "currency"], + "properties": { + "totalIncVat": { "type": "integer" }, + "totalExVat": { "type": "integer" }, + "totalVat": { "type": "integer" }, + "currency": { "type": "string" }, + "discount": { "type": "integer" } + } + }, + "CartItem": { + "type": "object", + "required": ["sku", "quantity", "unitPrice", "totalPrice", "taxRate"], + "properties": { + "sku": { "type": "string" }, + "quantity": { "type": "integer" }, + "name": { "type": "string" }, + "unitPrice": { "type": "integer" }, + "totalPrice": { "type": "integer" }, + "taxRate": { "type": "integer" }, + "image": { "type": "string" }, + "itemId": { "type": "integer" }, + "customFields": { + "type": "object", + "additionalProperties": { "type": "string" } + } + } + }, + "CartResponse": { + "type": "object", + "required": ["id", "totals", "status"], + "properties": { + "id": { "type": "string" }, + "items": { + "type": "array", + "items": { "$ref": "#/components/schemas/CartItem" } + }, + "totals": { "$ref": "#/components/schemas/Totals" }, + "status": { "type": "string" } + } + }, + "CreateCheckoutRequest": { + "type": "object", + "properties": { + "cartId": { "type": "string" }, + "items": { + "type": "array", + "items": { "$ref": "#/components/schemas/CartItemInput" } + }, + "line_items": { + "type": "array", + "items": { "$ref": "#/components/schemas/CartItemInput" } + }, + "country": { "type": "string" }, + "currency": { "type": "string" }, + "customerId": { "type": "string" } + } + }, + "DeliveryInput": { + "type": "object", + "required": ["provider"], + "properties": { + "provider": { "type": "string" }, + "itemIds": { + "type": "array", + "items": { "type": "integer" } + } + } + }, + "ContactInput": { + "type": "object", + "properties": { + "email": { "type": "string", "format": "email" }, + "phone": { "type": "string" }, + "name": { "type": "string" }, + "postalCode": { "type": "string" } + } + }, + "PickupPointInput": { + "type": "object", + "required": ["deliveryId", "id"], + "properties": { + "deliveryId": { "type": "integer" }, + "id": { "type": "string" }, + "name": { "type": "string" }, + "address": { "type": "string" }, + "city": { "type": "string" }, + "zip": { "type": "string" } + } + }, + "UpdateCheckoutRequest": { + "type": "object", + "properties": { + "delivery": { "$ref": "#/components/schemas/DeliveryInput" }, + "contact": { "$ref": "#/components/schemas/ContactInput" }, + "pickupPoint": { "$ref": "#/components/schemas/PickupPointInput" } + } + }, + "CompleteCheckoutRequest": { + "type": "object", + "properties": { + "paymentToken": { "type": "string" }, + "provider": { "type": "string" }, + "currency": { "type": "string" }, + "country": { "type": "string" }, + "customerId": { "type": "string" } + } + }, + "PickupPointResponse": { + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "address": { "type": "string" }, + "city": { "type": "string" }, + "zip": { "type": "string" } + } + }, + "DeliveryResponse": { + "type": "object", + "required": ["id", "provider", "price", "items"], + "properties": { + "id": { "type": "integer" }, + "provider": { "type": "string" }, + "price": { "type": "integer" }, + "items": { + "type": "array", + "items": { "type": "integer" } + }, + "pickupPoint": { "$ref": "#/components/schemas/PickupPointResponse" } + } + }, + "ContactResponse": { + "type": "object", + "properties": { + "email": { "type": "string", "format": "email" }, + "phone": { "type": "string" }, + "name": { "type": "string" }, + "postalCode": { "type": "string" } + } + }, + "PaymentResponse": { + "type": "object", + "required": ["id", "provider", "amount", "currency"], + "properties": { + "id": { "type": "string" }, + "provider": { "type": "string" }, + "amount": { "type": "integer" }, + "currency": { "type": "string" }, + "status": { "type": "string" } + } + }, + "CheckoutResponse": { + "type": "object", + "required": ["id", "cartId", "status", "totals"], + "properties": { + "id": { "type": "string" }, + "cartId": { "type": "string" }, + "status": { "type": "string" }, + "items": { + "type": "array", + "items": { "$ref": "#/components/schemas/CartItem" } + }, + "totals": { "$ref": "#/components/schemas/Totals" }, + "deliveries": { + "type": "array", + "items": { "$ref": "#/components/schemas/DeliveryResponse" } + }, + "contact": { "$ref": "#/components/schemas/ContactResponse" }, + "orderId": { "type": "string" }, + "payments": { + "type": "array", + "items": { "$ref": "#/components/schemas/PaymentResponse" } + } + } + }, + "OrderResponse": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "orderId": { "type": "string" }, + "status": { "type": "string" }, + "currency": { "type": "string" }, + "country": { "type": "string" }, + "locale": { "type": "string" }, + "lines": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "payments": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "fulfillments": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "returns": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "additionalProperties": true + }, + "CreateCustomerRequest": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "email": { "type": "string", "format": "email" }, + "phone": { "type": "string" }, + "language": { "type": "string" }, + "currency": { "type": "string" }, + "avatarUrl": { "type": "string", "format": "uri" } + } + }, + "UpdateCustomerRequest": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "email": { "type": "string", "format": "email" }, + "phone": { "type": "string" }, + "language": { "type": "string" }, + "currency": { "type": "string" }, + "avatarUrl": { "type": "string", "format": "uri" } + } + }, + "AddressInput": { + "type": "object", + "required": ["addressLine1", "city", "zip", "country"], + "properties": { + "label": { "type": "string" }, + "fullName": { "type": "string" }, + "addressLine1": { "type": "string" }, + "addressLine2": { "type": "string" }, + "city": { "type": "string" }, + "state": { "type": "string" }, + "zip": { "type": "string" }, + "country": { "type": "string" }, + "phone": { "type": "string" }, + "isDefaultShipping": { "type": "boolean" }, + "isDefaultBilling": { "type": "boolean" } + } + }, + "AddressResponse": { + "type": "object", + "required": ["id", "addressLine1", "city", "zip", "country"], + "properties": { + "id": { "type": "integer" }, + "label": { "type": "string" }, + "fullName": { "type": "string" }, + "addressLine1": { "type": "string" }, + "addressLine2": { "type": "string" }, + "city": { "type": "string" }, + "state": { "type": "string" }, + "zip": { "type": "string" }, + "country": { "type": "string" }, + "phone": { "type": "string" }, + "isDefaultShipping": { "type": "boolean" }, + "isDefaultBilling": { "type": "boolean" } + } + }, + "OrderRef": { + "type": "object", + "required": ["orderReference"], + "properties": { + "orderReference": { "type": "string" }, + "status": { "type": "string" } + } + }, + "CustomerResponse": { + "type": "object", + "required": ["id", "addresses", "orders", "emailVerified"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "email": { "type": "string", "format": "email" }, + "phone": { "type": "string" }, + "language": { "type": "string" }, + "currency": { "type": "string" }, + "avatarUrl": { "type": "string", "format": "uri" }, + "emailVerified": { "type": "boolean" }, + "addresses": { + "type": "array", + "items": { "$ref": "#/components/schemas/AddressResponse" } + }, + "orders": { + "type": "array", + "items": { "$ref": "#/components/schemas/OrderRef" } + } + } + }, + "RegisterRequest": { + "type": "object", + "required": ["email", "password"], + "properties": { + "email": { "type": "string", "format": "email" }, + "password": { "type": "string", "minLength": 8 }, + "name": { "type": "string" }, + "phone": { "type": "string" } + } + }, + "LoginRequest": { + "type": "object", + "required": ["email", "password"], + "properties": { + "email": { "type": "string", "format": "email" }, + "password": { "type": "string", "minLength": 8 } + } + }, + "TokenRequest": { + "type": "object", + "required": ["token"], + "properties": { + "token": { "type": "string" } + } + }, + "ResetRequest": { + "type": "object", + "required": ["email"], + "properties": { + "email": { "type": "string", "format": "email" } + } + }, + "ResetCompleteRequest": { + "type": "object", + "required": ["token", "password"], + "properties": { + "token": { "type": "string" }, + "password": { "type": "string", "minLength": 8 } + } + }, + "CatalogObject": { + "type": "object", + "additionalProperties": true + }, + "CatalogListResponse": { + "type": "object", + "properties": { + "products": { + "type": "array", + "items": { "$ref": "#/components/schemas/CatalogObject" } + } + }, + "additionalProperties": true + }, + "CatalogObjectResponse": { + "type": "object", + "properties": { + "product": { "$ref": "#/components/schemas/CatalogObject" } + }, + "additionalProperties": true + }, + "CatalogFeedResponse": { + "type": "object", + "properties": { + "products": { + "type": "array", + "items": { "$ref": "#/components/schemas/CatalogObject" } + }, + "cursor": { "type": "string" }, + "hasNextPage": { "type": "boolean" }, + "totalCount": { "type": "integer" } + }, + "additionalProperties": true + } + } + } +} +` diff --git a/go.mod b/go.mod index 658c2a7..067381d 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module git.k6n.net/mats/go-cart-actor go 1.26.2 require ( + git.k6n.net/mats/platform v0.0.0-00010101000000-000000000000 github.com/adyen/adyen-go-api-library/v21 v21.1.0 github.com/google/uuid v1.6.0 github.com/prometheus/client_golang v1.23.2 diff --git a/internal/ucp/cart_test.go b/internal/ucp/cart_test.go index ea7ae7a..37563bb 100644 --- a/internal/ucp/cart_test.go +++ b/internal/ucp/cart_test.go @@ -12,6 +12,7 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/go-cart-actor/pkg/actor" messages "git.k6n.net/mats/go-cart-actor/proto/cart" + "git.k6n.net/mats/platform/money" "google.golang.org/protobuf/proto" ) @@ -230,7 +231,7 @@ func TestCartResponse_Conversion(t *testing.T) { func mustPrice(incVat int64, totalVat int64) *cart.Price { return &cart.Price{ - IncVat: incVat, - VatRates: map[float32]int64{25: totalVat}, + IncVat: money.Cents(incVat), + VatRates: map[int]money.Cents{2500: money.Cents(totalVat)}, // 25% in basis points } } diff --git a/internal/ucp/checkout.go b/internal/ucp/checkout.go index b0b98e8..05cd664 100644 --- a/internal/ucp/checkout.go +++ b/internal/ucp/checkout.go @@ -15,6 +15,7 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/cart" profilePkg "git.k6n.net/mats/go-cart-actor/pkg/profile" checkoutMessages "git.k6n.net/mats/go-cart-actor/proto/checkout" + "git.k6n.net/mats/platform/money" "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -447,15 +448,15 @@ type orderLine struct { Reference string `json:"reference"` Sku string `json:"sku"` Name string `json:"name"` - Quantity int32 `json:"quantity"` - UnitPrice int64 `json:"unitPrice"` - TaxRate int32 `json:"taxRate"` + Quantity int32 `json:"quantity"` + UnitPrice money.Cents `json:"unitPrice"` + TaxRate int32 `json:"taxRate"` } type orderPayment struct { - Provider string `json:"provider"` - Reference string `json:"reference"` - Amount int64 `json:"amount"` + Provider string `json:"provider"` + Reference string `json:"reference"` + Amount money.Cents `json:"amount"` } // CreateOrder implements OrderApplier. It builds a from-checkout request from @@ -476,10 +477,10 @@ func (c *OrderHTTPClient) CreateOrder(ctx context.Context, checkoutID uint64, g } // Compute total from cart items + deliveries. - var totalAmount int64 + var totalAmount money.Cents lines := buildOrderLines(g) for _, l := range lines { - totalAmount += l.UnitPrice * int64(l.Quantity) + totalAmount += l.UnitPrice.Mul(int64(l.Quantity)) } req := fromCheckoutReq{ @@ -554,8 +555,9 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine { Name: name, Quantity: int32(it.Quantity), UnitPrice: it.Price.IncVat, - // CartItem.Tax is percentage x 100 (e.g. 2500 = 25.00%). Convert to raw percent (25). - TaxRate: int32(it.Tax / 100), + // CartItem.Tax and OrderLine.TaxRate share the platform basis-point scale + // (2500 = 25%), so the rate passes through unchanged. + TaxRate: int32(it.Tax), }) } for _, d := range g.Deliveries { @@ -568,7 +570,7 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine { Name: "Delivery", Quantity: 1, UnitPrice: d.Price.IncVat, - TaxRate: 25, + TaxRate: 2500, // 25% in basis points }) } return lines @@ -672,7 +674,7 @@ func checkoutGrainToResponse(id string, g *checkout.CheckoutGrain) CheckoutRespo resp.Payments = append(resp.Payments, PaymentResponse{ Id: p.PaymentId, Provider: p.Provider, - Amount: p.Amount, + Amount: money.Cents(p.Amount), Currency: p.Currency, Status: string(p.Status), }) diff --git a/internal/ucp/order.go b/internal/ucp/order.go index f538865..564abe1 100644 --- a/internal/ucp/order.go +++ b/internal/ucp/order.go @@ -175,7 +175,7 @@ func (s *OrderServer) handleIssueRefund(w http.ResponseWriter, r *http.Request) amount := req.Amount if amount <= 0 { - amount = g.CapturedAmount - g.RefundedAmount + amount = (g.CapturedAmount - g.RefundedAmount).Int64() } if amount <= 0 { writeError(w, http.StatusBadRequest, "no captured amount to refund") diff --git a/internal/ucp/order_test.go b/internal/ucp/order_test.go index 6eae3f2..e4a35df 100644 --- a/internal/ucp/order_test.go +++ b/internal/ucp/order_test.go @@ -12,6 +12,7 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/order" messages "git.k6n.net/mats/go-cart-actor/proto/order" + "git.k6n.net/mats/platform/money" "google.golang.org/protobuf/proto" ) @@ -86,10 +87,10 @@ func (a *testOrderApplier) Apply(_ context.Context, id uint64, msgs ...proto.Mes case *messages.IssueRefund: g.Refunds = append(g.Refunds, order.Refund{ Provider: m.Provider, - Amount: m.Amount, + Amount: money.Cents(m.Amount), Reference: m.Reference, }) - g.RefundedAmount += m.Amount + g.RefundedAmount += money.Cents(m.Amount) if g.RefundedAmount >= g.CapturedAmount { g.Status = order.StatusRefunded } diff --git a/internal/ucp/signing.go b/internal/ucp/signing.go index 9928dfd..9be77c5 100644 --- a/internal/ucp/signing.go +++ b/internal/ucp/signing.go @@ -1,6 +1,7 @@ package ucp import ( + "bytes" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" @@ -31,8 +32,8 @@ type SigningConfig struct { // NewSigningConfigFromPEM parses a PEM-encoded ECDSA P-256 private key and // returns a SigningConfig ready for use. // -// data — PEM block containing an EC PRIVATE KEY (PKCS#8 or SEC1) -// keyID — the kid matched by the UCP profile signing_keys entry +// data — PEM block containing an EC PRIVATE KEY (PKCS#8 or SEC1) +// keyID — the kid matched by the UCP profile signing_keys entry func NewSigningConfigFromPEM(pemData []byte, keyID string) (*SigningConfig, error) { block, _ := pem.Decode(pemData) if block == nil { @@ -72,8 +73,8 @@ func NewSigningConfigFromPEM(pemData []byte, keyID string) (*SigningConfig, erro // WithSigning wraps h with an HTTP middleware that adds RFC 9421 HTTP Message // Signatures to every response. The Signature-Input and Signature headers are -// computed over @status, content-type, and x-ucp-timestamp, signed with the -// configured ECDSA P-256 key. +// computed over @status, content-type, x-ucp-timestamp, and content-digest, +// signed with the configured ECDSA P-256 key. // // Mount it on any existing UCP handler: // @@ -83,11 +84,9 @@ func WithSigning(h http.Handler, cfg *SigningConfig) http.Handler { return h // pass through without signing } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sw := &signedWriter{ - ResponseWriter: w, - cfg: cfg, - } + sw := newSignedWriter(w, cfg) h.ServeHTTP(sw, r) + sw.flush() }) } @@ -96,10 +95,24 @@ func WithSigning(h http.Handler, cfg *SigningConfig) http.Handler { // --------------------------------------------------------------------------- type signedWriter struct { - http.ResponseWriter - cfg *SigningConfig - statusCode int - wroteHeader bool + responseWriter http.ResponseWriter + header http.Header + body bytes.Buffer + cfg *SigningConfig + statusCode int + wroteHeader bool +} + +func newSignedWriter(w http.ResponseWriter, cfg *SigningConfig) *signedWriter { + return &signedWriter{ + responseWriter: w, + header: make(http.Header), + cfg: cfg, + } +} + +func (w *signedWriter) Header() http.Header { + return w.header } func (w *signedWriter) WriteHeader(statusCode int) { @@ -108,28 +121,44 @@ func (w *signedWriter) WriteHeader(statusCode int) { } w.wroteHeader = true w.statusCode = statusCode - - created := time.Now().Unix() - w.ResponseWriter.Header().Set("x-ucp-timestamp", strconv.FormatInt(created, 10)) - - // Add signature headers before flushing. - w.addSignatureHeaders(created) - - w.ResponseWriter.WriteHeader(statusCode) } func (w *signedWriter) Write(p []byte) (int, error) { if !w.wroteHeader { w.WriteHeader(http.StatusOK) } - return w.ResponseWriter.Write(p) + return w.body.Write(p) +} + +func (w *signedWriter) flush() { + if !w.wroteHeader { + w.WriteHeader(http.StatusOK) + } + if w.header.Get("Content-Type") == "" && w.body.Len() > 0 { + w.header.Set("Content-Type", http.DetectContentType(w.body.Bytes())) + } + + created := time.Now().Unix() + w.header.Set("x-ucp-timestamp", strconv.FormatInt(created, 10)) + w.header.Set("Content-Digest", buildContentDigest(w.body.Bytes())) + + // Add signature headers before flushing. + w.addSignatureHeaders(created) + + dst := w.responseWriter.Header() + for k, vals := range w.header { + dst[k] = append([]string(nil), vals...) + } + w.responseWriter.WriteHeader(w.statusCode) + _, _ = w.responseWriter.Write(w.body.Bytes()) } // addSignatureHeaders computes and writes the Signature-Input and Signature // headers per RFC 9421 §3.2 / §3.3. func (w *signedWriter) addSignatureHeaders(created int64) { - contentType := w.ResponseWriter.Header().Get("Content-Type") + contentType := w.header.Get("Content-Type") sigTimestamp := strconv.FormatInt(created, 10) + contentDigest := w.header.Get("Content-Digest") // Covered components (in order). RFC 9421 §3.2: derived component names // (@status) are bare identifiers; HTTP header field names are sf-strings @@ -138,6 +167,7 @@ func (w *signedWriter) addSignatureHeaders(created int64) { "@status", `"content-type"`, `"x-ucp-timestamp"`, + `"content-digest"`, } // Build the signature base string (RFC 9421 §2.2). @@ -148,6 +178,8 @@ func (w *signedWriter) addSignatureHeaders(created int64) { base.WriteByte('\n') base.WriteString(fmt.Sprintf(`"x-ucp-timestamp": %s`, sigTimestamp)) base.WriteByte('\n') + base.WriteString(fmt.Sprintf(`"content-digest": %s`, contentDigest)) + base.WriteByte('\n') // Append the signature-params pseudo-line (RFC 9421 §2.2). compList := strings.Join(components, " ") @@ -178,6 +210,11 @@ func (w *signedWriter) addSignatureHeaders(created int64) { // Signature: sig1=:base64url: sigValue := fmt.Sprintf("sig1=:%s:", sigB64) - w.ResponseWriter.Header().Set("Signature-Input", sigInput) - w.ResponseWriter.Header().Set("Signature", sigValue) + w.header.Set("Signature-Input", sigInput) + w.header.Set("Signature", sigValue) +} + +func buildContentDigest(body []byte) string { + sum := sha256.Sum256(body) + return "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":" } diff --git a/internal/ucp/signing_test.go b/internal/ucp/signing_test.go index 0235af6..c6dc81a 100644 --- a/internal/ucp/signing_test.go +++ b/internal/ucp/signing_test.go @@ -63,6 +63,7 @@ func TestWithSigning_AddsHeaders(t *testing.T) { sigInput := rec.Header().Get("Signature-Input") sig := rec.Header().Get("Signature") ts := rec.Header().Get("x-ucp-timestamp") + digest := rec.Header().Get("Content-Digest") if sigInput == "" { t.Fatal("expected Signature-Input header") @@ -73,9 +74,12 @@ func TestWithSigning_AddsHeaders(t *testing.T) { if ts == "" { t.Fatal("expected x-ucp-timestamp header") } + if digest == "" { + t.Fatal("expected Content-Digest header") + } // Verify the signature input format. - if !strings.HasPrefix(sigInput, `sig1=(@status "content-type" "x-ucp-timestamp");`) { + if !strings.HasPrefix(sigInput, `sig1=(@status "content-type" "x-ucp-timestamp" "content-digest");`) { t.Fatalf("unexpected Signature-Input format: %q", sigInput) } if !strings.Contains(sigInput, `keyid="k6n-ecdsa-2026"`) { @@ -155,6 +159,9 @@ func TestWithSigning_WithUCPCartHandler(t *testing.T) { if rec.Header().Get("x-ucp-timestamp") == "" { t.Fatal("expected x-ucp-timestamp on UCP cart response") } + if rec.Header().Get("Content-Digest") == "" { + t.Fatal("expected Content-Digest on UCP cart response") + } } // mustLoadTestKey loads the test PEM for signing tests. diff --git a/internal/ucp/types.go b/internal/ucp/types.go index 93dcb56..407564a 100644 --- a/internal/ucp/types.go +++ b/internal/ucp/types.go @@ -19,11 +19,12 @@ import ( "encoding/json" "net/http" + "git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/go-cart-actor/pkg/checkout" "git.k6n.net/mats/go-cart-actor/pkg/order" "git.k6n.net/mats/go-cart-actor/pkg/profile" - "git.k6n.net/mats/go-cart-actor/pkg/actor" + "git.k6n.net/mats/platform/money" "google.golang.org/protobuf/proto" ) @@ -105,8 +106,8 @@ type CartItemInput struct { Sku string `json:"sku"` Quantity int `json:"quantity,omitempty"` Name string `json:"name,omitempty"` - Price int64 `json:"price,omitempty"` // inc-vat in minor units (öre) - TaxRate float64 `json:"taxRate,omitempty"` // e.g. 25 or 12.5 + Price money.Cents `json:"price,omitempty"` // inc-vat in minor units (öre / money.Cents on the wire) + TaxRate int `json:"taxRate,omitempty"` // basis points: 2500 = 25%, 1250 = 12.5% (matches OrderLine.TaxRate / CartItem.Tax) Image string `json:"image,omitempty"` StoreId *string `json:"storeId,omitempty"` Children []CartItemInput `json:"children,omitempty"` @@ -115,16 +116,16 @@ type CartItemInput struct { // VoucherInput is the UCP wire format for a voucher code to apply. type VoucherInput struct { - Code string `json:"code"` - Value int64 `json:"value,omitempty"` + Code string `json:"code"` + Value money.Cents `json:"value,omitempty"` } // CartResponse is the UCP cart response body. type CartResponse struct { - Id string `json:"id"` - Items []CartItem `json:"items,omitempty"` - Totals Totals `json:"totals"` - Status string `json:"status"` + Id string `json:"id"` + Items []CartItem `json:"items,omitempty"` + Totals Totals `json:"totals"` + Status string `json:"status"` } // CartItem is the UCP wire format for a cart line item in responses. @@ -132,8 +133,8 @@ type CartItem struct { Sku string `json:"sku"` Quantity int `json:"quantity"` Name string `json:"name,omitempty"` - UnitPrice int64 `json:"unitPrice"` // inc-vat minor units - TotalPrice int64 `json:"totalPrice"` // inc-vat minor units + UnitPrice money.Cents `json:"unitPrice"` // inc-vat minor units + TotalPrice money.Cents `json:"totalPrice"` // inc-vat minor units TaxRate int `json:"taxRate"` Image string `json:"image,omitempty"` ItemId uint32 `json:"itemId,omitempty"` @@ -142,11 +143,11 @@ type CartItem struct { // Totals is the UCP wire format for price breakdown. type Totals struct { - TotalIncVat int64 `json:"totalIncVat"` - TotalExVat int64 `json:"totalExVat"` - TotalVat int64 `json:"totalVat"` - Currency string `json:"currency"` - Discount int64 `json:"discount,omitempty"` + TotalIncVat money.Cents `json:"totalIncVat"` + TotalExVat money.Cents `json:"totalExVat"` + TotalVat money.Cents `json:"totalVat"` + Currency string `json:"currency"` + Discount money.Cents `json:"discount,omitempty"` } // --------------------------------------------------------------------------- @@ -205,24 +206,24 @@ type CompleteCheckoutRequest struct { // CheckoutResponse is the UCP checkout session response. type CheckoutResponse struct { - Id string `json:"id"` - CartId string `json:"cartId"` - Status string `json:"status"` - Items []CartItem `json:"items,omitempty"` - Totals Totals `json:"totals"` - Deliveries []DeliveryResponse `json:"deliveries,omitempty"` - Contact *ContactResponse `json:"contact,omitempty"` - OrderId *string `json:"orderId,omitempty"` - Payments []PaymentResponse `json:"payments,omitempty"` + Id string `json:"id"` + CartId string `json:"cartId"` + Status string `json:"status"` + Items []CartItem `json:"items,omitempty"` + Totals Totals `json:"totals"` + Deliveries []DeliveryResponse `json:"deliveries,omitempty"` + Contact *ContactResponse `json:"contact,omitempty"` + OrderId *string `json:"orderId,omitempty"` + Payments []PaymentResponse `json:"payments,omitempty"` } // DeliveryResponse is the UCP wire format for a delivery option. type DeliveryResponse struct { - Id uint32 `json:"id"` - Provider string `json:"provider"` - Price int64 `json:"price"` - Items []uint32 `json:"items"` - PickupPoint *PickupPointResp `json:"pickupPoint,omitempty"` + Id uint32 `json:"id"` + Provider string `json:"provider"` + Price money.Cents `json:"price"` + Items []uint32 `json:"items"` + PickupPoint *PickupPointResp `json:"pickupPoint,omitempty"` } // PickupPointResp is the UCP wire format for a pickup point. @@ -244,11 +245,11 @@ type ContactResponse struct { // PaymentResponse is the UCP wire format for a payment record. type PaymentResponse struct { - Id string `json:"id"` - Provider string `json:"provider"` - Amount int64 `json:"amount"` - Currency string `json:"currency"` - Status string `json:"status"` + Id string `json:"id"` + Provider string `json:"provider"` + Amount money.Cents `json:"amount"` + Currency string `json:"currency"` + Status string `json:"status"` } // --------------------------------------------------------------------------- @@ -257,24 +258,24 @@ type PaymentResponse struct { // OrderResponse is the UCP order response body. type OrderResponse struct { - OrderId string `json:"orderId"` - Reference string `json:"reference,omitempty"` - CartId string `json:"cartId,omitempty"` - Status string `json:"status"` - Currency string `json:"currency,omitempty"` - TotalAmount int64 `json:"totalAmount"` - TotalTax int64 `json:"totalTax"` - Lines []OrderLineResp `json:"lines,omitempty"` - Payments []OrderPaymentResp `json:"payments,omitempty"` - Fulfillments []OrderFulfillmentResp `json:"fulfillments,omitempty"` - Returns []OrderReturnResp `json:"returns,omitempty"` - Refunds []OrderRefundResp `json:"refunds,omitempty"` - CapturedAmount int64 `json:"capturedAmount"` - RefundedAmount int64 `json:"refundedAmount"` - CustomerEmail string `json:"customerEmail,omitempty"` - CustomerName string `json:"customerName,omitempty"` - PlacedAt string `json:"placedAt,omitempty"` - UpdatedAt string `json:"updatedAt,omitempty"` + OrderId string `json:"orderId"` + Reference string `json:"reference,omitempty"` + CartId string `json:"cartId,omitempty"` + Status string `json:"status"` + Currency string `json:"currency,omitempty"` + TotalAmount money.Cents `json:"totalAmount"` + TotalTax money.Cents `json:"totalTax"` + Lines []OrderLineResp `json:"lines,omitempty"` + Payments []OrderPaymentResp `json:"payments,omitempty"` + Fulfillments []OrderFulfillmentResp `json:"fulfillments,omitempty"` + Returns []OrderReturnResp `json:"returns,omitempty"` + Refunds []OrderRefundResp `json:"refunds,omitempty"` + CapturedAmount money.Cents `json:"capturedAmount"` + RefundedAmount money.Cents `json:"refundedAmount"` + CustomerEmail string `json:"customerEmail,omitempty"` + CustomerName string `json:"customerName,omitempty"` + PlacedAt string `json:"placedAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` } // OrderLineResp represents a single order line item in responses. @@ -282,21 +283,21 @@ type OrderLineResp struct { Reference string `json:"reference"` Sku string `json:"sku"` Name string `json:"name,omitempty"` - Quantity int `json:"quantity"` - UnitPrice int64 `json:"unitPrice"` - TaxRate int `json:"taxRate"` - TotalAmount int64 `json:"totalAmount"` - TotalTax int64 `json:"totalTax"` - Fulfilled int `json:"fulfilled,omitempty"` + Quantity int `json:"quantity"` + UnitPrice money.Cents `json:"unitPrice"` + TaxRate int `json:"taxRate"` + TotalAmount money.Cents `json:"totalAmount"` + TotalTax money.Cents `json:"totalTax"` + Fulfilled int `json:"fulfilled,omitempty"` } // OrderPaymentResp represents a payment record on an order. type OrderPaymentResp 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"` } @@ -318,9 +319,9 @@ type OrderReturnResp struct { // OrderRefundResp represents a refund record. type OrderRefundResp 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"` } // OrderLineEntry is a lightweight reference+quantity pair used in fulfillment @@ -408,17 +409,18 @@ type UpdateAddressRequest struct { // ProfileData is the UCP business profile served at /.well-known/ucp. type ProfileData struct { - UCP Profile `json:"ucp"` + UCP Profile `json:"ucp"` + SigningKeys []SigningKey `json:"signing_keys,omitempty"` } // Profile is the core UCP profile object. type Profile struct { - Version string `json:"version"` - Business BusinessInfo `json:"business,omitempty"` - Services map[string][]ServiceBinding `json:"services"` - Capabilities map[string][]CapabilityDecl `json:"capabilities"` - PaymentHandlers map[string][]PaymentHandlerDecl `json:"payment_handlers,omitempty"` - SupportedVersions []string `json:"supported_versions,omitempty"` + Version string `json:"version"` + Business BusinessInfo `json:"business,omitempty"` + Services map[string][]ServiceBinding `json:"services"` + Capabilities map[string][]CapabilityDecl `json:"capabilities"` + PaymentHandlers map[string][]PaymentHandlerDecl `json:"payment_handlers,omitempty"` + SupportedVersions []string `json:"supported_versions,omitempty"` } // BusinessInfo describes the business behind a UCP profile. @@ -455,13 +457,23 @@ type OpDef struct { // PaymentHandlerDecl describes a payment handler specification. type PaymentHandlerDecl struct { - ID string `json:"id"` - Version string `json:"version"` - Spec string `json:"spec"` - Schema string `json:"schema"` + ID string `json:"id"` + Version string `json:"version"` + Spec string `json:"spec"` + Schema string `json:"schema"` Description string `json:"description,omitempty"` } +// SigningKey describes a published verification key for signed UCP responses. +type SigningKey struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + Crv string `json:"crv,omitempty"` + X string `json:"x,omitempty"` + Y string `json:"y,omitempty"` + Alg string `json:"alg,omitempty"` +} + // --------------------------------------------------------------------------- // HTTP helpers // --------------------------------------------------------------------------- diff --git a/pkg/actor/base62-id.go b/pkg/actor/base62-id.go index a5ea1d3..39a3d39 100644 --- a/pkg/actor/base62-id.go +++ b/pkg/actor/base62-id.go @@ -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 -} diff --git a/pkg/actor/grpc_server.go b/pkg/actor/grpc_server.go index 40239f5..20b50f7 100644 --- a/pkg/actor/grpc_server.go +++ b/pkg/actor/grpc_server.go @@ -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) } }() diff --git a/pkg/actor/log_listerner.go b/pkg/actor/log_listerner.go index 45b1151..0c03846 100644 --- a/pkg/actor/log_listerner.go +++ b/pkg/actor/log_listerner.go @@ -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." (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) } } diff --git a/pkg/actor/mutation_registry.go b/pkg/actor/mutation_registry.go index 7a059a4..ee1222f 100644 --- a/pkg/actor/mutation_registry.go +++ b/pkg/actor/mutation_registry.go @@ -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) diff --git a/pkg/backofficeadmin/app.go b/pkg/backofficeadmin/app.go index 2dc3f47..1615f83 100644 --- a/pkg/backofficeadmin/app.go +++ b/pkg/backofficeadmin/app.go @@ -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 + }) } diff --git a/pkg/cart/cart-grain.go b/pkg/cart/cart-grain.go index 210a521..361c788 100644 --- a/pkg/cart/cart-grain.go +++ b/pkg/cart/cart-grain.go @@ -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 diff --git a/pkg/cart/cart_id.go b/pkg/cart/cart_id.go index 19d28d9..987a39c 100644 --- a/pkg/cart/cart_id.go +++ b/pkg/cart/cart_id.go @@ -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 -} diff --git a/pkg/cart/cart_id_test.go b/pkg/cart/cart_id_test.go index 272bf41..e02c05a 100644 --- a/pkg/cart/cart_id_test.go +++ b/pkg/cart/cart_id_test.go @@ -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 } diff --git a/pkg/cart/mcp/jsonrpc.go b/pkg/cart/mcp/jsonrpc.go deleted file mode 100644 index 71f6263..0000000 --- a/pkg/cart/mcp/jsonrpc.go +++ /dev/null @@ -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}} -} diff --git a/pkg/cart/mcp/mcp.go b/pkg/cart/mcp/mcp.go index 3027173..578c154 100644 --- a/pkg/cart/mcp/mcp.go +++ b/pkg/cart/mcp/mcp.go @@ -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() } diff --git a/pkg/cart/mcp/tools.go b/pkg/cart/mcp/tools.go index 9067ee3..4233a6b 100644 --- a/pkg/cart/mcp/tools.go +++ b/pkg/cart/mcp/tools.go @@ -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 -} diff --git a/pkg/cart/mutation_add_item.go b/pkg/cart/mutation_add_item.go index 3e7aa0e..8726933 100644 --- a/pkg/cart/mutation_add_item.go +++ b/pkg/cart/mutation_add_item.go @@ -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) } diff --git a/pkg/cart/price.go b/pkg/cart/price.go index c0186aa..4710727 100644 --- a/pkg/cart/price.go +++ b/pkg/cart/price.go @@ -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...) } diff --git a/pkg/cart/price_test.go b/pkg/cart/price_test.go index 37f39cf..c7b3811 100644 --- a/pkg/cart/price_test.go +++ b/pkg/cart/price_test.go @@ -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) } } diff --git a/pkg/cart/tax.go b/pkg/cart/tax.go index 08d9c2e..495f23d 100644 --- a/pkg/cart/tax.go +++ b/pkg/cart/tax.go @@ -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() } diff --git a/pkg/cart/tax_nordic.go b/pkg/cart/tax_nordic.go index 27d5d48..f8d3c6b 100644 --- a/pkg/cart/tax_nordic.go +++ b/pkg/cart/tax_nordic.go @@ -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) } diff --git a/pkg/cart/tax_test.go b/pkg/cart/tax_test.go index e65eae1..9643a3b 100644 --- a/pkg/cart/tax_test.go +++ b/pkg/cart/tax_test.go @@ -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 diff --git a/pkg/checkout/mutation_initialize_checkout.go b/pkg/checkout/mutation_initialize_checkout.go index b747d48..f9deb18 100644 --- a/pkg/checkout/mutation_initialize_checkout.go +++ b/pkg/checkout/mutation_initialize_checkout.go @@ -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 } diff --git a/pkg/checkout/mutation_payment_completed.go b/pkg/checkout/mutation_payment_completed.go index 18ab139..a7eeae6 100644 --- a/pkg/checkout/mutation_payment_completed.go +++ b/pkg/checkout/mutation_payment_completed.go @@ -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 diff --git a/pkg/discovery/discovery_test.go b/pkg/discovery/discovery_test.go index 122d327..1f01ca8 100644 --- a/pkg/discovery/discovery_test.go +++ b/pkg/discovery/discovery_test.go @@ -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", diff --git a/pkg/order/actions.go b/pkg/order/actions.go index 151046e..f896a50 100644 --- a/pkg/order/actions.go +++ b/pkg/order/actions.go @@ -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"` diff --git a/pkg/order/emit_created.go b/pkg/order/emit_created.go new file mode 100644 index 0000000..b7e89ae --- /dev/null +++ b/pkg/order/emit_created.go @@ -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(), + } +} diff --git a/pkg/order/flow_test.go b/pkg/order/flow_test.go index 27be625..c684e53 100644 --- a/pkg/order/flow_test.go +++ b/pkg/order/flow_test.go @@ -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 } diff --git a/pkg/order/flows/place-and-pay.json b/pkg/order/flows/place-and-pay.json index f599045..cab23c7 100644 --- a/pkg/order/flows/place-and-pay.json +++ b/pkg/order/flows/place-and-pay.json @@ -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" } + ] } } ] diff --git a/pkg/order/id.go b/pkg/order/id.go index 1b3e162..505eb80 100644 --- a/pkg/order/id.go +++ b/pkg/order/id.go @@ -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 } diff --git a/pkg/order/mcp/jsonrpc.go b/pkg/order/mcp/jsonrpc.go deleted file mode 100644 index 71f6263..0000000 --- a/pkg/order/mcp/jsonrpc.go +++ /dev/null @@ -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}} -} diff --git a/pkg/order/mcp/mcp.go b/pkg/order/mcp/mcp.go index b845c71..30308e8 100644 --- a/pkg/order/mcp/mcp.go +++ b/pkg/order/mcp/mcp.go @@ -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() } diff --git a/pkg/order/mcp/mcp_test.go b/pkg/order/mcp/mcp_test.go index ab74e27..692e394 100644 --- a/pkg/order/mcp/mcp_test.go +++ b/pkg/order/mcp/mcp_test.go @@ -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 { diff --git a/pkg/order/mcp/tools.go b/pkg/order/mcp/tools.go index 4798ae7..f369726 100644 --- a/pkg/order/mcp/tools.go +++ b/pkg/order/mcp/tools.go @@ -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 -} diff --git a/pkg/order/mutations.go b/pkg/order/mutations.go index bae5fa9..af221d1 100644 --- a/pkg/order/mutations.go +++ b/pkg/order/mutations.go @@ -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 diff --git a/pkg/order/order-grain.go b/pkg/order/order-grain.go index 3a1e2eb..4c62552 100644 --- a/pkg/order/order-grain.go +++ b/pkg/order/order-grain.go @@ -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"` diff --git a/pkg/order/tax.go b/pkg/order/tax.go index c43f516..3758cf0 100644 --- a/pkg/order/tax.go +++ b/pkg/order/tax.go @@ -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() } diff --git a/pkg/order/tax_test.go b/pkg/order/tax_test.go index 97df97a..55b9dc5 100644 --- a/pkg/order/tax_test.go +++ b/pkg/order/tax_test.go @@ -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 diff --git a/pkg/profile/id.go b/pkg/profile/id.go index 0fbdb4e..7d3187d 100644 --- a/pkg/profile/id.go +++ b/pkg/profile/id.go @@ -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 } diff --git a/pkg/promotions/apply.go b/pkg/promotions/apply.go index 06e33a5..8e2664b 100644 --- a/pkg/promotions/apply.go +++ b/pkg/promotions/apply.go @@ -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) } diff --git a/pkg/promotions/apply_test.go b/pkg/promotions/apply_test.go index 2f3d718..f1f76c3 100644 --- a/pkg/promotions/apply_test.go +++ b/pkg/promotions/apply_test.go @@ -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}, diff --git a/pkg/promotions/eval.go b/pkg/promotions/eval.go index 31af521..7843d4f 100644 --- a/pkg/promotions/eval.go +++ b/pkg/promotions/eval.go @@ -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: diff --git a/pkg/promotions/eval_test.go b/pkg/promotions/eval_test.go index 02d56bb..d1aee98 100644 --- a/pkg/promotions/eval_test.go +++ b/pkg/promotions/eval_test.go @@ -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 } diff --git a/pkg/promotions/evaluate.go b/pkg/promotions/evaluate.go index 8c348a3..a975226 100644 --- a/pkg/promotions/evaluate.go +++ b/pkg/promotions/evaluate.go @@ -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. diff --git a/pkg/promotions/mcp/admin.go b/pkg/promotions/mcp/admin.go index 5b4d5d2..8d97e43 100644 --- a/pkg/promotions/mcp/admin.go +++ b/pkg/promotions/mcp/admin.go @@ -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) diff --git a/pkg/promotions/mcp/jsonrpc.go b/pkg/promotions/mcp/jsonrpc.go deleted file mode 100644 index 71f6263..0000000 --- a/pkg/promotions/mcp/jsonrpc.go +++ /dev/null @@ -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}} -} diff --git a/pkg/promotions/mcp/mcp.go b/pkg/promotions/mcp/mcp.go index eeb31e7..1018a20 100644 --- a/pkg/promotions/mcp/mcp.go +++ b/pkg/promotions/mcp/mcp.go @@ -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() } diff --git a/pkg/promotions/mcp/public.go b/pkg/promotions/mcp/public.go index b8f6903..ca6b789 100644 --- a/pkg/promotions/mcp/public.go +++ b/pkg/promotions/mcp/public.go @@ -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, }) } diff --git a/pkg/promotions/mcp/tools.go b/pkg/promotions/mcp/tools.go index d938771..069c294 100644 --- a/pkg/promotions/mcp/tools.go +++ b/pkg/promotions/mcp/tools.go @@ -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("") } diff --git a/pkg/voucher/parser.go b/pkg/voucher/parser.go index 7d41e3d..7ce3d06 100644 --- a/pkg/voucher/parser.go +++ b/pkg/voucher/parser.go @@ -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 } diff --git a/pkg/voucher/parser_test.go b/pkg/voucher/parser_test.go index d9f4de4..8a63f21 100644 --- a/pkg/voucher/parser_test.go +++ b/pkg/voucher/parser_test.go @@ -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{ diff --git a/pkg/voucher/service.go b/pkg/voucher/service.go index 06579c9..620925c 100644 --- a/pkg/voucher/service.go +++ b/pkg/voucher/service.go @@ -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, diff --git a/proto/order.proto b/proto/order.proto index f8716e5..ca53999 100644 --- a/proto/order.proto +++ b/proto/order.proto @@ -19,6 +19,7 @@ message OrderLine { int32 tax_rate = 6; // percent int64 total_amount = 7; int64 total_tax = 8; + string location = 9; // inventory location / store id for commit; empty = order country } // PlaceOrder creates the order (only legal when the order does not yet exist). diff --git a/proto/order/order.pb.go b/proto/order/order.pb.go index 120c499..04061bd 100644 --- a/proto/order/order.pb.go +++ b/proto/order/order.pb.go @@ -31,6 +31,7 @@ type OrderLine struct { TaxRate int32 `protobuf:"varint,6,opt,name=tax_rate,json=taxRate,proto3" json:"tax_rate,omitempty"` // percent TotalAmount int64 `protobuf:"varint,7,opt,name=total_amount,json=totalAmount,proto3" json:"total_amount,omitempty"` TotalTax int64 `protobuf:"varint,8,opt,name=total_tax,json=totalTax,proto3" json:"total_tax,omitempty"` + Location string `protobuf:"bytes,9,opt,name=location,proto3" json:"location,omitempty"` // inventory location / store id for commit; empty = order country unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -121,6 +122,13 @@ func (x *OrderLine) GetTotalTax() int64 { return 0 } +func (x *OrderLine) GetLocation() string { + if x != nil { + return x.Location + } + return "" +} + // PlaceOrder creates the order (only legal when the order does not yet exist). type PlaceOrder struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -939,7 +947,7 @@ var File_order_proto protoreflect.FileDescriptor var file_order_proto_rawDesc = string([]byte{ 0x0a, 0x0b, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0e, 0x6f, - 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0xe5, 0x01, + 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0x81, 0x02, 0x0a, 0x09, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x4c, 0x69, 0x6e, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x6b, 0x75, @@ -954,122 +962,124 @@ var file_order_proto_rawDesc = string([]byte{ 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x74, 0x61, 0x78, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x74, 0x6f, 0x74, - 0x61, 0x6c, 0x54, 0x61, 0x78, 0x22, 0xcf, 0x03, 0x0a, 0x0a, 0x50, 0x6c, 0x61, 0x63, 0x65, 0x4f, - 0x72, 0x64, 0x65, 0x72, 0x12, 0x27, 0x0a, 0x0f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x72, 0x65, - 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f, - 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x17, 0x0a, - 0x07, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, - 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, - 0x63, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, - 0x63, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x06, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, - 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, - 0x6e, 0x74, 0x72, 0x79, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x61, 0x6d, - 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61, - 0x6c, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x6f, 0x74, 0x61, 0x6c, - 0x5f, 0x74, 0x61, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x74, 0x6f, 0x74, 0x61, - 0x6c, 0x54, 0x61, 0x78, 0x12, 0x2f, 0x0a, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x08, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05, - 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, - 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63, - 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, - 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0a, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72, 0x4e, 0x61, 0x6d, - 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64, - 0x72, 0x65, 0x73, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x62, 0x69, 0x6c, 0x6c, - 0x69, 0x6e, 0x67, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x68, - 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x0c, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x41, 0x64, - 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x20, 0x0a, 0x0c, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x64, 0x5f, - 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x70, 0x6c, 0x61, - 0x63, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x22, 0x79, 0x0a, 0x10, 0x41, 0x75, 0x74, 0x68, 0x6f, - 0x72, 0x69, 0x7a, 0x65, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, - 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, - 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, - 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, - 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x13, 0x0a, - 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, - 0x4d, 0x73, 0x22, 0x77, 0x0a, 0x0e, 0x43, 0x61, 0x70, 0x74, 0x75, 0x72, 0x65, 0x50, 0x61, 0x79, - 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, + 0x61, 0x6c, 0x54, 0x61, 0x78, 0x12, 0x1a, 0x0a, 0x08, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x22, 0xcf, 0x03, 0x0a, 0x0a, 0x50, 0x6c, 0x61, 0x63, 0x65, 0x4f, 0x72, 0x64, 0x65, 0x72, + 0x12, 0x27, 0x0a, 0x0f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, + 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, + 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x63, 0x61, 0x72, + 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74, + 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x12, 0x16, + 0x0a, 0x06, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, + 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, + 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x41, 0x6d, 0x6f, + 0x75, 0x6e, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x74, 0x61, 0x78, + 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x54, 0x61, 0x78, + 0x12, 0x2f, 0x0a, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x19, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, + 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05, 0x6c, 0x69, 0x6e, 0x65, + 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72, 0x5f, 0x65, 0x6d, + 0x61, 0x69, 0x6c, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, + 0x6d, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x75, 0x73, 0x74, + 0x6f, 0x6d, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, + 0x0f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, + 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x41, + 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, + 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x0f, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, + 0x73, 0x12, 0x20, 0x0a, 0x0c, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, + 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x64, 0x41, + 0x74, 0x4d, 0x73, 0x22, 0x79, 0x0a, 0x10, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, + 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, + 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x72, + 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, + 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x77, + 0x0a, 0x0e, 0x43, 0x61, 0x70, 0x74, 0x75, 0x72, 0x65, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, + 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, + 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x61, 0x6d, + 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, + 0x63, 0x65, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x4b, 0x0a, 0x0f, 0x46, 0x75, 0x6c, 0x66, 0x69, + 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, + 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, + 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x22, 0xd5, 0x01, 0x0a, 0x11, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x46, + 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x61, + 0x72, 0x72, 0x69, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x61, 0x72, + 0x72, 0x69, 0x65, 0x72, 0x12, 0x27, 0x0a, 0x0f, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, + 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, + 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x21, 0x0a, + 0x0c, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x75, 0x72, 0x69, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0b, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x55, 0x72, 0x69, + 0x12, 0x35, 0x0a, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x1f, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, + 0x2e, 0x46, 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, + 0x52, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x24, 0x0a, 0x0d, + 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x13, 0x0a, + 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, + 0x4d, 0x73, 0x22, 0x3a, 0x0a, 0x0b, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x4f, 0x72, 0x64, 0x65, + 0x72, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, + 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x83, + 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x65, 0x74, 0x75, 0x72, 0x6e, + 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, + 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x35, 0x0a, 0x05, 0x6c, 0x69, 0x6e, 0x65, + 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x46, 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, + 0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x12, + 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, + 0x61, 0x74, 0x4d, 0x73, 0x22, 0x91, 0x01, 0x0a, 0x0b, 0x49, 0x73, 0x73, 0x75, 0x65, 0x52, 0x65, + 0x66, 0x75, 0x6e, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x66, - 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x4b, 0x0a, 0x0f, 0x46, - 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x12, 0x1c, - 0x0a, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08, - 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, - 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0xd5, 0x01, 0x0a, 0x11, 0x43, 0x72, 0x65, - 0x61, 0x74, 0x65, 0x46, 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x0e, - 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x18, - 0x0a, 0x07, 0x63, 0x61, 0x72, 0x72, 0x69, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x63, 0x61, 0x72, 0x72, 0x69, 0x65, 0x72, 0x12, 0x27, 0x0a, 0x0f, 0x74, 0x72, 0x61, 0x63, - 0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x4e, 0x75, 0x6d, 0x62, 0x65, - 0x72, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x75, 0x72, - 0x69, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, - 0x67, 0x55, 0x72, 0x69, 0x12, 0x35, 0x0a, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x05, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x73, 0x2e, 0x46, 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, - 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x13, 0x0a, 0x05, 0x61, - 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, - 0x22, 0x24, 0x0a, 0x0d, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x72, 0x64, 0x65, - 0x72, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x3a, 0x0a, 0x0b, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, - 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x13, 0x0a, - 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, - 0x4d, 0x73, 0x22, 0x83, 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x65, - 0x74, 0x75, 0x72, 0x6e, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x02, 0x69, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x35, 0x0a, 0x05, - 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x72, - 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x46, 0x75, 0x6c, - 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05, 0x6c, 0x69, - 0x6e, 0x65, 0x73, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x91, 0x01, 0x0a, 0x0b, 0x49, 0x73, 0x73, - 0x75, 0x65, 0x52, 0x65, 0x66, 0x75, 0x6e, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09, - 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65, - 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, - 0x65, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0xe7, 0x01, 0x0a, - 0x0f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, - 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, - 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a, - 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, - 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x42, 0x0a, 0x0c, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x5f, - 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x72, - 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x46, 0x75, 0x6c, - 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x0b, 0x72, 0x65, - 0x74, 0x75, 0x72, 0x6e, 0x4c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x36, 0x0a, 0x09, 0x6e, 0x65, 0x77, - 0x5f, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, - 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x72, - 0x64, 0x65, 0x72, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x08, 0x6e, 0x65, 0x77, 0x4c, 0x69, 0x6e, 0x65, - 0x73, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0xa2, 0x01, 0x0a, 0x10, 0x45, 0x64, 0x69, 0x74, 0x4f, - 0x72, 0x64, 0x65, 0x72, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x73, - 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x41, - 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x27, 0x0a, 0x0f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, - 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, - 0x0e, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, - 0x25, 0x0a, 0x0e, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x5f, 0x70, 0x72, 0x69, 0x63, - 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, - 0x67, 0x50, 0x72, 0x69, 0x63, 0x65, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x42, 0x3b, 0x5a, 0x39, 0x67, - 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f, - 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x2f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x3b, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, - 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, + 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x74, 0x75, 0x72, + 0x6e, 0x49, 0x64, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0xe7, 0x01, 0x0a, 0x0f, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x0e, 0x0a, 0x02, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1b, 0x0a, 0x09, + 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, + 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, + 0x6e, 0x12, 0x42, 0x0a, 0x0c, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x6c, 0x69, 0x6e, 0x65, + 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x46, 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, + 0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x0b, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, + 0x4c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x36, 0x0a, 0x09, 0x6e, 0x65, 0x77, 0x5f, 0x6c, 0x69, 0x6e, + 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, + 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x4c, + 0x69, 0x6e, 0x65, 0x52, 0x08, 0x6e, 0x65, 0x77, 0x4c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x13, 0x0a, + 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, + 0x4d, 0x73, 0x22, 0xa2, 0x01, 0x0a, 0x10, 0x45, 0x64, 0x69, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, + 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x68, 0x69, 0x70, 0x70, + 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x0f, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x41, 0x64, 0x64, 0x72, 0x65, + 0x73, 0x73, 0x12, 0x27, 0x0a, 0x0f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x62, 0x69, 0x6c, + 0x6c, 0x69, 0x6e, 0x67, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x73, + 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x5f, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x0d, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x50, 0x72, 0x69, + 0x63, 0x65, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x42, 0x3b, 0x5a, 0x39, 0x67, 0x69, 0x74, 0x2e, 0x6b, + 0x36, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f, 0x67, 0x6f, 0x2d, 0x63, + 0x61, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, + 0x6f, 0x72, 0x64, 0x65, 0x72, 0x3b, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var (