platform
This commit is contained in:
1 parent
e706f7d907
commit
21bcb63440
42 files changed
+5903
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user