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

190 lines
5.1 KiB
Go

package library
import (
"time"
"github.com/zap-proto/zip"
"sync"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/models/shippingrates"
"github.com/hanzoai/commerce/models/store"
"github.com/hanzoai/commerce/models/taxrates"
"github.com/hanzoai/commerce/models/types/country"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/util/json"
"github.com/hanzoai/commerce/util/json/http"
)
// Countries Loading
type SubDivision struct {
Name string `json:"name"`
IsoCode string `json:"code"`
}
type Country struct {
Name string `json:"name"`
IsoCode string `json:"code"`
SubDivisions []SubDivision `json:"subdivisions"`
}
// countries is the shop's own view of the world — each country with its
// subdivisions — built on FIRST USE.
//
// It was an init(), and it walked every country's subdivisions to build a second
// copy of a dataset that had itself just been unmarshalled from 5.1 MB of YAML.
// Between them the two cost every process linking this package 121 ms before it
// could serve anything, for a list that two handlers answer with and nothing else
// reads. The shop asks for it when a shop is being loaded.
var countries = sync.OnceValue(func() []Country {
out := make([]Country, 0)
for _, c := range country.All() {
sdvs := make([]SubDivision, 0)
for _, sd := range c.SubDivisions() {
sdvs = append(sdvs, SubDivision{sd.Name, sd.Code})
}
out = append(out, Country{c.Name.Common, c.Codes.Alpha2, sdvs})
}
return out
})
// CountryLastUpdated is when this process's country data came into being, which is
// process start: the corpus is compiled in, so it cannot change while we run. The
// init() that set it also BUILT the list, and a client's freshness question does not
// need the list — only its age — so the stamp is read here and the list is not.
var CountryLastUpdated = time.Now()
// ShopJS request and response
type LoadShopJSReq struct {
HasCountries bool `json:"hasCountries"`
HasTaxRates bool `json:"hasTaxRates"`
HasShippingRates bool `json:"hasShippingRates"`
LastChecked time.Time `json:"lastChecked"`
StoreId string `json:"storeId"`
}
type LoadShopJSRes struct {
Countries []Country `json:"countries,omitempty"`
TaxRates *taxrates.TaxRates `json:"taxRates,omitempty"`
ShippingRates *shippingrates.ShippingRates `json:"shippingRates,omitempty"`
Currency currency.Type `json:"currency,omitempty"`
StoreId string `json:"storeId,omitempty"`
Live bool `json:"live"`
}
func LoadShopJS(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
// Decode response body to get ShopJS Params
req := &LoadShopJSReq{}
if err := json.DecodeBytes(c.Body(), req); err != nil {
return http.Fail(c, 400, "Failed decode request body", err)
}
// Default store if StoreId is left blank
if req.StoreId == "" {
req.StoreId = org.DefaultStore
}
stor := store.New(db)
if err := stor.GetById(req.StoreId); err != nil {
return http.Fail(c, 404, "Store `"+req.StoreId+"` not found", err)
}
// Build response
res := LoadShopJSRes{StoreId: req.StoreId}
// Determine Test Mode
res.Live = org.Live
if !req.HasCountries ||
req.LastChecked.Before(CountryLastUpdated) {
res.Countries = countries()
}
if res.Currency == "" {
res.Currency = stor.Currency
}
if res.Currency == "" {
res.Currency = org.Currency
}
if res.Currency == "" {
res.Currency = currency.USD
}
if req.HasTaxRates {
tr := taxrates.New(db)
if ok, err := tr.Query().Filter("StoreId=", req.StoreId).Get(); ok {
if req.LastChecked.Before(tr.UpdatedAt) {
res.TaxRates = tr
}
} else if err != nil {
return http.Fail(c, 500, err.Error(), err)
}
} else {
tr := taxrates.New(db)
if ok, err := tr.Query().Filter("StoreId=", req.StoreId).Get(); ok {
res.TaxRates = tr
} else if err != nil {
return http.Fail(c, 500, err.Error(), err)
}
}
if req.HasShippingRates {
sr := shippingrates.New(db)
if ok, err := sr.Query().Filter("StoreId=", req.StoreId).Get(); ok {
if req.LastChecked.Before(sr.UpdatedAt) {
res.ShippingRates = sr
}
} else if err != nil {
return http.Fail(c, 500, err.Error(), err)
}
} else {
sr := shippingrates.New(db)
if ok, err := sr.Query().Filter("StoreId=", req.StoreId).Get(); ok {
res.ShippingRates = sr
} else if err != nil {
return http.Fail(c, 500, err.Error(), err)
}
}
return http.Render(c, 200, res)
}
type LoadDaishoReq struct {
HasCountries bool `json:"hasCountries"`
LastChecked time.Time `json:"lastChecked"`
}
type LoadDaishoRes struct {
Countries []Country `json:"countries,omitempty"`
}
func LoadDaisho(c *zip.Ctx) error {
// Decode response body to get ShopJS Params
req := &LoadDaishoReq{}
if err := json.DecodeBytes(c.Body(), req); err != nil {
return http.Fail(c, 400, "Failed decode request body", err)
}
// Build response
res := LoadDaishoRes{}
if !req.HasCountries ||
req.LastChecked.Before(CountryLastUpdated) {
res.Countries = countries()
}
return http.Render(c, 200, res)
}