Files
zeekay 1943f1c093 Hanzo Commerce
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans,
usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0.

Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
2026-09-23 21:52:16 -07:00

102 lines
3.0 KiB
Go

package engine
import (
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/billinginvoice"
"github.com/hanzoai/commerce/models/taxrate"
"github.com/hanzoai/commerce/models/taxregion"
"github.com/hanzoai/commerce/types"
"github.com/hanzoai/money"
)
// TaxLine represents a single tax computation on an invoice.
type TaxLine struct {
TaxRateId string `json:"taxRateId"`
Description string `json:"description"`
Amount int64 `json:"amount"` // tax amount in cents
Rate float64 `json:"rate"` // e.g., 0.0875 for 8.75%
Inclusive bool `json:"inclusive"`
Jurisdiction string `json:"jurisdiction"`
}
// CalculateInvoiceTax computes tax for an invoice based on the customer address
// using the existing TaxRate and TaxRegion models. Returns tax lines and total.
func CalculateInvoiceTax(db *datastore.Datastore, inv *billinginvoice.BillingInvoice, customerAddress *types.Address) ([]TaxLine, int64, error) {
if customerAddress == nil {
return nil, 0, nil
}
rootKey := db.NewKey("synckey", "", 1, nil)
// Find matching tax regions by country and state/province
regions := make([]*taxregion.TaxRegion, 0)
rq := taxregion.Query(db).Ancestor(rootKey)
if customerAddress.Country != "" {
rq = rq.Filter("CountryCode=", customerAddress.Country)
}
if _, err := rq.GetAll(&regions); err != nil {
return nil, 0, nil // no regions = no tax
}
// Filter by state/province if available
matchedRegions := make([]*taxregion.TaxRegion, 0)
for _, r := range regions {
if r.ProvinceCode == "" || r.ProvinceCode == customerAddress.State {
matchedRegions = append(matchedRegions, r)
}
}
if len(matchedRegions) == 0 {
return nil, 0, nil
}
// Get tax rates for matched regions
var taxLines []TaxLine
var totalTax int64
for _, region := range matchedRegions {
rates := make([]*taxrate.TaxRate, 0)
tq := taxrate.Query(db).Ancestor(rootKey).
Filter("TaxRegionId=", region.Id())
if _, err := tq.GetAll(&rates); err != nil {
continue
}
for _, rate := range rates {
// Each rate is a separate jurisdiction's claim on the same subtotal, so each
// is rounded on its own and the total is their sum — a jurisdiction is
// remitted the amount on ITS line, and a total rounded once over the combined
// rate would not match the lines that make it up.
//
// These rates are our own TaxRate rows, not a figure returned by a tax
// provider, so the part-cent is ours to compute and truncating it
// under-collected tax we still owe.
r, err := money.RateFromFloat(rate.Rate)
if err != nil {
return nil, 0, err
}
taxAmount, err := money.ScaleMinor(inv.Subtotal, r)
if err != nil {
return nil, 0, err
}
jurisdiction := region.CountryCode
if region.ProvinceCode != "" {
jurisdiction += "-" + region.ProvinceCode
}
taxLines = append(taxLines, TaxLine{
TaxRateId: rate.Id(),
Description: rate.Name,
Amount: taxAmount,
Rate: rate.Rate,
Jurisdiction: jurisdiction,
})
totalTax += taxAmount
}
}
return taxLines, totalTax, nil
}