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

This commit is contained in:
2026-06-28 17:51:52 +02:00
parent 7db0d236c7
commit aa8b2bdedc
84 changed files with 4328 additions and 2344 deletions
+1 -1
View File
@@ -305,7 +305,7 @@ func (c *CartGrain) UpdateTotals() {
voucher.Applied = true
continue
}
value := NewPriceFromIncVat(voucher.Value, 25)
value := NewPriceFromIncVat(voucher.Value, 2500) // 25% in basis points
if c.TotalPrice.IncVat <= value.IncVat {
// don't apply discounts to more than the total price
continue
+12 -78
View File
@@ -1,11 +1,10 @@
package cart
import (
"crypto/rand"
"encoding/json"
"fmt"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/platform/uid"
)
// cart_id.go
@@ -36,30 +35,16 @@ import (
//
// ---------------------------------------------------------------------------
type CartId actor.GrainId
// CartId is a 64-bit cart identifier with a compact base62 string form,
// backed by the shared platform/uid package.
type CartId uid.ID
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
// Reverse lookup (0xFF marks invalid)
var base62Rev [256]byte
func init() {
for i := range base62Rev {
base62Rev[i] = 0xFF
}
for i := 0; i < len(base62Alphabet); i++ {
base62Rev[base62Alphabet[i]] = byte(i)
}
}
// String returns the canonical base62 encoding of the 64-bit id.
func (id CartId) String() string {
return encodeBase62(uint64(id))
}
// String returns the canonical base62 encoding.
func (id CartId) String() string { return uid.ID(id).String() }
// MarshalJSON encodes the cart id as a JSON string.
func (id CartId) MarshalJSON() ([]byte, error) {
return json.Marshal(id.String())
return json.Marshal(uid.ID(id).String())
}
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
@@ -78,23 +63,11 @@ func (id *CartId) UnmarshalJSON(data []byte) error {
// NewCartId generates a new cryptographically random non-zero 64-bit id.
func NewCartId() (CartId, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
id, err := uid.New()
if err != nil {
return 0, fmt.Errorf("NewCartId: %w", err)
}
u := (uint64(b[0]) << 56) |
(uint64(b[1]) << 48) |
(uint64(b[2]) << 40) |
(uint64(b[3]) << 32) |
(uint64(b[4]) << 24) |
(uint64(b[5]) << 16) |
(uint64(b[6]) << 8) |
uint64(b[7])
if u == 0 {
// Extremely unlikely; regenerate once to avoid "0" identifier if desired.
return NewCartId()
}
return CartId(u), nil
return CartId(id), nil
}
// MustNewCartId panics if generation fails.
@@ -107,19 +80,9 @@ func MustNewCartId() CartId {
}
// ParseCartId parses a base62 string into a CartId.
// Returns (0,false) for invalid input.
func ParseCartId(s string) (CartId, bool) {
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
// Provide a slightly looser upper bound (<=16) only if you anticipate future
// extensions; here we stay strict.
if len(s) == 0 || len(s) > 11 {
return 0, false
}
u, ok := decodeBase62(s)
if !ok {
return 0, false
}
return CartId(u), true
id, ok := uid.Parse(s)
return CartId(id), ok
}
// MustParseCartId panics on invalid base62 input.
@@ -130,32 +93,3 @@ func MustParseCartId(s string) CartId {
}
return id
}
// encodeBase62 converts a uint64 to base62 (shortest form).
func encodeBase62(u uint64) string {
if u == 0 {
return "0"
}
var buf [11]byte
i := len(buf)
for u > 0 {
i--
buf[i] = base62Alphabet[u%62]
u /= 62
}
return string(buf[i:])
}
// decodeBase62 converts base62 text to uint64.
func decodeBase62(s string) (uint64, bool) {
var v uint64
for i := 0; i < len(s); i++ {
c := s[i]
d := base62Rev[c]
if d == 0xFF {
return 0, false
}
v = v*62 + uint64(d)
}
return v, true
}
+14 -15
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"testing"
"git.k6n.net/mats/platform/uid"
)
// TestNewCartIdUniqueness generates many ids and checks for collisions.
@@ -96,26 +98,25 @@ func TestJSONMarshalUnmarshalCartId(t *testing.T) {
// TestBase62LengthBound checks worst-case length (near max uint64).
func TestBase62LengthBound(t *testing.T) {
// Largest uint64
const maxU64 = ^uint64(0)
s := encodeBase62(maxU64)
s := uid.ID(maxU64).String()
if len(s) > 11 {
t.Fatalf("max uint64 encoded length > 11: %d (%s)", len(s), s)
}
dec, ok := decodeBase62(s)
if !ok || dec != maxU64 {
t.Fatalf("decode failed for max uint64: ok=%v dec=%d want=%d", ok, dec, maxU64)
dec, ok := uid.Parse(s)
if !ok || uint64(dec) != maxU64 {
t.Fatalf("decode failed for max uint64: ok=%v dec=%d want=%d", ok, uint64(dec), maxU64)
}
}
// TestZeroEncoding ensures zero value encodes to "0" and parses back.
func TestZeroEncoding(t *testing.T) {
if s := encodeBase62(0); s != "0" {
t.Fatalf("encodeBase62(0) expected '0', got %q", s)
if s := uid.ID(0).String(); s != "0" {
t.Fatalf("uid.ID(0).String() expected '0', got %q", s)
}
v, ok := decodeBase62("0")
v, ok := uid.Parse("0")
if !ok || v != 0 {
t.Fatalf("decodeBase62('0') failed: ok=%v v=%d", ok, v)
t.Fatalf("uid.Parse('0') failed: ok=%v v=%d", ok, v)
}
if _, ok := ParseCartId("0"); !ok {
t.Fatalf("ParseCartId(\"0\") should succeed")
@@ -145,16 +146,14 @@ func BenchmarkNewCartId(b *testing.B) {
// BenchmarkEncodeBase62 measures encoding performance.
func BenchmarkEncodeBase62(b *testing.B) {
// Precompute sample values
samples := make([]uint64, 1024)
for i := range samples {
// Spread bits without crypto randomness overhead
samples[i] = (uint64(i) << 53) ^ (uint64(i) * 0x9E3779B185EBCA87)
}
b.ResetTimer()
var sink string
for i := 0; i < b.N; i++ {
sink = encodeBase62(samples[i%len(samples)])
sink = uid.ID(samples[i%len(samples)]).String()
}
_ = sink
}
@@ -163,16 +162,16 @@ func BenchmarkEncodeBase62(b *testing.B) {
func BenchmarkDecodeBase62(b *testing.B) {
encoded := make([]string, 1024)
for i := range encoded {
encoded[i] = encodeBase62((uint64(i) << 32) | uint64(i))
encoded[i] = uid.ID((uint64(i) << 32) | uint64(i)).String()
}
b.ResetTimer()
var sum uint64
for i := 0; i < b.N; i++ {
v, ok := decodeBase62(encoded[i%len(encoded)])
v, ok := uid.Parse(encoded[i%len(encoded)])
if !ok {
b.Fatalf("decode failure for %s", encoded[i%len(encoded)])
}
sum ^= v
sum ^= uint64(v)
}
_ = sum
}
-44
View File
@@ -1,44 +0,0 @@
package mcp
import "encoding/json"
// JSON-RPC 2.0 envelope types (the wire format MCP rides on). Notifications are
// requests with no id and must not receive a response.
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
func (r *rpcRequest) isNotification() bool { return len(r.ID) == 0 }
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
const (
codeParseError = -32700
codeInvalidRequest = -32600
codeMethodNotFound = -32601
codeInvalidParams = -32602
codeInternalError = -32603
)
func result(id json.RawMessage, v any) *rpcResponse {
return &rpcResponse{JSONRPC: "2.0", ID: id, Result: v}
}
func errorResponse(id json.RawMessage, code int, msg string) *rpcResponse {
return &rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}}
}
+11 -121
View File
@@ -3,27 +3,25 @@
// (list items, change quantities, remove items, clear carts, apply vouchers,
// manage users, etc.).
//
// Transport is streamable HTTP (JSON-RPC 2.0 over POST). It is mounted by
// cmd/cart under /mcp on the same HTTP server as the cart API. Tools map
// 1:1 to cart grain read/apply operations; no restart is needed because every
// tool call goes through the shared grain pool.
// Transport (JSON-RPC 2.0 over streamable HTTP), dispatch, and the schema/result
// helpers live in platform/mcp; this package only defines the cart-specific
// tools and wires them to the grain pool. Mounted by cmd/cart under /mcp.
package mcp
import (
"context"
"encoding/json"
"io"
"net/http"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
pmcp "git.k6n.net/mats/platform/mcp"
"net/http"
"google.golang.org/protobuf/proto"
)
const (
serverName = "cart"
serverVersion = "0.1.0"
protocolVersion = "2025-06-18"
serverName = "cart"
serverVersion = "0.1.0"
)
// Applier is the minimal grain-pool interface the cart MCP needs: read a
@@ -37,123 +35,15 @@ type Applier interface {
// Server is the MCP edge over the cart grain pool.
type Server struct {
applier Applier
tools []tool
mcp *pmcp.Server
}
// New builds an MCP server exposing the cart grain as tools.
func New(applier Applier) *Server {
s := &Server{applier: applier}
s.tools = s.buildTools()
s.mcp = pmcp.NewServer(pmcp.Info{Name: serverName, Version: serverVersion}, s.buildTools()...)
return s
}
// Handler returns the streamable-HTTP handler to mount (e.g. at /mcp).
func (s *Server) Handler() http.Handler {
return http.HandlerFunc(s.serveHTTP)
}
func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
if err != nil {
writeRPC(w, errorResponse(nil, codeParseError, "read error"))
return
}
if isBatch(body) {
var reqs []rpcRequest
if err := json.Unmarshal(body, &reqs); err != nil {
writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON"))
return
}
var out []*rpcResponse
for i := range reqs {
if resp := s.dispatch(&reqs[i]); resp != nil {
out = append(out, resp)
}
}
if len(out) == 0 {
w.WriteHeader(http.StatusAccepted)
return
}
writeRPC(w, out)
return
}
var req rpcRequest
if err := json.Unmarshal(body, &req); err != nil {
writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON"))
return
}
resp := s.dispatch(&req)
if resp == nil {
w.WriteHeader(http.StatusAccepted)
return
}
writeRPC(w, resp)
}
func (s *Server) dispatch(req *rpcRequest) *rpcResponse {
if req.JSONRPC != "2.0" {
if req.isNotification() {
return nil
}
return errorResponse(req.ID, codeInvalidRequest, "jsonrpc must be \"2.0\"")
}
switch req.Method {
case "initialize":
return result(req.ID, s.initialize(req.Params))
case "ping":
return result(req.ID, struct{}{})
case "tools/list":
return result(req.ID, map[string]any{"tools": s.tools})
case "tools/call":
return s.callTool(req)
case "notifications/initialized", "notifications/cancelled":
return nil
default:
if req.isNotification() {
return nil
}
return errorResponse(req.ID, codeMethodNotFound, "unknown method: "+req.Method)
}
}
func (s *Server) initialize(params json.RawMessage) map[string]any {
pv := protocolVersion
if len(params) > 0 {
var p struct {
ProtocolVersion string `json:"protocolVersion"`
}
if json.Unmarshal(params, &p) == nil && p.ProtocolVersion != "" {
pv = p.ProtocolVersion
}
}
return map[string]any{
"protocolVersion": pv,
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": serverName, "version": serverVersion},
}
}
func isBatch(body []byte) bool {
for _, b := range body {
switch b {
case ' ', '\t', '\r', '\n':
continue
case '[':
return true
default:
return false
}
}
return false
}
func writeRPC(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(v)
}
func (s *Server) Handler() http.Handler { return s.mcp.Handler() }
+68 -200
View File
@@ -4,107 +4,33 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
pmcp "git.k6n.net/mats/platform/mcp"
)
// tool is one MCP tool: a name, a description, a JSON-Schema for its arguments,
// and the handler that runs it.
type tool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"inputSchema"`
invoke func(args json.RawMessage) (any, error) `json:"-"`
}
func (s *Server) callTool(req *rpcRequest) *rpcResponse {
var p struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &p); err != nil {
return errorResponse(req.ID, codeInvalidParams, "invalid params")
}
// Extract UCP meta from arguments (UCP-Agent profile advertisement).
// Per the UCP spec, meta is a sibling key inside arguments:
// "arguments": { "meta": { "ucp-agent": { "profile": "..." } }, ... }
args := extractUCPAgentMeta(&p.Arguments)
var t *tool
for i := range s.tools {
if s.tools[i].Name == p.Name {
t = &s.tools[i]
break
}
}
if t == nil {
return errorResponse(req.ID, codeInvalidParams, "unknown tool: "+p.Name)
}
out, err := t.invoke(args)
if err != nil {
return result(req.ID, toolError(err))
}
return result(req.ID, toolText(out))
}
// extractUCPAgentMeta checks if the JSON arguments contain a "meta" key with
// a nested "ucp-agent.profile" field. If found, it logs the profile (for
// observability) and strips the "meta" key before returning the cleaned
// arguments to the tool handler, so the meta object does not leak into the
// tool's argument namespace.
func extractUCPAgentMeta(arguments *json.RawMessage) json.RawMessage {
if arguments == nil || len(*arguments) == 0 {
return *arguments
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(*arguments, &obj); err != nil {
return *arguments
}
metaRaw, hasMeta := obj["meta"]
if !hasMeta {
return *arguments
}
// Try to extract the profile for observability.
var meta struct {
UCPAgent *struct {
Profile string `json:"profile"`
} `json:"ucp-agent"`
}
if err := json.Unmarshal(metaRaw, &meta); err == nil && meta.UCPAgent != nil && meta.UCPAgent.Profile != "" {
log.Printf("ucp-agent: profile=%s", meta.UCPAgent.Profile)
}
// Strip meta from arguments before passing to the tool handler.
delete(obj, "meta")
cleaned, err := json.Marshal(obj)
if err != nil {
return *arguments
}
return cleaned
}
func (s *Server) buildTools() []tool {
return []tool{
// buildTools returns the cart's MCP tools (transport/dispatch live in platform/mcp).
func (s *Server) buildTools() []pmcp.Tool {
return []pmcp.Tool{
{
Name: "get_cart",
Description: "Get the full state of a cart by its base62 cart id: items, totals, vouchers, promotions, user info, currency, language, checkout status, subscription details, and all applied/pending promotions with progress nudges.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
InputSchema: pmcp.Object(pmcp.Props{
"cartId": pmcp.String("the base62 cart id"),
}, []string{"cartId"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
}
if err := decode(args, &a); err != nil {
if err := pmcp.Decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
g, err := s.applier.Get(ctx, uint64(id))
if err != nil {
return nil, fmt.Errorf("get cart: %w", err)
}
@@ -114,21 +40,21 @@ func (s *Server) buildTools() []tool {
{
Name: "get_cart_items",
Description: "List all items in a cart with their SKU, name, quantity, unit price, total price, stock, tax rate, markings, custom fields, and optional child/parent relationships.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
InputSchema: pmcp.Object(pmcp.Props{
"cartId": pmcp.String("the base62 cart id"),
}, []string{"cartId"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
}
if err := decode(args, &a); err != nil {
if err := pmcp.Decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
g, err := s.applier.Get(ctx, uint64(id))
if err != nil {
return nil, fmt.Errorf("get cart: %w", err)
}
@@ -138,21 +64,21 @@ func (s *Server) buildTools() []tool {
{
Name: "get_cart_totals",
Description: "Get price breakdown for a cart: total incVat, total exVat, VAT breakdown by rate, total discount (vouchers + promotions), and per-item totals.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
InputSchema: pmcp.Object(pmcp.Props{
"cartId": pmcp.String("the base62 cart id"),
}, []string{"cartId"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
}
if err := decode(args, &a); err != nil {
if err := pmcp.Decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
g, err := s.applier.Get(ctx, uint64(id))
if err != nil {
return nil, fmt.Errorf("get cart: %w", err)
}
@@ -168,21 +94,21 @@ func (s *Server) buildTools() []tool {
{
Name: "get_cart_promotions",
Description: "Get all applied and pending promotions on a cart, including discount amounts and progress nudges (e.g. 'spend 1200 SEK more for free shipping').",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
InputSchema: pmcp.Object(pmcp.Props{
"cartId": pmcp.String("the base62 cart id"),
}, []string{"cartId"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
}
if err := decode(args, &a); err != nil {
if err := pmcp.Decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
g, err := s.applier.Get(ctx, uint64(id))
if err != nil {
return nil, fmt.Errorf("get cart: %w", err)
}
@@ -195,21 +121,21 @@ func (s *Server) buildTools() []tool {
{
Name: "get_cart_vouchers",
Description: "List all vouchers applied to a cart with their codes, values, rules, and whether they are currently active.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
InputSchema: pmcp.Object(pmcp.Props{
"cartId": pmcp.String("the base62 cart id"),
}, []string{"cartId"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
}
if err := decode(args, &a); err != nil {
if err := pmcp.Decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
g, err := s.applier.Get(context.Background(), uint64(id))
g, err := s.applier.Get(ctx, uint64(id))
if err != nil {
return nil, fmt.Errorf("get cart: %w", err)
}
@@ -219,25 +145,25 @@ func (s *Server) buildTools() []tool {
{
Name: "update_item_quantity",
Description: "Change the quantity of a line item in a cart. Use quantity=0 to remove the item. Returns the updated cart state.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
"itemId": integer("the line item id (numeric, e.g. 1, 2, 3)"),
"quantity": integer("the new quantity (0 removes the item)"),
InputSchema: pmcp.Object(pmcp.Props{
"cartId": pmcp.String("the base62 cart id"),
"itemId": pmcp.Integer("the line item id (numeric, e.g. 1, 2, 3)"),
"quantity": pmcp.Integer("the new quantity (0 removes the item)"),
}, []string{"cartId", "itemId", "quantity"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
ItemID int `json:"itemId"`
Quantity int32 `json:"quantity"`
}
if err := decode(args, &a); err != nil {
if err := pmcp.Decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.ChangeQuantity{
res, err := s.applier.Apply(ctx, uint64(id), &messages.ChangeQuantity{
Id: uint32(a.ItemID),
Quantity: a.Quantity,
})
@@ -256,23 +182,23 @@ func (s *Server) buildTools() []tool {
{
Name: "remove_cart_item",
Description: "Remove a line item (and its children) from a cart by item id. Returns the updated cart state.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
"itemId": integer("the line item id to remove (numeric, e.g. 1, 2, 3)"),
InputSchema: pmcp.Object(pmcp.Props{
"cartId": pmcp.String("the base62 cart id"),
"itemId": pmcp.Integer("the line item id to remove (numeric, e.g. 1, 2, 3)"),
}, []string{"cartId", "itemId"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
ItemID int `json:"itemId"`
}
if err := decode(args, &a); err != nil {
if err := pmcp.Decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.RemoveItem{Id: uint32(a.ItemID)})
res, err := s.applier.Apply(ctx, uint64(id), &messages.RemoveItem{Id: uint32(a.ItemID)})
if err != nil {
return nil, fmt.Errorf("remove item: %w", err)
}
@@ -287,21 +213,21 @@ func (s *Server) buildTools() []tool {
{
Name: "clear_cart",
Description: "Remove all items and vouchers from a cart, resetting it to an empty state. The cart id, user id, and currency are preserved. Returns the updated (empty) cart.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
InputSchema: pmcp.Object(pmcp.Props{
"cartId": pmcp.String("the base62 cart id"),
}, []string{"cartId"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
}
if err := decode(args, &a); err != nil {
if err := pmcp.Decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.ClearCartRequest{})
res, err := s.applier.Apply(ctx, uint64(id), &messages.ClearCartRequest{})
if err != nil {
return nil, fmt.Errorf("clear cart: %w", err)
}
@@ -316,14 +242,14 @@ func (s *Server) buildTools() []tool {
{
Name: "apply_voucher",
Description: "Apply a voucher code to a cart. The voucher value is in öre (e.g. 10000 = 100 kr). Returns the updated cart with the voucher applied.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
"code": str("the voucher code"),
"value": integer("the voucher value in öre (e.g. 10000 = 100 kr)"),
"description": str("optional description of the voucher"),
"rules": stringArray("optional list of rule expressions for when the voucher applies"),
InputSchema: pmcp.Object(pmcp.Props{
"cartId": pmcp.String("the base62 cart id"),
"code": pmcp.String("the voucher code"),
"value": pmcp.Integer("the voucher value in öre (e.g. 10000 = 100 kr)"),
"description": pmcp.String("optional description of the voucher"),
"rules": pmcp.StringArray("optional list of rule expressions for when the voucher applies"),
}, []string{"cartId", "code", "value"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
Code string `json:"code"`
@@ -331,14 +257,14 @@ func (s *Server) buildTools() []tool {
Description string `json:"description"`
Rules []string `json:"rules"`
}
if err := decode(args, &a); err != nil {
if err := pmcp.Decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.AddVoucher{
res, err := s.applier.Apply(ctx, uint64(id), &messages.AddVoucher{
Code: a.Code,
Value: a.Value,
Description: a.Description,
@@ -358,23 +284,23 @@ func (s *Server) buildTools() []tool {
{
Name: "remove_voucher",
Description: "Remove a voucher from a cart by its voucher id. Returns the updated cart.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
"voucherId": integer("the voucher id to remove (numeric)"),
InputSchema: pmcp.Object(pmcp.Props{
"cartId": pmcp.String("the base62 cart id"),
"voucherId": pmcp.Integer("the voucher id to remove (numeric)"),
}, []string{"cartId", "voucherId"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
VoucherID int `json:"voucherId"`
}
if err := decode(args, &a); err != nil {
if err := pmcp.Decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.RemoveVoucher{Id: uint32(a.VoucherID)})
res, err := s.applier.Apply(ctx, uint64(id), &messages.RemoveVoucher{Id: uint32(a.VoucherID)})
if err != nil {
return nil, fmt.Errorf("remove voucher: %w", err)
}
@@ -389,23 +315,23 @@ func (s *Server) buildTools() []tool {
{
Name: "set_cart_user",
Description: "Set or update the user ID on a cart, linking it to a customer account. Returns the updated cart.",
InputSchema: object(props{
"cartId": str("the base62 cart id"),
"userId": str("the user/customer id"),
InputSchema: pmcp.Object(pmcp.Props{
"cartId": pmcp.String("the base62 cart id"),
"userId": pmcp.String("the user/customer id"),
}, []string{"cartId", "userId"}),
invoke: func(args json.RawMessage) (any, error) {
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
CartID string `json:"cartId"`
UserID string `json:"userId"`
}
if err := decode(args, &a); err != nil {
if err := pmcp.Decode(args, &a); err != nil {
return nil, err
}
id, ok := cart.ParseCartId(a.CartID)
if !ok {
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
}
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.SetUserId{UserId: a.UserID})
res, err := s.applier.Apply(ctx, uint64(id), &messages.SetUserId{UserId: a.UserID})
if err != nil {
return nil, fmt.Errorf("set user: %w", err)
}
@@ -421,61 +347,3 @@ func (s *Server) buildTools() []tool {
}
// ---- result + schema helpers -----------------------------------------------
func toolText(v any) map[string]any {
b, err := json.Marshal(v)
if err != nil {
return toolError(err)
}
return map[string]any{
"content": []map[string]any{{"type": "text", "text": string(b)}},
}
}
func toolError(err error) map[string]any {
return map[string]any{
"isError": true,
"content": []map[string]any{{"type": "text", "text": err.Error()}},
}
}
// decode unmarshals tool arguments, tolerating empty/absent arguments.
func decode(args json.RawMessage, v any) error {
if len(args) == 0 || string(args) == "null" {
return nil
}
if err := json.Unmarshal(args, v); err != nil {
return fmt.Errorf("invalid arguments: %w", err)
}
return nil
}
type props map[string]json.RawMessage
func object(p props, required []string) json.RawMessage {
m := map[string]any{
"type": "object",
"properties": p,
}
if len(required) > 0 {
m["required"] = required
}
b, _ := json.Marshal(m)
return b
}
func str(desc string) json.RawMessage { return scalar("string", desc) }
func integer(d string) json.RawMessage { return scalar("integer", d) }
func obj(desc string) json.RawMessage { return scalar("object", desc) }
func scalar(typ, desc string) json.RawMessage {
b, _ := json.Marshal(map[string]any{"type": typ, "description": desc})
return b
}
func stringArray(desc string) json.RawMessage {
b, _ := json.Marshal(map[string]any{
"type": "array", "description": desc, "items": map[string]string{"type": "string"},
})
return b
}
+9 -7
View File
@@ -98,12 +98,14 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
defer g.mu.Unlock()
g.lastItemId++
taxRate := float32(25.0)
// m.Tax is the rate in basis points (2500 = 25%, 1250 = 12.5%) — the single
// platform scale; flows straight through, no conversion.
rateBp := 2500
if m.Tax > 0 {
taxRate = float32(int(m.Tax) / 100)
rateBp = int(m.Tax)
}
pricePerItem := NewPriceFromIncVat(m.Price, taxRate)
pricePerItem := NewPriceFromIncVat(m.Price, rateBp)
needsReservation := true
if m.ReservationEndTime != nil {
@@ -115,7 +117,7 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
ItemId: uint32(m.ItemId),
Quantity: uint16(m.Quantity),
Sku: m.Sku,
Tax: int(taxRate * 100),
Tax: rateBp,
Meta: &ItemMeta{
Name: m.Name,
Image: m.Image,
@@ -139,7 +141,7 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
Stock: uint16(m.Stock),
Disclaimer: m.Disclaimer,
OrgPrice: getOrgPrice(m.OrgPrice, taxRate),
OrgPrice: getOrgPrice(m.OrgPrice, rateBp),
ArticleType: m.ArticleType,
StoreId: m.StoreId,
@@ -167,9 +169,9 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
return nil
}
func getOrgPrice(orgPrice int64, taxRate float32) *Price {
func getOrgPrice(orgPrice int64, rateBp int) *Price {
if orgPrice <= 0 {
return nil
}
return NewPriceFromIncVat(orgPrice, taxRate)
return NewPriceFromIncVat(orgPrice, rateBp)
}
+18 -151
View File
@@ -1,159 +1,26 @@
package cart
import (
"encoding/json"
"strconv"
"git.k6n.net/mats/platform/money"
"git.k6n.net/mats/platform/tax"
)
func GetTaxAmount(total int64, tax int) int64 {
taxD := 10000 / float64(tax)
return int64(float64(total) / float64((1 + taxD)))
// Price represents a monetary amount with optional VAT breakdown by rate.
// The canonical definition is now in platform/tax; this is a type alias
// for backward compatibility.
type Price = tax.Price
// NewPrice returns a new zero Price.
func NewPrice() *Price { return tax.NewPrice() }
// NewPriceFromIncVat creates a Price from an inc-VAT total and a tax rate in
// basis points (2500 = 25%, 1250 = 12.5%). Re-exported from platform/tax.
func NewPriceFromIncVat(incVat int64, rateBp int) *Price {
return tax.NewPriceFromIncVat(money.Cents(incVat), rateBp)
}
type Price struct {
IncVat int64 `json:"incVat"`
VatRates map[float32]int64 `json:"vat,omitempty"`
}
// MultiplyPrice multiplies a Price by quantity and returns a new Price.
func MultiplyPrice(p Price, qty int64) *Price { return tax.MultiplyPrice(p, qty) }
func NewPrice() *Price {
return &Price{
IncVat: 0,
VatRates: make(map[float32]int64),
}
}
func NewPriceFromIncVat(incVat int64, taxRate float32) *Price {
tax := GetTaxAmount(incVat, int(taxRate*100))
return &Price{
IncVat: incVat,
VatRates: map[float32]int64{
taxRate: tax,
},
}
}
func (p *Price) ValueExVat() int64 {
exVat := p.IncVat
for _, amount := range p.VatRates {
exVat -= amount
}
return exVat
}
func (p *Price) TotalVat() int64 {
total := int64(0)
for _, amount := range p.VatRates {
total += amount
}
return total
}
func MultiplyPrice(p Price, qty int64) *Price {
ret := &Price{
IncVat: p.IncVat * qty,
VatRates: make(map[float32]int64),
}
for rate, amount := range p.VatRates {
ret.VatRates[rate] = amount * qty
}
return ret
}
func (p *Price) Multiply(qty int64) {
p.IncVat *= qty
for rate, amount := range p.VatRates {
p.VatRates[rate] = amount * qty
}
}
func (p Price) MarshalJSON() ([]byte, error) {
// Build a stable wire format without calling Price.MarshalJSON recursively
exVat := p.ValueExVat()
var vat map[string]int64
if len(p.VatRates) > 0 {
vat = make(map[string]int64, len(p.VatRates))
for rate, amount := range p.VatRates {
// Rely on default formatting that trims trailing zeros for whole numbers
// Using %g could output scientific notation for large numbers; float32 rates here are small.
key := trimFloat(rate)
vat[key] = amount
}
}
type wire struct {
ExVat int64 `json:"exVat"`
IncVat int64 `json:"incVat"`
Vat map[string]int64 `json:"vat,omitempty"`
}
return json.Marshal(wire{ExVat: exVat, IncVat: p.IncVat, Vat: vat})
}
func (p *Price) UnmarshalJSON(data []byte) error {
type wire struct {
ExVat int64 `json:"exVat"`
IncVat int64 `json:"incVat"`
Vat map[string]int64 `json:"vat,omitempty"`
}
var w wire
if err := json.Unmarshal(data, &w); err != nil {
return err
}
p.IncVat = w.IncVat
if len(w.Vat) > 0 {
p.VatRates = make(map[float32]int64, len(w.Vat))
for rateStr, amount := range w.Vat {
rate, err := strconv.ParseFloat(rateStr, 32)
if err != nil {
return err
}
p.VatRates[float32(rate)] = amount
}
} else {
p.VatRates = make(map[float32]int64)
}
return nil
}
// trimFloat converts a float32 tax rate like 25 or 12.5 into a compact string without
// unnecessary decimals ("25", "12.5").
func trimFloat(f float32) string {
// Convert via FormatFloat then trim trailing zeros and dot.
s := strconv.FormatFloat(float64(f), 'f', -1, 32)
return s
}
func (p *Price) Add(price Price) {
p.IncVat += price.IncVat
for rate, amount := range price.VatRates {
p.VatRates[rate] += amount
}
}
func (p *Price) Subtract(price Price) {
p.IncVat -= price.IncVat
for rate, amount := range price.VatRates {
p.VatRates[rate] -= amount
}
}
func SumPrices(prices ...Price) *Price {
if len(prices) == 0 {
return NewPrice()
}
aggregated := NewPrice()
for _, price := range prices {
aggregated.IncVat += price.IncVat
for rate, amount := range price.VatRates {
aggregated.VatRates[rate] += amount
}
}
if len(aggregated.VatRates) == 0 {
aggregated.VatRates = nil
}
return aggregated
}
// SumPrices aggregates multiple Prices into one.
func SumPrices(prices ...Price) *Price { return tax.SumPrices(prices...) }
+51 -46
View File
@@ -3,10 +3,12 @@ package cart
import (
"encoding/json"
"testing"
"git.k6n.net/mats/platform/money"
)
func TestPriceMarshalJSON(t *testing.T) {
p := Price{IncVat: 13700, VatRates: map[float32]int64{25: 2500, 12: 1200}}
p := Price{IncVat: 13700, VatRates: map[int]money.Cents{2500: 2500, 1200: 1200}}
// ExVat = 13700 - (2500+1200) = 10000
data, err := json.Marshal(p)
if err != nil {
@@ -27,29 +29,29 @@ func TestPriceMarshalJSON(t *testing.T) {
if out.IncVat != 13700 {
t.Fatalf("expected incVat 13700 got %d", out.IncVat)
}
if out.Vat["25"] != 2500 || out.Vat["12"] != 1200 {
if out.Vat["2500"] != 2500 || out.Vat["1200"] != 1200 {
t.Fatalf("unexpected vat map: %#v", out.Vat)
}
}
func TestNewPriceFromIncVat(t *testing.T) {
p := NewPriceFromIncVat(1250, 25)
p := NewPriceFromIncVat(1250, 2500) // 25% in basis points
if p.IncVat != 1250 {
t.Fatalf("expected IncVat %d got %d", 1250, p.IncVat)
}
if p.VatRates[25] != 250 {
t.Fatalf("expected VAT 25 rate %d got %d", 250, p.VatRates[25])
if p.VatRates[2500] != 250 {
t.Fatalf("expected VAT 2500bp rate %d got %d", 250, p.VatRates[2500])
}
if p.ValueExVat() != 1000 {
t.Fatalf("expected exVat %d got %d", 750, p.ValueExVat())
t.Fatalf("expected exVat %d got %d", 1000, p.ValueExVat())
}
}
func TestSumPrices(t *testing.T) {
// We'll construct prices via raw struct since constructor expects tax math.
// IncVat already includes vat portions.
a := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}} // ex=1000
b := Price{IncVat: 2740, VatRates: map[float32]int64{25: 500, 12: 240}} // ex=2000
// IncVat already includes vat portions. Keys are basis points (2500 = 25%).
a := Price{IncVat: 1250, VatRates: map[int]money.Cents{2500: 250}} // ex=1000
b := Price{IncVat: 2740, VatRates: map[int]money.Cents{2500: 500, 1200: 240}} // ex=2000
c := Price{IncVat: 0, VatRates: nil}
sum := SumPrices(a, b, c)
@@ -60,11 +62,11 @@ func TestSumPrices(t *testing.T) {
if len(sum.VatRates) != 2 {
t.Fatalf("expected 2 vat rates got %d", len(sum.VatRates))
}
if sum.VatRates[25] != 750 {
t.Fatalf("expected 25%% vat 750 got %d", sum.VatRates[25])
if sum.VatRates[2500] != 750 {
t.Fatalf("expected 25%% vat 750 got %d", sum.VatRates[2500])
}
if sum.VatRates[12] != 240 {
t.Fatalf("expected 12%% vat 240 got %d", sum.VatRates[12])
if sum.VatRates[1200] != 240 {
t.Fatalf("expected 12%% vat 240 got %d", sum.VatRates[1200])
}
if sum.ValueExVat() != 3000 { // 3990 - (750+240)
t.Fatalf("expected exVat 3000 got %d", sum.ValueExVat())
@@ -73,19 +75,21 @@ func TestSumPrices(t *testing.T) {
func TestSumPricesEmpty(t *testing.T) {
sum := SumPrices()
if sum.IncVat != 0 || sum.VatRates == nil { // constructor sets empty map
// SumPrices nils an empty VatRates map (cleaner JSON); a nil map still reads
// as zero, so only the totals need to be zero here.
if sum.IncVat != 0 || sum.TotalVat() != 0 {
t.Fatalf("expected zero price got %#v", sum)
}
}
func TestMultiplyPriceFunction(t *testing.T) {
base := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
base := Price{IncVat: 1250, VatRates: map[int]money.Cents{2500: 250}}
multiplied := MultiplyPrice(base, 3)
if multiplied.IncVat != 1250*3 {
t.Fatalf("expected IncVat %d got %d", 1250*3, multiplied.IncVat)
}
if multiplied.VatRates[25] != 250*3 {
t.Fatalf("expected VAT 25 rate %d got %d", 250*3, multiplied.VatRates[25])
if multiplied.VatRates[2500] != 250*3 {
t.Fatalf("expected VAT 2500bp rate %d got %d", 250*3, multiplied.VatRates[2500])
}
if multiplied.ValueExVat() != (1250-250)*3 {
t.Fatalf("expected exVat %d got %d", (1250-250)*3, multiplied.ValueExVat())
@@ -93,8 +97,8 @@ func TestMultiplyPriceFunction(t *testing.T) {
}
func TestPriceAddSubtract(t *testing.T) {
a := Price{IncVat: 1000, VatRates: map[float32]int64{25: 200}}
b := Price{IncVat: 500, VatRates: map[float32]int64{25: 100, 12: 54}}
a := Price{IncVat: 1000, VatRates: map[int]money.Cents{2500: 200}}
b := Price{IncVat: 500, VatRates: map[int]money.Cents{2500: 100, 1200: 54}}
acc := NewPrice()
acc.Add(a)
@@ -103,7 +107,7 @@ func TestPriceAddSubtract(t *testing.T) {
if acc.IncVat != 1500 {
t.Fatalf("expected IncVat 1500 got %d", acc.IncVat)
}
if acc.VatRates[25] != 300 || acc.VatRates[12] != 54 {
if acc.VatRates[2500] != 300 || acc.VatRates[1200] != 54 {
t.Fatalf("unexpected VAT map: %#v", acc.VatRates)
}
@@ -113,46 +117,47 @@ func TestPriceAddSubtract(t *testing.T) {
if acc.IncVat != 0 {
t.Fatalf("expected IncVat 0 got %d", acc.IncVat)
}
if len(acc.VatRates) != 2 || acc.VatRates[25] != 0 || acc.VatRates[12] != 0 {
if len(acc.VatRates) != 2 || acc.VatRates[2500] != 0 || acc.VatRates[1200] != 0 {
t.Fatalf("expected zeroed vat rates got %#v", acc.VatRates)
}
}
func TestPriceMultiplyMethod(t *testing.T) {
p := Price{IncVat: 2000, VatRates: map[float32]int64{25: 400}}
p := Price{IncVat: 2000, VatRates: map[int]money.Cents{2500: 400}}
// Value before multiply
exBefore := p.ValueExVat()
p.Multiply(2)
if p.IncVat != 4000 {
t.Fatalf("expected IncVat 4000 got %d", p.IncVat)
}
if p.VatRates[25] != 800 {
t.Fatalf("expected VAT 800 got %d", p.VatRates[25])
if p.VatRates[2500] != 800 {
t.Fatalf("expected VAT 800 got %d", p.VatRates[2500])
}
if p.ValueExVat() != exBefore*2 {
t.Fatalf("expected exVat %d got %d", exBefore*2, p.ValueExVat())
}
}
func TestGetTaxAmount(t *testing.T) {
func TestComputeTax(t *testing.T) {
tests := []struct {
total int64
tax int
rateBp int
expected int64
desc string
}{
{1250, 2500, 250, "25% VAT"}, // 1250 / (1 + 100/25) = 1250 / 5 = 250
{1000, 2000, 166, "20% VAT"}, // 1000 / (1 + 100/20) = 1000 / 6 ≈ 166
// ex-VAT-first integer math: tax = total - total*10000/(10000+rateBp).
{1250, 2500, 250, "25% VAT"}, // exVat 1000, tax 250
{1000, 2000, 167, "20% VAT"}, // exVat 1000*10000/12000=833, tax 167 (Klarna convention)
{1200, 2500, 240, "25% VAT on 1200"},
{0, 2500, 0, "zero total"},
{100, 1000, 9, "10% VAT"}, // tax=1000 for 10%, 100 / (1 + 100/10) = 100 / 11 ≈ 9
{100, 10000, 50, "100% VAT"}, // tax=10000 for 100%, 100 / (1 + 100/100) = 100 / 2 = 50
{100, 1000, 10, "10% VAT"}, // exVat 100*10000/11000=90, tax 10
{100, 10000, 50, "100% VAT"}, // exVat 50, tax 50
}
for _, tt := range tests {
result := GetTaxAmount(tt.total, tt.tax)
result := ComputeTax(money.Cents(tt.total), tt.rateBp).Int64()
if result != tt.expected {
t.Errorf("GetTaxAmount(%d, %d) [%s] = %d; expected %d", tt.total, tt.tax, tt.desc, result, tt.expected)
t.Errorf("ComputeTax(%d, %d) [%s] = %d; expected %d", tt.total, tt.rateBp, tt.desc, result, tt.expected)
}
}
}
@@ -170,11 +175,11 @@ func TestNewPriceFromIncVatEdgeCases(t *testing.T) {
t.Errorf("expected exVat 1000, got %d", p.ValueExVat())
}
// High VAT rate, e.g., 50%
p = NewPriceFromIncVat(1500, 50)
expectedVat := int64(1500 / (1 + 100/50)) // 1500 / 3 = 500
if p.VatRates[50] != expectedVat {
t.Errorf("expected VAT %d for 50%%, got %d", expectedVat, p.VatRates[50])
// High VAT rate, e.g., 50% = 5000 basis points
p = NewPriceFromIncVat(1500, 5000)
expectedVat := money.Cents(500) // exVat 1500*10000/15000=1000, tax 500
if p.VatRates[5000] != expectedVat {
t.Errorf("expected VAT %d for 50%%, got %d", expectedVat, p.VatRates[5000])
}
if p.ValueExVat() != 1500-expectedVat {
t.Errorf("expected exVat %d, got %d", 1500-expectedVat, p.ValueExVat())
@@ -182,7 +187,7 @@ func TestNewPriceFromIncVatEdgeCases(t *testing.T) {
}
func TestPriceValueExVatAndTotalVat(t *testing.T) {
p := Price{IncVat: 13700, VatRates: map[float32]int64{25: 2500, 12: 1200}}
p := Price{IncVat: 13700, VatRates: map[int]money.Cents{2500: 2500, 1200: 1200}}
exVat := p.ValueExVat()
totalVat := p.TotalVat()
if exVat != 10000 {
@@ -206,33 +211,33 @@ func TestPriceValueExVatAndTotalVat(t *testing.T) {
}
func TestMultiplyPriceWithZeroQty(t *testing.T) {
base := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
base := Price{IncVat: 1250, VatRates: map[int]money.Cents{2500: 250}}
multiplied := MultiplyPrice(base, 0)
if multiplied.IncVat != 0 {
t.Errorf("expected IncVat 0, got %d", multiplied.IncVat)
}
if len(multiplied.VatRates) != 1 || multiplied.VatRates[25] != 0 {
if len(multiplied.VatRates) != 1 || multiplied.VatRates[2500] != 0 {
t.Errorf("expected VAT 0, got %v", multiplied.VatRates)
}
}
func TestPriceAddSubtractEdgeCases(t *testing.T) {
a := Price{IncVat: 1000, VatRates: map[float32]int64{25: 200}}
b := Price{IncVat: 500, VatRates: map[float32]int64{12: 54}} // Different rate
a := Price{IncVat: 1000, VatRates: map[int]money.Cents{2500: 200}}
b := Price{IncVat: 500, VatRates: map[int]money.Cents{1200: 54}} // Different rate
acc := NewPrice()
acc.Add(a)
acc.Add(b)
if acc.VatRates[25] != 200 || acc.VatRates[12] != 54 {
t.Errorf("expected VAT 25:200, 12:54, got %v", acc.VatRates)
if acc.VatRates[2500] != 200 || acc.VatRates[1200] != 54 {
t.Errorf("expected VAT 2500:200, 1200:54, got %v", acc.VatRates)
}
// Subtract more than added (negative VAT)
acc.Subtract(a)
acc.Subtract(b)
acc.Subtract(a) // Subtract extra a
if acc.VatRates[25] != -200 || acc.VatRates[12] != 0 {
t.Errorf("expected negative VAT for 25 after over-subtract, got %v", acc.VatRates)
if acc.VatRates[2500] != -200 || acc.VatRates[1200] != 0 {
t.Errorf("expected negative VAT for 2500 after over-subtract, got %v", acc.VatRates)
}
}
+17 -51
View File
@@ -1,58 +1,24 @@
package cart
import (
"git.k6n.net/mats/platform/money"
"git.k6n.net/mats/platform/tax"
)
// TaxProvider computes taxes for orders and line items.
// It mirrors the PaymentProvider and ShippingProvider seam patterns: one
// interface, interchangeable implementations.
//
// All tax rates are expressed as raw percent (e.g. 25 = 25 %). This matches the
// OrderLine.tax_rate proto convention.
type TaxProvider interface {
// Name returns the provider name for identification (logging, metrics).
Name() string
// The canonical definition is now in platform/tax; this is a type alias
// for backward compatibility.
type TaxProvider = tax.Provider
// ComputeTax computes the tax portion from a gross (inc-VAT) total and tax
// rate. Both total and return value are in minor currency units (ore).
// taxRate is expressed as raw percent (e.g. 25 = 25 %).
//
// Formula: tax = total x rate / (100 + rate)
//
// When taxRate <= 0 the result is 0 (untaxed / zero-rated items).
ComputeTax(total int64, taxRate int) int64
// DefaultTaxRate returns the default VAT rate for a country, expressed as
// raw percent (e.g. 25 = 25 %). Used for deliveries and items where no
// explicit rate was recorded at add-to-cart time.
//
// Return 0 if the country is unknown or untaxed.
DefaultTaxRate(country string) int
// ComputeTax returns the VAT portion of an inc-VAT total. rateBp is basis points
// (2500 = 25%). Re-exported from platform/tax.
func ComputeTax(total money.Cents, rateBp int) money.Cents {
return money.Cents(tax.Compute(total.Int64(), rateBp))
}
// ComputeTax is the shared tax formula: tax = total x rate / (100 + rate).
// taxRate is raw percent (e.g. 25 = 25 %). When taxRate <= 0 the result is 0.
//
// This differs from GetTaxAmount which uses the CartItem convention
// (percent x 100, e.g. 2500 = 25.00 %) with formula total x rate / (10000 + rate).
func ComputeTax(total int64, taxRate int) int64 {
if taxRate <= 0 {
return 0
}
return total * int64(taxRate) / int64(100+taxRate)
}
// StaticTaxProvider is a stateless, always-available TaxProvider.
// Re-exported from platform/tax for backward compatibility.
type StaticTaxProvider = tax.Static
// StaticTaxProvider is a stateless, always-available TaxProvider that simply
// runs the formula without any country-specific rates. Use for local dev,
// tests, and as a no-dependency default.
type StaticTaxProvider struct{}
var _ TaxProvider = (*StaticTaxProvider)(nil)
func NewStaticTaxProvider() *StaticTaxProvider { return &StaticTaxProvider{} }
func (p *StaticTaxProvider) Name() string { return "static" }
func (p *StaticTaxProvider) ComputeTax(total int64, taxRate int) int64 {
return ComputeTax(total, taxRate)
}
// DefaultTaxRate returns 0 for any country (no awareness).
func (p *StaticTaxProvider) DefaultTaxRate(_ string) int { return 0 }
// NewStaticTaxProvider returns a new StaticTaxProvider.
func NewStaticTaxProvider() *StaticTaxProvider { return tax.NewStatic() }
+8 -50
View File
@@ -1,55 +1,13 @@
package cart
// NordicTaxProvider implements TaxProvider for the Nordic countries
// (SE, NO, DK, FI). It encodes the standard statutory VAT rates:
//
// SE: 25 % (standard), 12 % (food, hotels), 6 % (books, transport)
// NO: 25 % (standard), 15 % (food), 12 %
// DK: 25 % (standard)
// FI: 25 % (standard; actual rate is 25.5 % as of 2026)
//
// All rates are raw percent (25 = 25 %). Fractional rates (FI 25.5) are
// truncated to the nearest integer per the raw-percent convention.
//
// NordicTaxProvider does not make network calls — all logic is local math.
// An external provider (Avalara, TaxJar) can be added by implementing
// TaxProvider and switching on TAX_PROVIDER.
type NordicTaxProvider struct {
defaultCountry string
}
import "git.k6n.net/mats/platform/tax"
// NewNordicTaxProvider returns a NordicTaxProvider. defaultCountry is used
// when DefaultTaxRate is called with an empty country code; it defaults to
// "SE" if empty.
// NordicTaxProvider implements TaxProvider for the Nordic countries.
// The canonical definition is now in platform/tax; this is a type alias
// for backward compatibility.
type NordicTaxProvider = tax.Nordic
// NewNordicTaxProvider returns a new NordicTaxProvider.
func NewNordicTaxProvider(defaultCountry string) *NordicTaxProvider {
if defaultCountry == "" {
defaultCountry = "SE"
}
return &NordicTaxProvider{defaultCountry: defaultCountry}
}
var _ TaxProvider = (*NordicTaxProvider)(nil)
func (p *NordicTaxProvider) Name() string { return "nordic" }
func (p *NordicTaxProvider) ComputeTax(total int64, taxRate int) int64 {
return ComputeTax(total, taxRate)
}
// nordicStandardRates maps country code → default standard VAT rate (raw percent).
var nordicStandardRates = map[string]int{
"SE": 25,
"NO": 25,
"DK": 25,
"FI": 25,
}
func (p *NordicTaxProvider) DefaultTaxRate(country string) int {
if country == "" {
country = p.defaultCountry
}
if rate, ok := nordicStandardRates[country]; ok {
return rate
}
return nordicStandardRates[p.defaultCountry]
return tax.NewNordic(defaultCountry)
}
+24 -42
View File
@@ -11,28 +11,29 @@ func TestNordicTaxProvider_Name(t *testing.T) {
}
}
func TestNordicTaxProvider_ComputeTax(t *testing.T) {
func TestNordicTaxProvider_Compute(t *testing.T) {
// taxRate is basis points (2500 = 25%).
tests := []struct {
total int64
taxRate int
want int64
desc string
}{
{0, 25, 0, "zero total"},
{1250, 25, 250, "25 % VAT on 1250"},
{1000, 20, 166, "20 % VAT on 1000"},
{1200, 25, 240, "25 % VAT on 1200"},
{25000, 25, 5000, "25 % VAT on 25000"},
{100, 10, 9, "10 % VAT on 100"},
{100, 100, 50, "100 % VAT on 100"},
{0, 2500, 0, "zero total"},
{1250, 2500, 250, "25 % VAT on 1250"},
{1000, 2000, 167, "20 % VAT on 1000"},
{1200, 2500, 240, "25 % VAT on 1200"},
{25000, 2500, 5000, "25 % VAT on 25000"},
{100, 1000, 10, "10 % VAT on 100"},
{100, 10000, 50, "100 % VAT on 100"},
{100, 0, 0, "zero-rate"},
{100, -1, 0, "negative rate -> zero"},
}
for _, tt := range tests {
p := NewNordicTaxProvider("SE")
got := p.ComputeTax(tt.total, tt.taxRate)
got := p.Compute(tt.total, tt.taxRate)
if got != tt.want {
t.Errorf("ComputeTax(%d, %d) [%s] = %d; want %d", tt.total, tt.taxRate, tt.desc, got, tt.want)
t.Errorf("Compute(%d, %d) [%s] = %d; want %d", tt.total, tt.taxRate, tt.desc, got, tt.want)
}
}
}
@@ -43,12 +44,12 @@ func TestNordicTaxProvider_DefaultTaxRate(t *testing.T) {
want int
desc string
}{
{"SE", 25, "Sweden"},
{"NO", 25, "Norway"},
{"DK", 25, "Denmark"},
{"FI", 25, "Finland"},
{"", 25, "empty -> default SE"},
{"DE", 25, "unknown -> fallback SE"},
{"SE", 2500, "Sweden"},
{"NO", 2500, "Norway"},
{"DK", 2500, "Denmark"},
{"FI", 2550, "Finland (25.5%)"},
{"", 2500, "empty -> default SE"},
{"DE", 2500, "unknown -> fallback SE"},
}
for _, tt := range tests {
p := NewNordicTaxProvider("SE")
@@ -59,22 +60,22 @@ func TestNordicTaxProvider_DefaultTaxRate(t *testing.T) {
}
}
func TestStaticTaxProvider_ComputeTax(t *testing.T) {
func TestStaticTaxProvider_Compute(t *testing.T) {
p := NewStaticTaxProvider()
tests := []struct {
total int64
taxRate int
want int64
}{
{1250, 25, 250},
{25000, 25, 5000},
{1000, 20, 166},
{0, 25, 0},
{1250, 2500, 250},
{25000, 2500, 5000},
{1000, 2000, 167},
{0, 2500, 0},
}
for _, tt := range tests {
got := p.ComputeTax(tt.total, tt.taxRate)
got := p.Compute(tt.total, tt.taxRate)
if got != tt.want {
t.Errorf("ComputeTax(%d, %d) = %d; want %d", tt.total, tt.taxRate, got, tt.want)
t.Errorf("Compute(%d, %d) = %d; want %d", tt.total, tt.taxRate, got, tt.want)
}
}
}
@@ -86,25 +87,6 @@ func TestStaticTaxProvider_DefaultTaxRate(t *testing.T) {
}
}
func TestComputeTax(t *testing.T) {
tests := []struct {
total int64
taxRate int
want int64
desc string
}{
{1250, 25, 250, "canonical: 25 %"},
{25000, 25, 5000, "25k with 25 %% -> 5k tax"},
{0, 25, 0, "zero total"},
}
for _, tt := range tests {
got := ComputeTax(tt.total, tt.taxRate)
if got != tt.want {
t.Errorf("ComputeTax(%d, %d) [%s] = %d; want %d", tt.total, tt.taxRate, tt.desc, got, tt.want)
}
}
}
func TestTaxProviderImplementsInterface(t *testing.T) {
var p TaxProvider = NewNordicTaxProvider("SE")
_ = p