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

423 lines
11 KiB
Go

package export
import (
"encoding/xml"
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/datastore/iface"
"github.com/hanzoai/commerce/datastore/key"
"github.com/hanzoai/commerce/log"
"github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/models/lineitem"
"github.com/hanzoai/commerce/models/order"
"github.com/hanzoai/commerce/models/payment"
"github.com/hanzoai/commerce/models/types/fulfillment"
"github.com/hanzoai/commerce/models/user"
"github.com/hanzoai/commerce/util/hashid"
)
// <?xml version="1.0" encoding="utf-8"?>
// <Orders>
// <Order>
// <OrderID><![CDATA[123456]]></OrderID>
// <OrderNumber><![CDATA[ABC123]]></OrderNumber>
// <OrderDate>12/8/2011 21:56 PM</OrderDate>
// <OrderStatus><![CDATA[AwaitingShipment]]></OrderStatus>
// <LastModified>12/8/2011 12:56 PM</LastModified>
// <ShippingMethod><![CDATA[USPSPriorityMail]]></ShippingMethod>
// <PaymentMethod><![CDATA[Credit Card]]></PaymentMethod>
// <OrderTotal>123.45</OrderTotal>
// <TaxAmount>0.00</TaxAmount>
// <ShippingAmount>4.50</ShippingAmount>
// <CustomerNotes><![CDATA[Please make sure it gets here by Dec. 22nd!]]></CustomerNotes>
// <InternalNotes><![CDATA[Ship by December 18th via Priority Mail.]]></InternalNotes>
// <Customer>
// <CustomerCode><![CDATA[[email protected]]]></CustomerCode>
// <BillTo>
// <Name><![CDATA[The President]]></Name>
// <Company><![CDATA[US Govt]]></Company>
// <Phone><![CDATA[512-555-5555]]></Phone>
// <Email><![CDATA[[email protected]]]></Email>
// </BillTo>
// <ShipTo>
// <Name><![CDATA[The President]]></Name>
// <Company><![CDATA[US Govt]]></Company>
// <Address1><![CDATA[1600 Pennsylvania Ave]]></Address1>
// <Address2></Address2>
// <City><![CDATA[Washington]]></City>
// <State><![CDATA[DC]]></State>
// <PostalCode><![CDATA[20500]]></PostalCode>
// <Country><![CDATA[US]]></Country>
// <Phone><![CDATA[512-555-5555]]></Phone>
// </ShipTo>
// </Customer>
// <Items>
// <Item>
// <SKU><![CDATA[FD88821]]></SKU>
// <Name><![CDATA[My Product Name]]></Name>
// <ImageUrl><![CDATA[http://www.mystore.com/products/12345.jpg]]></ImageUrl>
// <Weight>8</Weight>
// <WeightUnits>Ounces</WeightUnits>
// <Quantity>2</Quantity>
// <UnitPrice>13.99</UnitPrice>
// <Location><![CDATA[A1-B2]]></Location>
// <Options>
// <Option>
// <Name><![CDATA[Size]]></Name>
// <Value><![CDATA[Large]]></Value>
// <Weight>10</Weight>
// </Option>
// <Option>
// <Name><![CDATA[Color]]></Name>
// <Value><![CDATA[Green]]></Value>
// <Weight>5</Weight>
// </Option>
// </Options>
// </Item>
// </Items>
// </Order>
// </Orders>
func parseDate(s string) time.Time {
date, err := time.Parse("01/02/2006 15:04", s)
if err != nil {
log.Panic("Unable to parse start date: %v", err)
}
return date
}
func renderDate(date Date) string {
return time.Time(date).Format("01/02/2006 15:04")
}
type CDATA string
func (c CDATA) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return e.EncodeElement(struct {
S string `xml:",innerxml"`
}{
S: "<![CDATA[" + string(c) + "]]>",
}, start)
}
type Date time.Time
func (d Date) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return e.EncodeElement(struct {
S string `xml:",innerxml"`
}{
S: renderDate(d),
}, start)
}
type Option struct {
XMLName xml.Name `xml:"Option"`
Name CDATA
Value CDATA
Weight string
}
type Item struct {
XMLName xml.Name `xml:"Item"`
SKU CDATA
Name CDATA
ImageUrl CDATA
Weight string
WeightUnits string
Quantity int
UnitPrice string
Location CDATA
Options struct {
Options []Option
}
}
func newItem(ord *order.Order, item lineitem.LineItem) Item {
si := Item{}
si.SKU = CDATA(item.ProductSlug)
si.Name = CDATA(item.ProductName)
if item.VariantSKU != "" {
si.SKU = CDATA(item.VariantSKU)
}
if item.VariantName != "" {
si.SKU = CDATA(item.VariantName)
}
// ShipStation's schema wants a bare decimal amount, which is what
// ToStringNoSymbol renders. Rendering it for a human and then stripping the
// symbol back off was always the long way round, and it broke on a negative
// the moment the sign moved in front of the symbol.
si.UnitPrice = ord.Currency.ToStringNoSymbol(item.Price)
si.Quantity = item.Quantity
si.Weight = item.Weight.String()
si.WeightUnits = item.WeightUnit.Name()
return si
}
type Customer struct {
CustomerCode CDATA
BillTo struct {
Name CDATA
Company CDATA
Phone CDATA
Email CDATA
}
ShipTo struct {
Name CDATA
Company CDATA
Address1 CDATA
Address2 CDATA
City CDATA
State CDATA
PostalCode CDATA
Country CDATA
Phone CDATA
}
}
func newCustomer(ord *order.Order, usr *user.User) *Customer {
sc := &Customer{}
if usr == nil {
return sc
}
sc.CustomerCode = CDATA(usr.Id())
sc.BillTo.Name = CDATA(usr.Name())
sc.BillTo.Email = CDATA(usr.Email)
sc.BillTo.Phone = CDATA(usr.Phone)
sc.ShipTo.Name = CDATA(ord.ShippingAddress.Name)
sc.ShipTo.Phone = CDATA(usr.Phone)
sc.ShipTo.Address1 = CDATA(ord.ShippingAddress.Line1)
sc.ShipTo.Address2 = CDATA(ord.ShippingAddress.Line2)
sc.ShipTo.City = CDATA(ord.ShippingAddress.City)
sc.ShipTo.State = CDATA(ord.ShippingAddress.State)
sc.ShipTo.PostalCode = CDATA(ord.ShippingAddress.PostalCode)
sc.ShipTo.Country = CDATA(ord.ShippingAddress.Country)
// Default to user shipping info if missing
if sc.ShipTo.Address1 == "" && sc.ShipTo.City == "" && sc.ShipTo.Country == "" {
sc.ShipTo.Address1 = CDATA(usr.ShippingAddress.Line1)
sc.ShipTo.Address2 = CDATA(usr.ShippingAddress.Line2)
sc.ShipTo.City = CDATA(usr.ShippingAddress.City)
sc.ShipTo.State = CDATA(usr.ShippingAddress.State)
sc.ShipTo.PostalCode = CDATA(usr.ShippingAddress.PostalCode)
sc.ShipTo.Country = CDATA(usr.ShippingAddress.Country)
}
return sc
}
type Order struct {
XMLName xml.Name `xml:"Order"`
OrderID CDATA
OrderNumber int
OrderDate Date
OrderStatus CDATA
LastModified Date
ShippingMethod CDATA
PaymentMethod CDATA
OrderTotal string
TaxAmount string
ShippingAmount string
CustomerNotes CDATA
InternalNotes CDATA
// Need to nest items slice so we can have a proper XML node here
Items struct {
Items []Item
}
Customer *Customer
// Order Id
CustomField1 string
// Payment Ids
CustomField2 string
// User Id
CustomField3 string
}
func newOrder(ord *order.Order) *Order {
so := &Order{}
so.OrderID = CDATA(ord.Id())
so.OrderNumber = ord.Number
so.OrderDate = Date(ord.CreatedAt)
so.LastModified = Date(ord.UpdatedAt)
so.OrderTotal = ord.Currency.ToStringNoSymbol(ord.Total)
so.TaxAmount = ord.Currency.ToStringNoSymbol(ord.Tax)
so.ShippingAmount = ord.Currency.ToStringNoSymbol(ord.Shipping)
so.Items.Items = make([]Item, len(ord.Items))
for i, item := range ord.Items {
so.Items.Items[i] = newItem(ord, item)
}
// Try to figure out order status
if ord.Status == order.Locked || ord.PaymentStatus == payment.Unpaid || ord.PaymentStatus == payment.Disputed {
so.OrderStatus = CDATA("unpaid")
}
if ord.PaymentStatus == payment.Paid {
so.OrderStatus = CDATA("paid")
}
if ord.Fulfillment.Status == fulfillment.Tracked {
so.OrderStatus = CDATA("shipped")
}
if ord.Status == order.Cancelled || ord.PaymentStatus == payment.Cancelled || ord.PaymentStatus == payment.Failed || ord.PaymentStatus == payment.Fraudulent || ord.PaymentStatus == payment.Refunded {
so.OrderStatus = CDATA("cancelled")
}
if ord.Status == order.OnHold {
so.OrderStatus = CDATA("on_hold")
}
// Set internal notes/custom fields to useful values
so.InternalNotes = CDATA(fmt.Sprintf(`Order Status: %s
Payment Status: %s
Fullfillment Status: %s
Order Id: %s
Payment Ids: %s
User Id: %s`, ord.Status, ord.PaymentStatus, ord.Fulfillment.Status, ord.Id(), strings.Join(ord.PaymentIds, ", "), ord.UserId))
so.CustomField1 = ord.Id()
so.CustomField2 = ord.PaymentIds[0]
so.CustomField3 = ord.UserId
return so
}
type Response struct {
XMLName xml.Name `xml:"Orders"`
Orders []*Order
Pages int `xml:"pages,attr"`
}
func Export(c *zip.Ctx) error {
limit := 50
offset := 0
// Only support export action
action := c.Query("action")
if action != "export" {
log.Panic("Invalid action %s, only understand 'export'", action, c)
}
// Parse offset
page, err := strconv.Atoi(c.Query("page"))
if err == nil && page > 1 {
offset = limit * (page - 1)
}
log.Debug("Page %s, err %s", page, err, c)
// Get start/end dates
startDate := parseDate(c.Query("start_date"))
endDate := parseDate(c.Query("end_date"))
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
// Query out relevant orders
q := order.Query(db).Order("UpdatedAt").
Filter("UpdatedAt >=", startDate).
Filter("UpdatedAt <", endDate)
// Calculate total pages
count, _ := q.Count()
pages := int(math.Ceil(float64(count) / float64(limit)))
log.Debug("Query: pages: %v, limit %v, offset %v", pages, limit, offset, c)
// Get current page of orders
orders := make([]*order.Order, 0, 0)
_, err = q.Limit(limit).Offset(offset).GetAll(&orders)
if err != nil {
log.Panic("Unable to fetch orders between %s and %s, page %s: %v", startDate, endDate, page, err, c)
}
// Build XML response
res := &Response{}
res.Pages = pages
res.Orders = make([]*Order, 0)
ctx := db.Context
keys := make([]iface.Key, 0)
validOrders := make([]*order.Order, 0)
// Fetch orders
for _, ord := range orders {
// Filter out test orders
if ord.Test {
log.Debug("Test order, ignoring: %v", ord, c)
continue
}
// Skip broken orders
if len(ord.PaymentIds) == 0 {
log.Error("Order has no payments associated: %#v", ord, c)
continue
}
// Save user key for later
if dbKey, err := hashid.DecodeKey(ctx, ord.UserId); err != nil {
log.Warn("Could not decode key %v: %v", dbKey, err, c)
} else {
keys = append(keys, key.FromDBKey(dbKey))
validOrders = append(validOrders, ord)
// Store order
res.Orders = append(res.Orders, newOrder(ord))
}
}
// Fetch users
users := make([]user.User, len(keys))
if err := db.GetMulti(keys, users); err != nil {
log.Warn("Unable to fetch all users using keys %v: %v", keys, err, c)
log.Warn("Found users: %v", users, c)
}
// Set customers
for i, ord := range validOrders {
usr := users[i]
customer := newCustomer(ord, &usr)
res.Orders[i].Customer = customer
// Can't ship to someone without a country
if string(customer.ShipTo.Country) == "" {
log.Warn("Missing COUNTRY: %#v, %#v, %#v", customer, ord, users[i], c)
res.Orders[i] = nil
continue
}
// Set as locked if still needs first/lastname
if usr.FirstName == "" && usr.LastName == "" {
res.Orders[i].OrderStatus = CDATA("on_hold")
}
}
buf, _ := xml.MarshalIndent(res, "", " ")
buf = append([]byte(xml.Header), buf...)
c.SetHeader("Content-Type", "text/xml")
return c.Bytes(200, buf)
}