mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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
102 lines
3.0 KiB
Go
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(®ions); 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
|
|
}
|