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