This commit is contained in:
mats committed 2026-06-28 17:52:49 +02:00
1 parent e706f7d907
commit 21bcb63440
42 files changed
+5903

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
package tax
// nordicStandardRates maps country code -> default standard VAT rate in basis
// points of a percent (2500 = 25%).
var nordicStandardRates = map[string]int{
"SE": 2500,
"NO": 2500,
"DK": 2500,
"FI": 2550, // 25.5% as of 2024 — exact in basis points, no truncation
}
// Nordic implements Provider for the Nordic countries (SE, NO, DK, FI).
// It encodes the standard statutory VAT rates (basis points):
//
// SE: 25 % (2500)
// NO: 25 % (2500)
// DK: 25 % (2500)
// FI: 25.5 % (2550)
//
// Rates are integer basis points, so fractional rates (FI 25.5) are represented
// exactly — no float, no truncation.
//
// Nordic does not make network calls — all logic is local math. An external
// provider (Avalara, TaxJar) can be added by implementing Provider and
// switching on TAX_PROVIDER.
type Nordic struct {
defaultCountry string
}
// NewNordic returns a Nordic provider. defaultCountry is used when
// DefaultTaxRate is called with an empty country code; it defaults to "SE"
// if empty.
func NewNordic(defaultCountry string) *Nordic {
if defaultCountry == "" {
defaultCountry = "SE"
}
return &Nordic{defaultCountry: defaultCountry}
}
var _ Provider = (*Nordic)(nil)
func (p *Nordic) Name() string { return "nordic" }
func (p *Nordic) Compute(total int64, taxRate int) int64 {
return Compute(total, taxRate)
}
func (p *Nordic) DefaultTaxRate(country string) int {
if country == "" {
country = p.defaultCountry
}
if rate, ok := nordicStandardRates[country]; ok {
return rate
}
return nordicStandardRates[p.defaultCountry]
}
+175
View File
@@ -0,0 +1,175 @@
package tax
import (
"encoding/json"
"strconv"
"git.k6n.net/mats/platform/money"
)
// Price is a monetary amount in minor units (money.Cents) with its VAT broken
// down by rate. IncVat is the gross total; VatRates maps each tax rate — basis
// points of a percent, 2500 = 25%, 1250 = 12.5% (the single platform scale,
// matching OrderLine.tax_rate) — to its VAT amount in minor units.
//
// All arithmetic is integer (money.Cents is int64-backed); VAT is only ever
// computed by Compute (ex-VAT-first), always on a real total. Aggregation
// (Add/SumPrices) only sums integers, so totals never drift from the sum of
// their parts. The JSON wire shape is unchanged — Cents marshals as int64.
type Price struct {
IncVat money.Cents `json:"incVat"`
VatRates map[int]money.Cents `json:"vat,omitempty"`
}
// NewPrice returns a new zero Price.
func NewPrice() *Price {
return &Price{
IncVat: 0,
VatRates: make(map[int]money.Cents),
}
}
// NewPriceFromIncVat builds a Price from a gross total and a tax rate in basis
// points (2500 = 25%, 1250 = 12.5%). VAT is computed once via Compute, so
// exVat + vat == incVat exactly.
func NewPriceFromIncVat(incVat money.Cents, rateBp int) *Price {
return &Price{
IncVat: incVat,
VatRates: map[int]money.Cents{
rateBp: money.Cents(Compute(incVat.Int64(), rateBp)),
},
}
}
// ValueExVat returns the price excluding VAT.
func (p *Price) ValueExVat() money.Cents {
exVat := p.IncVat
for _, amount := range p.VatRates {
exVat -= amount
}
return exVat
}
// TotalVat returns the total VAT amount.
func (p *Price) TotalVat() money.Cents {
total := money.Cents(0)
for _, amount := range p.VatRates {
total += amount
}
return total
}
// MultiplyPrice scales a Price to a quantity and returns a new Price.
//
// For a single-rate price (a line item) it recomputes the VAT on the *scaled
// total* via NewPriceFromIncVat — not by multiplying the per-unit VAT — so the
// result matches a payment provider that re-derives tax from the line total, and
// VAT rounding never accumulates per unit. VAT is only ever computed on a real
// total.
//
// A zero-rate price scales its (untaxed) IncVat. A multi-rate price (which a line
// item never is — totals are built by Add, never Multiply) falls back to scaling
// each component, since IncVat can't be split across rates.
func MultiplyPrice(p Price, qty int64) *Price {
if len(p.VatRates) == 1 {
for rate := range p.VatRates {
return NewPriceFromIncVat(p.IncVat.Mul(qty), rate)
}
}
ret := &Price{
IncVat: p.IncVat.Mul(qty),
VatRates: make(map[int]money.Cents, len(p.VatRates)),
}
for rate, amount := range p.VatRates {
ret.VatRates[rate] = amount.Mul(qty)
}
return ret
}
// Multiply scales the Price by a quantity in place (see MultiplyPrice).
func (p *Price) Multiply(qty int64) {
res := MultiplyPrice(*p, qty)
p.IncVat = res.IncVat
p.VatRates = res.VatRates
}
// MarshalJSON serializes Price as a wire-friendly object with exVat, incVat, and
// vat (rate-keyed by raw percent as a string, e.g. "25"). Cents marshals as
// int64, so the wire bytes are byte-identical to the pre-money.Cents shape.
func (p Price) MarshalJSON() ([]byte, error) {
exVat := p.ValueExVat()
var vat map[string]money.Cents
if len(p.VatRates) > 0 {
vat = make(map[string]money.Cents, len(p.VatRates))
for rate, amount := range p.VatRates {
vat[strconv.Itoa(rate)] = amount
}
}
type wire struct {
ExVat money.Cents `json:"exVat"`
IncVat money.Cents `json:"incVat"`
Vat map[string]money.Cents `json:"vat,omitempty"`
}
return json.Marshal(wire{ExVat: exVat, IncVat: p.IncVat, Vat: vat})
}
// UnmarshalJSON deserializes Price from its wire format.
func (p *Price) UnmarshalJSON(data []byte) error {
type wire struct {
ExVat money.Cents `json:"exVat"`
IncVat money.Cents `json:"incVat"`
Vat map[string]money.Cents `json:"vat,omitempty"`
}
var w wire
if err := json.Unmarshal(data, &w); err != nil {
return err
}
p.IncVat = w.IncVat
p.VatRates = make(map[int]money.Cents, len(w.Vat))
for rateStr, amount := range w.Vat {
// Tolerate legacy float-style keys ("2500", "1250") by truncating to the
// basis point integer convention.
rate, err := strconv.Atoi(rateStr)
if err != nil {
f, ferr := strconv.ParseFloat(rateStr, 64)
if ferr != nil {
return err
}
rate = int(f)
}
p.VatRates[rate] = amount
}
return nil
}
// Add adds another Price's values to this one.
func (p *Price) Add(price Price) {
p.IncVat += price.IncVat
for rate, amount := range price.VatRates {
p.VatRates[rate] += amount
}
}
// Subtract subtracts another Price's values from this one.
func (p *Price) Subtract(price Price) {
p.IncVat -= price.IncVat
for rate, amount := range price.VatRates {
p.VatRates[rate] -= amount
}
}
// SumPrices aggregates multiple Prices into one.
func SumPrices(prices ...Price) *Price {
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
}
+62
View File
@@ -0,0 +1,62 @@
// Package tax provides the tax computation seam: a Provider interface
// (mirroring PaymentProvider and ShippingProvider patterns), the shared Compute
// formula, and a stateless Static provider for dev/test.
//
// SCALE: all tax rates are integer **basis points of a percent** — rate × 100,
// so 25% = 2500, 12.5% = 1250, 8.1% = 810. This is the single platform-wide
// scale (Price.VatRates, CartItem.Tax, OrderLine.tax_rate, the payment-provider
// wire). Integer basis points support fractional VAT rates (Ireland 13.5%,
// France 5.5%, Finland 25.5%) exactly without ever using a float.
package tax
// Provider computes taxes for orders and line items.
type Provider interface {
// Name returns the provider name for identification (logging, metrics).
Name() string
// Compute computes the VAT portion from a gross (inc-VAT) total and tax
// rate. Both total and return value are in minor currency units (öre).
// rateBp is basis points of a percent (2500 = 25%, 1250 = 12.5%).
//
// Ex-VAT-first integer math: exVat = total*10000/(10000+rateBp); tax = total - exVat.
// When rateBp <= 0 the result is 0 (untaxed / zero-rated items).
Compute(total int64, rateBp int) int64
// DefaultTaxRate returns the default VAT rate for a country in basis points
// (2500 = 25%). Used for deliveries and items where no explicit rate was
// recorded at add-to-cart time. Returns 0 if the country is unknown/untaxed.
DefaultTaxRate(country string) int
}
// Compute returns the VAT portion of an inc-VAT total using integer (cent)
// arithmetic only, ex-VAT-first so exVat + tax == total exactly and the result
// matches the payment-provider convention (no per-line rounding drift):
//
// exVat = total * 10000 / (10000 + rateBp)
// tax = total - exVat
//
// rateBp is basis points of a percent (2500 = 25%, 1250 = 12.5%) — the single
// platform-wide rate scale. When rateBp <= 0 the result is 0 (untaxed).
func Compute(total int64, rateBp int) int64 {
if rateBp <= 0 {
return 0
}
exVat := total * 10000 / int64(10000+rateBp)
return total - exVat
}
// Static is a stateless, always-available Provider that simply runs the
// formula without any country-specific rates. Use for local dev, tests,
// and as a no-dependency default.
type Static struct{}
var _ Provider = (*Static)(nil)
// NewStatic returns a new Static provider.
func NewStatic() *Static { return &Static{} }
func (p *Static) Name() string { return "static" }
func (p *Static) Compute(total int64, taxRate int) int64 {
return Compute(total, taxRate)
}
func (p *Static) DefaultTaxRate(_ string) int { return 0 }
+223
View File
@@ -0,0 +1,223 @@
package tax
import (
"encoding/json"
"testing"
)
func TestStatic_Name(t *testing.T) {
p := NewStatic()
if got := p.Name(); got != "static" {
t.Errorf("Name() = %q, want %q", got, "static")
}
}
func TestStatic_Compute(t *testing.T) {
// rateBp is basis points (2500 = 25%).
p := NewStatic()
tests := []struct {
total int64
rateBp int
want int64
}{
{1250, 2500, 250},
{25000, 2500, 5000},
{1000, 2000, 167},
{0, 2500, 0},
}
for _, tt := range tests {
got := p.Compute(tt.total, tt.rateBp)
if got != tt.want {
t.Errorf("Compute(%d, %d) = %d; want %d", tt.total, tt.rateBp, got, tt.want)
}
}
}
func TestStatic_DefaultTaxRate(t *testing.T) {
p := NewStatic()
if got := p.DefaultTaxRate("SE"); got != 0 {
t.Errorf("DefaultTaxRate(\"SE\") = %d; want 0", got)
}
}
func TestNordic_Name(t *testing.T) {
p := NewNordic("SE")
if got := p.Name(); got != "nordic" {
t.Errorf("Name() = %q, want %q", got, "nordic")
}
}
func TestNordic_Compute(t *testing.T) {
// rateBp is basis points (2500 = 25%).
tests := []struct {
total int64
rateBp int
want int64
desc string
}{
{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 := NewNordic("SE")
got := p.Compute(tt.total, tt.rateBp)
if got != tt.want {
t.Errorf("Compute(%d, %d) [%s] = %d; want %d", tt.total, tt.rateBp, tt.desc, got, tt.want)
}
}
}
func TestNordic_DefaultTaxRate(t *testing.T) {
tests := []struct {
country string
want int
desc string
}{
{"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 := NewNordic("SE")
got := p.DefaultTaxRate(tt.country)
if got != tt.want {
t.Errorf("DefaultTaxRate(%q) [%s] = %d; want %d", tt.country, tt.desc, got, tt.want)
}
}
}
func TestCompute(t *testing.T) {
// rateBp is basis points (2500 = 25%).
tests := []struct {
total int64
rateBp int
want int64
desc string
}{
{1250, 2500, 250, "canonical: 25 %"},
{25000, 2500, 5000, "25k with 25 % -> 5k tax"},
{0, 2500, 0, "zero total"},
{1000, 1250, 112, "fractional: 12.5 %"}, // exVat 1000*10000/11250=888, tax 112
}
for _, tt := range tests {
got := Compute(tt.total, tt.rateBp)
if got != tt.want {
t.Errorf("Compute(%d, %d) [%s] = %d; want %d", tt.total, tt.rateBp, tt.desc, got, tt.want)
}
}
}
func TestProviderInterface(t *testing.T) {
var p Provider = NewNordic("SE")
_ = p
p2 := NewStatic()
_ = p2
}
func TestPrice_NewAndString(t *testing.T) {
p := NewPrice()
if p.IncVat != 0 {
t.Errorf("NewPrice().IncVat = %d; want 0", p.IncVat)
}
}
func TestPrice_NewPriceFromIncVat(t *testing.T) {
p := NewPriceFromIncVat(1250, 2500) // 25% in basis points
if p.IncVat != 1250 {
t.Errorf("IncVat = %d; want 1250", p.IncVat)
}
if len(p.VatRates) != 1 {
t.Errorf("expected 1 vat rate, got %d", len(p.VatRates))
}
}
func TestPrice_ValueExVat(t *testing.T) {
p := NewPriceFromIncVat(1250, 2500) // 25%
exVat := p.ValueExVat()
if exVat != 1000 {
t.Errorf("ValueExVat() = %d; want 1000", exVat)
}
}
func TestPrice_TotalVat(t *testing.T) {
p := NewPriceFromIncVat(1250, 2500) // 25%
totalVat := p.TotalVat()
if totalVat != 250 {
t.Errorf("TotalVat() = %d; want 250", totalVat)
}
}
func TestPrice_Multiply(t *testing.T) {
p := NewPriceFromIncVat(1250, 2500)
p.Multiply(2)
if p.IncVat != 2500 {
t.Errorf("IncVat after Multiply(2) = %d; want 2500", p.IncVat)
}
}
func TestPrice_MultiplyPrice(t *testing.T) {
p := NewPriceFromIncVat(1250, 2500)
p2 := MultiplyPrice(*p, 3)
if p2.IncVat != 3750 {
t.Errorf("IncVat = %d; want 3750", p2.IncVat)
}
}
func TestPrice_Add(t *testing.T) {
a := NewPriceFromIncVat(1250, 2500)
b := NewPriceFromIncVat(2500, 2500)
a.Add(*b)
if a.IncVat != 3750 {
t.Errorf("IncVat after Add = %d; want 3750", a.IncVat)
}
}
func TestPrice_Subtract(t *testing.T) {
a := NewPriceFromIncVat(2500, 2500)
b := NewPriceFromIncVat(1250, 2500)
a.Subtract(*b)
if a.IncVat != 1250 {
t.Errorf("IncVat after Subtract = %d; want 1250", a.IncVat)
}
}
func TestPrice_SumPrices(t *testing.T) {
a := NewPriceFromIncVat(1250, 2500)
b := NewPriceFromIncVat(2500, 2500)
total := SumPrices(*a, *b)
if total.IncVat != 3750 {
t.Errorf("SumPrices IncVat = %d; want 3750", total.IncVat)
}
}
func TestPrice_JSONRoundTrip(t *testing.T) {
p := NewPriceFromIncVat(1250, 2500)
data, err := json.Marshal(p)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var p2 Price
if err := json.Unmarshal(data, &p2); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if p2.IncVat != p.IncVat {
t.Errorf("IncVat mismatch: have %d, want %d", p2.IncVat, p.IncVat)
}
}
func TestPrice_SumPricesEmpty(t *testing.T) {
total := SumPrices()
if total.IncVat != 0 {
t.Errorf("SumPrices() IncVat = %d; want 0", total.IncVat)
}
}