Files
commerce/api/tax/handlers.go
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

189 lines
4.7 KiB
Go

package tax
import (
"context"
"math"
"time"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/models/taxprovider"
"github.com/hanzoai/commerce/models/taxrate"
"github.com/hanzoai/commerce/models/taxraterule"
"github.com/hanzoai/commerce/models/taxregion"
"github.com/hanzoai/commerce/util/json"
jsonhttp "github.com/hanzoai/commerce/util/json/http"
"github.com/hanzoai/commerce/util/rest"
)
// Ensure imports are used.
var _ = taxprovider.TaxProvider{}
var _ = taxraterule.TaxRateRule{}
func Route(router *zip.Group, args ...zip.Handler) {
namespaced := middleware.Namespace()
// CRUD for tax models
rest.New(taxregion.TaxRegion{}).Route(router, args...)
rest.New(taxrate.TaxRate{}).Route(router, args...)
rest.New(taxraterule.TaxRateRule{}).Route(router, args...)
rest.New(taxprovider.TaxProvider{}).Route(router, args...)
// Tax calculation endpoint
calcApi := rest.New("/tax")
calcApi.POST("/calculate", append(args, namespaced, Calculate)...)
calcApi.Route(router, args...)
}
// Request/response types for tax calculation.
type calcItem struct {
Amount float64 `json:"amount"`
Quantity int `json:"quantity"`
}
type calcAddress struct {
CountryCode string `json:"countryCode"`
ProvinceCode string `json:"provinceCode"`
}
type calcRequest struct {
Items []calcItem `json:"items"`
ShippingAddress calcAddress `json:"shippingAddress"`
}
type calcItemResult struct {
Amount float64 `json:"amount"`
Quantity int `json:"quantity"`
TaxRate float64 `json:"taxRate"`
Tax float64 `json:"tax"`
}
type calcResponse struct {
Items []calcItemResult `json:"items"`
TotalTax float64 `json:"totalTax"`
}
// Calculate computes tax for a list of items given a shipping address.
//
// It looks up the TaxRegion matching the provided countryCode and
// provinceCode, queries all TaxRates for that region, sums combinable
// rates (or uses the default rate), then applies the effective rate to
// each item.
func Calculate(c *zip.Ctx) error {
var req calcRequest
if err := json.DecodeBytes(c.Body(), &req); err != nil {
return jsonhttp.Fail(c, 400, "Invalid request body", err)
}
if len(req.Items) == 0 {
return jsonhttp.Fail(c, 400, "No items provided", nil)
}
ctx := c.Context()
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
db := datastore.New(ctx)
// Find matching tax region by country + province.
region := taxregion.New(db)
ok, err := region.Query().
Filter("CountryCode=", req.ShippingAddress.CountryCode).
Filter("ProvinceCode=", req.ShippingAddress.ProvinceCode).
Get()
if err != nil {
return jsonhttp.Fail(c, 500, "Failed to query tax region", err)
}
// If no province-level region, try country-level (empty province).
if !ok {
region = taxregion.New(db)
ok, err = region.Query().
Filter("CountryCode=", req.ShippingAddress.CountryCode).
Filter("ProvinceCode=", "").
Get()
if err != nil {
return jsonhttp.Fail(c, 500, "Failed to query tax region", err)
}
}
// No region found -- zero tax.
if !ok {
items := make([]calcItemResult, len(req.Items))
for i, item := range req.Items {
items[i] = calcItemResult{
Amount: item.Amount,
Quantity: item.Quantity,
TaxRate: 0,
Tax: 0,
}
}
return jsonhttp.Render(c, 200, calcResponse{Items: items, TotalTax: 0})
}
// Fetch tax rates for this region.
regionId := region.Id()
var rates []*taxrate.TaxRate
q := taxrate.Query(db).Filter("TaxRegionId=", regionId)
if _, err := q.GetAll(&rates); err != nil {
return jsonhttp.Fail(c, 500, "Failed to query tax rates", err)
}
// Compute effective rate: sum all combinable rates, or fall back to
// the first default rate.
effectiveRate := 0.0
hasCombined := false
for _, r := range rates {
if r.IsCombinable {
effectiveRate += r.Rate
hasCombined = true
}
}
if !hasCombined {
for _, r := range rates {
if r.IsDefault {
effectiveRate = r.Rate
break
}
}
// If still zero and rates exist, use the first one.
if effectiveRate == 0 && len(rates) > 0 {
effectiveRate = rates[0].Rate
}
}
// Calculate per-item tax.
totalTax := 0.0
items := make([]calcItemResult, len(req.Items))
for i, item := range req.Items {
lineTotal := item.Amount * float64(item.Quantity)
tax := roundCents(lineTotal * effectiveRate)
totalTax += tax
items[i] = calcItemResult{
Amount: item.Amount,
Quantity: item.Quantity,
TaxRate: effectiveRate,
Tax: tax,
}
}
totalTax = roundCents(totalTax)
return jsonhttp.Render(c, 200, calcResponse{
Items: items,
TotalTax: totalTax,
})
}
// roundCents rounds to 2 decimal places (cents).
func roundCents(v float64) float64 {
return math.Round(v*100) / 100
}