Files
platform/tax/price.go
T
2026-06-28 17:52:49 +02:00

176 lines
5.1 KiB
Go

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
}