all the refactor
This commit is contained in:
+138
-25
@@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# UCP API test suite
|
# 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:
|
# Prerequisites:
|
||||||
# make up-infra (infra compose up — order :8092, cart :8090)
|
# make up-infra (infra compose up — order :8092, cart :8090)
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
# # Single service:
|
# # Single service:
|
||||||
# bash api-tests/test_ucp.sh --order
|
# bash api-tests/test_ucp.sh --order
|
||||||
# bash api-tests/test_ucp.sh --cart
|
# bash api-tests/test_ucp.sh --cart
|
||||||
|
# bash api-tests/test_ucp.sh --checkout
|
||||||
#
|
#
|
||||||
# # Verify signatures (requires signing key mounted):
|
# # Verify signatures (requires signing key mounted):
|
||||||
# bash api-tests/test_ucp.sh --verify-sig
|
# bash api-tests/test_ucp.sh --verify-sig
|
||||||
@@ -36,16 +38,20 @@ elif [ "$MODE" = "--order" ]; then
|
|||||||
ORDER_URL="http://localhost:8092"
|
ORDER_URL="http://localhost:8092"
|
||||||
elif [ "$MODE" = "--cart" ]; then
|
elif [ "$MODE" = "--cart" ]; then
|
||||||
CART_URL="http://localhost:8090"
|
CART_URL="http://localhost:8090"
|
||||||
|
elif [ "$MODE" = "--checkout" ]; then
|
||||||
|
CART_URL="http://localhost:8090"
|
||||||
|
CHECKOUT_URL="http://localhost:8094"
|
||||||
elif [ "$MODE" = "--verify-sig" ]; then
|
elif [ "$MODE" = "--verify-sig" ]; then
|
||||||
# If signing is enabled (key mounted), you need the full stack or direct with
|
# If signing is enabled (key mounted), you need the full stack or direct with
|
||||||
# env var — try both common ports.
|
# env var — try both common ports.
|
||||||
ORDER_URL="${ORDER_URL:-http://localhost:8092}"
|
ORDER_URL="${ORDER_URL:-http://localhost:8092}"
|
||||||
CART_URL="${CART_URL:-http://localhost:8090}"
|
CART_URL="${CART_URL:-http://localhost:8090}"
|
||||||
|
CHECKOUT_URL="${CHECKOUT_URL:-http://localhost:8094}"
|
||||||
else
|
else
|
||||||
# Default: infra stack, direct to Docker ports
|
# Default: infra stack, direct to Docker ports
|
||||||
CART_URL="${CART_URL:-http://localhost:8090}"
|
CART_URL="${CART_URL:-http://localhost:8090}"
|
||||||
ORDER_URL="${ORDER_URL:-http://localhost:8092}"
|
ORDER_URL="${ORDER_URL:-http://localhost:8092}"
|
||||||
CHECKOUT_URL="${CHECKOUT_URL:-}"
|
CHECKOUT_URL="${CHECKOUT_URL:-http://localhost:8094}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "═══════════════════════════════════════════════════════════════"
|
echo "═══════════════════════════════════════════════════════════════"
|
||||||
@@ -210,20 +216,25 @@ if [ -n "${CART_URL:-}" ]; then
|
|||||||
echo " response: $(echo "$cart_body" | head -c 400)"
|
echo " response: $(echo "$cart_body" | head -c 400)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Add item to cart ────────────────────────────────────────────────────────
|
# ── Replace cart contents via UCP ───────────────────────────────────────────
|
||||||
echo "--- 2.3 POST /ucp/v1/carts/{id}/items ---"
|
echo "--- 2.3 PUT /ucp/v1/carts/{id} ---"
|
||||||
add_resp=$(curl -s -w '\n%{http_code}' -X POST "$CART_URL/ucp/v1/carts/$CART_ID/items" \
|
add_resp=$(curl -s -w '\n%{http_code}' -X PUT "$CART_URL/ucp/v1/carts/$CART_ID" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{
|
-d '{
|
||||||
"sku": "TEST-SKU-1",
|
"country": "se",
|
||||||
"name": "Test Product",
|
"items": [
|
||||||
"quantity": 1,
|
{
|
||||||
"unitPrice": 19900,
|
"sku": "TEST-SKU-1",
|
||||||
"taxRate": 2500
|
"name": "Test Product",
|
||||||
|
"quantity": 1,
|
||||||
|
"price": 19900,
|
||||||
|
"taxRate": 25
|
||||||
|
}
|
||||||
|
]
|
||||||
}')
|
}')
|
||||||
add_code=$(echo "$add_resp" | tail -1)
|
add_code=$(echo "$add_resp" | tail -1)
|
||||||
add_body=$(echo "$add_resp" | sed '$d')
|
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 ────────────────────────────────────────────────────────────────
|
# ── Get cart ────────────────────────────────────────────────────────────────
|
||||||
echo "--- 2.4 GET /ucp/v1/carts/{id} ---"
|
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_code=$(echo "$get_cart_resp" | tail -1)
|
||||||
get_cart_body=$(echo "$get_cart_resp" | sed '$d')
|
get_cart_body=$(echo "$get_cart_resp" | sed '$d')
|
||||||
assert_status "Get cart" 200 "$get_cart_code" "$get_cart_body"
|
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 ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
# SECTION 3: Full flow — cart → checkout → order (via edge/nginx only)
|
# SECTION 3: Checkout service tests
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
if [ "$MODE" = "--edge" ] && [ -n "${CHECKOUT_URL:-}" ]; then
|
if [ -n "${CHECKOUT_URL:-}" ] && [ -n "${CART_ID:-}" ]; then
|
||||||
header "3. Full Flow (via nginx)"
|
header "3. Checkout Service → $CHECKOUT_URL"
|
||||||
|
|
||||||
# ── This section needs the full compose stack with nginx on :8080,
|
# ── Health ──────────────────────────────────────────────────────────────────
|
||||||
# and checkout must be reachable. Skip for infra/direct modes.
|
echo "--- 3.1 Health check ---"
|
||||||
echo "--- 3.1 Create checkout session ---"
|
resp=$(curl -sf "$CHECKOUT_URL/healthz" 2>&1) && pass "/healthz" || fail "/healthz: $resp"
|
||||||
checkout_resp=$(curl -s -w '\n%{http_code}' -X POST "$CHECKOUT_URL/ucp/v1/checkout-sessions" \
|
|
||||||
|
# ── 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' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{
|
-d '{
|
||||||
"cartId": "'"$CART_ID"'",
|
"cartId": "'"$CART_ID"'",
|
||||||
"currency": "SEK",
|
"currency": "SEK",
|
||||||
"country": "se"
|
"country": "se"
|
||||||
}')
|
}')
|
||||||
checkout_code=$(echo "$checkout_resp" | tail -1)
|
checkout_code=$(echo "$checkout_resp" | tail -1)
|
||||||
checkout_body=$(echo "$checkout_resp" | sed '$d')
|
checkout_body=$(echo "$checkout_resp" | sed '$d')
|
||||||
assert_status "Create checkout session" 201 "$checkout_code" "$checkout_body"
|
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
|
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
|
if [ "$MODE" = "--verify-sig" ]; then
|
||||||
header "4. Signature Verification"
|
header "5. Signature Verification"
|
||||||
|
|
||||||
# Verify that the response includes RFC 9421 HTTP Message Signatures.
|
# Verify that the response includes RFC 9421 HTTP Message Signatures.
|
||||||
# Requires the service to have UCP_SIGNING_KEY_PATH set and the key mounted.
|
# 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)
|
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
|
echo "$sig_output" | while IFS= read -r line; do
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/backofficeadmin"
|
"git.k6n.net/mats/go-cart-actor/pkg/backofficeadmin"
|
||||||
"git.k6n.net/mats/platform/config"
|
"git.k6n.net/mats/platform/config"
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
"git.k6n.net/mats/platform/rabbit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -66,9 +66,9 @@ func main() {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
var conn *amqp.Connection
|
var conn *rabbit.Conn
|
||||||
if amqpURL != "" {
|
if amqpURL != "" {
|
||||||
conn, err = amqp.Dial(amqpURL)
|
conn, err = rabbit.Dial(amqpURL, "cart-backoffice")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+180
-31
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/internal/ucp"
|
"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/telemetry"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
"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/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"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
@@ -116,6 +120,123 @@ func matchesSkuAndLocation(update inventory.InventoryResult, item cart.CartItem)
|
|||||||
return false
|
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() {
|
func main() {
|
||||||
|
|
||||||
// cartPort is the bare HTTP port. It drives both the local listener and the
|
// cartPort is the bare HTTP port. It drives both the local listener and the
|
||||||
@@ -282,29 +403,48 @@ func main() {
|
|||||||
|
|
||||||
cartMCP := cartmcp.New(pool)
|
cartMCP := cartmcp.New(pool)
|
||||||
|
|
||||||
// Publish each applied mutation to the "cart"/"mutation" RabbitMQ topic so the
|
// Stream each applied mutation to the shared "mutations" exchange (routing key
|
||||||
// backoffice /commerce live feed (and other consumers) see cart activity.
|
// mutation.cart) so the backoffice /commerce live feed (and other consumers)
|
||||||
// Best-effort: if AMQP is unreachable the cart still serves; the feed stays empty.
|
// see cart activity. Best-effort: if AMQP is unreachable the cart still serves.
|
||||||
if amqpUrl != "" {
|
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)
|
log.Printf("cart: AMQP connect failed, mutation feed disabled: %v", derr)
|
||||||
} else {
|
} else {
|
||||||
if ch, cerr := conn.Channel(); cerr == nil {
|
listener := actor.NewAmqpListener(conn.Connection(), "cart", actor.MutationSummary)
|
||||||
_ = messaging.DefineTopic(ch, "cart", "mutation") // idempotent; non-fatal
|
listener.DefineTopics()
|
||||||
_ = ch.Close()
|
pool.AddListener(listener)
|
||||||
}
|
log.Printf("cart: mutation feed enabled (mutation.cart)")
|
||||||
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)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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{
|
app := &App{
|
||||||
pool: pool,
|
pool: pool,
|
||||||
@@ -374,6 +514,23 @@ func main() {
|
|||||||
debugMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
debugMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||||
debugMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
debugMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||||
debugMux.Handle("/metrics", promhttp.Handler())
|
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) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
|
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
|
||||||
grainCount, capacity := app.pool.LocalUsage()
|
grainCount, capacity := app.pool.LocalUsage()
|
||||||
@@ -434,19 +591,11 @@ func main() {
|
|||||||
srvErr <- srv.ListenAndServe()
|
srvErr <- srv.ListenAndServe()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// listener := inventory.NewInventoryChangeListener(rdb, context.Background(), func(changes []inventory.InventoryChange) {
|
// Inventory change consumption used to live here over the bare Redis
|
||||||
// for _, change := range changes {
|
// `inventory_changed` channel; it is now owned by the cart-inventory
|
||||||
// log.Printf("inventory change: %v", change)
|
// service, which translates quantity changes into inventory.level_changed
|
||||||
// inventoryPubSub.Publish(change)
|
// bus crossings. The cart reads exact stock synchronously when it needs it.
|
||||||
// }
|
// See docs/inventory-shape.md.
|
||||||
// })
|
|
||||||
|
|
||||||
// go func() {
|
|
||||||
// err := listener.Start()
|
|
||||||
// if err != nil {
|
|
||||||
// log.Fatalf("Unable to start inventory listener: %v", err)
|
|
||||||
// }
|
|
||||||
// }()
|
|
||||||
|
|
||||||
log.Printf("Server started at %s (debug %s)", httpAddr, debugAddr)
|
log.Printf("Server started at %s (debug %s)", httpAddr, debugAddr)
|
||||||
|
|
||||||
|
|||||||
+54
-5
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -41,12 +42,19 @@ var (
|
|||||||
type PoolServer struct {
|
type PoolServer struct {
|
||||||
actor.GrainPool[cart.CartGrain]
|
actor.GrainPool[cart.CartGrain]
|
||||||
pod_name string
|
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{
|
srv := &PoolServer{
|
||||||
GrainPool: pool,
|
GrainPool: pool,
|
||||||
pod_name: pod_name,
|
pod_name: pod_name,
|
||||||
|
idx: idx,
|
||||||
}
|
}
|
||||||
|
|
||||||
return srv
|
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 {
|
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||||
sku := r.PathValue("sku")
|
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)
|
msg, err := GetItemAddMessage(r.Context(), sku, 1, getCountryFromHost(r.Host), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
data, err := s.ApplyLocal(r.Context(), id, msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -154,16 +178,32 @@ type itemGroup struct {
|
|||||||
// drive child pricing, then children are fetched concurrently. Input order is
|
// drive child pricing, then children are fetched concurrently. Input order is
|
||||||
// preserved. Items that fail to fetch are skipped (logged), matching the prior
|
// preserved. Items that fail to fetch are skipped (logged), matching the prior
|
||||||
// best-effort behavior.
|
// 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))
|
groups := make([]itemGroup, len(items))
|
||||||
wg := sync.WaitGroup{}
|
wg := sync.WaitGroup{}
|
||||||
for i, itm := range items {
|
for i, itm := range items {
|
||||||
wg.Go(func() {
|
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)
|
parentMsg, parentProduct, err := BuildItemMessage(ctx, itm.Sku, itm.Quantity, country, itm.StoreId, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("error adding item %s: %v", itm.Sku, err)
|
log.Printf("error adding item %s: %v", itm.Sku, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if idx != nil {
|
||||||
|
if p, ok := idx.Get(itm.Sku); ok {
|
||||||
|
ApplyProjectionOverlay(parentMsg, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
parentMsg.CustomFields = itm.CustomFields
|
parentMsg.CustomFields = itm.CustomFields
|
||||||
groups[i].parent = parentMsg
|
groups[i].parent = parentMsg
|
||||||
|
|
||||||
@@ -174,11 +214,20 @@ func buildItemGroups(ctx context.Context, items []Item, country string) []itemGr
|
|||||||
cwg := sync.WaitGroup{}
|
cwg := sync.WaitGroup{}
|
||||||
for j, child := range itm.Children {
|
for j, child := range itm.Children {
|
||||||
cwg.Go(func() {
|
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)
|
childMsg, _, err := BuildItemMessage(ctx, child.Sku, child.Quantity, country, child.StoreId, parentProduct)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("error adding child %s of %s: %v", child.Sku, itm.Sku, err)
|
log.Printf("error adding child %s of %s: %v", child.Sku, itm.Sku, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if idx != nil {
|
||||||
|
if p, ok := idx.Get(child.Sku); ok {
|
||||||
|
ApplyProjectionOverlay(childMsg, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
childMsg.CustomFields = child.CustomFields
|
childMsg.CustomFields = child.CustomFields
|
||||||
children[j] = childMsg
|
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 {
|
if _, err := s.ApplyLocal(r.Context(), id, &messages.ClearCartRequest{}); err != nil {
|
||||||
return err
|
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)
|
reply, err := s.applyItemGroups(r.Context(), id, groups)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -286,7 +335,7 @@ func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Reque
|
|||||||
return err
|
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)
|
reply, err := s.applyItemGroups(r.Context(), id, groups)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -326,7 +375,7 @@ func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request
|
|||||||
Children: addRequest.Children,
|
Children: addRequest.Children,
|
||||||
CustomFields: addRequest.CustomFields,
|
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)
|
reply, err := s.applyItemGroups(r.Context(), id, groups)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -201,6 +201,9 @@ func ToItemAddMessage(item *ProductItem, parent *ProductItem, storeId *string, q
|
|||||||
Name: item.Title,
|
Name: item.Title,
|
||||||
Image: item.Img,
|
Image: item.Img,
|
||||||
Stock: stock,
|
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),
|
Tax: int32(item.Vat * 100),
|
||||||
SellerId: strconv.Itoa(item.SupplierId),
|
SellerId: strconv.Itoa(item.SupplierId),
|
||||||
SellerName: item.SupplierName,
|
SellerName: item.SupplierName,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func newTestPoolServer(t *testing.T) *PoolServer {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("new pool: %v", err)
|
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.
|
// 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}},
|
Children: []Item{{Sku: exampleId, Quantity: 1, StoreId: &childStore}},
|
||||||
}}
|
}}
|
||||||
|
|
||||||
groups := buildItemGroups(ctx, items, "se")
|
groups := buildItemGroups(ctx, items, "se", nil)
|
||||||
if len(groups) != 1 {
|
if len(groups) != 1 {
|
||||||
t.Fatalf("groups = %d, want 1", len(groups))
|
t.Fatalf("groups = %d, want 1", len(groups))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
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"
|
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/common"
|
||||||
"github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator"
|
"github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator"
|
||||||
@@ -232,27 +231,9 @@ func (s *CheckoutPoolServer) createAdyenOrder(ctx context.Context, checkoutId ca
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.inventoryService != nil && grain.CartState != nil && !grain.InventoryReserved {
|
// Inventory is NOT committed here — see the note in KlarnaPushHandler. The
|
||||||
if rerr := s.inventoryService.ReserveInventory(ctx, getInventoryRequests(grain.CartState.Items)...); rerr != nil {
|
// order saga emits order.created and the inventory service reacts
|
||||||
log.Printf("from-checkout: inventory reservation failed for %s: %v", checkoutId.String(), rerr)
|
// (commit + release + level crossing), idempotently and off the revenue path.
|
||||||
} 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",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
country := getCountryFromCurrency(item.Amount.Currency)
|
country := getCountryFromCurrency(item.Amount.Currency)
|
||||||
if _, oerr := createOrderFromSettledCheckout(ctx, s, grain, settledPayment{
|
if _, oerr := createOrderFromSettledCheckout(ctx, s, grain, settledPayment{
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"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/checkout"
|
||||||
|
"git.k6n.net/mats/platform/tax"
|
||||||
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
|
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/common"
|
||||||
)
|
)
|
||||||
@@ -66,7 +67,7 @@ type CheckoutMeta struct {
|
|||||||
// CartItem / Delivery to expose that data and propagate it here.
|
// CartItem / Delivery to expose that data and propagate it here.
|
||||||
// tp is an optional TaxProvider; when non-nil, its DefaultTaxRate is used for
|
// tp is an optional TaxProvider; when non-nil, its DefaultTaxRate is used for
|
||||||
// delivery lines instead of the hardcoded 2500 (25 % as CartItem format).
|
// 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 {
|
if grain == nil {
|
||||||
return nil, nil, fmt.Errorf("nil grain")
|
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
|
// 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 %).
|
// format (percent x 100, e.g. 2500 = 25.00 %). This matches the platform basis-point
|
||||||
func defaultKlarnaTaxRate(tp cart.TaxProvider, country string) int {
|
// 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 {
|
if tp == nil {
|
||||||
return 2500
|
return 2500
|
||||||
}
|
}
|
||||||
return tp.DefaultTaxRate(country) * 100
|
return tp.DefaultTaxRate(country)
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultAdyenTaxRate returns the default tax rate for the purchase country, in Adyen
|
// 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.
|
// format (taxPercentage in basis points, e.g. 2500 = 25 %). This matches the platform
|
||||||
func defaultAdyenTaxRate(tp cart.TaxProvider, country string) int64 {
|
// 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 {
|
if tp == nil {
|
||||||
return 25
|
return 2500
|
||||||
}
|
}
|
||||||
return int64(tp.DefaultTaxRate(country))
|
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 {
|
if grain == nil {
|
||||||
return nil, fmt.Errorf("nil grain")
|
return nil, fmt.Errorf("nil grain")
|
||||||
}
|
}
|
||||||
@@ -237,10 +241,10 @@ func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta
|
|||||||
}
|
}
|
||||||
lineItems = append(lineItems, adyenCheckout.LineItem{
|
lineItems = append(lineItems, adyenCheckout.LineItem{
|
||||||
Quantity: common.PtrInt64(int64(it.Quantity)),
|
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),
|
Description: common.PtrString(it.Meta.Name),
|
||||||
AmountExcludingTax: common.PtrInt64(it.TotalPrice.ValueExVat()),
|
AmountExcludingTax: common.PtrInt64(it.TotalPrice.ValueExVat().Int64()),
|
||||||
TaxAmount: common.PtrInt64(it.TotalPrice.TotalVat()),
|
TaxAmount: common.PtrInt64(it.TotalPrice.TotalVat().Int64()),
|
||||||
TaxPercentage: common.PtrInt64(int64(it.Tax)),
|
TaxPercentage: common.PtrInt64(int64(it.Tax)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -263,8 +267,8 @@ func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta
|
|||||||
if d == nil {
|
if d == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
amountIncTax := d.Price.IncVat
|
amountIncTax := d.Price.IncVat.Int64()
|
||||||
amountExTax := d.Price.ValueExVat()
|
amountExTax := d.Price.ValueExVat().Int64()
|
||||||
if hasFreeShipping {
|
if hasFreeShipping {
|
||||||
amountIncTax = 0
|
amountIncTax = 0
|
||||||
amountExTax = 0
|
amountExTax = 0
|
||||||
@@ -284,7 +288,7 @@ func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta
|
|||||||
return &adyenCheckout.CreateCheckoutSessionRequest{
|
return &adyenCheckout.CreateCheckoutSessionRequest{
|
||||||
Reference: grain.Id.String(),
|
Reference: grain.Id.String(),
|
||||||
Amount: adyenCheckout.Amount{
|
Amount: adyenCheckout.Amount{
|
||||||
Value: total.IncVat,
|
Value: total.IncVat.Int64(),
|
||||||
Currency: currency,
|
Currency: currency,
|
||||||
},
|
},
|
||||||
CountryCode: common.PtrString(country),
|
CountryCode: common.PtrString(country),
|
||||||
|
|||||||
@@ -181,36 +181,12 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.inventoryService != nil {
|
// Inventory is NOT committed here. Checkout announces a completed sale; the
|
||||||
inventoryRequests := getInventoryRequests(grain.CartState.Items)
|
// order saga emits order.created and the inventory service reacts (commit +
|
||||||
invStatus := "success"
|
// release the cart hold + emit a level crossing), idempotently. This keeps a
|
||||||
if err = s.inventoryService.ReserveInventory(r.Context(), inventoryRequests...); err != nil {
|
// down inventory service from blocking the revenue path — the payment is
|
||||||
// The payment is already settled at Klarna (checkout_complete), so the
|
// already settled at Klarna, so the sale is a fact, not a request. See
|
||||||
// order MUST be created. Inventory is best-effort at this point — a
|
// docs/inventory-shape.md.
|
||||||
// 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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
s.ApplyAnywhere(r.Context(), grain.Id, &messages.PaymentCompleted{
|
s.ApplyAnywhere(r.Context(), grain.Id, &messages.PaymentCompleted{
|
||||||
PaymentId: orderId,
|
PaymentId: orderId,
|
||||||
@@ -266,8 +242,6 @@ func createOrderFromCheckout(ctx context.Context, s *CheckoutPoolServer, grain *
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func getLocationId(item *cart.CartItem) inventory.LocationID {
|
func getLocationId(item *cart.CartItem) inventory.LocationID {
|
||||||
if item.StoreId == nil || *item.StoreId == "" {
|
if item.StoreId == nil || *item.StoreId == "" {
|
||||||
return "se"
|
return "se"
|
||||||
@@ -282,7 +256,7 @@ func shouldTrackInventory(item *cart.CartItem) bool {
|
|||||||
if item.InventoryTracked {
|
if item.InventoryTracked {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return item.Cgm == "55010"
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
|
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
|
||||||
|
|||||||
+21
-8
@@ -12,17 +12,17 @@ import (
|
|||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/internal/ucp"
|
"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/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/checkout"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
|
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||||
"git.k6n.net/mats/platform/config"
|
"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/adyen"
|
||||||
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
"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.
|
// selectTaxProvider picks the tax provider from the environment.
|
||||||
// Default is NordicTaxProvider with SE as the default country.
|
// Default is NordicTaxProvider with SE as the default country.
|
||||||
// Set TAX_PROVIDER=static for dev and tests (no country awareness).
|
// Set TAX_PROVIDER=static for dev and tests (no country awareness).
|
||||||
func selectTaxProvider() cart.TaxProvider {
|
func selectTaxProvider() tax.Provider {
|
||||||
switch os.Getenv("TAX_PROVIDER") {
|
switch os.Getenv("TAX_PROVIDER") {
|
||||||
case "static":
|
case "static":
|
||||||
return cart.NewStaticTaxProvider()
|
return tax.NewStatic()
|
||||||
case "nordic":
|
case "nordic":
|
||||||
return cart.NewNordicTaxProvider(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
|
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
|
||||||
default:
|
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
|
var orderHandler *AmqpOrderHandler
|
||||||
conn, err := amqp.Dial(amqpUrl)
|
conn, err := rabbit.Dial(amqpUrl, "cart-checkout")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
||||||
}
|
}
|
||||||
orderHandler = NewAmqpOrderHandler(conn)
|
orderHandler = NewAmqpOrderHandler(conn.Connection())
|
||||||
if err := orderHandler.DefineQueue(); err != nil {
|
if err := orderHandler.DefineQueue(); err != nil {
|
||||||
log.Fatalf("failed to declare order queue: %v", err)
|
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 = NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient, orderClient, orderHandler)
|
||||||
syncedServer.inventoryService = inventoryService
|
syncedServer.inventoryService = inventoryService
|
||||||
syncedServer.reservationService = reservationService
|
syncedServer.reservationService = reservationService
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ type OrderLine struct {
|
|||||||
Quantity int32 `json:"quantity"`
|
Quantity int32 `json:"quantity"`
|
||||||
UnitPrice int64 `json:"unitPrice"`
|
UnitPrice int64 `json:"unitPrice"`
|
||||||
TaxRate int32 `json:"taxRate"`
|
TaxRate int32 `json:"taxRate"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateOrderResult is the success response from POST /api/orders/from-checkout.
|
// CreateOrderResult is the success response from POST /api/orders/from-checkout.
|
||||||
|
|||||||
@@ -43,21 +43,29 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
|
|||||||
if it == nil {
|
if it == nil {
|
||||||
continue
|
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{
|
lines = append(lines, OrderLine{
|
||||||
Reference: it.Sku,
|
Reference: it.Sku,
|
||||||
Sku: it.Sku,
|
Sku: it.Sku,
|
||||||
Name: it.Meta.Name,
|
Name: it.Meta.Name,
|
||||||
Quantity: int32(it.Quantity),
|
Quantity: int32(it.Quantity),
|
||||||
UnitPrice: it.Price.IncVat,
|
UnitPrice: it.Price.IncVat.Int64(),
|
||||||
// CartItem.Tax is percentage x 100 (e.g. 2500 = 25.00%). Convert to raw percent (25).
|
// CartItem.Tax and OrderLine.TaxRate share the platform basis-point scale
|
||||||
TaxRate: int32(it.Tax / 100),
|
// (2500 = 25%), so the rate passes through unchanged.
|
||||||
|
TaxRate: int32(it.Tax),
|
||||||
|
Location: location,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, d := range grain.Deliveries {
|
for _, d := range grain.Deliveries {
|
||||||
if d == nil {
|
if d == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
unitPrice := d.Price.IncVat
|
unitPrice := d.Price.IncVat.Int64()
|
||||||
if hasFreeShipping {
|
if hasFreeShipping {
|
||||||
unitPrice = 0
|
unitPrice = 0
|
||||||
}
|
}
|
||||||
@@ -70,7 +78,7 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
|
|||||||
Name: "Delivery",
|
Name: "Delivery",
|
||||||
Quantity: 1,
|
Quantity: 1,
|
||||||
UnitPrice: unitPrice,
|
UnitPrice: unitPrice,
|
||||||
TaxRate: 25,
|
TaxRate: 2500, // 25% in basis points
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +90,7 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
|
|||||||
}
|
}
|
||||||
discountVal := int64(0)
|
discountVal := int64(0)
|
||||||
if ap.Discount != nil {
|
if ap.Discount != nil {
|
||||||
discountVal = ap.Discount.IncVat
|
discountVal = ap.Discount.IncVat.Int64()
|
||||||
}
|
}
|
||||||
if discountVal <= 0 {
|
if discountVal <= 0 {
|
||||||
continue
|
continue
|
||||||
@@ -93,7 +101,7 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
|
|||||||
Name: "Promotion: " + ap.Name,
|
Name: "Promotion: " + ap.Name,
|
||||||
Quantity: 1,
|
Quantity: 1,
|
||||||
UnitPrice: -discountVal,
|
UnitPrice: -discountVal,
|
||||||
TaxRate: 25, // default standard tax rate for promotion discount
|
TaxRate: 2500, // default standard tax rate (25%) in basis points
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"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/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"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"
|
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
||||||
|
|
||||||
adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
||||||
@@ -53,7 +54,7 @@ type CheckoutPoolServer struct {
|
|||||||
orderHandler *AmqpOrderHandler
|
orderHandler *AmqpOrderHandler
|
||||||
inventoryService *inventory.RedisInventoryService
|
inventoryService *inventory.RedisInventoryService
|
||||||
reservationService *inventory.RedisCartReservationService
|
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 {
|
func NewCheckoutPoolServer(pool actor.GrainPool[checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient, orderClient *OrderClient, orderHandler *AmqpOrderHandler) *CheckoutPoolServer {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+125
-20
@@ -6,14 +6,18 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
"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/index"
|
||||||
"git.k6n.net/mats/slask-finder/pkg/messaging"
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"github.com/redis/go-redis/v9/maintnotifications"
|
"github.com/redis/go-redis/v9/maintnotifications"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/platform/rabbit"
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,6 +70,12 @@ var country = "se"
|
|||||||
var redisAddress = "10.10.3.18:6379"
|
var redisAddress = "10.10.3.18:6379"
|
||||||
var redisPassword = "slaskredis"
|
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() {
|
func init() {
|
||||||
// Override redis config from environment variables if set
|
// Override redis config from environment variables if set
|
||||||
if addr, ok := os.LookupEnv("REDIS_ADDRESS"); ok {
|
if addr, ok := os.LookupEnv("REDIS_ADDRESS"); ok {
|
||||||
@@ -77,6 +87,19 @@ func init() {
|
|||||||
if ctry, ok := os.LookupEnv("COUNTRY"); ok {
|
if ctry, ok := os.LookupEnv("COUNTRY"); ok {
|
||||||
country = ctry
|
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() {
|
func main() {
|
||||||
@@ -114,39 +137,121 @@ func main() {
|
|||||||
rdb: rdb,
|
rdb: rdb,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
svc: *s,
|
svc: *s,
|
||||||
|
country: country,
|
||||||
|
low: lowWatermark,
|
||||||
}
|
}
|
||||||
|
|
||||||
amqpUrl, ok := os.LookupEnv("RABBIT_HOST")
|
amqpUrl, ok := os.LookupEnv("RABBIT_HOST")
|
||||||
if ok {
|
if ok {
|
||||||
log.Printf("Connecting to rabbitmq")
|
log.Printf("Connecting to rabbitmq")
|
||||||
conn, err := amqp.DialConfig(amqpUrl, amqp.Config{
|
conn, err := rabbit.Dial(amqpUrl, "cart-inventory")
|
||||||
Properties: amqp.NewConnectionProperties(),
|
|
||||||
})
|
|
||||||
//a.conn = conn
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to connect to RabbitMQ: %v", err)
|
log.Fatalf("Failed to connect to RabbitMQ: %v", err)
|
||||||
}
|
}
|
||||||
ch, err := conn.Channel()
|
// The catalog-feed handler emits inventory.level_changed crossings
|
||||||
if err != nil {
|
// directly on this connection as it writes stock.
|
||||||
log.Fatalf("Failed to open a channel: %v", err)
|
stockhandler.conn = conn
|
||||||
}
|
// Reconnecting consumer: re-listen on reconnect.
|
||||||
// items listener
|
conn.NotifyOnReconnect(func() {
|
||||||
err = messaging.ListenToTopic(ch, country, "item_added", func(d amqp.Delivery) error {
|
ch, err := conn.Channel()
|
||||||
wg := &sync.WaitGroup{}
|
if err != nil {
|
||||||
err = index.ForEachRawDataItemInJSONArray(d.Body, func(item *index.RawDataItem) error {
|
log.Printf("inventory: channel on reconnect: %v", err)
|
||||||
stockhandler.HandleItem(item, wg)
|
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()
|
wg.Wait()
|
||||||
log.Print("Batch done...")
|
log.Print("Batch done...")
|
||||||
return err
|
return nil
|
||||||
})
|
}); err != nil {
|
||||||
return 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
|
// Start HTTP server
|
||||||
log.Println("Starting HTTP server on :8080")
|
log.Println("Starting HTTP server on :8080")
|
||||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
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:<x>". 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 ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||||
|
"git.k6n.net/mats/platform/rabbit"
|
||||||
"git.k6n.net/mats/slask-finder/pkg/types"
|
"git.k6n.net/mats/slask-finder/pkg/types"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
@@ -15,12 +16,17 @@ type StockHandler struct {
|
|||||||
ctx context.Context
|
ctx context.Context
|
||||||
svc inventory.RedisInventoryService
|
svc inventory.RedisInventoryService
|
||||||
MainStockLocationID inventory.LocationID
|
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) {
|
func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) {
|
||||||
wg.Go(func() {
|
wg.Go(func() {
|
||||||
ctx := s.ctx
|
ctx := s.ctx
|
||||||
pipe := s.rdb.Pipeline()
|
|
||||||
centralStock, ok := item.GetNumberFieldValue("inStock")
|
centralStock, ok := item.GetNumberFieldValue("inStock")
|
||||||
if !ok {
|
if !ok {
|
||||||
centralStock = 0
|
centralStock = 0
|
||||||
@@ -28,16 +34,17 @@ func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) {
|
|||||||
sku, ok := item.GetStringFieldValue("sku")
|
sku, ok := item.GetStringFieldValue("sku")
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Printf("unable to parse central stock for item: sku missing")
|
log.Printf("unable to parse central stock for item: sku missing")
|
||||||
centralStock = 0
|
return
|
||||||
} else {
|
|
||||||
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), s.MainStockLocationID, int64(centralStock))
|
|
||||||
}
|
}
|
||||||
// for id, value := range item.GetStock() {
|
// One linear pass: write the authoritative quantity, then derive and
|
||||||
// s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), inventory.LocationID(id), int64(value))
|
// emit the level crossing from the same value. No Redis pub/sub
|
||||||
// }
|
// round-trip — Redis stays the internal store, the bus carries levels.
|
||||||
_, err := pipe.Exec(ctx)
|
pipe := s.rdb.Pipeline()
|
||||||
if err != nil {
|
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)
|
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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-39
@@ -6,6 +6,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/platform/rabbit"
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ func newRabbitPublisher(url string) *rabbitPublisher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *rabbitPublisher) connect() error {
|
func (p *rabbitPublisher) connect() error {
|
||||||
conn, err := amqp.Dial(p.url)
|
conn, err := rabbit.Connect(p.url, "cart-order-publisher")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
s.logger.Warn("order ingest disabled: amqp dial failed", "err", err)
|
s.logger.Warn("order ingest disabled: amqp dial failed", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ch, err := conn.Channel()
|
// Reconnecting consumer: re-declare queue and re-consume on reconnect.
|
||||||
if err != nil {
|
conn.NotifyOnReconnect(func() {
|
||||||
s.logger.Warn("order ingest disabled: channel failed", "err", err)
|
ch, err := conn.Channel()
|
||||||
_ = conn.Close()
|
if err != nil {
|
||||||
return
|
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 {
|
q, err := ch.QueueDeclare("order-queue", false, false, false, false, nil)
|
||||||
s.logger.Warn("order ingest disabled: queue declare failed", "err", err)
|
if err != nil {
|
||||||
_ = ch.Close()
|
s.logger.Warn("order ingest: queue declare on reconnect", "err", err)
|
||||||
_ = conn.Close()
|
_ = ch.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msgs, err := ch.Consume(q.Name, "order-service", true, false, false, false, nil)
|
msgs, err := ch.Consume(q.Name, "order-service", true, false, false, false, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn("order ingest disabled: consume failed", "err", err)
|
s.logger.Warn("order ingest: consume on reconnect", "err", err)
|
||||||
_ = ch.Close()
|
_ = ch.Close()
|
||||||
_ = conn.Close()
|
return
|
||||||
return
|
}
|
||||||
}
|
s.logger.Info("order ingest: consuming from order-queue (reconnect)")
|
||||||
s.logger.Info("ingesting orders from order-queue")
|
go func() {
|
||||||
go func() {
|
defer ch.Close()
|
||||||
defer conn.Close()
|
for {
|
||||||
defer ch.Close()
|
select {
|
||||||
for {
|
case <-ctx.Done():
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case d, ok := <-msgs:
|
|
||||||
if !ok {
|
|
||||||
s.logger.Warn("order ingest: channel closed")
|
|
||||||
return
|
return
|
||||||
}
|
case d, ok := <-msgs:
|
||||||
if err := ingestOrderFromQueue(ctx, s, d.Body); err != nil {
|
if !ok {
|
||||||
s.logger.Error("order queue ingest failed", "err", err)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}()
|
||||||
}()
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-10
@@ -66,9 +66,33 @@ func (s *server) loadOrder(w http.ResponseWriter, r *http.Request) (order.OrderI
|
|||||||
return id, g, true
|
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
|
// applyAndRespond applies a mutation and returns the updated order. A rejected
|
||||||
// state transition (handler error) maps to 409 Conflict.
|
// 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)
|
res, err := s.applier.Apply(r.Context(), uint64(id), msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, err)
|
writeErr(w, http.StatusInternalServerError, err)
|
||||||
@@ -80,6 +104,11 @@ func (s *server) applyAndRespond(w http.ResponseWriter, r *http.Request, id orde
|
|||||||
return
|
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))
|
grain, err := s.applier.Get(r.Context(), uint64(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, err)
|
writeErr(w, http.StatusInternalServerError, err)
|
||||||
@@ -105,6 +134,12 @@ func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
|
||||||
|
if exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
var req fulfillReq
|
var req fulfillReq
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeErr(w, http.StatusBadRequest, err)
|
writeErr(w, http.StatusBadRequest, err)
|
||||||
@@ -182,7 +217,7 @@ func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
|
|||||||
for _, l := range req.Lines {
|
for _, l := range req.Lines {
|
||||||
msg.Lines = append(msg.Lines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
|
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 {
|
type exchangeReq struct {
|
||||||
@@ -208,6 +243,12 @@ func (s *server) handleExchange(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
|
||||||
|
if exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
var req exchangeReq
|
var req exchangeReq
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeErr(w, http.StatusBadRequest, err)
|
writeErr(w, http.StatusBadRequest, err)
|
||||||
@@ -236,7 +277,7 @@ func (s *server) handleExchange(w http.ResponseWriter, r *http.Request) {
|
|||||||
TotalTax: l.TotalTax,
|
TotalTax: l.TotalTax,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
s.applyAndRespond(w, r, id, msg)
|
s.applyAndRespond(w, r, id, msg, idemKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
type editDetailsReq struct {
|
type editDetailsReq struct {
|
||||||
@@ -250,6 +291,12 @@ func (s *server) handleEditDetails(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
|
||||||
|
if exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
var req editDetailsReq
|
var req editDetailsReq
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeErr(w, http.StatusBadRequest, err)
|
writeErr(w, http.StatusBadRequest, err)
|
||||||
@@ -261,16 +308,21 @@ func (s *server) handleEditDetails(w http.ResponseWriter, r *http.Request) {
|
|||||||
ShippingPrice: req.ShippingPrice,
|
ShippingPrice: req.ShippingPrice,
|
||||||
AtMs: nowMs(),
|
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) {
|
func (s *server) handleComplete(w http.ResponseWriter, r *http.Request) {
|
||||||
id, _, ok := s.loadOrder(w, r)
|
id, _, ok := s.loadOrder(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
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) {
|
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 {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
|
||||||
|
if exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
}
|
}
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
_ = 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 {
|
type returnReq struct {
|
||||||
@@ -298,6 +356,12 @@ func (s *server) handleReturn(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
|
||||||
|
if exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
var req returnReq
|
var req returnReq
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeErr(w, http.StatusBadRequest, err)
|
writeErr(w, http.StatusBadRequest, err)
|
||||||
@@ -308,7 +372,7 @@ func (s *server) handleReturn(w http.ResponseWriter, r *http.Request) {
|
|||||||
for _, l := range req.Lines {
|
for _, l := range req.Lines {
|
||||||
msg.Lines = append(msg.Lines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
|
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) {
|
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 {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
|
||||||
|
if exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Amount int64 `json:"amount"`
|
Amount int64 `json:"amount"`
|
||||||
}
|
}
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
amount := req.Amount
|
amount := req.Amount
|
||||||
if amount <= 0 {
|
if amount <= 0 {
|
||||||
amount = g.CapturedAmount - g.RefundedAmount // default: full remaining
|
amount = (g.CapturedAmount - g.RefundedAmount).Int64() // default: full remaining
|
||||||
}
|
}
|
||||||
captureRef := capturedRef(g)
|
captureRef := capturedRef(g)
|
||||||
ref, err := s.provider.Refund(r.Context(), captureRef, amount)
|
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,
|
Amount: ref.Amount,
|
||||||
Reference: ref.Reference,
|
Reference: ref.Reference,
|
||||||
AtMs: nowMs(),
|
AtMs: nowMs(),
|
||||||
})
|
}, idemKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- saga / flow admin (a) ------------------------------------------------
|
// --- saga / flow admin (a) ------------------------------------------------
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/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
|
// 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
|
var total, totalTax int64
|
||||||
for _, l := range req.Lines {
|
for _, l := range req.Lines {
|
||||||
lineTotal := l.UnitPrice * int64(l.Quantity)
|
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
|
total += lineTotal
|
||||||
totalTax += lineTax
|
totalTax += lineTax
|
||||||
po.Lines = append(po.Lines, &messages.OrderLine{
|
po.Lines = append(po.Lines, &messages.OrderLine{
|
||||||
@@ -176,6 +177,7 @@ func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messag
|
|||||||
TaxRate: l.TaxRate,
|
TaxRate: l.TaxRate,
|
||||||
TotalAmount: lineTotal,
|
TotalAmount: lineTotal,
|
||||||
TotalTax: lineTax,
|
TotalTax: lineTax,
|
||||||
|
Location: l.Location,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
po.TotalAmount = total
|
po.TotalAmount = total
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-10
@@ -28,6 +28,8 @@ import (
|
|||||||
"git.k6n.net/mats/go-cart-actor/pkg/outbox"
|
"git.k6n.net/mats/go-cart-actor/pkg/outbox"
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
||||||
"git.k6n.net/mats/platform/config"
|
"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/encoding/protojson"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
@@ -42,7 +44,7 @@ type server struct {
|
|||||||
freg *flow.Registry
|
freg *flow.Registry
|
||||||
flows *flowStore
|
flows *flowStore
|
||||||
provider order.PaymentProvider
|
provider order.PaymentProvider
|
||||||
taxProvider order.TaxProvider
|
taxProvider tax.Provider
|
||||||
defaultFlow string
|
defaultFlow string
|
||||||
dataDir string
|
dataDir string
|
||||||
idem *idempotency.Store
|
idem *idempotency.Store
|
||||||
@@ -112,11 +114,37 @@ func main() {
|
|||||||
}
|
}
|
||||||
go box.Run(context.Background(), newRabbitPublisher(amqpURL), 5*time.Second, logger)
|
go box.Run(context.Background(), newRabbitPublisher(amqpURL), 5*time.Second, logger)
|
||||||
emitPub = box
|
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()
|
freg := flow.NewRegistry()
|
||||||
flow.RegisterBuiltinHooks(freg)
|
flow.RegisterBuiltinHooks(freg)
|
||||||
order.RegisterFlowActions(freg, applier, provider)
|
order.RegisterFlowActions(freg, applier, provider)
|
||||||
order.RegisterEmitHook(freg, emitPub)
|
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)
|
engine := flow.NewEngine(freg, logger)
|
||||||
|
|
||||||
def, err := order.EmbeddedFlow("place-and-pay")
|
def, err := order.EmbeddedFlow("place-and-pay")
|
||||||
@@ -248,8 +276,9 @@ type lineReq struct {
|
|||||||
Sku string `json:"sku"`
|
Sku string `json:"sku"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Quantity int32 `json:"quantity"`
|
Quantity int32 `json:"quantity"`
|
||||||
UnitPrice int64 `json:"unitPrice"` // inc-vat, minor units
|
UnitPrice int64 `json:"unitPrice"` // inc-vat, minor units
|
||||||
TaxRate int32 `json:"taxRate"` // percent
|
TaxRate int32 `json:"taxRate"` // basis points (2500 = 25%)
|
||||||
|
Location string `json:"location,omitempty"` // inventory commit location / store id
|
||||||
}
|
}
|
||||||
|
|
||||||
type checkoutReq struct {
|
type checkoutReq struct {
|
||||||
@@ -343,7 +372,7 @@ func (s *server) buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.P
|
|||||||
var total, totalTax int64
|
var total, totalTax int64
|
||||||
for _, l := range req.Lines {
|
for _, l := range req.Lines {
|
||||||
lineTotal := l.UnitPrice * int64(l.Quantity)
|
lineTotal := l.UnitPrice * int64(l.Quantity)
|
||||||
lineTax := s.taxProvider.ComputeTax(lineTotal, int(l.TaxRate))
|
lineTax := s.taxProvider.Compute(lineTotal, int(l.TaxRate))
|
||||||
total += lineTotal
|
total += lineTotal
|
||||||
totalTax += lineTax
|
totalTax += lineTax
|
||||||
po.Lines = append(po.Lines, &messages.OrderLine{
|
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(),
|
OrderId: order.OrderId(raw).String(),
|
||||||
Reference: g.OrderReference,
|
Reference: g.OrderReference,
|
||||||
Status: g.Status,
|
Status: g.Status,
|
||||||
TotalAmount: g.TotalAmount,
|
TotalAmount: g.TotalAmount.Int64(),
|
||||||
CapturedAmount: g.CapturedAmount,
|
CapturedAmount: g.CapturedAmount.Int64(),
|
||||||
Currency: g.Currency,
|
Currency: g.Currency,
|
||||||
PlacedAt: g.PlacedAt,
|
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
|
// NordicTaxProvider with SE as the default country (matching the current
|
||||||
// Nordic-only footprint). Set TAX_PROVIDER=static for dev and tests
|
// Nordic-only footprint). Set TAX_PROVIDER=static for dev and tests
|
||||||
// (no country awareness).
|
// (no country awareness).
|
||||||
func selectTaxProvider() order.TaxProvider {
|
func selectTaxProvider() tax.Provider {
|
||||||
provider := os.Getenv("TAX_PROVIDER")
|
provider := os.Getenv("TAX_PROVIDER")
|
||||||
switch provider {
|
switch provider {
|
||||||
case "static":
|
case "static":
|
||||||
return order.NewStaticTaxProvider()
|
return tax.NewStatic()
|
||||||
case "nordic":
|
case "nordic":
|
||||||
return order.NewNordicTaxProvider(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
|
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
|
||||||
default:
|
default:
|
||||||
return order.NewNordicTaxProvider(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
|
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
|
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
|
||||||
"git.k6n.net/mats/platform/config"
|
"git.k6n.net/mats/platform/config"
|
||||||
|
"git.k6n.net/mats/platform/rabbit"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
)
|
)
|
||||||
@@ -126,6 +127,20 @@ func main() {
|
|||||||
log.Fatalf("Error creating profile pool: %v\n", err)
|
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'
|
// Clustering control plane: gRPC server on :1337 serves peer pools'
|
||||||
// ownership announcements and remote Apply/Get calls; UseDiscovery watches
|
// ownership announcements and remote Apply/Get calls; UseDiscovery watches
|
||||||
// for sibling profile pods (label actor-pool=profile) and wires them into the
|
// for sibling profile pods (label actor-pool=profile) and wires them into the
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ module git.k6n.net/mats/go-cart-actor
|
|||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
git.k6n.net/mats/platform v0.0.0-00010101000000-000000000000
|
||||||
github.com/adyen/adyen-go-api-library/v21 v21.1.0
|
github.com/adyen/adyen-go-api-library/v21 v21.1.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
"git.k6n.net/mats/platform/money"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -230,7 +231,7 @@ func TestCartResponse_Conversion(t *testing.T) {
|
|||||||
|
|
||||||
func mustPrice(incVat int64, totalVat int64) *cart.Price {
|
func mustPrice(incVat int64, totalVat int64) *cart.Price {
|
||||||
return &cart.Price{
|
return &cart.Price{
|
||||||
IncVat: incVat,
|
IncVat: money.Cents(incVat),
|
||||||
VatRates: map[float32]int64{25: totalVat},
|
VatRates: map[int]money.Cents{2500: money.Cents(totalVat)}, // 25% in basis points
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-12
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
profilePkg "git.k6n.net/mats/go-cart-actor/pkg/profile"
|
profilePkg "git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
checkoutMessages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
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/anypb"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
@@ -447,15 +448,15 @@ type orderLine struct {
|
|||||||
Reference string `json:"reference"`
|
Reference string `json:"reference"`
|
||||||
Sku string `json:"sku"`
|
Sku string `json:"sku"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Quantity int32 `json:"quantity"`
|
Quantity int32 `json:"quantity"`
|
||||||
UnitPrice int64 `json:"unitPrice"`
|
UnitPrice money.Cents `json:"unitPrice"`
|
||||||
TaxRate int32 `json:"taxRate"`
|
TaxRate int32 `json:"taxRate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type orderPayment struct {
|
type orderPayment struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Reference string `json:"reference"`
|
Reference string `json:"reference"`
|
||||||
Amount int64 `json:"amount"`
|
Amount money.Cents `json:"amount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateOrder implements OrderApplier. It builds a from-checkout request from
|
// 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.
|
// Compute total from cart items + deliveries.
|
||||||
var totalAmount int64
|
var totalAmount money.Cents
|
||||||
lines := buildOrderLines(g)
|
lines := buildOrderLines(g)
|
||||||
for _, l := range lines {
|
for _, l := range lines {
|
||||||
totalAmount += l.UnitPrice * int64(l.Quantity)
|
totalAmount += l.UnitPrice.Mul(int64(l.Quantity))
|
||||||
}
|
}
|
||||||
|
|
||||||
req := fromCheckoutReq{
|
req := fromCheckoutReq{
|
||||||
@@ -554,8 +555,9 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
|
|||||||
Name: name,
|
Name: name,
|
||||||
Quantity: int32(it.Quantity),
|
Quantity: int32(it.Quantity),
|
||||||
UnitPrice: it.Price.IncVat,
|
UnitPrice: it.Price.IncVat,
|
||||||
// CartItem.Tax is percentage x 100 (e.g. 2500 = 25.00%). Convert to raw percent (25).
|
// CartItem.Tax and OrderLine.TaxRate share the platform basis-point scale
|
||||||
TaxRate: int32(it.Tax / 100),
|
// (2500 = 25%), so the rate passes through unchanged.
|
||||||
|
TaxRate: int32(it.Tax),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, d := range g.Deliveries {
|
for _, d := range g.Deliveries {
|
||||||
@@ -568,7 +570,7 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
|
|||||||
Name: "Delivery",
|
Name: "Delivery",
|
||||||
Quantity: 1,
|
Quantity: 1,
|
||||||
UnitPrice: d.Price.IncVat,
|
UnitPrice: d.Price.IncVat,
|
||||||
TaxRate: 25,
|
TaxRate: 2500, // 25% in basis points
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return lines
|
return lines
|
||||||
@@ -672,7 +674,7 @@ func checkoutGrainToResponse(id string, g *checkout.CheckoutGrain) CheckoutRespo
|
|||||||
resp.Payments = append(resp.Payments, PaymentResponse{
|
resp.Payments = append(resp.Payments, PaymentResponse{
|
||||||
Id: p.PaymentId,
|
Id: p.PaymentId,
|
||||||
Provider: p.Provider,
|
Provider: p.Provider,
|
||||||
Amount: p.Amount,
|
Amount: money.Cents(p.Amount),
|
||||||
Currency: p.Currency,
|
Currency: p.Currency,
|
||||||
Status: string(p.Status),
|
Status: string(p.Status),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ func (s *OrderServer) handleIssueRefund(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
amount := req.Amount
|
amount := req.Amount
|
||||||
if amount <= 0 {
|
if amount <= 0 {
|
||||||
amount = g.CapturedAmount - g.RefundedAmount
|
amount = (g.CapturedAmount - g.RefundedAmount).Int64()
|
||||||
}
|
}
|
||||||
if amount <= 0 {
|
if amount <= 0 {
|
||||||
writeError(w, http.StatusBadRequest, "no captured amount to refund")
|
writeError(w, http.StatusBadRequest, "no captured amount to refund")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
||||||
|
"git.k6n.net/mats/platform/money"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,10 +87,10 @@ func (a *testOrderApplier) Apply(_ context.Context, id uint64, msgs ...proto.Mes
|
|||||||
case *messages.IssueRefund:
|
case *messages.IssueRefund:
|
||||||
g.Refunds = append(g.Refunds, order.Refund{
|
g.Refunds = append(g.Refunds, order.Refund{
|
||||||
Provider: m.Provider,
|
Provider: m.Provider,
|
||||||
Amount: m.Amount,
|
Amount: money.Cents(m.Amount),
|
||||||
Reference: m.Reference,
|
Reference: m.Reference,
|
||||||
})
|
})
|
||||||
g.RefundedAmount += m.Amount
|
g.RefundedAmount += money.Cents(m.Amount)
|
||||||
if g.RefundedAmount >= g.CapturedAmount {
|
if g.RefundedAmount >= g.CapturedAmount {
|
||||||
g.Status = order.StatusRefunded
|
g.Status = order.StatusRefunded
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-24
@@ -1,6 +1,7 @@
|
|||||||
package ucp
|
package ucp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"crypto/elliptic"
|
"crypto/elliptic"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -31,8 +32,8 @@ type SigningConfig struct {
|
|||||||
// NewSigningConfigFromPEM parses a PEM-encoded ECDSA P-256 private key and
|
// NewSigningConfigFromPEM parses a PEM-encoded ECDSA P-256 private key and
|
||||||
// returns a SigningConfig ready for use.
|
// returns a SigningConfig ready for use.
|
||||||
//
|
//
|
||||||
// data — PEM block containing an EC PRIVATE KEY (PKCS#8 or SEC1)
|
// data — PEM block containing an EC PRIVATE KEY (PKCS#8 or SEC1)
|
||||||
// keyID — the kid matched by the UCP profile signing_keys entry
|
// keyID — the kid matched by the UCP profile signing_keys entry
|
||||||
func NewSigningConfigFromPEM(pemData []byte, keyID string) (*SigningConfig, error) {
|
func NewSigningConfigFromPEM(pemData []byte, keyID string) (*SigningConfig, error) {
|
||||||
block, _ := pem.Decode(pemData)
|
block, _ := pem.Decode(pemData)
|
||||||
if block == nil {
|
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
|
// WithSigning wraps h with an HTTP middleware that adds RFC 9421 HTTP Message
|
||||||
// Signatures to every response. The Signature-Input and Signature headers are
|
// Signatures to every response. The Signature-Input and Signature headers are
|
||||||
// computed over @status, content-type, and x-ucp-timestamp, signed with the
|
// computed over @status, content-type, x-ucp-timestamp, and content-digest,
|
||||||
// configured ECDSA P-256 key.
|
// signed with the configured ECDSA P-256 key.
|
||||||
//
|
//
|
||||||
// Mount it on any existing UCP handler:
|
// 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 h // pass through without signing
|
||||||
}
|
}
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
sw := &signedWriter{
|
sw := newSignedWriter(w, cfg)
|
||||||
ResponseWriter: w,
|
|
||||||
cfg: cfg,
|
|
||||||
}
|
|
||||||
h.ServeHTTP(sw, r)
|
h.ServeHTTP(sw, r)
|
||||||
|
sw.flush()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,10 +95,24 @@ func WithSigning(h http.Handler, cfg *SigningConfig) http.Handler {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type signedWriter struct {
|
type signedWriter struct {
|
||||||
http.ResponseWriter
|
responseWriter http.ResponseWriter
|
||||||
cfg *SigningConfig
|
header http.Header
|
||||||
statusCode int
|
body bytes.Buffer
|
||||||
wroteHeader bool
|
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) {
|
func (w *signedWriter) WriteHeader(statusCode int) {
|
||||||
@@ -108,28 +121,44 @@ func (w *signedWriter) WriteHeader(statusCode int) {
|
|||||||
}
|
}
|
||||||
w.wroteHeader = true
|
w.wroteHeader = true
|
||||||
w.statusCode = statusCode
|
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) {
|
func (w *signedWriter) Write(p []byte) (int, error) {
|
||||||
if !w.wroteHeader {
|
if !w.wroteHeader {
|
||||||
w.WriteHeader(http.StatusOK)
|
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
|
// addSignatureHeaders computes and writes the Signature-Input and Signature
|
||||||
// headers per RFC 9421 §3.2 / §3.3.
|
// headers per RFC 9421 §3.2 / §3.3.
|
||||||
func (w *signedWriter) addSignatureHeaders(created int64) {
|
func (w *signedWriter) addSignatureHeaders(created int64) {
|
||||||
contentType := w.ResponseWriter.Header().Get("Content-Type")
|
contentType := w.header.Get("Content-Type")
|
||||||
sigTimestamp := strconv.FormatInt(created, 10)
|
sigTimestamp := strconv.FormatInt(created, 10)
|
||||||
|
contentDigest := w.header.Get("Content-Digest")
|
||||||
|
|
||||||
// Covered components (in order). RFC 9421 §3.2: derived component names
|
// Covered components (in order). RFC 9421 §3.2: derived component names
|
||||||
// (@status) are bare identifiers; HTTP header field names are sf-strings
|
// (@status) are bare identifiers; HTTP header field names are sf-strings
|
||||||
@@ -138,6 +167,7 @@ func (w *signedWriter) addSignatureHeaders(created int64) {
|
|||||||
"@status",
|
"@status",
|
||||||
`"content-type"`,
|
`"content-type"`,
|
||||||
`"x-ucp-timestamp"`,
|
`"x-ucp-timestamp"`,
|
||||||
|
`"content-digest"`,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the signature base string (RFC 9421 §2.2).
|
// Build the signature base string (RFC 9421 §2.2).
|
||||||
@@ -148,6 +178,8 @@ func (w *signedWriter) addSignatureHeaders(created int64) {
|
|||||||
base.WriteByte('\n')
|
base.WriteByte('\n')
|
||||||
base.WriteString(fmt.Sprintf(`"x-ucp-timestamp": %s`, sigTimestamp))
|
base.WriteString(fmt.Sprintf(`"x-ucp-timestamp": %s`, sigTimestamp))
|
||||||
base.WriteByte('\n')
|
base.WriteByte('\n')
|
||||||
|
base.WriteString(fmt.Sprintf(`"content-digest": %s`, contentDigest))
|
||||||
|
base.WriteByte('\n')
|
||||||
|
|
||||||
// Append the signature-params pseudo-line (RFC 9421 §2.2).
|
// Append the signature-params pseudo-line (RFC 9421 §2.2).
|
||||||
compList := strings.Join(components, " ")
|
compList := strings.Join(components, " ")
|
||||||
@@ -178,6 +210,11 @@ func (w *signedWriter) addSignatureHeaders(created int64) {
|
|||||||
// Signature: sig1=:base64url:
|
// Signature: sig1=:base64url:
|
||||||
sigValue := fmt.Sprintf("sig1=:%s:", sigB64)
|
sigValue := fmt.Sprintf("sig1=:%s:", sigB64)
|
||||||
|
|
||||||
w.ResponseWriter.Header().Set("Signature-Input", sigInput)
|
w.header.Set("Signature-Input", sigInput)
|
||||||
w.ResponseWriter.Header().Set("Signature", sigValue)
|
w.header.Set("Signature", sigValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildContentDigest(body []byte) string {
|
||||||
|
sum := sha256.Sum256(body)
|
||||||
|
return "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ func TestWithSigning_AddsHeaders(t *testing.T) {
|
|||||||
sigInput := rec.Header().Get("Signature-Input")
|
sigInput := rec.Header().Get("Signature-Input")
|
||||||
sig := rec.Header().Get("Signature")
|
sig := rec.Header().Get("Signature")
|
||||||
ts := rec.Header().Get("x-ucp-timestamp")
|
ts := rec.Header().Get("x-ucp-timestamp")
|
||||||
|
digest := rec.Header().Get("Content-Digest")
|
||||||
|
|
||||||
if sigInput == "" {
|
if sigInput == "" {
|
||||||
t.Fatal("expected Signature-Input header")
|
t.Fatal("expected Signature-Input header")
|
||||||
@@ -73,9 +74,12 @@ func TestWithSigning_AddsHeaders(t *testing.T) {
|
|||||||
if ts == "" {
|
if ts == "" {
|
||||||
t.Fatal("expected x-ucp-timestamp header")
|
t.Fatal("expected x-ucp-timestamp header")
|
||||||
}
|
}
|
||||||
|
if digest == "" {
|
||||||
|
t.Fatal("expected Content-Digest header")
|
||||||
|
}
|
||||||
|
|
||||||
// Verify the signature input format.
|
// 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)
|
t.Fatalf("unexpected Signature-Input format: %q", sigInput)
|
||||||
}
|
}
|
||||||
if !strings.Contains(sigInput, `keyid="k6n-ecdsa-2026"`) {
|
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") == "" {
|
if rec.Header().Get("x-ucp-timestamp") == "" {
|
||||||
t.Fatal("expected x-ucp-timestamp on UCP cart response")
|
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.
|
// mustLoadTestKey loads the test PEM for signing tests.
|
||||||
|
|||||||
+90
-78
@@ -19,11 +19,12 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"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/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"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/order"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
"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"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -105,8 +106,8 @@ type CartItemInput struct {
|
|||||||
Sku string `json:"sku"`
|
Sku string `json:"sku"`
|
||||||
Quantity int `json:"quantity,omitempty"`
|
Quantity int `json:"quantity,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Price int64 `json:"price,omitempty"` // inc-vat in minor units (öre)
|
Price money.Cents `json:"price,omitempty"` // inc-vat in minor units (öre / money.Cents on the wire)
|
||||||
TaxRate float64 `json:"taxRate,omitempty"` // e.g. 25 or 12.5
|
TaxRate int `json:"taxRate,omitempty"` // basis points: 2500 = 25%, 1250 = 12.5% (matches OrderLine.TaxRate / CartItem.Tax)
|
||||||
Image string `json:"image,omitempty"`
|
Image string `json:"image,omitempty"`
|
||||||
StoreId *string `json:"storeId,omitempty"`
|
StoreId *string `json:"storeId,omitempty"`
|
||||||
Children []CartItemInput `json:"children,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.
|
// VoucherInput is the UCP wire format for a voucher code to apply.
|
||||||
type VoucherInput struct {
|
type VoucherInput struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Value int64 `json:"value,omitempty"`
|
Value money.Cents `json:"value,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CartResponse is the UCP cart response body.
|
// CartResponse is the UCP cart response body.
|
||||||
type CartResponse struct {
|
type CartResponse struct {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
Items []CartItem `json:"items,omitempty"`
|
Items []CartItem `json:"items,omitempty"`
|
||||||
Totals Totals `json:"totals"`
|
Totals Totals `json:"totals"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CartItem is the UCP wire format for a cart line item in responses.
|
// CartItem is the UCP wire format for a cart line item in responses.
|
||||||
@@ -132,8 +133,8 @@ type CartItem struct {
|
|||||||
Sku string `json:"sku"`
|
Sku string `json:"sku"`
|
||||||
Quantity int `json:"quantity"`
|
Quantity int `json:"quantity"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
UnitPrice int64 `json:"unitPrice"` // inc-vat minor units
|
UnitPrice money.Cents `json:"unitPrice"` // inc-vat minor units
|
||||||
TotalPrice int64 `json:"totalPrice"` // inc-vat minor units
|
TotalPrice money.Cents `json:"totalPrice"` // inc-vat minor units
|
||||||
TaxRate int `json:"taxRate"`
|
TaxRate int `json:"taxRate"`
|
||||||
Image string `json:"image,omitempty"`
|
Image string `json:"image,omitempty"`
|
||||||
ItemId uint32 `json:"itemId,omitempty"`
|
ItemId uint32 `json:"itemId,omitempty"`
|
||||||
@@ -142,11 +143,11 @@ type CartItem struct {
|
|||||||
|
|
||||||
// Totals is the UCP wire format for price breakdown.
|
// Totals is the UCP wire format for price breakdown.
|
||||||
type Totals struct {
|
type Totals struct {
|
||||||
TotalIncVat int64 `json:"totalIncVat"`
|
TotalIncVat money.Cents `json:"totalIncVat"`
|
||||||
TotalExVat int64 `json:"totalExVat"`
|
TotalExVat money.Cents `json:"totalExVat"`
|
||||||
TotalVat int64 `json:"totalVat"`
|
TotalVat money.Cents `json:"totalVat"`
|
||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
Discount int64 `json:"discount,omitempty"`
|
Discount money.Cents `json:"discount,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -205,24 +206,24 @@ type CompleteCheckoutRequest struct {
|
|||||||
|
|
||||||
// CheckoutResponse is the UCP checkout session response.
|
// CheckoutResponse is the UCP checkout session response.
|
||||||
type CheckoutResponse struct {
|
type CheckoutResponse struct {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
CartId string `json:"cartId"`
|
CartId string `json:"cartId"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Items []CartItem `json:"items,omitempty"`
|
Items []CartItem `json:"items,omitempty"`
|
||||||
Totals Totals `json:"totals"`
|
Totals Totals `json:"totals"`
|
||||||
Deliveries []DeliveryResponse `json:"deliveries,omitempty"`
|
Deliveries []DeliveryResponse `json:"deliveries,omitempty"`
|
||||||
Contact *ContactResponse `json:"contact,omitempty"`
|
Contact *ContactResponse `json:"contact,omitempty"`
|
||||||
OrderId *string `json:"orderId,omitempty"`
|
OrderId *string `json:"orderId,omitempty"`
|
||||||
Payments []PaymentResponse `json:"payments,omitempty"`
|
Payments []PaymentResponse `json:"payments,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeliveryResponse is the UCP wire format for a delivery option.
|
// DeliveryResponse is the UCP wire format for a delivery option.
|
||||||
type DeliveryResponse struct {
|
type DeliveryResponse struct {
|
||||||
Id uint32 `json:"id"`
|
Id uint32 `json:"id"`
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Price int64 `json:"price"`
|
Price money.Cents `json:"price"`
|
||||||
Items []uint32 `json:"items"`
|
Items []uint32 `json:"items"`
|
||||||
PickupPoint *PickupPointResp `json:"pickupPoint,omitempty"`
|
PickupPoint *PickupPointResp `json:"pickupPoint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PickupPointResp is the UCP wire format for a pickup point.
|
// 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.
|
// PaymentResponse is the UCP wire format for a payment record.
|
||||||
type PaymentResponse struct {
|
type PaymentResponse struct {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Amount int64 `json:"amount"`
|
Amount money.Cents `json:"amount"`
|
||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -257,24 +258,24 @@ type PaymentResponse struct {
|
|||||||
|
|
||||||
// OrderResponse is the UCP order response body.
|
// OrderResponse is the UCP order response body.
|
||||||
type OrderResponse struct {
|
type OrderResponse struct {
|
||||||
OrderId string `json:"orderId"`
|
OrderId string `json:"orderId"`
|
||||||
Reference string `json:"reference,omitempty"`
|
Reference string `json:"reference,omitempty"`
|
||||||
CartId string `json:"cartId,omitempty"`
|
CartId string `json:"cartId,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Currency string `json:"currency,omitempty"`
|
Currency string `json:"currency,omitempty"`
|
||||||
TotalAmount int64 `json:"totalAmount"`
|
TotalAmount money.Cents `json:"totalAmount"`
|
||||||
TotalTax int64 `json:"totalTax"`
|
TotalTax money.Cents `json:"totalTax"`
|
||||||
Lines []OrderLineResp `json:"lines,omitempty"`
|
Lines []OrderLineResp `json:"lines,omitempty"`
|
||||||
Payments []OrderPaymentResp `json:"payments,omitempty"`
|
Payments []OrderPaymentResp `json:"payments,omitempty"`
|
||||||
Fulfillments []OrderFulfillmentResp `json:"fulfillments,omitempty"`
|
Fulfillments []OrderFulfillmentResp `json:"fulfillments,omitempty"`
|
||||||
Returns []OrderReturnResp `json:"returns,omitempty"`
|
Returns []OrderReturnResp `json:"returns,omitempty"`
|
||||||
Refunds []OrderRefundResp `json:"refunds,omitempty"`
|
Refunds []OrderRefundResp `json:"refunds,omitempty"`
|
||||||
CapturedAmount int64 `json:"capturedAmount"`
|
CapturedAmount money.Cents `json:"capturedAmount"`
|
||||||
RefundedAmount int64 `json:"refundedAmount"`
|
RefundedAmount money.Cents `json:"refundedAmount"`
|
||||||
CustomerEmail string `json:"customerEmail,omitempty"`
|
CustomerEmail string `json:"customerEmail,omitempty"`
|
||||||
CustomerName string `json:"customerName,omitempty"`
|
CustomerName string `json:"customerName,omitempty"`
|
||||||
PlacedAt string `json:"placedAt,omitempty"`
|
PlacedAt string `json:"placedAt,omitempty"`
|
||||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrderLineResp represents a single order line item in responses.
|
// OrderLineResp represents a single order line item in responses.
|
||||||
@@ -282,21 +283,21 @@ type OrderLineResp struct {
|
|||||||
Reference string `json:"reference"`
|
Reference string `json:"reference"`
|
||||||
Sku string `json:"sku"`
|
Sku string `json:"sku"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Quantity int `json:"quantity"`
|
Quantity int `json:"quantity"`
|
||||||
UnitPrice int64 `json:"unitPrice"`
|
UnitPrice money.Cents `json:"unitPrice"`
|
||||||
TaxRate int `json:"taxRate"`
|
TaxRate int `json:"taxRate"`
|
||||||
TotalAmount int64 `json:"totalAmount"`
|
TotalAmount money.Cents `json:"totalAmount"`
|
||||||
TotalTax int64 `json:"totalTax"`
|
TotalTax money.Cents `json:"totalTax"`
|
||||||
Fulfilled int `json:"fulfilled,omitempty"`
|
Fulfilled int `json:"fulfilled,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrderPaymentResp represents a payment record on an order.
|
// OrderPaymentResp represents a payment record on an order.
|
||||||
type OrderPaymentResp struct {
|
type OrderPaymentResp struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Authorized int64 `json:"authorized"`
|
Authorized money.Cents `json:"authorized"`
|
||||||
Captured int64 `json:"captured"`
|
Captured money.Cents `json:"captured"`
|
||||||
Refunded int64 `json:"refunded"`
|
Refunded money.Cents `json:"refunded"`
|
||||||
AuthRef string `json:"authRef,omitempty"`
|
AuthRef string `json:"authRef,omitempty"`
|
||||||
CaptureRef string `json:"captureRef,omitempty"`
|
CaptureRef string `json:"captureRef,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,9 +319,9 @@ type OrderReturnResp struct {
|
|||||||
|
|
||||||
// OrderRefundResp represents a refund record.
|
// OrderRefundResp represents a refund record.
|
||||||
type OrderRefundResp struct {
|
type OrderRefundResp struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Amount int64 `json:"amount"`
|
Amount money.Cents `json:"amount"`
|
||||||
Reference string `json:"reference,omitempty"`
|
Reference string `json:"reference,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrderLineEntry is a lightweight reference+quantity pair used in fulfillment
|
// 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.
|
// ProfileData is the UCP business profile served at /.well-known/ucp.
|
||||||
type ProfileData struct {
|
type ProfileData struct {
|
||||||
UCP Profile `json:"ucp"`
|
UCP Profile `json:"ucp"`
|
||||||
|
SigningKeys []SigningKey `json:"signing_keys,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Profile is the core UCP profile object.
|
// Profile is the core UCP profile object.
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Business BusinessInfo `json:"business,omitempty"`
|
Business BusinessInfo `json:"business,omitempty"`
|
||||||
Services map[string][]ServiceBinding `json:"services"`
|
Services map[string][]ServiceBinding `json:"services"`
|
||||||
Capabilities map[string][]CapabilityDecl `json:"capabilities"`
|
Capabilities map[string][]CapabilityDecl `json:"capabilities"`
|
||||||
PaymentHandlers map[string][]PaymentHandlerDecl `json:"payment_handlers,omitempty"`
|
PaymentHandlers map[string][]PaymentHandlerDecl `json:"payment_handlers,omitempty"`
|
||||||
SupportedVersions []string `json:"supported_versions,omitempty"`
|
SupportedVersions []string `json:"supported_versions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BusinessInfo describes the business behind a UCP profile.
|
// BusinessInfo describes the business behind a UCP profile.
|
||||||
@@ -455,13 +457,23 @@ type OpDef struct {
|
|||||||
|
|
||||||
// PaymentHandlerDecl describes a payment handler specification.
|
// PaymentHandlerDecl describes a payment handler specification.
|
||||||
type PaymentHandlerDecl struct {
|
type PaymentHandlerDecl struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Spec string `json:"spec"`
|
Spec string `json:"spec"`
|
||||||
Schema string `json:"schema"`
|
Schema string `json:"schema"`
|
||||||
Description string `json:"description,omitempty"`
|
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
|
// HTTP helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+16
-81
@@ -1,38 +1,24 @@
|
|||||||
package actor
|
package actor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"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)
|
// MarshalJSON encodes the id as a JSON string.
|
||||||
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.
|
|
||||||
func (id GrainId) MarshalJSON() ([]byte, error) {
|
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 {
|
func (id *GrainId) UnmarshalJSON(data []byte) error {
|
||||||
var s string
|
var s string
|
||||||
if err := json.Unmarshal(data, &s); err != nil {
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
@@ -40,7 +26,7 @@ func (id *GrainId) UnmarshalJSON(data []byte) error {
|
|||||||
}
|
}
|
||||||
parsed, ok := ParseGrainId(s)
|
parsed, ok := ParseGrainId(s)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("invalid cart id: %q", s)
|
return fmt.Errorf("invalid grain id: %q", s)
|
||||||
}
|
}
|
||||||
*id = parsed
|
*id = parsed
|
||||||
return nil
|
return nil
|
||||||
@@ -48,23 +34,11 @@ func (id *GrainId) UnmarshalJSON(data []byte) error {
|
|||||||
|
|
||||||
// NewGrainId generates a new cryptographically random non-zero 64-bit id.
|
// NewGrainId generates a new cryptographically random non-zero 64-bit id.
|
||||||
func NewGrainId() (GrainId, error) {
|
func NewGrainId() (GrainId, error) {
|
||||||
var b [8]byte
|
id, err := uid.New()
|
||||||
if _, err := rand.Read(b[:]); err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("NewGrainId: %w", err)
|
return 0, fmt.Errorf("NewGrainId: %w", err)
|
||||||
}
|
}
|
||||||
u := (uint64(b[0]) << 56) |
|
return GrainId(id), nil
|
||||||
(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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MustNewGrainId panics if generation fails.
|
// MustNewGrainId panics if generation fails.
|
||||||
@@ -77,55 +51,16 @@ func MustNewGrainId() GrainId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ParseGrainId parses a base62 string into a GrainId.
|
// ParseGrainId parses a base62 string into a GrainId.
|
||||||
// Returns (0,false) for invalid input.
|
|
||||||
func ParseGrainId(s string) (GrainId, bool) {
|
func ParseGrainId(s string) (GrainId, bool) {
|
||||||
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
|
id, ok := uid.Parse(s)
|
||||||
// Provide a slightly looser upper bound (<=16) only if you anticipate future
|
return GrainId(id), ok
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MustParseGrainId panics on invalid base62 input.
|
// MustParseGrainId panics on invalid base62 input.
|
||||||
func MustParseGrainId(s string) GrainId {
|
func MustParseGrainId(s string) GrainId {
|
||||||
id, ok := ParseGrainId(s)
|
id, ok := ParseGrainId(s)
|
||||||
if !ok {
|
if !ok {
|
||||||
panic(fmt.Sprintf("invalid cart id: %q", s))
|
panic(fmt.Sprintf("invalid grain id: %q", s))
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
// encodeBase62 converts a uint64 to base62 (shortest form).
|
|
||||||
func encodeBase62(u uint64) string {
|
|
||||||
if u == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
var buf [11]byte
|
|
||||||
i := len(buf)
|
|
||||||
for u > 0 {
|
|
||||||
i--
|
|
||||||
buf[i] = base62Alphabet[u%62]
|
|
||||||
u /= 62
|
|
||||||
}
|
|
||||||
return string(buf[i:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// decodeBase62 converts base62 text to uint64.
|
|
||||||
func decodeBase62(s string) (uint64, bool) {
|
|
||||||
var v uint64
|
|
||||||
for i := 0; i < len(s); i++ {
|
|
||||||
c := s[i]
|
|
||||||
d := base62Rev[c]
|
|
||||||
if d == 0xFF {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
v = v*62 + uint64(d)
|
|
||||||
}
|
|
||||||
return v, true
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -284,9 +284,14 @@ func NewControlServer[V any](config ServerConfig, pool GrainPool[V]) (*grpc.Serv
|
|||||||
reflection.Register(grpcServer)
|
reflection.Register(grpcServer)
|
||||||
|
|
||||||
log.Printf("gRPC server listening as %s on %s", pool.Hostname(), config.Addr)
|
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() {
|
go func() {
|
||||||
if err := grpcServer.Serve(lis); err != nil {
|
if err := grpcServer.Serve(lis); err != nil {
|
||||||
log.Fatalf("failed to serve gRPC: %v", err)
|
log.Printf("gRPC server %s serve error: %v", config.Addr, err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
+58
-17
@@ -1,47 +1,88 @@
|
|||||||
package actor
|
package actor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"log"
|
"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"
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MutationExchange is the shared topic exchange carrying grain mutation streams.
|
||||||
|
// Routing key is "mutation.<grain>" (e.g. mutation.cart), so a consumer binds
|
||||||
|
// "mutation.#" for every grain type or "mutation.cart" for one. This is a
|
||||||
|
// debug/observability feed (live cart/checkout/profile activity), NOT a
|
||||||
|
// domain-event source — domain events use platform/event's vocabulary.
|
||||||
|
const MutationExchange = "mutations"
|
||||||
|
|
||||||
|
// MutationType is the event.Type for a given grain's mutation stream, e.g.
|
||||||
|
// MutationType("cart") == "mutation.cart".
|
||||||
|
func MutationType(grain string) event.Type { return event.Type("mutation." + grain) }
|
||||||
|
|
||||||
type LogListener interface {
|
type LogListener interface {
|
||||||
AppendMutations(id uint64, msg ...ApplyResult)
|
AppendMutations(id uint64, msg ...ApplyResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AmqpListener struct {
|
// MutationSummary is the default feed transform: it reports the applied mutation
|
||||||
conn *amqp.Connection
|
// type names. The grain id and name travel in the Event's Subject/Source, so the
|
||||||
transformer func(id uint64, msg []ApplyResult) (any, error)
|
// payload stays small and uniform across grains.
|
||||||
}
|
func MutationSummary(_ uint64, results []ApplyResult) (any, error) {
|
||||||
|
types := make([]string, 0, len(results))
|
||||||
func NewAmqpListener(conn *amqp.Connection, transformer func(id uint64, msg []ApplyResult) (any, error)) *AmqpListener {
|
for _, r := range results {
|
||||||
return &AmqpListener{
|
types = append(types, r.Type)
|
||||||
conn: conn,
|
|
||||||
transformer: transformer,
|
|
||||||
}
|
}
|
||||||
|
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() {
|
func (l *AmqpListener) DefineTopics() {
|
||||||
ch, err := l.conn.Channel()
|
ch, err := l.conn.Channel()
|
||||||
if err != nil {
|
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()
|
defer ch.Close()
|
||||||
if err := messaging.DefineTopic(ch, "cart", "mutation"); err != nil {
|
if err := ch.ExchangeDeclare(MutationExchange, "topic", true, false, false, false, nil); err != nil {
|
||||||
log.Fatalf("Failed to declare topic mutation: %v", err)
|
log.Printf("mutation feed (%s): declare exchange: %v", l.grain, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *AmqpListener) AppendMutations(id uint64, msg ...ApplyResult) {
|
func (l *AmqpListener) AppendMutations(id uint64, msg ...ApplyResult) {
|
||||||
data, err := l.transformer(id, msg)
|
payload, err := l.transform(id, msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to transform mutation event: %v", err)
|
log.Printf("mutation feed (%s): transform: %v", l.grain, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err = messaging.SendChange(l.conn, "cart", "mutation", data)
|
body, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to send mutation event: %v", err)
|
log.Printf("mutation feed (%s): marshal: %v", l.grain, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ev := event.Event{
|
||||||
|
Type: l.typ,
|
||||||
|
Source: l.grain,
|
||||||
|
Subject: strconv.FormatUint(id, 10),
|
||||||
|
Payload: body,
|
||||||
|
}
|
||||||
|
if err := rabbit.PublishEvent(l.conn, MutationExchange, ev); err != nil {
|
||||||
|
log.Printf("mutation feed (%s): publish: %v", l.grain, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,21 @@ type RegisteredMutation[V any, T proto.Message] struct {
|
|||||||
msgType reflect.Type
|
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] {
|
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().
|
// 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)
|
// 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 {
|
if rt != nil && rt.Kind() == reflect.Pointer {
|
||||||
return reflect.New(rt.Elem()).Interface().(proto.Message)
|
return reflect.New(rt.Elem()).Interface().(proto.Message)
|
||||||
}
|
}
|
||||||
log.Fatalf("expected to create proto message got %+v", rt)
|
panic(fmt.Sprintf("NewMutation: T must be a pointer to a proto message, got %v", rt))
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
instance := create()
|
instance := create()
|
||||||
rt := reflect.TypeOf(instance)
|
rt := reflect.TypeOf(instance)
|
||||||
|
|||||||
+30
-43
@@ -23,7 +23,7 @@ import (
|
|||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
"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"
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
"github.com/redis/go-redis/v9"
|
"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
|
// Start runs the WebSocket hub loop and, when conn is non-nil, the RabbitMQ
|
||||||
// mutation consumer that broadcasts cart mutations to connected clients. The
|
// mutation consumer that broadcasts cart mutations to connected clients. The
|
||||||
// background goroutines stop when ctx is cancelled.
|
// consumer re-subscribes automatically on reconnect (conn is a managed
|
||||||
func (a *App) Start(ctx context.Context, conn *amqp.Connection) error {
|
// reconnecting connection). Background goroutines stop when ctx is cancelled.
|
||||||
|
func (a *App) Start(ctx context.Context, conn *rabbit.Conn) error {
|
||||||
go a.hub.Run()
|
go a.hub.Run()
|
||||||
if conn != nil {
|
if conn != nil {
|
||||||
if err := startMutationConsumer(ctx, conn, a.hub); err != nil {
|
startMutationConsumer(ctx, conn, a.hub)
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -177,44 +176,32 @@ func (a *App) Shutdown(_ context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startMutationConsumer subscribes to the cart "mutation" topic and forwards
|
// startMutationConsumer subscribes to the cart "mutation" topic and forwards
|
||||||
// each message to the hub for broadcast over WebSocket. Best-effort: a full hub
|
// each message to the hub for broadcast over WebSocket. It (re)subscribes on the
|
||||||
// queue drops the message rather than blocking.
|
// initial connect and on every reconnect — via conn.NotifyOnReconnect — so a
|
||||||
func startMutationConsumer(ctx context.Context, conn *amqp.Connection, hub *Hub) error {
|
// broker blip doesn't silently kill the feed. Best-effort: a full hub queue
|
||||||
ch, err := conn.Channel()
|
// drops the message rather than blocking.
|
||||||
if err != nil {
|
func startMutationConsumer(ctx context.Context, conn *rabbit.Conn, hub *Hub) {
|
||||||
_ = conn.Close()
|
conn.NotifyOnReconnect(func() {
|
||||||
return err
|
ch, err := conn.Channel()
|
||||||
}
|
if err != nil {
|
||||||
msgs, err := messaging.DeclareBindAndConsume(ch, "cart", "mutation")
|
log.Printf("mutation consumer: open channel: %v", err)
|
||||||
if err != nil {
|
return
|
||||||
_ = ch.Close()
|
}
|
||||||
return err
|
// 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
|
||||||
go func() {
|
// WebSocket clients, which is exactly what a debug feed wants.
|
||||||
defer ch.Close()
|
if err := rabbit.ListenToPattern(ch, actor.MutationExchange, "mutation.#", func(d amqp.Delivery) error {
|
||||||
for {
|
if hub != nil {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case hub.broadcast <- d.Body:
|
||||||
return
|
default:
|
||||||
case m, ok := <-msgs:
|
// hub queue full: drop to avoid blocking
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("mutation consumer: subscribe: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
})
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ func (c *CartGrain) UpdateTotals() {
|
|||||||
voucher.Applied = true
|
voucher.Applied = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
value := NewPriceFromIncVat(voucher.Value, 25)
|
value := NewPriceFromIncVat(voucher.Value, 2500) // 25% in basis points
|
||||||
if c.TotalPrice.IncVat <= value.IncVat {
|
if c.TotalPrice.IncVat <= value.IncVat {
|
||||||
// don't apply discounts to more than the total price
|
// don't apply discounts to more than the total price
|
||||||
continue
|
continue
|
||||||
|
|||||||
+12
-78
@@ -1,11 +1,10 @@
|
|||||||
package cart
|
package cart
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/mats/platform/uid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cart_id.go
|
// 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"
|
// String returns the canonical base62 encoding.
|
||||||
|
func (id CartId) 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 CartId) String() string {
|
|
||||||
return encodeBase62(uint64(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalJSON encodes the cart id as a JSON string.
|
// MarshalJSON encodes the cart id as a JSON string.
|
||||||
func (id CartId) MarshalJSON() ([]byte, error) {
|
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.
|
// 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.
|
// NewCartId generates a new cryptographically random non-zero 64-bit id.
|
||||||
func NewCartId() (CartId, error) {
|
func NewCartId() (CartId, error) {
|
||||||
var b [8]byte
|
id, err := uid.New()
|
||||||
if _, err := rand.Read(b[:]); err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("NewCartId: %w", err)
|
return 0, fmt.Errorf("NewCartId: %w", err)
|
||||||
}
|
}
|
||||||
u := (uint64(b[0]) << 56) |
|
return CartId(id), nil
|
||||||
(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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MustNewCartId panics if generation fails.
|
// MustNewCartId panics if generation fails.
|
||||||
@@ -107,19 +80,9 @@ func MustNewCartId() CartId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ParseCartId parses a base62 string into a CartId.
|
// ParseCartId parses a base62 string into a CartId.
|
||||||
// Returns (0,false) for invalid input.
|
|
||||||
func ParseCartId(s string) (CartId, bool) {
|
func ParseCartId(s string) (CartId, bool) {
|
||||||
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
|
id, ok := uid.Parse(s)
|
||||||
// Provide a slightly looser upper bound (<=16) only if you anticipate future
|
return CartId(id), ok
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MustParseCartId panics on invalid base62 input.
|
// MustParseCartId panics on invalid base62 input.
|
||||||
@@ -130,32 +93,3 @@ func MustParseCartId(s string) CartId {
|
|||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
// encodeBase62 converts a uint64 to base62 (shortest form).
|
|
||||||
func encodeBase62(u uint64) string {
|
|
||||||
if u == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
var buf [11]byte
|
|
||||||
i := len(buf)
|
|
||||||
for u > 0 {
|
|
||||||
i--
|
|
||||||
buf[i] = base62Alphabet[u%62]
|
|
||||||
u /= 62
|
|
||||||
}
|
|
||||||
return string(buf[i:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// decodeBase62 converts base62 text to uint64.
|
|
||||||
func decodeBase62(s string) (uint64, bool) {
|
|
||||||
var v uint64
|
|
||||||
for i := 0; i < len(s); i++ {
|
|
||||||
c := s[i]
|
|
||||||
d := base62Rev[c]
|
|
||||||
if d == 0xFF {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
v = v*62 + uint64(d)
|
|
||||||
}
|
|
||||||
return v, true
|
|
||||||
}
|
|
||||||
|
|||||||
+14
-15
@@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/platform/uid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestNewCartIdUniqueness generates many ids and checks for collisions.
|
// 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).
|
// TestBase62LengthBound checks worst-case length (near max uint64).
|
||||||
func TestBase62LengthBound(t *testing.T) {
|
func TestBase62LengthBound(t *testing.T) {
|
||||||
// Largest uint64
|
|
||||||
const maxU64 = ^uint64(0)
|
const maxU64 = ^uint64(0)
|
||||||
s := encodeBase62(maxU64)
|
s := uid.ID(maxU64).String()
|
||||||
if len(s) > 11 {
|
if len(s) > 11 {
|
||||||
t.Fatalf("max uint64 encoded length > 11: %d (%s)", len(s), s)
|
t.Fatalf("max uint64 encoded length > 11: %d (%s)", len(s), s)
|
||||||
}
|
}
|
||||||
dec, ok := decodeBase62(s)
|
dec, ok := uid.Parse(s)
|
||||||
if !ok || dec != maxU64 {
|
if !ok || uint64(dec) != maxU64 {
|
||||||
t.Fatalf("decode failed for max uint64: ok=%v dec=%d want=%d", ok, 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.
|
// TestZeroEncoding ensures zero value encodes to "0" and parses back.
|
||||||
func TestZeroEncoding(t *testing.T) {
|
func TestZeroEncoding(t *testing.T) {
|
||||||
if s := encodeBase62(0); s != "0" {
|
if s := uid.ID(0).String(); s != "0" {
|
||||||
t.Fatalf("encodeBase62(0) expected '0', got %q", s)
|
t.Fatalf("uid.ID(0).String() expected '0', got %q", s)
|
||||||
}
|
}
|
||||||
v, ok := decodeBase62("0")
|
v, ok := uid.Parse("0")
|
||||||
if !ok || v != 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 {
|
if _, ok := ParseCartId("0"); !ok {
|
||||||
t.Fatalf("ParseCartId(\"0\") should succeed")
|
t.Fatalf("ParseCartId(\"0\") should succeed")
|
||||||
@@ -145,16 +146,14 @@ func BenchmarkNewCartId(b *testing.B) {
|
|||||||
|
|
||||||
// BenchmarkEncodeBase62 measures encoding performance.
|
// BenchmarkEncodeBase62 measures encoding performance.
|
||||||
func BenchmarkEncodeBase62(b *testing.B) {
|
func BenchmarkEncodeBase62(b *testing.B) {
|
||||||
// Precompute sample values
|
|
||||||
samples := make([]uint64, 1024)
|
samples := make([]uint64, 1024)
|
||||||
for i := range samples {
|
for i := range samples {
|
||||||
// Spread bits without crypto randomness overhead
|
|
||||||
samples[i] = (uint64(i) << 53) ^ (uint64(i) * 0x9E3779B185EBCA87)
|
samples[i] = (uint64(i) << 53) ^ (uint64(i) * 0x9E3779B185EBCA87)
|
||||||
}
|
}
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
var sink string
|
var sink string
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
sink = encodeBase62(samples[i%len(samples)])
|
sink = uid.ID(samples[i%len(samples)]).String()
|
||||||
}
|
}
|
||||||
_ = sink
|
_ = sink
|
||||||
}
|
}
|
||||||
@@ -163,16 +162,16 @@ func BenchmarkEncodeBase62(b *testing.B) {
|
|||||||
func BenchmarkDecodeBase62(b *testing.B) {
|
func BenchmarkDecodeBase62(b *testing.B) {
|
||||||
encoded := make([]string, 1024)
|
encoded := make([]string, 1024)
|
||||||
for i := range encoded {
|
for i := range encoded {
|
||||||
encoded[i] = encodeBase62((uint64(i) << 32) | uint64(i))
|
encoded[i] = uid.ID((uint64(i) << 32) | uint64(i)).String()
|
||||||
}
|
}
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
var sum uint64
|
var sum uint64
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
v, ok := decodeBase62(encoded[i%len(encoded)])
|
v, ok := uid.Parse(encoded[i%len(encoded)])
|
||||||
if !ok {
|
if !ok {
|
||||||
b.Fatalf("decode failure for %s", encoded[i%len(encoded)])
|
b.Fatalf("decode failure for %s", encoded[i%len(encoded)])
|
||||||
}
|
}
|
||||||
sum ^= v
|
sum ^= uint64(v)
|
||||||
}
|
}
|
||||||
_ = sum
|
_ = sum
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
package mcp
|
|
||||||
|
|
||||||
import "encoding/json"
|
|
||||||
|
|
||||||
// JSON-RPC 2.0 envelope types (the wire format MCP rides on). Notifications are
|
|
||||||
// requests with no id and must not receive a response.
|
|
||||||
|
|
||||||
type rpcRequest struct {
|
|
||||||
JSONRPC string `json:"jsonrpc"`
|
|
||||||
ID json.RawMessage `json:"id,omitempty"`
|
|
||||||
Method string `json:"method"`
|
|
||||||
Params json.RawMessage `json:"params,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *rpcRequest) isNotification() bool { return len(r.ID) == 0 }
|
|
||||||
|
|
||||||
type rpcResponse struct {
|
|
||||||
JSONRPC string `json:"jsonrpc"`
|
|
||||||
ID json.RawMessage `json:"id,omitempty"`
|
|
||||||
Result any `json:"result,omitempty"`
|
|
||||||
Error *rpcError `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type rpcError struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Data any `json:"data,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
codeParseError = -32700
|
|
||||||
codeInvalidRequest = -32600
|
|
||||||
codeMethodNotFound = -32601
|
|
||||||
codeInvalidParams = -32602
|
|
||||||
codeInternalError = -32603
|
|
||||||
)
|
|
||||||
|
|
||||||
func result(id json.RawMessage, v any) *rpcResponse {
|
|
||||||
return &rpcResponse{JSONRPC: "2.0", ID: id, Result: v}
|
|
||||||
}
|
|
||||||
|
|
||||||
func errorResponse(id json.RawMessage, code int, msg string) *rpcResponse {
|
|
||||||
return &rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}}
|
|
||||||
}
|
|
||||||
+11
-121
@@ -3,27 +3,25 @@
|
|||||||
// (list items, change quantities, remove items, clear carts, apply vouchers,
|
// (list items, change quantities, remove items, clear carts, apply vouchers,
|
||||||
// manage users, etc.).
|
// manage users, etc.).
|
||||||
//
|
//
|
||||||
// Transport is streamable HTTP (JSON-RPC 2.0 over POST). It is mounted by
|
// Transport (JSON-RPC 2.0 over streamable HTTP), dispatch, and the schema/result
|
||||||
// cmd/cart under /mcp on the same HTTP server as the cart API. Tools map
|
// helpers live in platform/mcp; this package only defines the cart-specific
|
||||||
// 1:1 to cart grain read/apply operations; no restart is needed because every
|
// tools and wires them to the grain pool. Mounted by cmd/cart under /mcp.
|
||||||
// tool call goes through the shared grain pool.
|
|
||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"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/cart"
|
||||||
|
pmcp "git.k6n.net/mats/platform/mcp"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
serverName = "cart"
|
serverName = "cart"
|
||||||
serverVersion = "0.1.0"
|
serverVersion = "0.1.0"
|
||||||
protocolVersion = "2025-06-18"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Applier is the minimal grain-pool interface the cart MCP needs: read a
|
// 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.
|
// Server is the MCP edge over the cart grain pool.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
applier Applier
|
applier Applier
|
||||||
tools []tool
|
mcp *pmcp.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds an MCP server exposing the cart grain as tools.
|
// New builds an MCP server exposing the cart grain as tools.
|
||||||
func New(applier Applier) *Server {
|
func New(applier Applier) *Server {
|
||||||
s := &Server{applier: applier}
|
s := &Server{applier: applier}
|
||||||
s.tools = s.buildTools()
|
s.mcp = pmcp.NewServer(pmcp.Info{Name: serverName, Version: serverVersion}, s.buildTools()...)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler returns the streamable-HTTP handler to mount (e.g. at /mcp).
|
// Handler returns the streamable-HTTP handler to mount (e.g. at /mcp).
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler { return s.mcp.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)
|
|
||||||
}
|
|
||||||
|
|||||||
+68
-200
@@ -4,107 +4,33 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/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,
|
// buildTools returns the cart's MCP tools (transport/dispatch live in platform/mcp).
|
||||||
// and the handler that runs it.
|
func (s *Server) buildTools() []pmcp.Tool {
|
||||||
type tool struct {
|
return []pmcp.Tool{
|
||||||
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{
|
|
||||||
{
|
{
|
||||||
Name: "get_cart",
|
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.",
|
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{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"cartId": str("the base62 cart id"),
|
"cartId": pmcp.String("the base62 cart id"),
|
||||||
}, []string{"cartId"}),
|
}, []string{"cartId"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
CartID string `json:"cartId"`
|
CartID string `json:"cartId"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := cart.ParseCartId(a.CartID)
|
id, ok := cart.ParseCartId(a.CartID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get cart: %w", err)
|
return nil, fmt.Errorf("get cart: %w", err)
|
||||||
}
|
}
|
||||||
@@ -114,21 +40,21 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "get_cart_items",
|
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.",
|
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{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"cartId": str("the base62 cart id"),
|
"cartId": pmcp.String("the base62 cart id"),
|
||||||
}, []string{"cartId"}),
|
}, []string{"cartId"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
CartID string `json:"cartId"`
|
CartID string `json:"cartId"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := cart.ParseCartId(a.CartID)
|
id, ok := cart.ParseCartId(a.CartID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get cart: %w", err)
|
return nil, fmt.Errorf("get cart: %w", err)
|
||||||
}
|
}
|
||||||
@@ -138,21 +64,21 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "get_cart_totals",
|
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.",
|
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{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"cartId": str("the base62 cart id"),
|
"cartId": pmcp.String("the base62 cart id"),
|
||||||
}, []string{"cartId"}),
|
}, []string{"cartId"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
CartID string `json:"cartId"`
|
CartID string `json:"cartId"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := cart.ParseCartId(a.CartID)
|
id, ok := cart.ParseCartId(a.CartID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get cart: %w", err)
|
return nil, fmt.Errorf("get cart: %w", err)
|
||||||
}
|
}
|
||||||
@@ -168,21 +94,21 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "get_cart_promotions",
|
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').",
|
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{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"cartId": str("the base62 cart id"),
|
"cartId": pmcp.String("the base62 cart id"),
|
||||||
}, []string{"cartId"}),
|
}, []string{"cartId"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
CartID string `json:"cartId"`
|
CartID string `json:"cartId"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := cart.ParseCartId(a.CartID)
|
id, ok := cart.ParseCartId(a.CartID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get cart: %w", err)
|
return nil, fmt.Errorf("get cart: %w", err)
|
||||||
}
|
}
|
||||||
@@ -195,21 +121,21 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "get_cart_vouchers",
|
Name: "get_cart_vouchers",
|
||||||
Description: "List all vouchers applied to a cart with their codes, values, rules, and whether they are currently active.",
|
Description: "List all vouchers applied to a cart with their codes, values, rules, and whether they are currently active.",
|
||||||
InputSchema: object(props{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"cartId": str("the base62 cart id"),
|
"cartId": pmcp.String("the base62 cart id"),
|
||||||
}, []string{"cartId"}),
|
}, []string{"cartId"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
CartID string `json:"cartId"`
|
CartID string `json:"cartId"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := cart.ParseCartId(a.CartID)
|
id, ok := cart.ParseCartId(a.CartID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get cart: %w", err)
|
return nil, fmt.Errorf("get cart: %w", err)
|
||||||
}
|
}
|
||||||
@@ -219,25 +145,25 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "update_item_quantity",
|
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.",
|
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{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"cartId": str("the base62 cart id"),
|
"cartId": pmcp.String("the base62 cart id"),
|
||||||
"itemId": integer("the line item id (numeric, e.g. 1, 2, 3)"),
|
"itemId": pmcp.Integer("the line item id (numeric, e.g. 1, 2, 3)"),
|
||||||
"quantity": integer("the new quantity (0 removes the item)"),
|
"quantity": pmcp.Integer("the new quantity (0 removes the item)"),
|
||||||
}, []string{"cartId", "itemId", "quantity"}),
|
}, []string{"cartId", "itemId", "quantity"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
CartID string `json:"cartId"`
|
CartID string `json:"cartId"`
|
||||||
ItemID int `json:"itemId"`
|
ItemID int `json:"itemId"`
|
||||||
Quantity int32 `json:"quantity"`
|
Quantity int32 `json:"quantity"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := cart.ParseCartId(a.CartID)
|
id, ok := cart.ParseCartId(a.CartID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
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),
|
Id: uint32(a.ItemID),
|
||||||
Quantity: a.Quantity,
|
Quantity: a.Quantity,
|
||||||
})
|
})
|
||||||
@@ -256,23 +182,23 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "remove_cart_item",
|
Name: "remove_cart_item",
|
||||||
Description: "Remove a line item (and its children) from a cart by item id. Returns the updated cart state.",
|
Description: "Remove a line item (and its children) from a cart by item id. Returns the updated cart state.",
|
||||||
InputSchema: object(props{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"cartId": str("the base62 cart id"),
|
"cartId": pmcp.String("the base62 cart id"),
|
||||||
"itemId": integer("the line item id to remove (numeric, e.g. 1, 2, 3)"),
|
"itemId": pmcp.Integer("the line item id to remove (numeric, e.g. 1, 2, 3)"),
|
||||||
}, []string{"cartId", "itemId"}),
|
}, []string{"cartId", "itemId"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
CartID string `json:"cartId"`
|
CartID string `json:"cartId"`
|
||||||
ItemID int `json:"itemId"`
|
ItemID int `json:"itemId"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := cart.ParseCartId(a.CartID)
|
id, ok := cart.ParseCartId(a.CartID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("remove item: %w", err)
|
return nil, fmt.Errorf("remove item: %w", err)
|
||||||
}
|
}
|
||||||
@@ -287,21 +213,21 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "clear_cart",
|
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.",
|
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{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"cartId": str("the base62 cart id"),
|
"cartId": pmcp.String("the base62 cart id"),
|
||||||
}, []string{"cartId"}),
|
}, []string{"cartId"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
CartID string `json:"cartId"`
|
CartID string `json:"cartId"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := cart.ParseCartId(a.CartID)
|
id, ok := cart.ParseCartId(a.CartID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("clear cart: %w", err)
|
return nil, fmt.Errorf("clear cart: %w", err)
|
||||||
}
|
}
|
||||||
@@ -316,14 +242,14 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "apply_voucher",
|
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.",
|
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{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"cartId": str("the base62 cart id"),
|
"cartId": pmcp.String("the base62 cart id"),
|
||||||
"code": str("the voucher code"),
|
"code": pmcp.String("the voucher code"),
|
||||||
"value": integer("the voucher value in öre (e.g. 10000 = 100 kr)"),
|
"value": pmcp.Integer("the voucher value in öre (e.g. 10000 = 100 kr)"),
|
||||||
"description": str("optional description of the voucher"),
|
"description": pmcp.String("optional description of the voucher"),
|
||||||
"rules": stringArray("optional list of rule expressions for when the voucher applies"),
|
"rules": pmcp.StringArray("optional list of rule expressions for when the voucher applies"),
|
||||||
}, []string{"cartId", "code", "value"}),
|
}, []string{"cartId", "code", "value"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
CartID string `json:"cartId"`
|
CartID string `json:"cartId"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
@@ -331,14 +257,14 @@ func (s *Server) buildTools() []tool {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Rules []string `json:"rules"`
|
Rules []string `json:"rules"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := cart.ParseCartId(a.CartID)
|
id, ok := cart.ParseCartId(a.CartID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
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,
|
Code: a.Code,
|
||||||
Value: a.Value,
|
Value: a.Value,
|
||||||
Description: a.Description,
|
Description: a.Description,
|
||||||
@@ -358,23 +284,23 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "remove_voucher",
|
Name: "remove_voucher",
|
||||||
Description: "Remove a voucher from a cart by its voucher id. Returns the updated cart.",
|
Description: "Remove a voucher from a cart by its voucher id. Returns the updated cart.",
|
||||||
InputSchema: object(props{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"cartId": str("the base62 cart id"),
|
"cartId": pmcp.String("the base62 cart id"),
|
||||||
"voucherId": integer("the voucher id to remove (numeric)"),
|
"voucherId": pmcp.Integer("the voucher id to remove (numeric)"),
|
||||||
}, []string{"cartId", "voucherId"}),
|
}, []string{"cartId", "voucherId"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
CartID string `json:"cartId"`
|
CartID string `json:"cartId"`
|
||||||
VoucherID int `json:"voucherId"`
|
VoucherID int `json:"voucherId"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := cart.ParseCartId(a.CartID)
|
id, ok := cart.ParseCartId(a.CartID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("remove voucher: %w", err)
|
return nil, fmt.Errorf("remove voucher: %w", err)
|
||||||
}
|
}
|
||||||
@@ -389,23 +315,23 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "set_cart_user",
|
Name: "set_cart_user",
|
||||||
Description: "Set or update the user ID on a cart, linking it to a customer account. Returns the updated cart.",
|
Description: "Set or update the user ID on a cart, linking it to a customer account. Returns the updated cart.",
|
||||||
InputSchema: object(props{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"cartId": str("the base62 cart id"),
|
"cartId": pmcp.String("the base62 cart id"),
|
||||||
"userId": str("the user/customer id"),
|
"userId": pmcp.String("the user/customer id"),
|
||||||
}, []string{"cartId", "userId"}),
|
}, []string{"cartId", "userId"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
CartID string `json:"cartId"`
|
CartID string `json:"cartId"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := cart.ParseCartId(a.CartID)
|
id, ok := cart.ParseCartId(a.CartID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("set user: %w", err)
|
return nil, fmt.Errorf("set user: %w", err)
|
||||||
}
|
}
|
||||||
@@ -421,61 +347,3 @@ func (s *Server) buildTools() []tool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- result + schema helpers -----------------------------------------------
|
// ---- result + schema helpers -----------------------------------------------
|
||||||
|
|
||||||
func toolText(v any) map[string]any {
|
|
||||||
b, err := json.Marshal(v)
|
|
||||||
if err != nil {
|
|
||||||
return toolError(err)
|
|
||||||
}
|
|
||||||
return map[string]any{
|
|
||||||
"content": []map[string]any{{"type": "text", "text": string(b)}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toolError(err error) map[string]any {
|
|
||||||
return map[string]any{
|
|
||||||
"isError": true,
|
|
||||||
"content": []map[string]any{{"type": "text", "text": err.Error()}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// decode unmarshals tool arguments, tolerating empty/absent arguments.
|
|
||||||
func decode(args json.RawMessage, v any) error {
|
|
||||||
if len(args) == 0 || string(args) == "null" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(args, v); err != nil {
|
|
||||||
return fmt.Errorf("invalid arguments: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type props map[string]json.RawMessage
|
|
||||||
|
|
||||||
func object(p props, required []string) json.RawMessage {
|
|
||||||
m := map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": p,
|
|
||||||
}
|
|
||||||
if len(required) > 0 {
|
|
||||||
m["required"] = required
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(m)
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func str(desc string) json.RawMessage { return scalar("string", desc) }
|
|
||||||
func integer(d string) json.RawMessage { return scalar("integer", d) }
|
|
||||||
func obj(desc string) json.RawMessage { return scalar("object", desc) }
|
|
||||||
|
|
||||||
func scalar(typ, desc string) json.RawMessage {
|
|
||||||
b, _ := json.Marshal(map[string]any{"type": typ, "description": desc})
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func stringArray(desc string) json.RawMessage {
|
|
||||||
b, _ := json.Marshal(map[string]any{
|
|
||||||
"type": "array", "description": desc, "items": map[string]string{"type": "string"},
|
|
||||||
})
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -98,12 +98,14 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
|
|||||||
defer g.mu.Unlock()
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
g.lastItemId++
|
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 {
|
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
|
needsReservation := true
|
||||||
if m.ReservationEndTime != nil {
|
if m.ReservationEndTime != nil {
|
||||||
@@ -115,7 +117,7 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
|
|||||||
ItemId: uint32(m.ItemId),
|
ItemId: uint32(m.ItemId),
|
||||||
Quantity: uint16(m.Quantity),
|
Quantity: uint16(m.Quantity),
|
||||||
Sku: m.Sku,
|
Sku: m.Sku,
|
||||||
Tax: int(taxRate * 100),
|
Tax: rateBp,
|
||||||
Meta: &ItemMeta{
|
Meta: &ItemMeta{
|
||||||
Name: m.Name,
|
Name: m.Name,
|
||||||
Image: m.Image,
|
Image: m.Image,
|
||||||
@@ -139,7 +141,7 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
|
|||||||
Stock: uint16(m.Stock),
|
Stock: uint16(m.Stock),
|
||||||
Disclaimer: m.Disclaimer,
|
Disclaimer: m.Disclaimer,
|
||||||
|
|
||||||
OrgPrice: getOrgPrice(m.OrgPrice, taxRate),
|
OrgPrice: getOrgPrice(m.OrgPrice, rateBp),
|
||||||
ArticleType: m.ArticleType,
|
ArticleType: m.ArticleType,
|
||||||
|
|
||||||
StoreId: m.StoreId,
|
StoreId: m.StoreId,
|
||||||
@@ -167,9 +169,9 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getOrgPrice(orgPrice int64, taxRate float32) *Price {
|
func getOrgPrice(orgPrice int64, rateBp int) *Price {
|
||||||
if orgPrice <= 0 {
|
if orgPrice <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return NewPriceFromIncVat(orgPrice, taxRate)
|
return NewPriceFromIncVat(orgPrice, rateBp)
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-151
@@ -1,159 +1,26 @@
|
|||||||
package cart
|
package cart
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"git.k6n.net/mats/platform/money"
|
||||||
"strconv"
|
"git.k6n.net/mats/platform/tax"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetTaxAmount(total int64, tax int) int64 {
|
// Price represents a monetary amount with optional VAT breakdown by rate.
|
||||||
taxD := 10000 / float64(tax)
|
// The canonical definition is now in platform/tax; this is a type alias
|
||||||
return int64(float64(total) / float64((1 + taxD)))
|
// 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 {
|
// MultiplyPrice multiplies a Price by quantity and returns a new Price.
|
||||||
IncVat int64 `json:"incVat"`
|
func MultiplyPrice(p Price, qty int64) *Price { return tax.MultiplyPrice(p, qty) }
|
||||||
VatRates map[float32]int64 `json:"vat,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPrice() *Price {
|
// SumPrices aggregates multiple Prices into one.
|
||||||
return &Price{
|
func SumPrices(prices ...Price) *Price { return tax.SumPrices(prices...) }
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
+51
-46
@@ -3,10 +3,12 @@ package cart
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/platform/money"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPriceMarshalJSON(t *testing.T) {
|
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
|
// ExVat = 13700 - (2500+1200) = 10000
|
||||||
data, err := json.Marshal(p)
|
data, err := json.Marshal(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -27,29 +29,29 @@ func TestPriceMarshalJSON(t *testing.T) {
|
|||||||
if out.IncVat != 13700 {
|
if out.IncVat != 13700 {
|
||||||
t.Fatalf("expected incVat 13700 got %d", out.IncVat)
|
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)
|
t.Fatalf("unexpected vat map: %#v", out.Vat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewPriceFromIncVat(t *testing.T) {
|
func TestNewPriceFromIncVat(t *testing.T) {
|
||||||
p := NewPriceFromIncVat(1250, 25)
|
p := NewPriceFromIncVat(1250, 2500) // 25% in basis points
|
||||||
if p.IncVat != 1250 {
|
if p.IncVat != 1250 {
|
||||||
t.Fatalf("expected IncVat %d got %d", 1250, p.IncVat)
|
t.Fatalf("expected IncVat %d got %d", 1250, p.IncVat)
|
||||||
}
|
}
|
||||||
if p.VatRates[25] != 250 {
|
if p.VatRates[2500] != 250 {
|
||||||
t.Fatalf("expected VAT 25 rate %d got %d", 250, p.VatRates[25])
|
t.Fatalf("expected VAT 2500bp rate %d got %d", 250, p.VatRates[2500])
|
||||||
}
|
}
|
||||||
if p.ValueExVat() != 1000 {
|
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) {
|
func TestSumPrices(t *testing.T) {
|
||||||
// We'll construct prices via raw struct since constructor expects tax math.
|
// We'll construct prices via raw struct since constructor expects tax math.
|
||||||
// IncVat already includes vat portions.
|
// IncVat already includes vat portions. Keys are basis points (2500 = 25%).
|
||||||
a := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}} // ex=1000
|
a := Price{IncVat: 1250, VatRates: map[int]money.Cents{2500: 250}} // ex=1000
|
||||||
b := Price{IncVat: 2740, VatRates: map[float32]int64{25: 500, 12: 240}} // ex=2000
|
b := Price{IncVat: 2740, VatRates: map[int]money.Cents{2500: 500, 1200: 240}} // ex=2000
|
||||||
c := Price{IncVat: 0, VatRates: nil}
|
c := Price{IncVat: 0, VatRates: nil}
|
||||||
|
|
||||||
sum := SumPrices(a, b, c)
|
sum := SumPrices(a, b, c)
|
||||||
@@ -60,11 +62,11 @@ func TestSumPrices(t *testing.T) {
|
|||||||
if len(sum.VatRates) != 2 {
|
if len(sum.VatRates) != 2 {
|
||||||
t.Fatalf("expected 2 vat rates got %d", len(sum.VatRates))
|
t.Fatalf("expected 2 vat rates got %d", len(sum.VatRates))
|
||||||
}
|
}
|
||||||
if sum.VatRates[25] != 750 {
|
if sum.VatRates[2500] != 750 {
|
||||||
t.Fatalf("expected 25%% vat 750 got %d", sum.VatRates[25])
|
t.Fatalf("expected 25%% vat 750 got %d", sum.VatRates[2500])
|
||||||
}
|
}
|
||||||
if sum.VatRates[12] != 240 {
|
if sum.VatRates[1200] != 240 {
|
||||||
t.Fatalf("expected 12%% vat 240 got %d", sum.VatRates[12])
|
t.Fatalf("expected 12%% vat 240 got %d", sum.VatRates[1200])
|
||||||
}
|
}
|
||||||
if sum.ValueExVat() != 3000 { // 3990 - (750+240)
|
if sum.ValueExVat() != 3000 { // 3990 - (750+240)
|
||||||
t.Fatalf("expected exVat 3000 got %d", sum.ValueExVat())
|
t.Fatalf("expected exVat 3000 got %d", sum.ValueExVat())
|
||||||
@@ -73,19 +75,21 @@ func TestSumPrices(t *testing.T) {
|
|||||||
|
|
||||||
func TestSumPricesEmpty(t *testing.T) {
|
func TestSumPricesEmpty(t *testing.T) {
|
||||||
sum := SumPrices()
|
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)
|
t.Fatalf("expected zero price got %#v", sum)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMultiplyPriceFunction(t *testing.T) {
|
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)
|
multiplied := MultiplyPrice(base, 3)
|
||||||
if multiplied.IncVat != 1250*3 {
|
if multiplied.IncVat != 1250*3 {
|
||||||
t.Fatalf("expected IncVat %d got %d", 1250*3, multiplied.IncVat)
|
t.Fatalf("expected IncVat %d got %d", 1250*3, multiplied.IncVat)
|
||||||
}
|
}
|
||||||
if multiplied.VatRates[25] != 250*3 {
|
if multiplied.VatRates[2500] != 250*3 {
|
||||||
t.Fatalf("expected VAT 25 rate %d got %d", 250*3, multiplied.VatRates[25])
|
t.Fatalf("expected VAT 2500bp rate %d got %d", 250*3, multiplied.VatRates[2500])
|
||||||
}
|
}
|
||||||
if multiplied.ValueExVat() != (1250-250)*3 {
|
if multiplied.ValueExVat() != (1250-250)*3 {
|
||||||
t.Fatalf("expected exVat %d got %d", (1250-250)*3, multiplied.ValueExVat())
|
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) {
|
func TestPriceAddSubtract(t *testing.T) {
|
||||||
a := Price{IncVat: 1000, VatRates: map[float32]int64{25: 200}}
|
a := Price{IncVat: 1000, VatRates: map[int]money.Cents{2500: 200}}
|
||||||
b := Price{IncVat: 500, VatRates: map[float32]int64{25: 100, 12: 54}}
|
b := Price{IncVat: 500, VatRates: map[int]money.Cents{2500: 100, 1200: 54}}
|
||||||
|
|
||||||
acc := NewPrice()
|
acc := NewPrice()
|
||||||
acc.Add(a)
|
acc.Add(a)
|
||||||
@@ -103,7 +107,7 @@ func TestPriceAddSubtract(t *testing.T) {
|
|||||||
if acc.IncVat != 1500 {
|
if acc.IncVat != 1500 {
|
||||||
t.Fatalf("expected IncVat 1500 got %d", acc.IncVat)
|
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)
|
t.Fatalf("unexpected VAT map: %#v", acc.VatRates)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,46 +117,47 @@ func TestPriceAddSubtract(t *testing.T) {
|
|||||||
if acc.IncVat != 0 {
|
if acc.IncVat != 0 {
|
||||||
t.Fatalf("expected IncVat 0 got %d", acc.IncVat)
|
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)
|
t.Fatalf("expected zeroed vat rates got %#v", acc.VatRates)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPriceMultiplyMethod(t *testing.T) {
|
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
|
// Value before multiply
|
||||||
exBefore := p.ValueExVat()
|
exBefore := p.ValueExVat()
|
||||||
p.Multiply(2)
|
p.Multiply(2)
|
||||||
if p.IncVat != 4000 {
|
if p.IncVat != 4000 {
|
||||||
t.Fatalf("expected IncVat 4000 got %d", p.IncVat)
|
t.Fatalf("expected IncVat 4000 got %d", p.IncVat)
|
||||||
}
|
}
|
||||||
if p.VatRates[25] != 800 {
|
if p.VatRates[2500] != 800 {
|
||||||
t.Fatalf("expected VAT 800 got %d", p.VatRates[25])
|
t.Fatalf("expected VAT 800 got %d", p.VatRates[2500])
|
||||||
}
|
}
|
||||||
if p.ValueExVat() != exBefore*2 {
|
if p.ValueExVat() != exBefore*2 {
|
||||||
t.Fatalf("expected exVat %d got %d", exBefore*2, p.ValueExVat())
|
t.Fatalf("expected exVat %d got %d", exBefore*2, p.ValueExVat())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetTaxAmount(t *testing.T) {
|
func TestComputeTax(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
total int64
|
total int64
|
||||||
tax int
|
rateBp int
|
||||||
expected int64
|
expected int64
|
||||||
desc string
|
desc string
|
||||||
}{
|
}{
|
||||||
{1250, 2500, 250, "25% VAT"}, // 1250 / (1 + 100/25) = 1250 / 5 = 250
|
// ex-VAT-first integer math: tax = total - total*10000/(10000+rateBp).
|
||||||
{1000, 2000, 166, "20% VAT"}, // 1000 / (1 + 100/20) = 1000 / 6 ≈ 166
|
{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"},
|
{1200, 2500, 240, "25% VAT on 1200"},
|
||||||
{0, 2500, 0, "zero total"},
|
{0, 2500, 0, "zero total"},
|
||||||
{100, 1000, 9, "10% VAT"}, // tax=1000 for 10%, 100 / (1 + 100/10) = 100 / 11 ≈ 9
|
{100, 1000, 10, "10% VAT"}, // exVat 100*10000/11000=90, tax 10
|
||||||
{100, 10000, 50, "100% VAT"}, // tax=10000 for 100%, 100 / (1 + 100/100) = 100 / 2 = 50
|
{100, 10000, 50, "100% VAT"}, // exVat 50, tax 50
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
result := GetTaxAmount(tt.total, tt.tax)
|
result := ComputeTax(money.Cents(tt.total), tt.rateBp).Int64()
|
||||||
if result != tt.expected {
|
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())
|
t.Errorf("expected exVat 1000, got %d", p.ValueExVat())
|
||||||
}
|
}
|
||||||
|
|
||||||
// High VAT rate, e.g., 50%
|
// High VAT rate, e.g., 50% = 5000 basis points
|
||||||
p = NewPriceFromIncVat(1500, 50)
|
p = NewPriceFromIncVat(1500, 5000)
|
||||||
expectedVat := int64(1500 / (1 + 100/50)) // 1500 / 3 = 500
|
expectedVat := money.Cents(500) // exVat 1500*10000/15000=1000, tax 500
|
||||||
if p.VatRates[50] != expectedVat {
|
if p.VatRates[5000] != expectedVat {
|
||||||
t.Errorf("expected VAT %d for 50%%, got %d", expectedVat, p.VatRates[50])
|
t.Errorf("expected VAT %d for 50%%, got %d", expectedVat, p.VatRates[5000])
|
||||||
}
|
}
|
||||||
if p.ValueExVat() != 1500-expectedVat {
|
if p.ValueExVat() != 1500-expectedVat {
|
||||||
t.Errorf("expected exVat %d, got %d", 1500-expectedVat, p.ValueExVat())
|
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) {
|
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()
|
exVat := p.ValueExVat()
|
||||||
totalVat := p.TotalVat()
|
totalVat := p.TotalVat()
|
||||||
if exVat != 10000 {
|
if exVat != 10000 {
|
||||||
@@ -206,33 +211,33 @@ func TestPriceValueExVatAndTotalVat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMultiplyPriceWithZeroQty(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)
|
multiplied := MultiplyPrice(base, 0)
|
||||||
if multiplied.IncVat != 0 {
|
if multiplied.IncVat != 0 {
|
||||||
t.Errorf("expected IncVat 0, got %d", multiplied.IncVat)
|
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)
|
t.Errorf("expected VAT 0, got %v", multiplied.VatRates)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPriceAddSubtractEdgeCases(t *testing.T) {
|
func TestPriceAddSubtractEdgeCases(t *testing.T) {
|
||||||
a := Price{IncVat: 1000, VatRates: map[float32]int64{25: 200}}
|
a := Price{IncVat: 1000, VatRates: map[int]money.Cents{2500: 200}}
|
||||||
b := Price{IncVat: 500, VatRates: map[float32]int64{12: 54}} // Different rate
|
b := Price{IncVat: 500, VatRates: map[int]money.Cents{1200: 54}} // Different rate
|
||||||
|
|
||||||
acc := NewPrice()
|
acc := NewPrice()
|
||||||
acc.Add(a)
|
acc.Add(a)
|
||||||
acc.Add(b)
|
acc.Add(b)
|
||||||
|
|
||||||
if acc.VatRates[25] != 200 || acc.VatRates[12] != 54 {
|
if acc.VatRates[2500] != 200 || acc.VatRates[1200] != 54 {
|
||||||
t.Errorf("expected VAT 25:200, 12:54, got %v", acc.VatRates)
|
t.Errorf("expected VAT 2500:200, 1200:54, got %v", acc.VatRates)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subtract more than added (negative VAT)
|
// Subtract more than added (negative VAT)
|
||||||
acc.Subtract(a)
|
acc.Subtract(a)
|
||||||
acc.Subtract(b)
|
acc.Subtract(b)
|
||||||
acc.Subtract(a) // Subtract extra a
|
acc.Subtract(a) // Subtract extra a
|
||||||
if acc.VatRates[25] != -200 || acc.VatRates[12] != 0 {
|
if acc.VatRates[2500] != -200 || acc.VatRates[1200] != 0 {
|
||||||
t.Errorf("expected negative VAT for 25 after over-subtract, got %v", acc.VatRates)
|
t.Errorf("expected negative VAT for 2500 after over-subtract, got %v", acc.VatRates)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-51
@@ -1,58 +1,24 @@
|
|||||||
package cart
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.k6n.net/mats/platform/money"
|
||||||
|
"git.k6n.net/mats/platform/tax"
|
||||||
|
)
|
||||||
|
|
||||||
// TaxProvider computes taxes for orders and line items.
|
// TaxProvider computes taxes for orders and line items.
|
||||||
// It mirrors the PaymentProvider and ShippingProvider seam patterns: one
|
// The canonical definition is now in platform/tax; this is a type alias
|
||||||
// interface, interchangeable implementations.
|
// for backward compatibility.
|
||||||
//
|
type TaxProvider = tax.Provider
|
||||||
// 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
|
|
||||||
|
|
||||||
// ComputeTax computes the tax portion from a gross (inc-VAT) total and tax
|
// ComputeTax returns the VAT portion of an inc-VAT total. rateBp is basis points
|
||||||
// rate. Both total and return value are in minor currency units (ore).
|
// (2500 = 25%). Re-exported from platform/tax.
|
||||||
// taxRate is expressed as raw percent (e.g. 25 = 25 %).
|
func ComputeTax(total money.Cents, rateBp int) money.Cents {
|
||||||
//
|
return money.Cents(tax.Compute(total.Int64(), rateBp))
|
||||||
// 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 is the shared tax formula: tax = total x rate / (100 + rate).
|
// StaticTaxProvider is a stateless, always-available TaxProvider.
|
||||||
// taxRate is raw percent (e.g. 25 = 25 %). When taxRate <= 0 the result is 0.
|
// Re-exported from platform/tax for backward compatibility.
|
||||||
//
|
type StaticTaxProvider = tax.Static
|
||||||
// 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 that simply
|
// NewStaticTaxProvider returns a new StaticTaxProvider.
|
||||||
// runs the formula without any country-specific rates. Use for local dev,
|
func NewStaticTaxProvider() *StaticTaxProvider { return tax.NewStatic() }
|
||||||
// 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 }
|
|
||||||
|
|||||||
+8
-50
@@ -1,55 +1,13 @@
|
|||||||
package cart
|
package cart
|
||||||
|
|
||||||
// NordicTaxProvider implements TaxProvider for the Nordic countries
|
import "git.k6n.net/mats/platform/tax"
|
||||||
// (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
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewNordicTaxProvider returns a NordicTaxProvider. defaultCountry is used
|
// NordicTaxProvider implements TaxProvider for the Nordic countries.
|
||||||
// when DefaultTaxRate is called with an empty country code; it defaults to
|
// The canonical definition is now in platform/tax; this is a type alias
|
||||||
// "SE" if empty.
|
// for backward compatibility.
|
||||||
|
type NordicTaxProvider = tax.Nordic
|
||||||
|
|
||||||
|
// NewNordicTaxProvider returns a new NordicTaxProvider.
|
||||||
func NewNordicTaxProvider(defaultCountry string) *NordicTaxProvider {
|
func NewNordicTaxProvider(defaultCountry string) *NordicTaxProvider {
|
||||||
if defaultCountry == "" {
|
return tax.NewNordic(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]
|
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-42
@@ -11,28 +11,29 @@ func TestNordicTaxProvider_Name(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNordicTaxProvider_ComputeTax(t *testing.T) {
|
func TestNordicTaxProvider_Compute(t *testing.T) {
|
||||||
|
// taxRate is basis points (2500 = 25%).
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
total int64
|
total int64
|
||||||
taxRate int
|
taxRate int
|
||||||
want int64
|
want int64
|
||||||
desc string
|
desc string
|
||||||
}{
|
}{
|
||||||
{0, 25, 0, "zero total"},
|
{0, 2500, 0, "zero total"},
|
||||||
{1250, 25, 250, "25 % VAT on 1250"},
|
{1250, 2500, 250, "25 % VAT on 1250"},
|
||||||
{1000, 20, 166, "20 % VAT on 1000"},
|
{1000, 2000, 167, "20 % VAT on 1000"},
|
||||||
{1200, 25, 240, "25 % VAT on 1200"},
|
{1200, 2500, 240, "25 % VAT on 1200"},
|
||||||
{25000, 25, 5000, "25 % VAT on 25000"},
|
{25000, 2500, 5000, "25 % VAT on 25000"},
|
||||||
{100, 10, 9, "10 % VAT on 100"},
|
{100, 1000, 10, "10 % VAT on 100"},
|
||||||
{100, 100, 50, "100 % VAT on 100"},
|
{100, 10000, 50, "100 % VAT on 100"},
|
||||||
{100, 0, 0, "zero-rate"},
|
{100, 0, 0, "zero-rate"},
|
||||||
{100, -1, 0, "negative rate -> zero"},
|
{100, -1, 0, "negative rate -> zero"},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
p := NewNordicTaxProvider("SE")
|
p := NewNordicTaxProvider("SE")
|
||||||
got := p.ComputeTax(tt.total, tt.taxRate)
|
got := p.Compute(tt.total, tt.taxRate)
|
||||||
if got != tt.want {
|
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
|
want int
|
||||||
desc string
|
desc string
|
||||||
}{
|
}{
|
||||||
{"SE", 25, "Sweden"},
|
{"SE", 2500, "Sweden"},
|
||||||
{"NO", 25, "Norway"},
|
{"NO", 2500, "Norway"},
|
||||||
{"DK", 25, "Denmark"},
|
{"DK", 2500, "Denmark"},
|
||||||
{"FI", 25, "Finland"},
|
{"FI", 2550, "Finland (25.5%)"},
|
||||||
{"", 25, "empty -> default SE"},
|
{"", 2500, "empty -> default SE"},
|
||||||
{"DE", 25, "unknown -> fallback SE"},
|
{"DE", 2500, "unknown -> fallback SE"},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
p := NewNordicTaxProvider("SE")
|
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()
|
p := NewStaticTaxProvider()
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
total int64
|
total int64
|
||||||
taxRate int
|
taxRate int
|
||||||
want int64
|
want int64
|
||||||
}{
|
}{
|
||||||
{1250, 25, 250},
|
{1250, 2500, 250},
|
||||||
{25000, 25, 5000},
|
{25000, 2500, 5000},
|
||||||
{1000, 20, 166},
|
{1000, 2000, 167},
|
||||||
{0, 25, 0},
|
{0, 2500, 0},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
got := p.ComputeTax(tt.total, tt.taxRate)
|
got := p.Compute(tt.total, tt.taxRate)
|
||||||
if got != tt.want {
|
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) {
|
func TestTaxProviderImplementsInterface(t *testing.T) {
|
||||||
var p TaxProvider = NewNordicTaxProvider("SE")
|
var p TaxProvider = NewNordicTaxProvider("SE")
|
||||||
_ = p
|
_ = p
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func HandleInitializeCheckout(g *CheckoutGrain, m *messages.InitializeCheckout)
|
|||||||
}
|
}
|
||||||
|
|
||||||
g.CartTotalPrice = g.CartState.TotalPrice
|
g.CartTotalPrice = g.CartState.TotalPrice
|
||||||
g.AmountInCentsRemaining = g.CartTotalPrice.IncVat
|
g.AmountInCentsRemaining = g.CartTotalPrice.IncVat.Int64()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func HandlePaymentCompleted(g *CheckoutGrain, m *messages.PaymentCompleted) erro
|
|||||||
|
|
||||||
// Update checkout status
|
// Update checkout status
|
||||||
g.PaymentInProgress--
|
g.PaymentInProgress--
|
||||||
sum := g.CartState.TotalPrice.IncVat
|
sum := g.CartState.TotalPrice.IncVat.Int64()
|
||||||
|
|
||||||
for _, payment := range g.SettledPayments() {
|
for _, payment := range g.SettledPayments() {
|
||||||
sum -= payment.Amount
|
sum -= payment.Amount
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package discovery
|
package discovery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -9,14 +11,27 @@ import (
|
|||||||
"k8s.io/client-go/tools/clientcmd"
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDiscovery(t *testing.T) {
|
func kubeConfigPath(t *testing.T) string {
|
||||||
config, err := clientcmd.BuildConfigFromFlags("", "/home/mats/.kube/config")
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
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)
|
client, err := kubernetes.NewForConfig(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Error creating client: %v", err)
|
t.Fatalf("Error creating client: %v", err)
|
||||||
}
|
}
|
||||||
d := NewK8sDiscovery(client, metav1.ListOptions{
|
d := NewK8sDiscovery(client, metav1.ListOptions{
|
||||||
LabelSelector: "app",
|
LabelSelector: "app",
|
||||||
@@ -31,13 +46,14 @@ func TestDiscovery(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestWatch(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 {
|
if err != nil {
|
||||||
t.Errorf("Error building config: %v", err)
|
t.Fatalf("Error building config: %v", err)
|
||||||
}
|
}
|
||||||
client, err := kubernetes.NewForConfig(config)
|
client, err := kubernetes.NewForConfig(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Error creating client: %v", err)
|
t.Fatalf("Error creating client: %v", err)
|
||||||
}
|
}
|
||||||
d := NewK8sDiscovery(client, metav1.ListOptions{
|
d := NewK8sDiscovery(client, metav1.ListOptions{
|
||||||
LabelSelector: "app",
|
LabelSelector: "app",
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -108,7 +108,7 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
|
|||||||
amount, _ := st.Vars["authAmount"].(int64)
|
amount, _ := st.Vars["authAmount"].(int64)
|
||||||
if amount == 0 {
|
if amount == 0 {
|
||||||
if o, err := app.Get(ctx, st.ID); err == nil {
|
if o, err := app.Get(ctx, st.ID); err == nil {
|
||||||
amount = o.TotalAmount
|
amount = o.TotalAmount.Int64()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
capture, err := provider.Capture(ctx, authRef, amount)
|
capture, err := provider.Capture(ctx, authRef, amount)
|
||||||
@@ -154,7 +154,7 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
amount := o.CapturedAmount - o.RefundedAmount // default: full remaining
|
amount := (o.CapturedAmount - o.RefundedAmount).Int64() // default: full remaining
|
||||||
if len(params) > 0 {
|
if len(params) > 0 {
|
||||||
var p struct {
|
var p struct {
|
||||||
Amount int64 `json:"amount"`
|
Amount int64 `json:"amount"`
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package order
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||||
|
"git.k6n.net/mats/platform/event"
|
||||||
|
contract "git.k6n.net/mats/platform/order"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterOrderCreatedEmit registers the "emit_order_created" flow hook: it
|
||||||
|
// publishes an event.OrderCreated envelope (payload contract.Created) for the
|
||||||
|
// order, reading the grain via app and publishing durably via pub.
|
||||||
|
//
|
||||||
|
// It is a HOOK, not an action, on purpose: hook errors are logged, never abort
|
||||||
|
// the flow. The order is already placed and paid by the time this fires, so a
|
||||||
|
// transient publish hiccup must not roll it back — and pub is the durable outbox
|
||||||
|
// (a local fsync'd append), so "failure" here means disk error, then the relay
|
||||||
|
// retries delivery on its own. With a nil publisher (dev without AMQP) it is a
|
||||||
|
// no-op. Attach it to the "after" hooks of the final paid step, e.g. capture.
|
||||||
|
func RegisterOrderCreatedEmit(reg *flow.Registry, app Applier, pub Publisher, exchange, source string) {
|
||||||
|
reg.Hook("emit_order_created", func(ctx context.Context, st *flow.State, _ flow.HookInfo, _ json.RawMessage) error {
|
||||||
|
if pub == nil {
|
||||||
|
return nil // no broker configured (dev)
|
||||||
|
}
|
||||||
|
o, err := app.Get(ctx, st.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
created := buildOrderCreated(o)
|
||||||
|
if len(created.Lines) == 0 {
|
||||||
|
return nil // nothing for a reactor to act on
|
||||||
|
}
|
||||||
|
payload, err := created.Encode()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ev := event.Event{
|
||||||
|
ID: "order-created-" + created.OrderID,
|
||||||
|
Type: event.OrderCreated,
|
||||||
|
Source: source,
|
||||||
|
Subject: created.OrderID,
|
||||||
|
Payload: payload,
|
||||||
|
Time: time.Now(),
|
||||||
|
Meta: map[string]string{"country": created.Country},
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(ev)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return pub.Publish(exchange, string(event.OrderCreated), body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOrderCreated projects the grain onto the exported order.created contract.
|
||||||
|
// Lines carry only SKU + quantity (what reactors need); inventory commits at the
|
||||||
|
// order's Country location.
|
||||||
|
func buildOrderCreated(o *OrderGrain) contract.Created {
|
||||||
|
lines := make([]contract.Line, 0, len(o.Lines))
|
||||||
|
for _, l := range o.Lines {
|
||||||
|
if l.Sku == "" || l.Quantity <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, contract.Line{SKU: l.Sku, Quantity: l.Quantity, Location: l.Location})
|
||||||
|
}
|
||||||
|
return contract.Created{
|
||||||
|
OrderID: OrderId(o.Id).String(),
|
||||||
|
OrderReference: o.OrderReference,
|
||||||
|
CartID: o.CartId,
|
||||||
|
Country: o.Country,
|
||||||
|
Currency: o.Currency,
|
||||||
|
CustomerEmail: o.CustomerEmail,
|
||||||
|
Lines: lines,
|
||||||
|
PlacedAtMs: nowMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,9 @@ func newFlowRegistry(pool Applier, provider PaymentProvider) *flow.Registry {
|
|||||||
reg := flow.NewRegistry()
|
reg := flow.NewRegistry()
|
||||||
flow.RegisterBuiltinHooks(reg)
|
flow.RegisterBuiltinHooks(reg)
|
||||||
RegisterFlowActions(reg, pool, provider)
|
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
|
return reg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,10 @@
|
|||||||
"name": "capture",
|
"name": "capture",
|
||||||
"action": "capture_payment",
|
"action": "capture_payment",
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"after": [{ "type": "log", "params": { "message": "payment captured" } }]
|
"after": [
|
||||||
|
{ "type": "log", "params": { "message": "payment captured" } },
|
||||||
|
{ "type": "emit_order_created" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+10
-53
@@ -1,71 +1,28 @@
|
|||||||
package order
|
package order
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/platform/uid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OrderId is a 64-bit order identifier with a compact base62 string form, the
|
// OrderId is a 64-bit order identifier with a compact base62 string form.
|
||||||
// same scheme the cart uses (cart.CartId) so ids are consistent across the
|
type OrderId uid.ID
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// String returns the canonical base62 encoding.
|
// 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.
|
// NewOrderId generates a cryptographically random non-zero id.
|
||||||
func NewOrderId() (OrderId, error) {
|
func NewOrderId() (OrderId, error) {
|
||||||
var b [8]byte
|
id, err := uid.New()
|
||||||
if _, err := rand.Read(b[:]); err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("NewOrderId: %w", err)
|
return 0, fmt.Errorf("NewOrderId: %w", err)
|
||||||
}
|
}
|
||||||
u := uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 |
|
return OrderId(id), nil
|
||||||
uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7])
|
|
||||||
if u == 0 {
|
|
||||||
return NewOrderId()
|
|
||||||
}
|
|
||||||
return OrderId(u), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseOrderId parses a base62 string into an OrderId.
|
// ParseOrderId parses a base62 string into an OrderId.
|
||||||
func ParseOrderId(s string) (OrderId, bool) {
|
func ParseOrderId(s string) (OrderId, bool) {
|
||||||
if len(s) == 0 || len(s) > 11 {
|
id, ok := uid.Parse(s)
|
||||||
return 0, false
|
return OrderId(id), ok
|
||||||
}
|
|
||||||
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:])
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
package mcp
|
|
||||||
|
|
||||||
import "encoding/json"
|
|
||||||
|
|
||||||
// JSON-RPC 2.0 envelope types (the wire format MCP rides on). Notifications are
|
|
||||||
// requests with no id and must not receive a response.
|
|
||||||
|
|
||||||
type rpcRequest struct {
|
|
||||||
JSONRPC string `json:"jsonrpc"`
|
|
||||||
ID json.RawMessage `json:"id,omitempty"`
|
|
||||||
Method string `json:"method"`
|
|
||||||
Params json.RawMessage `json:"params,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *rpcRequest) isNotification() bool { return len(r.ID) == 0 }
|
|
||||||
|
|
||||||
type rpcResponse struct {
|
|
||||||
JSONRPC string `json:"jsonrpc"`
|
|
||||||
ID json.RawMessage `json:"id,omitempty"`
|
|
||||||
Result any `json:"result,omitempty"`
|
|
||||||
Error *rpcError `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type rpcError struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Data any `json:"data,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
codeParseError = -32700
|
|
||||||
codeInvalidRequest = -32600
|
|
||||||
codeMethodNotFound = -32601
|
|
||||||
codeInvalidParams = -32602
|
|
||||||
codeInternalError = -32603
|
|
||||||
)
|
|
||||||
|
|
||||||
func result(id json.RawMessage, v any) *rpcResponse {
|
|
||||||
return &rpcResponse{JSONRPC: "2.0", ID: id, Result: v}
|
|
||||||
}
|
|
||||||
|
|
||||||
func errorResponse(id json.RawMessage, code int, msg string) *rpcResponse {
|
|
||||||
return &rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}}
|
|
||||||
}
|
|
||||||
+9
-120
@@ -2,27 +2,24 @@
|
|||||||
// exposing the order grain as tools so an agent can inspect order state and
|
// exposing the order grain as tools so an agent can inspect order state and
|
||||||
// drive the order lifecycle (cancel, fulfill, complete, return, refund, etc.).
|
// drive the order lifecycle (cancel, fulfill, complete, return, refund, etc.).
|
||||||
//
|
//
|
||||||
// Transport is streamable HTTP (JSON-RPC 2.0 over POST). It is mounted by
|
// Transport/dispatch and the schema/result helpers live in platform/mcp; this
|
||||||
// cmd/order under /mcp on the same HTTP server as the order API. Tools map
|
// package only defines the order-specific tools and wires them to the grain
|
||||||
// 1:1 to order grain read/apply operations; no restart is needed because every
|
// pool. Mounted by cmd/order under /mcp.
|
||||||
// tool call goes through the shared grain pool.
|
|
||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||||
|
pmcp "git.k6n.net/mats/platform/mcp"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
serverName = "orders"
|
serverName = "orders"
|
||||||
serverVersion = "0.1.0"
|
serverVersion = "0.1.0"
|
||||||
protocolVersion = "2025-06-18"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// OrderApplier is the minimal grain-pool interface the order MCP needs: read an
|
// 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.
|
// Server is the MCP edge over the order grain pool.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
applier OrderApplier
|
applier OrderApplier
|
||||||
tools []tool
|
mcp *pmcp.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds an MCP server exposing the order grain as tools.
|
// New builds an MCP server exposing the order grain as tools.
|
||||||
func New(applier OrderApplier) *Server {
|
func New(applier OrderApplier) *Server {
|
||||||
s := &Server{applier: applier}
|
s := &Server{applier: applier}
|
||||||
s.tools = s.buildTools()
|
s.mcp = pmcp.NewServer(pmcp.Info{Name: serverName, Version: serverVersion}, s.buildTools()...)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler returns the streamable-HTTP handler to mount (e.g. at /mcp).
|
// Handler returns the streamable-HTTP handler to mount (e.g. at /mcp).
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler { return s.mcp.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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
t.Fatalf("%s: status %d", name, rec.Code)
|
||||||
}
|
}
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Result rawResult `json:"result"`
|
Result rawResult `json:"result"`
|
||||||
Error *struct{ Message string } `json:"error"`
|
Error *struct{ Message string } `json:"error"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
|||||||
+71
-210
@@ -4,108 +4,34 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/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,
|
// buildTools returns the order's MCP tools (transport/dispatch live in platform/mcp).
|
||||||
// and the handler that runs it.
|
func (s *Server) buildTools() []pmcp.Tool {
|
||||||
type tool struct {
|
return []pmcp.Tool{
|
||||||
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{
|
|
||||||
{
|
{
|
||||||
Name: "get_order",
|
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.",
|
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{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"orderId": str("the base62 order id"),
|
"orderId": pmcp.String("the base62 order id"),
|
||||||
}, []string{"orderId"}),
|
}, []string{"orderId"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
OrderID string `json:"orderId"`
|
OrderID string `json:"orderId"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := order.ParseOrderId(a.OrderID)
|
id, ok := order.ParseOrderId(a.OrderID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get order: %w", err)
|
return nil, fmt.Errorf("get order: %w", err)
|
||||||
}
|
}
|
||||||
@@ -118,21 +44,21 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "get_order_status",
|
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.",
|
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{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"orderId": str("the base62 order id"),
|
"orderId": pmcp.String("the base62 order id"),
|
||||||
}, []string{"orderId"}),
|
}, []string{"orderId"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
OrderID string `json:"orderId"`
|
OrderID string `json:"orderId"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := order.ParseOrderId(a.OrderID)
|
id, ok := order.ParseOrderId(a.OrderID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get order: %w", err)
|
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 nil, fmt.Errorf("order %q not found", a.OrderID)
|
||||||
}
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"orderId": a.OrderID,
|
"orderId": a.OrderID,
|
||||||
"status": string(g.Status),
|
"status": string(g.Status),
|
||||||
"nextTransitions": validTransitions(g.Status),
|
"nextTransitions": validTransitions(g.Status),
|
||||||
}, nil
|
}, nil
|
||||||
},
|
},
|
||||||
@@ -149,21 +75,21 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "get_order_lines",
|
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.",
|
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{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"orderId": str("the base62 order id"),
|
"orderId": pmcp.String("the base62 order id"),
|
||||||
}, []string{"orderId"}),
|
}, []string{"orderId"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
OrderID string `json:"orderId"`
|
OrderID string `json:"orderId"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := order.ParseOrderId(a.OrderID)
|
id, ok := order.ParseOrderId(a.OrderID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get order: %w", err)
|
return nil, fmt.Errorf("get order: %w", err)
|
||||||
}
|
}
|
||||||
@@ -176,21 +102,21 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "get_order_payments",
|
Name: "get_order_payments",
|
||||||
Description: "List all payment records on an order: provider, authorized/captured/refunded amounts, authorization and capture references.",
|
Description: "List all payment records on an order: provider, authorized/captured/refunded amounts, authorization and capture references.",
|
||||||
InputSchema: object(props{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"orderId": str("the base62 order id"),
|
"orderId": pmcp.String("the base62 order id"),
|
||||||
}, []string{"orderId"}),
|
}, []string{"orderId"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
OrderID string `json:"orderId"`
|
OrderID string `json:"orderId"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := order.ParseOrderId(a.OrderID)
|
id, ok := order.ParseOrderId(a.OrderID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get order: %w", err)
|
return nil, fmt.Errorf("get order: %w", err)
|
||||||
}
|
}
|
||||||
@@ -203,21 +129,21 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "get_order_fulfillments",
|
Name: "get_order_fulfillments",
|
||||||
Description: "List all fulfillments (shipments) on an order: carrier, tracking number/URI, shipped lines with quantities, and creation date.",
|
Description: "List all fulfillments (shipments) on an order: carrier, tracking number/URI, shipped lines with quantities, and creation date.",
|
||||||
InputSchema: object(props{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"orderId": str("the base62 order id"),
|
"orderId": pmcp.String("the base62 order id"),
|
||||||
}, []string{"orderId"}),
|
}, []string{"orderId"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
OrderID string `json:"orderId"`
|
OrderID string `json:"orderId"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := order.ParseOrderId(a.OrderID)
|
id, ok := order.ParseOrderId(a.OrderID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get order: %w", err)
|
return nil, fmt.Errorf("get order: %w", err)
|
||||||
}
|
}
|
||||||
@@ -230,21 +156,21 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "get_order_returns",
|
Name: "get_order_returns",
|
||||||
Description: "List all return requests (RMAs) on an order: reason, returned lines, and request date.",
|
Description: "List all return requests (RMAs) on an order: reason, returned lines, and request date.",
|
||||||
InputSchema: object(props{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"orderId": str("the base62 order id"),
|
"orderId": pmcp.String("the base62 order id"),
|
||||||
}, []string{"orderId"}),
|
}, []string{"orderId"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
OrderID string `json:"orderId"`
|
OrderID string `json:"orderId"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := order.ParseOrderId(a.OrderID)
|
id, ok := order.ParseOrderId(a.OrderID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get order: %w", err)
|
return nil, fmt.Errorf("get order: %w", err)
|
||||||
}
|
}
|
||||||
@@ -257,23 +183,23 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "cancel_order",
|
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.",
|
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{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"orderId": str("the base62 order id"),
|
"orderId": pmcp.String("the base62 order id"),
|
||||||
"reason": str("optional reason for cancellation"),
|
"reason": pmcp.String("optional reason for cancellation"),
|
||||||
}, []string{"orderId"}),
|
}, []string{"orderId"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
OrderID string `json:"orderId"`
|
OrderID string `json:"orderId"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := order.ParseOrderId(a.OrderID)
|
id, ok := order.ParseOrderId(a.OrderID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
|
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,
|
Reason: a.Reason,
|
||||||
AtMs: time.Now().UnixMilli(),
|
AtMs: time.Now().UnixMilli(),
|
||||||
})
|
})
|
||||||
@@ -291,14 +217,14 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "create_fulfillment",
|
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.",
|
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{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"orderId": str("the base62 order id"),
|
"orderId": pmcp.String("the base62 order id"),
|
||||||
"carrier": str("the shipping carrier name (e.g. PostNord, DHL)"),
|
"carrier": pmcp.String("the shipping carrier name (e.g. PostNord, DHL)"),
|
||||||
"trackingNumber": str("optional tracking number"),
|
"trackingNumber": pmcp.String("optional tracking number"),
|
||||||
"trackingUri": str("optional tracking URL"),
|
"trackingUri": pmcp.String("optional tracking URL"),
|
||||||
"lines": objArray("lines to fulfill, each with reference (string) and quantity (integer)"),
|
"lines": pmcp.ObjectArray("lines to fulfill, each with reference (string) and quantity (integer)"),
|
||||||
}, []string{"orderId", "lines"}),
|
}, []string{"orderId", "lines"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
OrderID string `json:"orderId"`
|
OrderID string `json:"orderId"`
|
||||||
Carrier string `json:"carrier"`
|
Carrier string `json:"carrier"`
|
||||||
@@ -309,7 +235,7 @@ func (s *Server) buildTools() []tool {
|
|||||||
Quantity int32 `json:"quantity"`
|
Quantity int32 `json:"quantity"`
|
||||||
} `json:"lines"`
|
} `json:"lines"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := order.ParseOrderId(a.OrderID)
|
id, ok := order.ParseOrderId(a.OrderID)
|
||||||
@@ -321,7 +247,7 @@ func (s *Server) buildTools() []tool {
|
|||||||
for i, l := range a.Lines {
|
for i, l := range a.Lines {
|
||||||
fulfillmentLines[i] = &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity}
|
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(),
|
Id: "f_" + fid.String(),
|
||||||
Carrier: a.Carrier,
|
Carrier: a.Carrier,
|
||||||
TrackingNumber: a.TrackingNumber,
|
TrackingNumber: a.TrackingNumber,
|
||||||
@@ -343,21 +269,21 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "complete_order",
|
Name: "complete_order",
|
||||||
Description: "Mark a fulfilled order as completed. Only allowed when the order is in 'fulfilled' status. Returns the updated order.",
|
Description: "Mark a fulfilled order as completed. Only allowed when the order is in 'fulfilled' status. Returns the updated order.",
|
||||||
InputSchema: object(props{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"orderId": str("the base62 order id"),
|
"orderId": pmcp.String("the base62 order id"),
|
||||||
}, []string{"orderId"}),
|
}, []string{"orderId"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
OrderID string `json:"orderId"`
|
OrderID string `json:"orderId"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := order.ParseOrderId(a.OrderID)
|
id, ok := order.ParseOrderId(a.OrderID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid order id %q", a.OrderID)
|
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(),
|
AtMs: time.Now().UnixMilli(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -374,12 +300,12 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "request_return",
|
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.",
|
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{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"orderId": str("the base62 order id"),
|
"orderId": pmcp.String("the base62 order id"),
|
||||||
"reason": str("the reason for the return"),
|
"reason": pmcp.String("the reason for the return"),
|
||||||
"lines": objArray("lines being returned, each with reference (string) and quantity (integer)"),
|
"lines": pmcp.ObjectArray("lines being returned, each with reference (string) and quantity (integer)"),
|
||||||
}, []string{"orderId", "reason", "lines"}),
|
}, []string{"orderId", "reason", "lines"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
OrderID string `json:"orderId"`
|
OrderID string `json:"orderId"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
@@ -388,7 +314,7 @@ func (s *Server) buildTools() []tool {
|
|||||||
Quantity int32 `json:"quantity"`
|
Quantity int32 `json:"quantity"`
|
||||||
} `json:"lines"`
|
} `json:"lines"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := order.ParseOrderId(a.OrderID)
|
id, ok := order.ParseOrderId(a.OrderID)
|
||||||
@@ -400,7 +326,7 @@ func (s *Server) buildTools() []tool {
|
|||||||
for i, l := range a.Lines {
|
for i, l := range a.Lines {
|
||||||
returnLines[i] = &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity}
|
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(),
|
Id: "r_" + rid.String(),
|
||||||
Reason: a.Reason,
|
Reason: a.Reason,
|
||||||
Lines: returnLines,
|
Lines: returnLines,
|
||||||
@@ -420,16 +346,16 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "issue_refund",
|
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.",
|
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{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"orderId": str("the base62 order id"),
|
"orderId": pmcp.String("the base62 order id"),
|
||||||
"amount": integer("refund amount in minor units (öre); omit for full remaining captured amount"),
|
"amount": pmcp.Integer("refund amount in minor units (öre); omit for full remaining captured amount"),
|
||||||
}, []string{"orderId"}),
|
}, []string{"orderId"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
OrderID string `json:"orderId"`
|
OrderID string `json:"orderId"`
|
||||||
Amount int64 `json:"amount"`
|
Amount int64 `json:"amount"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
id, ok := order.ParseOrderId(a.OrderID)
|
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 amount is 0 (omitted), default to full remaining captured amount.
|
||||||
if a.Amount <= 0 {
|
if a.Amount <= 0 {
|
||||||
g, err := s.applier.Get(context.Background(), uint64(id))
|
g, err := s.applier.Get(ctx, uint64(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get order for refund: %w", err)
|
return nil, fmt.Errorf("get order for refund: %w", err)
|
||||||
}
|
}
|
||||||
if g.Status == order.StatusNew {
|
if g.Status == order.StatusNew {
|
||||||
return nil, fmt.Errorf("order %q not found", a.OrderID)
|
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 {
|
if a.Amount <= 0 {
|
||||||
return nil, fmt.Errorf("refund amount must be positive")
|
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",
|
Provider: "mcp",
|
||||||
Amount: a.Amount,
|
Amount: a.Amount,
|
||||||
Reference: "ref-mcp-" + a.OrderID,
|
Reference: "ref-mcp-" + a.OrderID,
|
||||||
@@ -494,68 +420,3 @@ func validTransitions(s order.Status) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- result + schema helpers -----------------------------------------------
|
// ---- result + schema helpers -----------------------------------------------
|
||||||
|
|
||||||
func toolText(v any) map[string]any {
|
|
||||||
b, err := json.Marshal(v)
|
|
||||||
if err != nil {
|
|
||||||
return toolError(err)
|
|
||||||
}
|
|
||||||
return map[string]any{
|
|
||||||
"content": []map[string]any{{"type": "text", "text": string(b)}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toolError(err error) map[string]any {
|
|
||||||
return map[string]any{
|
|
||||||
"isError": true,
|
|
||||||
"content": []map[string]any{{"type": "text", "text": err.Error()}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// decode unmarshals tool arguments, tolerating empty/absent arguments.
|
|
||||||
func decode(args json.RawMessage, v any) error {
|
|
||||||
if len(args) == 0 || string(args) == "null" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(args, v); err != nil {
|
|
||||||
return fmt.Errorf("invalid arguments: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type props map[string]json.RawMessage
|
|
||||||
|
|
||||||
func object(p props, required []string) json.RawMessage {
|
|
||||||
m := map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": p,
|
|
||||||
}
|
|
||||||
if len(required) > 0 {
|
|
||||||
m["required"] = required
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(m)
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func str(desc string) json.RawMessage { return scalar("string", desc) }
|
|
||||||
func integer(d string) json.RawMessage { return scalar("integer", d) }
|
|
||||||
func obj(desc string) json.RawMessage { return scalar("object", desc) }
|
|
||||||
|
|
||||||
func scalar(typ, desc string) json.RawMessage {
|
|
||||||
b, _ := json.Marshal(map[string]any{"type": typ, "description": desc})
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func objArray(desc string) json.RawMessage {
|
|
||||||
b, _ := json.Marshal(map[string]any{
|
|
||||||
"type": "array", "description": desc, "items": map[string]string{"type": "object"},
|
|
||||||
})
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func stringArray(desc string) json.RawMessage {
|
|
||||||
b, _ := json.Marshal(map[string]any{
|
|
||||||
"type": "array", "description": desc, "items": map[string]string{"type": "string"},
|
|
||||||
})
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|||||||
+22
-20
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
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
|
// 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.Currency = m.GetCurrency()
|
||||||
o.Locale = m.GetLocale()
|
o.Locale = m.GetLocale()
|
||||||
o.Country = m.GetCountry()
|
o.Country = m.GetCountry()
|
||||||
o.TotalAmount = m.GetTotalAmount()
|
o.TotalAmount = money.Cents(m.GetTotalAmount())
|
||||||
o.TotalTax = m.GetTotalTax()
|
o.TotalTax = money.Cents(m.GetTotalTax())
|
||||||
o.CustomerEmail = m.GetCustomerEmail()
|
o.CustomerEmail = m.GetCustomerEmail()
|
||||||
o.CustomerName = m.GetCustomerName()
|
o.CustomerName = m.GetCustomerName()
|
||||||
if b := m.GetBillingAddress(); len(b) > 0 {
|
if b := m.GetBillingAddress(); len(b) > 0 {
|
||||||
@@ -54,10 +55,11 @@ func HandlePlaceOrder(o *OrderGrain, m *messages.PlaceOrder) error {
|
|||||||
Sku: l.GetSku(),
|
Sku: l.GetSku(),
|
||||||
Name: l.GetName(),
|
Name: l.GetName(),
|
||||||
Quantity: int(l.GetQuantity()),
|
Quantity: int(l.GetQuantity()),
|
||||||
UnitPrice: l.GetUnitPrice(),
|
UnitPrice: money.Cents(l.GetUnitPrice()),
|
||||||
TaxRate: int(l.GetTaxRate()),
|
TaxRate: int(l.GetTaxRate()),
|
||||||
TotalAmount: l.GetTotalAmount(),
|
TotalAmount: money.Cents(l.GetTotalAmount()),
|
||||||
TotalTax: l.GetTotalTax(),
|
TotalTax: money.Cents(l.GetTotalTax()),
|
||||||
|
Location: l.GetLocation(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
o.Status = StatusPending
|
o.Status = StatusPending
|
||||||
@@ -74,7 +76,7 @@ func HandleAuthorizePayment(o *OrderGrain, m *messages.AuthorizePayment) error {
|
|||||||
at := msToString(m.GetAtMs())
|
at := msToString(m.GetAtMs())
|
||||||
o.Payments = append(o.Payments, &Payment{
|
o.Payments = append(o.Payments, &Payment{
|
||||||
Provider: m.GetProvider(),
|
Provider: m.GetProvider(),
|
||||||
Authorized: m.GetAmount(),
|
Authorized: money.Cents(m.GetAmount()),
|
||||||
AuthRef: m.GetReference(),
|
AuthRef: m.GetReference(),
|
||||||
AuthorizedAt: at,
|
AuthorizedAt: at,
|
||||||
})
|
})
|
||||||
@@ -93,10 +95,10 @@ func HandleCapturePayment(o *OrderGrain, m *messages.CapturePayment) error {
|
|||||||
return fmt.Errorf("order: capture has no matching authorized payment")
|
return fmt.Errorf("order: capture has no matching authorized payment")
|
||||||
}
|
}
|
||||||
at := msToString(m.GetAtMs())
|
at := msToString(m.GetAtMs())
|
||||||
p.Captured += m.GetAmount()
|
p.Captured += money.Cents(m.GetAmount())
|
||||||
p.CaptureRef = m.GetReference()
|
p.CaptureRef = m.GetReference()
|
||||||
p.CapturedAt = at
|
p.CapturedAt = at
|
||||||
o.CapturedAmount += m.GetAmount()
|
o.CapturedAmount += money.Cents(m.GetAmount())
|
||||||
o.Status = StatusCaptured
|
o.Status = StatusCaptured
|
||||||
o.touch(at)
|
o.touch(at)
|
||||||
return nil
|
return nil
|
||||||
@@ -208,21 +210,21 @@ func HandleIssueRefund(o *OrderGrain, m *messages.IssueRefund) error {
|
|||||||
if m.GetAmount() <= 0 {
|
if m.GetAmount() <= 0 {
|
||||||
return fmt.Errorf("order: refund amount must be positive")
|
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")
|
return fmt.Errorf("order: refund exceeds captured amount")
|
||||||
}
|
}
|
||||||
at := msToString(m.GetAtMs())
|
at := msToString(m.GetAtMs())
|
||||||
o.Refunds = append(o.Refunds, Refund{
|
o.Refunds = append(o.Refunds, Refund{
|
||||||
Provider: m.GetProvider(),
|
Provider: m.GetProvider(),
|
||||||
Amount: m.GetAmount(),
|
Amount: money.Cents(m.GetAmount()),
|
||||||
Reference: m.GetReference(),
|
Reference: m.GetReference(),
|
||||||
ReturnID: m.GetReturnId(),
|
ReturnID: m.GetReturnId(),
|
||||||
IssuedAt: at,
|
IssuedAt: at,
|
||||||
})
|
})
|
||||||
o.RefundedAmount += m.GetAmount()
|
o.RefundedAmount += money.Cents(m.GetAmount())
|
||||||
for _, p := range o.Payments {
|
for _, p := range o.Payments {
|
||||||
if p.Provider == m.GetProvider() {
|
if p.Provider == m.GetProvider() {
|
||||||
p.Refunded += m.GetAmount()
|
p.Refunded += money.Cents(m.GetAmount())
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,10 +266,10 @@ func HandleRequestExchange(o *OrderGrain, m *messages.RequestExchange) error {
|
|||||||
Sku: nl.GetSku(),
|
Sku: nl.GetSku(),
|
||||||
Name: nl.GetName(),
|
Name: nl.GetName(),
|
||||||
Quantity: int(nl.GetQuantity()),
|
Quantity: int(nl.GetQuantity()),
|
||||||
UnitPrice: nl.GetUnitPrice(),
|
UnitPrice: money.Cents(nl.GetUnitPrice()),
|
||||||
TaxRate: int(nl.GetTaxRate()),
|
TaxRate: int(nl.GetTaxRate()),
|
||||||
TotalAmount: nl.GetTotalAmount(),
|
TotalAmount: money.Cents(nl.GetTotalAmount()),
|
||||||
TotalTax: nl.GetTotalTax(),
|
TotalTax: money.Cents(nl.GetTotalTax()),
|
||||||
}
|
}
|
||||||
ex.NewLines = append(ex.NewLines, line)
|
ex.NewLines = append(ex.NewLines, line)
|
||||||
|
|
||||||
@@ -296,14 +298,14 @@ func HandleEditOrderDetails(o *OrderGrain, m *messages.EditOrderDetails) error {
|
|||||||
o.BillingAddress = append([]byte(nil), b...)
|
o.BillingAddress = append([]byte(nil), b...)
|
||||||
}
|
}
|
||||||
|
|
||||||
if sp := m.GetShippingPrice(); sp > 0 {
|
if sp := money.Cents(m.GetShippingPrice()); sp > 0 {
|
||||||
found := false
|
found := false
|
||||||
var oldTotal int64
|
var oldTotal money.Cents
|
||||||
for i := range o.Lines {
|
for i := range o.Lines {
|
||||||
if o.Lines[i].Name == "Delivery" {
|
if o.Lines[i].Name == "Delivery" {
|
||||||
oldTotal = o.Lines[i].TotalAmount
|
oldTotal = o.Lines[i].TotalAmount
|
||||||
o.Lines[i].UnitPrice = sp
|
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.Lines[i].TotalTax = ComputeTax(o.Lines[i].TotalAmount, o.Lines[i].TaxRate)
|
||||||
o.TotalAmount = o.TotalAmount - oldTotal + o.Lines[i].TotalAmount
|
o.TotalAmount = o.TotalAmount - oldTotal + o.Lines[i].TotalAmount
|
||||||
found = true
|
found = true
|
||||||
@@ -317,9 +319,9 @@ func HandleEditOrderDetails(o *OrderGrain, m *messages.EditOrderDetails) error {
|
|||||||
Name: "Delivery",
|
Name: "Delivery",
|
||||||
Quantity: 1,
|
Quantity: 1,
|
||||||
UnitPrice: sp,
|
UnitPrice: sp,
|
||||||
TaxRate: 25,
|
TaxRate: 2500, // 25% in basis points
|
||||||
TotalAmount: sp,
|
TotalAmount: sp,
|
||||||
TotalTax: ComputeTax(sp, 25),
|
TotalTax: ComputeTax(sp, 2500),
|
||||||
}
|
}
|
||||||
o.Lines = append(o.Lines, line)
|
o.Lines = append(o.Lines, line)
|
||||||
o.TotalAmount += sp
|
o.TotalAmount += sp
|
||||||
|
|||||||
+22
-17
@@ -9,6 +9,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/platform/money"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Status is the order lifecycle state.
|
// Status is the order lifecycle state.
|
||||||
@@ -57,21 +59,24 @@ type Line struct {
|
|||||||
Sku string `json:"sku"`
|
Sku string `json:"sku"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Quantity int `json:"quantity"`
|
Quantity int `json:"quantity"`
|
||||||
UnitPrice int64 `json:"unitPrice"`
|
UnitPrice money.Cents `json:"unitPrice"`
|
||||||
TaxRate int `json:"taxRate"`
|
TaxRate int `json:"taxRate"`
|
||||||
TotalAmount int64 `json:"totalAmount"`
|
TotalAmount money.Cents `json:"totalAmount"`
|
||||||
TotalTax int64 `json:"totalTax"`
|
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 tracks how many units of this line have shipped.
|
||||||
Fulfilled int `json:"fulfilled"`
|
Fulfilled int `json:"fulfilled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payment records one authorization/capture against a provider.
|
// Payment records one authorization/capture against a provider.
|
||||||
type Payment struct {
|
type Payment struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Authorized int64 `json:"authorized"`
|
Authorized money.Cents `json:"authorized"`
|
||||||
Captured int64 `json:"captured"`
|
Captured money.Cents `json:"captured"`
|
||||||
Refunded int64 `json:"refunded"`
|
Refunded money.Cents `json:"refunded"`
|
||||||
AuthRef string `json:"authRef,omitempty"`
|
AuthRef string `json:"authRef,omitempty"`
|
||||||
CaptureRef string `json:"captureRef,omitempty"`
|
CaptureRef string `json:"captureRef,omitempty"`
|
||||||
AuthorizedAt string `json:"authorizedAt,omitempty"`
|
AuthorizedAt string `json:"authorizedAt,omitempty"`
|
||||||
CapturedAt string `json:"capturedAt,omitempty"`
|
CapturedAt string `json:"capturedAt,omitempty"`
|
||||||
@@ -113,9 +118,9 @@ type Exchange struct {
|
|||||||
|
|
||||||
// Refund records a refund issued against the order.
|
// Refund records a refund issued against the order.
|
||||||
type Refund struct {
|
type Refund struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Amount int64 `json:"amount"`
|
Amount money.Cents `json:"amount"`
|
||||||
Reference string `json:"reference,omitempty"`
|
Reference string `json:"reference,omitempty"`
|
||||||
ReturnID string `json:"returnId,omitempty"`
|
ReturnID string `json:"returnId,omitempty"`
|
||||||
IssuedAt string `json:"issuedAt,omitempty"`
|
IssuedAt string `json:"issuedAt,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -135,9 +140,9 @@ type OrderGrain struct {
|
|||||||
Locale string `json:"locale,omitempty"`
|
Locale string `json:"locale,omitempty"`
|
||||||
Country string `json:"country,omitempty"`
|
Country string `json:"country,omitempty"`
|
||||||
|
|
||||||
TotalAmount int64 `json:"totalAmount"`
|
TotalAmount money.Cents `json:"totalAmount"`
|
||||||
TotalTax int64 `json:"totalTax"`
|
TotalTax money.Cents `json:"totalTax"`
|
||||||
Lines []Line `json:"lines,omitempty"`
|
Lines []Line `json:"lines,omitempty"`
|
||||||
|
|
||||||
CustomerEmail string `json:"customerEmail,omitempty"`
|
CustomerEmail string `json:"customerEmail,omitempty"`
|
||||||
CustomerName string `json:"customerName,omitempty"`
|
CustomerName string `json:"customerName,omitempty"`
|
||||||
@@ -151,8 +156,8 @@ type OrderGrain struct {
|
|||||||
Refunds []Refund `json:"refunds,omitempty"`
|
Refunds []Refund `json:"refunds,omitempty"`
|
||||||
|
|
||||||
|
|
||||||
CapturedAmount int64 `json:"capturedAmount"`
|
CapturedAmount money.Cents `json:"capturedAmount"`
|
||||||
RefundedAmount int64 `json:"refundedAmount"`
|
RefundedAmount money.Cents `json:"refundedAmount"`
|
||||||
|
|
||||||
PlacedAt string `json:"placedAt,omitempty"`
|
PlacedAt string `json:"placedAt,omitempty"`
|
||||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||||
|
|||||||
+19
-14
@@ -1,30 +1,35 @@
|
|||||||
package order
|
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.
|
// TaxProvider computes taxes for orders and line items.
|
||||||
// The canonical definition is in pkg/cart; this is a re-export for
|
// The canonical definition is in platform/tax; this is a type alias
|
||||||
// backward compatibility.
|
// for backward compatibility.
|
||||||
type TaxProvider = cart.TaxProvider
|
type TaxProvider = tax.Provider
|
||||||
|
|
||||||
// ComputeTax is the shared tax formula: tax = total x rate / (100 + rate).
|
// ComputeTax is the shared ex-VAT-first tax formula. rateBp is basis points
|
||||||
// Re-exported from pkg/cart for backward compatibility.
|
// (2500 = 25%). Re-exported from platform/tax for backward compatibility.
|
||||||
func ComputeTax(total int64, taxRate int) int64 {
|
func ComputeTax(total money.Cents, rateBp int) money.Cents {
|
||||||
return cart.ComputeTax(total, taxRate)
|
return money.Cents(tax.Compute(total.Int64(), rateBp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// NordicTaxProvider implements TaxProvider for the Nordic countries.
|
// NordicTaxProvider implements TaxProvider for the Nordic countries.
|
||||||
// Re-exported from pkg/cart for backward compatibility.
|
// Re-exported from platform/tax for backward compatibility.
|
||||||
type NordicTaxProvider = cart.NordicTaxProvider
|
type NordicTaxProvider = tax.Nordic
|
||||||
|
|
||||||
|
// NewNordicTaxProvider returns a new NordicTaxProvider.
|
||||||
func NewNordicTaxProvider(defaultCountry string) *NordicTaxProvider {
|
func NewNordicTaxProvider(defaultCountry string) *NordicTaxProvider {
|
||||||
return cart.NewNordicTaxProvider(defaultCountry)
|
return tax.NewNordic(defaultCountry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StaticTaxProvider is a stateless TaxProvider with no country awareness.
|
// StaticTaxProvider is a stateless TaxProvider with no country awareness.
|
||||||
// Re-exported from pkg/cart for backward compatibility.
|
// Re-exported from platform/tax for backward compatibility.
|
||||||
type StaticTaxProvider = cart.StaticTaxProvider
|
type StaticTaxProvider = tax.Static
|
||||||
|
|
||||||
|
// NewStaticTaxProvider returns a new StaticTaxProvider.
|
||||||
func NewStaticTaxProvider() *StaticTaxProvider {
|
func NewStaticTaxProvider() *StaticTaxProvider {
|
||||||
return cart.NewStaticTaxProvider()
|
return tax.NewStatic()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ func TestReExports(t *testing.T) {
|
|||||||
if p.Name() != "nordic" {
|
if p.Name() != "nordic" {
|
||||||
t.Errorf("Name = %q", p.Name())
|
t.Errorf("Name = %q", p.Name())
|
||||||
}
|
}
|
||||||
if got := p.DefaultTaxRate("SE"); got != 25 {
|
if got := p.DefaultTaxRate("SE"); got != 2500 {
|
||||||
t.Errorf("DefaultTaxRate(SE) = %d, want 25", got)
|
t.Errorf("DefaultTaxRate(SE) = %d, want 2500", got)
|
||||||
}
|
}
|
||||||
if got := ComputeTax(1250, 25); got != 250 {
|
if got := ComputeTax(1250, 2500); got != 250 {
|
||||||
t.Errorf("ComputeTax(1250, 25) = %d, want 250", got)
|
t.Errorf("ComputeTax(1250, 2500) = %d, want 250", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StaticTaxProvider
|
// StaticTaxProvider
|
||||||
|
|||||||
+10
-52
@@ -1,70 +1,28 @@
|
|||||||
package profile
|
package profile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/platform/uid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProfileId is a 64-bit profile identifier with a compact base62 string form,
|
// 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 uid.ID
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// String returns the canonical base62 encoding.
|
// 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.
|
// NewProfileId generates a cryptographically random non-zero id.
|
||||||
func NewProfileId() (ProfileId, error) {
|
func NewProfileId() (ProfileId, error) {
|
||||||
var b [8]byte
|
id, err := uid.New()
|
||||||
if _, err := rand.Read(b[:]); err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("NewProfileId: %w", err)
|
return 0, fmt.Errorf("NewProfileId: %w", err)
|
||||||
}
|
}
|
||||||
u := uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 |
|
return ProfileId(id), nil
|
||||||
uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7])
|
|
||||||
if u == 0 {
|
|
||||||
return NewProfileId()
|
|
||||||
}
|
|
||||||
return ProfileId(u), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseProfileId parses a base62 string into a ProfileId.
|
// ParseProfileId parses a base62 string into a ProfileId.
|
||||||
func ParseProfileId(s string) (ProfileId, bool) {
|
func ParseProfileId(s string) (ProfileId, bool) {
|
||||||
if len(s) == 0 || len(s) > 11 {
|
id, ok := uid.Parse(s)
|
||||||
return 0, false
|
return ProfileId(id), ok
|
||||||
}
|
|
||||||
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:])
|
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-19
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"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
|
currentPct, _ := selectTier(tiers, total) // 0 when below the first tier
|
||||||
|
|
||||||
// Find the nearest tier whose floor is still above the current total.
|
// Find the nearest tier whose floor is still above the current total.
|
||||||
nextMin := int64(0)
|
nextMin := money.Cents(0)
|
||||||
nextPct := 0.0
|
nextPct := 0.0
|
||||||
found := false
|
found := false
|
||||||
for _, t := range tiers {
|
for _, t := range tiers {
|
||||||
@@ -177,8 +178,10 @@ func (tieredDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"remaining": nextMin - total,
|
// Untyped JSON-presentation map: emit raw minor-unit int64 so consumers
|
||||||
"threshold": nextMin,
|
// (and JSON) see a plain number, not money.Cents.
|
||||||
|
"remaining": (nextMin - total).Int64(),
|
||||||
|
"threshold": nextMin.Int64(),
|
||||||
"currentPercent": currentPct,
|
"currentPercent": currentPct,
|
||||||
"nextPercent": nextPct,
|
"nextPercent": nextPct,
|
||||||
}, true
|
}, true
|
||||||
@@ -201,13 +204,13 @@ func (s *PromotionService) cartTotalProgress(rule PromotionRule, ctx *PromotionE
|
|||||||
if remaining <= 0 || !s.withinReach(rule, ctx, threshold) {
|
if remaining <= 0 || !s.withinReach(rule, ctx, threshold) {
|
||||||
return nil, false
|
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
|
// 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
|
// 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.
|
// 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 := *ctx
|
||||||
probe.CartTotalIncVat = atTotal
|
probe.CartTotalIncVat = atTotal
|
||||||
return s.EvaluateRule(rule, &probe).Applicable
|
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
|
// cartTotalThreshold finds the lowest cart-total floor a rule requires (a
|
||||||
// cart_total >= / > condition), walking nested groups. Returns false when the
|
// cart_total >= / > condition), walking nested groups. Returns false when the
|
||||||
// rule has no such condition.
|
// rule has no such condition.
|
||||||
func cartTotalThreshold(rule PromotionRule) (int64, bool) {
|
func cartTotalThreshold(rule PromotionRule) (money.Cents, bool) {
|
||||||
var threshold int64
|
var threshold int64
|
||||||
found := false
|
found := false
|
||||||
var walk func(conds Conditions)
|
var walk func(conds Conditions)
|
||||||
@@ -248,7 +251,7 @@ func cartTotalThreshold(rule PromotionRule) (int64, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
walk(rule.Conditions)
|
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
|
// when a total lands exactly on a shared boundary the higher tier wins, since
|
||||||
// selectTier keeps the last match while scanning in order.
|
// selectTier keeps the last match while scanning in order.
|
||||||
type DiscountTier struct {
|
type DiscountTier struct {
|
||||||
MinTotal int64
|
MinTotal money.Cents
|
||||||
MaxTotal int64
|
MaxTotal money.Cents
|
||||||
Percent float64
|
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.
|
// 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
|
// Scans in declared order and keeps the last match so shared boundaries favour
|
||||||
// the higher tier.
|
// the higher tier.
|
||||||
func selectTier(tiers []DiscountTier, total int64) (float64, bool) {
|
func selectTier(tiers []DiscountTier, total money.Cents) (float64, bool) {
|
||||||
pct := 0.0
|
pct := 0.0
|
||||||
found := false
|
found := false
|
||||||
for _, t := range tiers {
|
for _, t := range tiers {
|
||||||
@@ -327,8 +330,8 @@ func scalePrice(p *cart.Price, pct float64) *cart.Price {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func scale(v int64, pct float64) int64 {
|
func scale(v money.Cents, pct float64) money.Cents {
|
||||||
return int64(math.Round(float64(v) * pct / 100.0))
|
return money.Cents(math.Round(float64(v.Int64()) * pct / 100.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseTiers reads the "tiers" array out of an Action.Config. Because configs
|
// parseTiers reads the "tiers" array out of an Action.Config. Because configs
|
||||||
@@ -347,8 +350,8 @@ func parseTiers(config map[string]interface{}) []DiscountTier {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tiers = append(tiers, DiscountTier{
|
tiers = append(tiers, DiscountTier{
|
||||||
MinTotal: int64(firstNum(m, "minTotal", "min_total")),
|
MinTotal: money.Cents(int64(firstNum(m, "minTotal", "min_total"))),
|
||||||
MaxTotal: int64(firstNum(m, "maxTotal", "max_total")),
|
MaxTotal: money.Cents(int64(firstNum(m, "maxTotal", "max_total"))),
|
||||||
Percent: firstNum(m, "percent", "discount_percent"),
|
Percent: firstNum(m, "percent", "discount_percent"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -381,7 +384,7 @@ func (buyXGetYEffect) Type() ActionType { return ActionBuyXGetY }
|
|||||||
|
|
||||||
type unitItem struct {
|
type unitItem struct {
|
||||||
item *cart.CartItem
|
item *cart.CartItem
|
||||||
price int64
|
price money.Cents
|
||||||
}
|
}
|
||||||
|
|
||||||
func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*cart.Price, bool) {
|
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
|
var bundleDiscount *cart.Price
|
||||||
switch a.BundleConfig.Pricing.Type {
|
switch a.BundleConfig.Pricing.Type {
|
||||||
case "fixed_price":
|
case "fixed_price":
|
||||||
targetPriceOre := int64(math.Round(a.BundleConfig.Pricing.Value * 100))
|
targetPriceOre := money.FromFloatMajor(a.BundleConfig.Pricing.Value)
|
||||||
if bundlePrice.IncVat > targetPriceOre {
|
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)
|
bundleDiscount = scalePrice(bundlePrice, pct)
|
||||||
}
|
}
|
||||||
case "percentage_discount":
|
case "percentage_discount":
|
||||||
bundleDiscount = scalePrice(bundlePrice, a.BundleConfig.Pricing.Value)
|
bundleDiscount = scalePrice(bundlePrice, a.BundleConfig.Pricing.Value)
|
||||||
case "fixed_discount":
|
case "fixed_discount":
|
||||||
discountOre := int64(math.Round(a.BundleConfig.Pricing.Value * 100))
|
discountOre := money.FromFloatMajor(a.BundleConfig.Pricing.Value)
|
||||||
pct := float64(discountOre) / float64(bundlePrice.IncVat) * 100
|
pct := float64(discountOre.Int64()) / float64(bundlePrice.IncVat.Int64()) * 100
|
||||||
bundleDiscount = scalePrice(bundlePrice, pct)
|
bundleDiscount = scalePrice(bundlePrice, pct)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"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) }
|
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 {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
total int64
|
total int64
|
||||||
wantDiscount int64
|
wantDiscount money.Cents
|
||||||
wantNet int64
|
wantNet money.Cents
|
||||||
}{
|
}{
|
||||||
{"below threshold (19 999 kr)", 1999900, 0, 1999900},
|
{"below threshold (19 999 kr)", 1999900, 0, 1999900},
|
||||||
{"low tier 5% (20 000 kr)", 2000000, 100000, 1900000},
|
{"low tier 5% (20 000 kr)", 2000000, 100000, 1900000},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
"git.k6n.net/mats/platform/money"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errInvalidTimeFormat = errors.New("invalid time format")
|
var errInvalidTimeFormat = errors.New("invalid time format")
|
||||||
@@ -28,14 +29,14 @@ type PromotionItem struct {
|
|||||||
SKU string
|
SKU string
|
||||||
Quantity uint16
|
Quantity uint16
|
||||||
Category string
|
Category string
|
||||||
PriceIncVat int64
|
PriceIncVat money.Cents
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromotionEvalContext carries all dynamic data required to evaluate promotion
|
// PromotionEvalContext carries all dynamic data required to evaluate promotion
|
||||||
// conditions. It can be constructed from a cart.CartGrain plus optional
|
// conditions. It can be constructed from a cart.CartGrain plus optional
|
||||||
// customer/order metadata.
|
// customer/order metadata.
|
||||||
type PromotionEvalContext struct {
|
type PromotionEvalContext struct {
|
||||||
CartTotalIncVat int64
|
CartTotalIncVat money.Cents
|
||||||
TotalItemQuantity uint32
|
TotalItemQuantity uint32
|
||||||
Items []PromotionItem
|
Items []PromotionItem
|
||||||
CustomerSegment string
|
CustomerSegment string
|
||||||
@@ -358,7 +359,7 @@ func evaluateGroup(g ConditionGroup, ctx *PromotionEvalContext) bool {
|
|||||||
func evaluateBaseCondition(b BaseCondition, ctx *PromotionEvalContext) bool {
|
func evaluateBaseCondition(b BaseCondition, ctx *PromotionEvalContext) bool {
|
||||||
switch b.Type {
|
switch b.Type {
|
||||||
case CondCartTotal:
|
case CondCartTotal:
|
||||||
return evalNumberCompare(float64(ctx.CartTotalIncVat), b)
|
return evalNumberCompare(float64(ctx.CartTotalIncVat.Int64()), b)
|
||||||
case CondItemQuantity:
|
case CondItemQuantity:
|
||||||
return evalNumberCompare(float64(ctx.TotalItemQuantity), b)
|
return evalNumberCompare(float64(ctx.TotalItemQuantity), b)
|
||||||
case CondCustomerSegment:
|
case CondCustomerSegment:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
"git.k6n.net/mats/platform/money"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Helpers ---------------------------------------------------------------
|
// --- Helpers ---------------------------------------------------------------
|
||||||
@@ -63,13 +64,13 @@ func makeCart(totalOverride int64, items []struct {
|
|||||||
}) *cart.CartGrain {
|
}) *cart.CartGrain {
|
||||||
g := cart.NewCartGrain(1, time.Now())
|
g := cart.NewCartGrain(1, time.Now())
|
||||||
for _, it := range items {
|
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{
|
g.Items = append(g.Items, &cart.CartItem{
|
||||||
Id: uint32(len(g.Items) + 1),
|
Id: uint32(len(g.Items) + 1),
|
||||||
Sku: it.sku,
|
Sku: it.sku,
|
||||||
Price: *p,
|
Price: *p,
|
||||||
TotalPrice: cart.Price{
|
TotalPrice: cart.Price{
|
||||||
IncVat: p.IncVat * int64(it.qty),
|
IncVat: p.IncVat.Mul(int64(it.qty)),
|
||||||
VatRates: p.VatRates,
|
VatRates: p.VatRates,
|
||||||
},
|
},
|
||||||
Quantity: it.qty,
|
Quantity: it.qty,
|
||||||
@@ -81,7 +82,7 @@ func makeCart(totalOverride int64, items []struct {
|
|||||||
// Recalculate totals
|
// Recalculate totals
|
||||||
g.UpdateTotals()
|
g.UpdateTotals()
|
||||||
if totalOverride >= 0 {
|
if totalOverride >= 0 {
|
||||||
g.TotalPrice.IncVat = totalOverride
|
g.TotalPrice.IncVat = money.Cents(totalOverride)
|
||||||
}
|
}
|
||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package promotions
|
package promotions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"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 {
|
if qty == 0 {
|
||||||
qty = 1
|
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 {
|
if vat == 0 {
|
||||||
vat = defaultVatRate(tp)
|
vat = defaultVatRate(tp)
|
||||||
}
|
}
|
||||||
@@ -102,13 +105,13 @@ func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain {
|
|||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultVatRate returns the default VAT rate (as a float32 for NewPriceFromIncVat)
|
// defaultVatRate returns the default VAT rate in basis points (2500 = 25%) from
|
||||||
// from the provider, or 25 if none is configured.
|
// the provider, or 2500 if none is configured.
|
||||||
func defaultVatRate(tp cart.TaxProvider) float32 {
|
func defaultVatRate(tp cart.TaxProvider) int {
|
||||||
if tp == nil {
|
if tp == nil {
|
||||||
return 25
|
return 2500
|
||||||
}
|
}
|
||||||
return float32(tp.DefaultTaxRate(""))
|
return tp.DefaultTaxRate("")
|
||||||
}
|
}
|
||||||
|
|
||||||
// contextOptions maps the optional customer/time fields onto context options.
|
// contextOptions maps the optional customer/time fields onto context options.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
"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
|
// 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). " +
|
"`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 " +
|
"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.",
|
"whose config.tiers each have minTotal/maxTotal (öre incl. VAT) and percent.",
|
||||||
InputSchema: object(props{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"promotion": obj("the full promotion rule object"),
|
"promotion": pmcp.ObjectValue("the full promotion rule object"),
|
||||||
}, []string{"promotion"}),
|
}, []string{"promotion"}),
|
||||||
Invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
Promotion json.RawMessage `json:"promotion"`
|
Promotion json.RawMessage `json:"promotion"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var rule promotions.PromotionRule
|
var rule promotions.PromotionRule
|
||||||
@@ -61,16 +62,16 @@ func AdminTools(store *promotions.Store, eval *promotions.PromotionService) []Ad
|
|||||||
{
|
{
|
||||||
Name: "set_promotion_status",
|
Name: "set_promotion_status",
|
||||||
Description: "Set a promotion's status (active|inactive|scheduled|expired) to toggle it on or off.",
|
Description: "Set a promotion's status (active|inactive|scheduled|expired) to toggle it on or off.",
|
||||||
InputSchema: object(props{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"id": str("the promotion id"),
|
"id": pmcp.String("the promotion id"),
|
||||||
"status": str("new status: active|inactive|scheduled|expired"),
|
"status": pmcp.String("new status: active|inactive|scheduled|expired"),
|
||||||
}, []string{"id", "status"}),
|
}, []string{"id", "status"}),
|
||||||
Invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !validStatus(a.Status) {
|
if !validStatus(a.Status) {
|
||||||
@@ -89,12 +90,12 @@ func AdminTools(store *promotions.Store, eval *promotions.PromotionService) []Ad
|
|||||||
{
|
{
|
||||||
Name: "delete_promotion",
|
Name: "delete_promotion",
|
||||||
Description: "Delete a promotion rule by id.",
|
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) {
|
Invoke: func(args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
found, err := store.Delete(a.ID)
|
found, err := store.Delete(a.ID)
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
package mcp
|
|
||||||
|
|
||||||
import "encoding/json"
|
|
||||||
|
|
||||||
// JSON-RPC 2.0 envelope types (the wire format MCP rides on). Notifications are
|
|
||||||
// requests with no id and must not receive a response.
|
|
||||||
|
|
||||||
type rpcRequest struct {
|
|
||||||
JSONRPC string `json:"jsonrpc"`
|
|
||||||
ID json.RawMessage `json:"id,omitempty"`
|
|
||||||
Method string `json:"method"`
|
|
||||||
Params json.RawMessage `json:"params,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *rpcRequest) isNotification() bool { return len(r.ID) == 0 }
|
|
||||||
|
|
||||||
type rpcResponse struct {
|
|
||||||
JSONRPC string `json:"jsonrpc"`
|
|
||||||
ID json.RawMessage `json:"id,omitempty"`
|
|
||||||
Result any `json:"result,omitempty"`
|
|
||||||
Error *rpcError `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type rpcError struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Data any `json:"data,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
codeParseError = -32700
|
|
||||||
codeInvalidRequest = -32600
|
|
||||||
codeMethodNotFound = -32601
|
|
||||||
codeInvalidParams = -32602
|
|
||||||
codeInternalError = -32603
|
|
||||||
)
|
|
||||||
|
|
||||||
func result(id json.RawMessage, v any) *rpcResponse {
|
|
||||||
return &rpcResponse{JSONRPC: "2.0", ID: id, Result: v}
|
|
||||||
}
|
|
||||||
|
|
||||||
func errorResponse(id json.RawMessage, code int, msg string) *rpcResponse {
|
|
||||||
return &rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}}
|
|
||||||
}
|
|
||||||
+10
-131
@@ -4,160 +4,39 @@
|
|||||||
// cart actually applies (e.g. Volymrabatt), plus preview the discount a cart
|
// cart actually applies (e.g. Volymrabatt), plus preview the discount a cart
|
||||||
// total would receive.
|
// total would receive.
|
||||||
//
|
//
|
||||||
// Transport is streamable HTTP (JSON-RPC 2.0 over POST). It is mounted by
|
// Transport/dispatch and the schema/result helpers live in platform/mcp; this
|
||||||
// cmd/cart under /mcp on the same HTTP server as the cart API. Tools mutate the
|
// package defines the promotion tools (full set via New, read-only via NewPublic)
|
||||||
// shared promotions.Store and persist to data/promotions.json; the cart's
|
// and the admin tools (AdminTools) that backoffice merges into the CMS MCP.
|
||||||
// mutation processor reads a fresh Store snapshot on every recompute, so edits
|
|
||||||
// take effect on the next cart change without a restart.
|
|
||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||||
|
pmcp "git.k6n.net/mats/platform/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
serverName = "cart-promotions"
|
serverName = "cart-promotions"
|
||||||
serverVersion = "0.1.0"
|
serverVersion = "0.1.0"
|
||||||
protocolVersion = "2025-06-18"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server is the MCP edge over the shared promotion Store and evaluator.
|
// Server is the MCP edge over the shared promotion Store and evaluator.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
store *promotions.Store
|
store *promotions.Store
|
||||||
eval *promotions.PromotionService
|
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 {
|
func New(store *promotions.Store, eval *promotions.PromotionService) *Server {
|
||||||
if eval == nil {
|
if eval == nil {
|
||||||
eval = promotions.NewPromotionService(nil)
|
eval = promotions.NewPromotionService(nil)
|
||||||
}
|
}
|
||||||
s := &Server{store: store, eval: eval}
|
s := &Server{store: store, eval: eval}
|
||||||
s.tools = s.buildTools()
|
s.mcp = pmcp.NewServer(pmcp.Info{Name: serverName, Version: serverVersion}, s.buildTools()...)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler returns the streamable-HTTP handler to mount (e.g. at /mcp).
|
// Handler returns the streamable-HTTP handler to mount (e.g. at /mcp).
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler { return s.mcp.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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
"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:
|
// 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)
|
eval = promotions.NewPromotionService(nil)
|
||||||
}
|
}
|
||||||
s := &Server{store: store, eval: eval}
|
s := &Server{store: store, eval: eval}
|
||||||
s.tools = s.buildPublicTools()
|
s.mcp = pmcp.NewServer(pmcp.Info{Name: serverName, Version: serverVersion}, s.buildPublicTools()...)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) buildPublicTools() []tool {
|
func (s *Server) buildPublicTools() []pmcp.Tool {
|
||||||
return []tool{
|
return []pmcp.Tool{
|
||||||
{
|
{
|
||||||
Name: "list_promotions",
|
Name: "list_promotions",
|
||||||
Description: "List all promotion rules the cart engine evaluates, optionally filtered by status (active|inactive|scheduled|expired).",
|
Description: "List all promotion rules the cart engine evaluates, optionally filtered by status (active|inactive|scheduled|expired).",
|
||||||
InputSchema: object(props{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"status": str("optional status filter: active|inactive|scheduled|expired"),
|
"status": pmcp.String("optional status filter: active|inactive|scheduled|expired"),
|
||||||
}, nil),
|
}, nil),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rules := s.store.List()
|
rules := s.store.List()
|
||||||
@@ -52,12 +54,12 @@ func (s *Server) buildPublicTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "get_promotion",
|
Name: "get_promotion",
|
||||||
Description: "Fetch a single promotion rule by id.",
|
Description: "Fetch a single 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) {
|
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
r, ok := s.store.Get(a.ID)
|
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 " +
|
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 " +
|
"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.",
|
"verifying Volymrabatt tiers without touching a real cart.",
|
||||||
InputSchema: object(props{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"cartTotalIncVat": integer("cart total incl. VAT, in öre"),
|
"cartTotalIncVat": pmcp.Integer("cart total incl. VAT, in öre"),
|
||||||
"itemQuantity": integer("optional total item quantity"),
|
"itemQuantity": pmcp.Integer("optional total item quantity"),
|
||||||
"customerSegment": str("optional customer segment"),
|
"customerSegment": pmcp.String("optional customer segment"),
|
||||||
}, []string{"cartTotalIncVat"}),
|
}, []string{"cartTotalIncVat"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
CartTotalIncVat int64 `json:"cartTotalIncVat"`
|
CartTotalIncVat int64 `json:"cartTotalIncVat"`
|
||||||
ItemQuantity int `json:"itemQuantity"`
|
ItemQuantity int `json:"itemQuantity"`
|
||||||
CustomerSegment string `json:"customerSegment"`
|
CustomerSegment string `json:"customerSegment"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
|
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%\"). " +
|
"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 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.",
|
"When omitted, all active promotions are evaluated. cartTotalIncVat is in öre incl. VAT.",
|
||||||
InputSchema: object(props{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"cartTotalIncVat": integer("cart total incl. VAT, in öre"),
|
"cartTotalIncVat": pmcp.Integer("cart total incl. VAT, in öre"),
|
||||||
"promotionId": str("optional: evaluate only this promotion (by id)"),
|
"promotionId": pmcp.String("optional: evaluate only this promotion (by id)"),
|
||||||
"itemQuantity": integer("optional total item quantity"),
|
"itemQuantity": pmcp.Integer("optional total item quantity"),
|
||||||
"customerSegment": str("optional customer segment"),
|
"customerSegment": pmcp.String("optional customer segment"),
|
||||||
}, []string{"cartTotalIncVat"}),
|
}, []string{"cartTotalIncVat"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
CartTotalIncVat int64 `json:"cartTotalIncVat"`
|
CartTotalIncVat int64 `json:"cartTotalIncVat"`
|
||||||
PromotionId string `json:"promotionId"`
|
PromotionId string `json:"promotionId"`
|
||||||
ItemQuantity int `json:"itemQuantity"`
|
ItemQuantity int `json:"itemQuantity"`
|
||||||
CustomerSegment string `json:"customerSegment"`
|
CustomerSegment string `json:"customerSegment"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,10 +153,10 @@ func (s *Server) buildPublicTools() []tool {
|
|||||||
evalResults := make([]map[string]any, 0, len(results))
|
evalResults := make([]map[string]any, 0, len(results))
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
evalResults = append(evalResults, map[string]any{
|
evalResults = append(evalResults, map[string]any{
|
||||||
"ruleId": r.Rule.ID,
|
"ruleId": r.Rule.ID,
|
||||||
"name": r.Rule.Name,
|
"name": r.Rule.Name,
|
||||||
"applicable": r.Applicable,
|
"applicable": r.Applicable,
|
||||||
"failedReason": r.FailedReason,
|
"failedReason": r.FailedReason,
|
||||||
"matchedActions": r.MatchedActions,
|
"matchedActions": r.MatchedActions,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-117
@@ -1,61 +1,32 @@
|
|||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
"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,
|
// tool is one MCP tool: a name, a description, a JSON-Schema for its arguments,
|
||||||
// and the handler that runs it.
|
// and the handler that runs it.
|
||||||
type tool struct {
|
func (s *Server) buildTools() []pmcp.Tool {
|
||||||
Name string `json:"name"`
|
return []pmcp.Tool{
|
||||||
Description string `json:"description"`
|
|
||||||
InputSchema json.RawMessage `json:"inputSchema"`
|
|
||||||
invoke func(args json.RawMessage) (any, error) `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) callTool(req *rpcRequest) *rpcResponse {
|
|
||||||
var p struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Arguments json.RawMessage `json:"arguments"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(req.Params, &p); err != nil {
|
|
||||||
return errorResponse(req.ID, codeInvalidParams, "invalid params")
|
|
||||||
}
|
|
||||||
var t *tool
|
|
||||||
for i := range s.tools {
|
|
||||||
if s.tools[i].Name == p.Name {
|
|
||||||
t = &s.tools[i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if t == nil {
|
|
||||||
return errorResponse(req.ID, codeInvalidParams, "unknown tool: "+p.Name)
|
|
||||||
}
|
|
||||||
out, err := t.invoke(p.Arguments)
|
|
||||||
if err != nil {
|
|
||||||
return result(req.ID, toolError(err))
|
|
||||||
}
|
|
||||||
return result(req.ID, toolText(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) buildTools() []tool {
|
|
||||||
return []tool{
|
|
||||||
{
|
{
|
||||||
Name: "list_promotions",
|
Name: "list_promotions",
|
||||||
Description: "List all promotion rules the cart engine evaluates, optionally filtered by status (active|inactive|scheduled|expired).",
|
Description: "List all promotion rules the cart engine evaluates, optionally filtered by status (active|inactive|scheduled|expired).",
|
||||||
InputSchema: object(props{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"status": str("optional status filter: active|inactive|scheduled|expired"),
|
"status": pmcp.String("optional status filter: active|inactive|scheduled|expired"),
|
||||||
}, nil),
|
}, nil),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rules := s.store.List()
|
rules := s.store.List()
|
||||||
@@ -74,12 +45,12 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "get_promotion",
|
Name: "get_promotion",
|
||||||
Description: "Fetch a single promotion rule by id.",
|
Description: "Fetch a single 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) {
|
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
r, ok := s.store.Get(a.ID)
|
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). " +
|
"`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 " +
|
"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.",
|
"whose config.tiers each have minTotal/maxTotal (öre incl. VAT) and percent.",
|
||||||
InputSchema: object(props{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"promotion": obj("the full promotion rule object"),
|
"promotion": pmcp.ObjectValue("the full promotion rule object"),
|
||||||
}, []string{"promotion"}),
|
}, []string{"promotion"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
Promotion json.RawMessage `json:"promotion"`
|
Promotion json.RawMessage `json:"promotion"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var rule promotions.PromotionRule
|
var rule promotions.PromotionRule
|
||||||
@@ -125,16 +96,16 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "set_promotion_status",
|
Name: "set_promotion_status",
|
||||||
Description: "Set a promotion's status (active|inactive|scheduled|expired) to toggle it on or off.",
|
Description: "Set a promotion's status (active|inactive|scheduled|expired) to toggle it on or off.",
|
||||||
InputSchema: object(props{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"id": str("the promotion id"),
|
"id": pmcp.String("the promotion id"),
|
||||||
"status": str("new status: active|inactive|scheduled|expired"),
|
"status": pmcp.String("new status: active|inactive|scheduled|expired"),
|
||||||
}, []string{"id", "status"}),
|
}, []string{"id", "status"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !validStatus(a.Status) {
|
if !validStatus(a.Status) {
|
||||||
@@ -153,12 +124,12 @@ func (s *Server) buildTools() []tool {
|
|||||||
{
|
{
|
||||||
Name: "delete_promotion",
|
Name: "delete_promotion",
|
||||||
Description: "Delete a promotion rule by id.",
|
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) {
|
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
found, err := s.store.Delete(a.ID)
|
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 " +
|
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 " +
|
"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.",
|
"verifying Volymrabatt tiers without touching a real cart.",
|
||||||
InputSchema: object(props{
|
InputSchema: pmcp.Object(pmcp.Props{
|
||||||
"cartTotalIncVat": integer("cart total incl. VAT, in öre"),
|
"cartTotalIncVat": pmcp.Integer("cart total incl. VAT, in öre"),
|
||||||
"itemQuantity": integer("optional total item quantity"),
|
"itemQuantity": pmcp.Integer("optional total item quantity"),
|
||||||
"customerSegment": str("optional customer segment"),
|
"customerSegment": pmcp.String("optional customer segment"),
|
||||||
}, []string{"cartTotalIncVat"}),
|
}, []string{"cartTotalIncVat"}),
|
||||||
invoke: func(args json.RawMessage) (any, error) {
|
Invoke: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
CartTotalIncVat int64 `json:"cartTotalIncVat"`
|
CartTotalIncVat int64 `json:"cartTotalIncVat"`
|
||||||
ItemQuantity int `json:"itemQuantity"`
|
ItemQuantity int `json:"itemQuantity"`
|
||||||
CustomerSegment string `json:"customerSegment"`
|
CustomerSegment string `json:"customerSegment"`
|
||||||
}
|
}
|
||||||
if err := decode(args, &a); err != nil {
|
if err := pmcp.Decode(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
g := syntheticCart(a.CartTotalIncVat, a.ItemQuantity, s.defaultVatRate())
|
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
|
// syntheticCart builds a one-line cart whose gross total is incVat öre so the
|
||||||
// promotion engine can be evaluated against an arbitrary total. defaultVatRate
|
// promotion engine can be evaluated against an arbitrary total. rateBp is the VAT
|
||||||
// is the VAT rate as a float32 percentage (e.g. 25 = 25 %).
|
// rate in basis points (2500 = 25%).
|
||||||
func syntheticCart(incVat int64, qty int, defaultVatRate float32) *cart.CartGrain {
|
func syntheticCart(incVat int64, qty int, rateBp int) *cart.CartGrain {
|
||||||
if qty <= 0 {
|
if qty <= 0 {
|
||||||
qty = 1
|
qty = 1
|
||||||
}
|
}
|
||||||
if defaultVatRate == 0 {
|
if rateBp == 0 {
|
||||||
defaultVatRate = 25
|
rateBp = 2500
|
||||||
}
|
}
|
||||||
g := cart.NewCartGrain(0, time.Now())
|
g := cart.NewCartGrain(0, time.Now())
|
||||||
g.Items = []*cart.CartItem{
|
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
|
// Quantity > 1 would multiply the row total; keep the gross equal to incVat by
|
||||||
// pricing a single unit at the full total.
|
// pricing a single unit at the full total.
|
||||||
@@ -240,55 +211,13 @@ func validStatus(s string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- result + schema helpers (mirrors the graph-cms MCP edge) -------------
|
// defaultVatRate is the VAT rate used to build the synthetic preview cart when
|
||||||
|
// the caller supplies none. Sourced from platform/tax (Nordic standard,
|
||||||
func toolText(v any) map[string]any {
|
// defaultCountry SE = 25%) rather than a magic constant.
|
||||||
b, err := json.Marshal(v)
|
//
|
||||||
if err != nil {
|
// NOTE: reconstructed during the platform/mcp migration (the original method was
|
||||||
return toolError(err)
|
// 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.
|
||||||
return map[string]any{
|
func (s *Server) defaultVatRate() int {
|
||||||
"content": []map[string]any{{"type": "text", "text": string(b)}},
|
return tax.NewNordic("").DefaultTaxRate("")
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/platform/money"
|
||||||
)
|
)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -74,7 +76,7 @@ const (
|
|||||||
type ruleCondition struct {
|
type ruleCondition struct {
|
||||||
Kind RuleKind
|
Kind RuleKind
|
||||||
StringVals []string // For sku / category multi-value list
|
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 reserved for future (e.g., >, >=, ==). Currently always ">=" for numeric kinds.
|
||||||
Operator string
|
Operator string
|
||||||
}
|
}
|
||||||
@@ -90,13 +92,13 @@ type RuleSet struct {
|
|||||||
type Item struct {
|
type Item struct {
|
||||||
Sku string
|
Sku string
|
||||||
Category string
|
Category string
|
||||||
UnitPrice int64 // Inc VAT (single unit)
|
UnitPrice money.Cents // Inc VAT (single unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvalContext bundles cart-like data necessary for evaluation.
|
// EvalContext bundles cart-like data necessary for evaluation.
|
||||||
type EvalContext struct {
|
type EvalContext struct {
|
||||||
Items []Item
|
Items []Item
|
||||||
CartTotalInc int64
|
CartTotalInc money.Cents
|
||||||
}
|
}
|
||||||
|
|
||||||
// Applies returns true if all rule conditions pass for the context.
|
// 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)
|
return ruleCondition{}, fmt.Errorf("negative threshold %d", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mv := money.Cents(value)
|
||||||
return ruleCondition{
|
return ruleCondition{
|
||||||
Kind: kind,
|
Kind: kind,
|
||||||
MinValue: &value,
|
MinValue: &mv,
|
||||||
Operator: ">=",
|
Operator: ">=",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ func TestParseRules_Invalid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRuleSet_Applies(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{
|
ctx := EvalContext{
|
||||||
Items: []Item{
|
Items: []Item{
|
||||||
|
|||||||
@@ -7,18 +7,19 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
"git.k6n.net/mats/platform/money"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Rule struct {
|
type Rule struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Value int64 `json:"value"`
|
Value money.Cents `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Voucher struct {
|
type Voucher struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Value int64 `json:"value"`
|
Value money.Cents `json:"value"`
|
||||||
Rules string `json:"rules"`
|
Rules string `json:"rules"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -42,7 +43,7 @@ func (s *Service) GetVoucher(code string) (*messages.AddVoucher, error) {
|
|||||||
|
|
||||||
return &messages.AddVoucher{
|
return &messages.AddVoucher{
|
||||||
Code: code,
|
Code: code,
|
||||||
Value: v.Value,
|
Value: v.Value.Int64(), // proto boundary: AddVoucher.Value is int64
|
||||||
Description: v.Description,
|
Description: v.Description,
|
||||||
VoucherRules: []string{
|
VoucherRules: []string{
|
||||||
v.Rules,
|
v.Rules,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ message OrderLine {
|
|||||||
int32 tax_rate = 6; // percent
|
int32 tax_rate = 6; // percent
|
||||||
int64 total_amount = 7;
|
int64 total_amount = 7;
|
||||||
int64 total_tax = 8;
|
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).
|
// PlaceOrder creates the order (only legal when the order does not yet exist).
|
||||||
|
|||||||
+123
-113
@@ -31,6 +31,7 @@ type OrderLine struct {
|
|||||||
TaxRate int32 `protobuf:"varint,6,opt,name=tax_rate,json=taxRate,proto3" json:"tax_rate,omitempty"` // percent
|
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"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@@ -121,6 +122,13 @@ func (x *OrderLine) GetTotalTax() int64 {
|
|||||||
return 0
|
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).
|
// PlaceOrder creates the order (only legal when the order does not yet exist).
|
||||||
type PlaceOrder struct {
|
type PlaceOrder struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@@ -939,7 +947,7 @@ var File_order_proto protoreflect.FileDescriptor
|
|||||||
|
|
||||||
var file_order_proto_rawDesc = string([]byte{
|
var file_order_proto_rawDesc = string([]byte{
|
||||||
0x0a, 0x0b, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0e, 0x6f,
|
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,
|
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,
|
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,
|
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,
|
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,
|
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,
|
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,
|
0x61, 0x6c, 0x54, 0x61, 0x78, 0x12, 0x1a, 0x0a, 0x08, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f,
|
||||||
0x72, 0x64, 0x65, 0x72, 0x12, 0x27, 0x0a, 0x0f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x72, 0x65,
|
0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f,
|
||||||
0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f,
|
0x6e, 0x22, 0xcf, 0x03, 0x0a, 0x0a, 0x50, 0x6c, 0x61, 0x63, 0x65, 0x4f, 0x72, 0x64, 0x65, 0x72,
|
||||||
0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x17, 0x0a,
|
0x12, 0x27, 0x0a, 0x0f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65,
|
||||||
0x07, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06,
|
0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72,
|
||||||
0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e,
|
0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x63, 0x61, 0x72,
|
||||||
0x63, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e,
|
0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74,
|
||||||
0x63, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01,
|
0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x03,
|
||||||
0x28, 0x09, 0x52, 0x06, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f,
|
0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x12, 0x16,
|
||||||
0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75,
|
0x0a, 0x06, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06,
|
||||||
0x6e, 0x74, 0x72, 0x79, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x61, 0x6d,
|
0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72,
|
||||||
0x6f, 0x75, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61,
|
0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79,
|
||||||
0x6c, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x6f, 0x74, 0x61, 0x6c,
|
0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74,
|
||||||
0x5f, 0x74, 0x61, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x74, 0x6f, 0x74, 0x61,
|
0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x41, 0x6d, 0x6f,
|
||||||
0x6c, 0x54, 0x61, 0x78, 0x12, 0x2f, 0x0a, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x08, 0x20,
|
0x75, 0x6e, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x74, 0x61, 0x78,
|
||||||
0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x54, 0x61, 0x78,
|
||||||
0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05,
|
0x12, 0x2f, 0x0a, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32,
|
||||||
0x6c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65,
|
0x19, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
|
||||||
0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63,
|
0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05, 0x6c, 0x69, 0x6e, 0x65,
|
||||||
0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d,
|
0x73, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72, 0x5f, 0x65, 0x6d,
|
||||||
0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0a, 0x20,
|
0x61, 0x69, 0x6c, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f,
|
||||||
0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72, 0x4e, 0x61, 0x6d,
|
0x6d, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x75, 0x73, 0x74,
|
||||||
0x65, 0x12, 0x27, 0x0a, 0x0f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64,
|
0x6f, 0x6d, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||||
0x72, 0x65, 0x73, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x62, 0x69, 0x6c, 0x6c,
|
0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a,
|
||||||
0x69, 0x6e, 0x67, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x68,
|
0x0f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
|
||||||
0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x0c,
|
0x18, 0x0b, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x41,
|
||||||
0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x41, 0x64,
|
0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69,
|
||||||
0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x20, 0x0a, 0x0c, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x64, 0x5f,
|
0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0c,
|
||||||
0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x70, 0x6c, 0x61,
|
0x52, 0x0f, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73,
|
||||||
0x63, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x22, 0x79, 0x0a, 0x10, 0x41, 0x75, 0x74, 0x68, 0x6f,
|
0x73, 0x12, 0x20, 0x0a, 0x0c, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d,
|
||||||
0x72, 0x69, 0x7a, 0x65, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70,
|
0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x64, 0x41,
|
||||||
0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70,
|
0x74, 0x4d, 0x73, 0x22, 0x79, 0x0a, 0x10, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65,
|
||||||
0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e,
|
0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69,
|
||||||
0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12,
|
0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69,
|
||||||
0x1c, 0x0a, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01,
|
0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20,
|
||||||
0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x13, 0x0a,
|
0x01, 0x28, 0x03, 0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x72,
|
||||||
0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74,
|
0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
|
||||||
0x4d, 0x73, 0x22, 0x77, 0x0a, 0x0e, 0x43, 0x61, 0x70, 0x74, 0x75, 0x72, 0x65, 0x50, 0x61, 0x79,
|
0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f,
|
||||||
0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72,
|
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,
|
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,
|
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,
|
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,
|
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,
|
0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e,
|
||||||
0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x4b, 0x0a, 0x0f, 0x46,
|
0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x74, 0x75, 0x72,
|
||||||
0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x12, 0x1c,
|
0x6e, 0x49, 0x64, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x01,
|
||||||
0x0a, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
|
0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0xe7, 0x01, 0x0a, 0x0f, 0x52, 0x65, 0x71,
|
||||||
0x09, 0x52, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08,
|
0x75, 0x65, 0x73, 0x74, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x0e, 0x0a, 0x02,
|
||||||
0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08,
|
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1b, 0x0a, 0x09,
|
||||||
0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0xd5, 0x01, 0x0a, 0x11, 0x43, 0x72, 0x65,
|
0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||||
0x61, 0x74, 0x65, 0x46, 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x0e,
|
0x08, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61,
|
||||||
0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x18,
|
0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f,
|
||||||
0x0a, 0x07, 0x63, 0x61, 0x72, 0x72, 0x69, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
|
0x6e, 0x12, 0x42, 0x0a, 0x0c, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x6c, 0x69, 0x6e, 0x65,
|
||||||
0x07, 0x63, 0x61, 0x72, 0x72, 0x69, 0x65, 0x72, 0x12, 0x27, 0x0a, 0x0f, 0x74, 0x72, 0x61, 0x63,
|
0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f,
|
||||||
0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28,
|
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x46, 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c,
|
||||||
0x09, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x4e, 0x75, 0x6d, 0x62, 0x65,
|
0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x0b, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e,
|
||||||
0x72, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x75, 0x72,
|
0x4c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x36, 0x0a, 0x09, 0x6e, 0x65, 0x77, 0x5f, 0x6c, 0x69, 0x6e,
|
||||||
0x69, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e,
|
0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72,
|
||||||
0x67, 0x55, 0x72, 0x69, 0x12, 0x35, 0x0a, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x05, 0x20,
|
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x4c,
|
||||||
0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
0x69, 0x6e, 0x65, 0x52, 0x08, 0x6e, 0x65, 0x77, 0x4c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x13, 0x0a,
|
||||||
0x61, 0x67, 0x65, 0x73, 0x2e, 0x46, 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74,
|
0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74,
|
||||||
0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x13, 0x0a, 0x05, 0x61,
|
0x4d, 0x73, 0x22, 0xa2, 0x01, 0x0a, 0x10, 0x45, 0x64, 0x69, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72,
|
||||||
0x74, 0x5f, 0x6d, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73,
|
0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x68, 0x69, 0x70, 0x70,
|
||||||
0x22, 0x24, 0x0a, 0x0d, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x72, 0x64, 0x65,
|
0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||||
0x72, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03,
|
0x0c, 0x52, 0x0f, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x41, 0x64, 0x64, 0x72, 0x65,
|
||||||
0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x3a, 0x0a, 0x0b, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c,
|
0x73, 0x73, 0x12, 0x27, 0x0a, 0x0f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64,
|
||||||
0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18,
|
0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x62, 0x69, 0x6c,
|
||||||
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x13, 0x0a,
|
0x6c, 0x69, 0x6e, 0x67, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x73,
|
||||||
0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74,
|
0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x5f, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x03, 0x20,
|
||||||
0x4d, 0x73, 0x22, 0x83, 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x65,
|
0x01, 0x28, 0x03, 0x52, 0x0d, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x50, 0x72, 0x69,
|
||||||
0x74, 0x75, 0x72, 0x6e, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
0x63, 0x65, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28,
|
||||||
0x52, 0x02, 0x69, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x02,
|
0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x42, 0x3b, 0x5a, 0x39, 0x67, 0x69, 0x74, 0x2e, 0x6b,
|
||||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x35, 0x0a, 0x05,
|
0x36, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f, 0x67, 0x6f, 0x2d, 0x63,
|
||||||
0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x72,
|
0x61, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
|
||||||
0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x46, 0x75, 0x6c,
|
0x6f, 0x72, 0x64, 0x65, 0x72, 0x3b, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
||||||
0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05, 0x6c, 0x69,
|
0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
Reference in New Issue
Block a user