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 }