mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-26 02:08:46 +01:00
feat(shipping): shippo label purchase endpoint
Adds POST /api/v1/orders/{id}/label (admin-only) that buys a shipping
label through Shippo and writes carrier + tracking + label fields back
onto the order, transitioning paid -> shipped.
This commit is contained in:
@@ -3,7 +3,9 @@ package handlers
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"terminalShop/pkg/shippo"
|
||||
"time"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
@@ -163,3 +165,97 @@ func (h *OrderHandler) UpdateTracking(w http.ResponseWriter, r *http.Request) {
|
||||
"status": order.Status,
|
||||
})
|
||||
}
|
||||
|
||||
// PurchaseLabel buys a shipping label from shippo for the order, writes
|
||||
// the carrier, tracking, and label fields onto the order, and changes the status
|
||||
// from paid to shipped
|
||||
func (h *OrderHandler) PurchaseLabel(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var order models.Order
|
||||
if err := db.Preload("Items.Coffee").Preload("User").Where("id = ?", id).First(&order).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if order.Status != models.OrderStatusPaid {
|
||||
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "only paid orders can be labeled", nil)
|
||||
return
|
||||
}
|
||||
if order.TrackingNumber != "" {
|
||||
utils.RespondError(w, http.StatusConflict, "ALREADY_LABELED", "order already has a tracking number", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Coffee.Ounces in ounces; shippo client expects kg
|
||||
const ozToKg = 0.0283495
|
||||
const defaultOunces = 12.0
|
||||
|
||||
lineItems := make([]shippo.LineItem, 0, len(order.Items))
|
||||
for _, it := range order.Items {
|
||||
oz := float64(it.Coffee.Ounces)
|
||||
if oz <= 0 {
|
||||
oz = defaultOunces
|
||||
}
|
||||
lineItems = append(lineItems, shippo.LineItem{
|
||||
Title: it.Name,
|
||||
Quantity: it.Quantity,
|
||||
WeightKg: oz * ozToKg,
|
||||
})
|
||||
}
|
||||
|
||||
to := shippo.Address{
|
||||
Name: order.ShippingName,
|
||||
Street1: order.ShippingStreet,
|
||||
Street2: order.ShippingStreet2,
|
||||
City: order.ShippingCity,
|
||||
State: order.ShippingState,
|
||||
Country: order.ShippingCountry,
|
||||
Zip: order.ShippingZip,
|
||||
Phone: order.ShippingPhone,
|
||||
}
|
||||
|
||||
client := shippo.NewClient(os.Getenv("SHIPPO_API_KEY"))
|
||||
result, err := client.CreateLabel(r.Context(), to, order.User.Email, lineItems)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadGateway, "SHIPPO_ERROR", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
updates := map[string]any{
|
||||
"carrier": result.Carrier,
|
||||
"tracking_number": result.TrackingNumber,
|
||||
"tracking_url": result.TrackingURL,
|
||||
"shipped_at": &now,
|
||||
"status": models.OrderStatusShipped,
|
||||
"shippo_transaction_id": result.TransactionID,
|
||||
"label_url": result.LabelURL,
|
||||
"label_cost_cents": result.CostCents,
|
||||
}
|
||||
if err := db.Model(&order).Updates(updates).Error; err != nil {
|
||||
audit.LabelOrphaned(order.ID, result.TransactionID, result.TrackingNumber, err)
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "label purchased but failed to record on order; ops will reconcile", nil)
|
||||
return
|
||||
}
|
||||
|
||||
audit.LabelPurchased(order.ID, result.Carrier, result.TrackingNumber, result.TransactionID, result.CostCents)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"order_id": order.ID,
|
||||
"carrier": result.Carrier,
|
||||
"service": result.ServiceLevel,
|
||||
"tracking_number": result.TrackingNumber,
|
||||
"tracking_url": result.TrackingURL,
|
||||
"label_url": result.LabelURL,
|
||||
"cost_cents": result.CostCents,
|
||||
"status": models.OrderStatusShipped,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ func SetupRoutes(
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAdmin)
|
||||
r.Patch("/orders/{id}/tracking", orderHandler.UpdateTracking)
|
||||
r.Post("/orders/{id}/label", orderHandler.PurchaseLabel)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ const (
|
||||
eventOrderFailed eventType = "order_failed"
|
||||
eventOrderRefunded eventType = "order_refunded"
|
||||
eventPaymentCritical eventType = "payment_critical"
|
||||
eventLabelPurchased eventType = "label_purchased"
|
||||
eventLabelOrphaned eventType = "label_orphaned"
|
||||
)
|
||||
|
||||
type event struct {
|
||||
@@ -154,3 +156,24 @@ func PaymentCritical(orderID uint, stripePaymentIntentID string, err error) {
|
||||
Error: err.Error(),
|
||||
}.attrs()...)
|
||||
}
|
||||
|
||||
func LabelPurchased(orderID uint, carrier, trackingNumber, shippoTxID string, costCents int) {
|
||||
slog.Info("audit", event{
|
||||
Event: eventLabelPurchased,
|
||||
OrderID: orderID,
|
||||
Carrier: carrier,
|
||||
Tracking: trackingNumber,
|
||||
StripeID: shippoTxID,
|
||||
Amount: costCents,
|
||||
}.attrs()...)
|
||||
}
|
||||
|
||||
func LabelOrphaned(orderID uint, shippoTxID, trackingNumber string, err error) {
|
||||
slog.Error("audit", event{
|
||||
Event: eventLabelOrphaned,
|
||||
OrderID: orderID,
|
||||
StripeID: shippoTxID,
|
||||
Tracking: trackingNumber,
|
||||
Error: err.Error(),
|
||||
}.attrs()...)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,11 @@ type Order struct {
|
||||
TrackingStatusDetails string `gorm:"size:512" json:"tracking_status_details"`
|
||||
TrackingStatusUpdatedAt *time.Time `json:"tracking_status_updated_at"`
|
||||
|
||||
// Shippo label shit, populated by the PurchaseLabel handler
|
||||
ShippoTransactionID string `gorm:"size:255;uniqueIndex" json:"-"`
|
||||
LabelURL string `gorm:"size:512" json:"-"`
|
||||
LabelCostCents int `json:"-"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel
|
||||
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
@@ -14,6 +17,11 @@ import (
|
||||
|
||||
const baseURL = "https://api.goshippo.com"
|
||||
|
||||
const (
|
||||
labelPollInterval = 1 * time.Second
|
||||
labelPollTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// Client handles communication with the Shippo API.
|
||||
type Client struct {
|
||||
apiKey string
|
||||
@@ -31,6 +39,301 @@ func NewClient(apiKey string) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
type Parcel struct {
|
||||
LengthCm float64
|
||||
WidthCm float64
|
||||
HeightCm float64
|
||||
WeightKg float64
|
||||
}
|
||||
|
||||
type LineItem struct {
|
||||
Title string
|
||||
Quantity int
|
||||
WeightKg float64
|
||||
}
|
||||
|
||||
type LabelResult struct {
|
||||
TransactionID string
|
||||
RateID string
|
||||
Carrier string
|
||||
ServiceLevel string
|
||||
TrackingNumber string
|
||||
TrackingURL string
|
||||
LabelURL string
|
||||
CostCents int
|
||||
}
|
||||
|
||||
type shipmentRate struct {
|
||||
ObjectID string `json:"object_id"`
|
||||
Amount string `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Provider string `json:"provider"`
|
||||
ServiceLevel struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"servicelevel"`
|
||||
}
|
||||
|
||||
type shipmentResponse struct {
|
||||
Status string `json:"status"`
|
||||
Rates []shipmentRate `json:"rates"`
|
||||
}
|
||||
|
||||
type shippoOrderResponse struct {
|
||||
ObjectID string `json:"object_id"`
|
||||
}
|
||||
|
||||
type transactionResponse struct {
|
||||
ObjectID string `json:"object_id"`
|
||||
Status string `json:"status"`
|
||||
ObjectState string `json:"object_state"`
|
||||
TrackingNumber string `json:"tracking_number"`
|
||||
TrackingURL string `json:"tracking_url_provider"`
|
||||
LabelURL string `json:"label_url"`
|
||||
Rate string `json:"rate"`
|
||||
Messages []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
|
||||
func (c *Client) CreateLabel(ctx context.Context, to Address, email string, items []LineItem) (*LabelResult, error) {
|
||||
from := fromAddress()
|
||||
if from.Street1 == "" {
|
||||
return nil, fmt.Errorf("SHIP_FROM_STREET1 not configured")
|
||||
}
|
||||
|
||||
totalKg := 0.0
|
||||
for _, it := range items {
|
||||
totalKg += float64(it.Quantity) * it.WeightKg
|
||||
}
|
||||
if totalKg <= 0 {
|
||||
return nil, fmt.Errorf("order has zero shipping weight")
|
||||
}
|
||||
|
||||
parcel := Parcel{LengthCm: 30, WidthCm: 30, HeightCm: 30, WeightKg: totalKg}
|
||||
|
||||
rate, err := c.createShipmentForRates(ctx, from, to, parcel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get rates: %w", err)
|
||||
}
|
||||
|
||||
shippoOrderID, err := c.createShippoOrder(ctx, to, email, items, totalKg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create shippo order: %w", err)
|
||||
}
|
||||
|
||||
tx, err := c.buyLabel(ctx, rate.ObjectID, shippoOrderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("buy label: %w", err)
|
||||
}
|
||||
|
||||
final, err := c.pollTransaction(ctx, tx.ObjectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("poll transaction: %w", err)
|
||||
}
|
||||
|
||||
costCents := parseAmountCents(rate.Amount)
|
||||
|
||||
return &LabelResult{
|
||||
TransactionID: final.ObjectID,
|
||||
RateID: rate.ObjectID,
|
||||
Carrier: rate.Provider,
|
||||
ServiceLevel: rate.ServiceLevel.Name,
|
||||
TrackingNumber: final.TrackingNumber,
|
||||
TrackingURL: final.TrackingURL,
|
||||
LabelURL: final.LabelURL,
|
||||
CostCents: costCents,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) createShipmentForRates(ctx context.Context, from, to Address, parcel Parcel) (*shipmentRate, error) {
|
||||
payload := map[string]any{
|
||||
"address_from": addrPayload(from),
|
||||
"address_to": addrPayload(to),
|
||||
"parcels": []map[string]any{{
|
||||
"length": parcel.LengthCm,
|
||||
"width": parcel.WidthCm,
|
||||
"height": parcel.HeightCm,
|
||||
"distance_unit": "cm",
|
||||
"weight": parcel.WeightKg,
|
||||
"mass_unit": "kg",
|
||||
}},
|
||||
"async": false,
|
||||
"extra": map[string]any{"bypass_address_validation": true},
|
||||
}
|
||||
|
||||
var resp shipmentResponse
|
||||
if err := c.doJSON(ctx, "POST", "/shipments", payload, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Status != "SUCCESS" || len(resp.Rates) == 0 {
|
||||
return nil, fmt.Errorf("shippo returned no rates (status=%s)", resp.Status)
|
||||
}
|
||||
sort.Slice(resp.Rates, func(i, j int) bool {
|
||||
ai, _ := strconv.ParseFloat(resp.Rates[i].Amount, 64)
|
||||
aj, _ := strconv.ParseFloat(resp.Rates[j].Amount, 64)
|
||||
return ai < aj
|
||||
})
|
||||
r := resp.Rates[0]
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (c *Client) createShippoOrder(ctx context.Context, to Address, email string, items []LineItem, totalKg float64) (string, error) {
|
||||
lines := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
lines = append(lines, map[string]any{
|
||||
"quantity": item.Quantity,
|
||||
"title": item.Title,
|
||||
"weight": item.WeightKg,
|
||||
"weight_unit": "kg",
|
||||
})
|
||||
}
|
||||
toPayload := addrPayload(to)
|
||||
if email != "" {
|
||||
toPayload["email"] = email
|
||||
}
|
||||
payload := map[string]any{
|
||||
"to_address": toPayload,
|
||||
"line_items": lines,
|
||||
"placed_at": time.Now().Format(time.RFC3339),
|
||||
"weight": totalKg,
|
||||
"weight_unit": "kg",
|
||||
}
|
||||
var resp shippoOrderResponse
|
||||
if err := c.doJSON(ctx, "POST", "/v1/orders", payload, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.ObjectID == "" {
|
||||
return "", fmt.Errorf("shippo order returned no object_id")
|
||||
}
|
||||
return resp.ObjectID, nil
|
||||
}
|
||||
|
||||
func (c *Client) buyLabel(ctx context.Context, rateID, shippoOrderID string) (*transactionResponse, error) {
|
||||
payload := map[string]any{
|
||||
"rate": rateID,
|
||||
"order": shippoOrderID,
|
||||
"async": true,
|
||||
}
|
||||
var resp transactionResponse
|
||||
if err := c.doJSON(ctx, "POST", "/v1/transactions", payload, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.ObjectState == "INVALID" {
|
||||
return nil, fmt.Errorf("transaction invalid: %s", firstMessage(resp.Messages))
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) pollTransaction(ctx context.Context, txID string) (*transactionResponse, error) {
|
||||
deadLine := time.Now().Add(labelPollTimeout)
|
||||
for {
|
||||
var resp transactionResponse
|
||||
if err := c.doJSON(ctx, "GET", "/transactions/"+txID, nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch resp.Status {
|
||||
case "SUCCESS":
|
||||
return &resp, nil
|
||||
case "ERROR":
|
||||
return nil, fmt.Errorf("shippo transaction error: %s", firstMessage(resp.Messages))
|
||||
}
|
||||
if time.Now().After(deadLine) {
|
||||
return nil, fmt.Errorf("timed out waiting for label (last status=%s)", resp.Status)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(labelPollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(ctx context.Context, method, path string, body any, out any) error {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, rdr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("new request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "ShippoToken "+c.apiKey)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("shippo %s %s -> %d: %s", method, path, resp.StatusCode, string(respBody))
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.Unmarshal(respBody, out); err != nil {
|
||||
return fmt.Errorf("parse reponse: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addrPayload(a Address) map[string]any {
|
||||
m := map[string]any{
|
||||
"name": a.Name,
|
||||
"street1": a.Street1,
|
||||
"city": a.City,
|
||||
"country": a.Country,
|
||||
"zip": a.Zip,
|
||||
}
|
||||
if a.Street2 != "" {
|
||||
m["street2"] = a.Street2
|
||||
}
|
||||
if a.State != "" {
|
||||
m["state"] = a.State
|
||||
}
|
||||
if a.Phone != "" {
|
||||
m["phone"] = a.Phone
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func parseAmountCents(s string) int {
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int(f * 100)
|
||||
}
|
||||
|
||||
func firstMessage(msgs []struct {
|
||||
Text string `json:"text"`
|
||||
}) string {
|
||||
if len(msgs) == 0 {
|
||||
return "no detail"
|
||||
}
|
||||
return msgs[0].Text
|
||||
}
|
||||
|
||||
func fromAddress() Address {
|
||||
return Address{
|
||||
Name: os.Getenv("SHIP_FROM_NAME"),
|
||||
Street1: os.Getenv("SHIP_FROM_STREET1"),
|
||||
Street2: os.Getenv("SHIP_FROM_STREET2"),
|
||||
City: os.Getenv("SHIP_FROM_CITY"),
|
||||
State: os.Getenv("SHIP_FROM_STATE"),
|
||||
Country: os.Getenv("SHIP_FROM_COUNTRY"),
|
||||
Zip: os.Getenv("SHIP_FROM_ZIP"),
|
||||
Phone: os.Getenv("SHIP_FROM_PHONE"),
|
||||
}
|
||||
}
|
||||
|
||||
// Address represents a shipping address for validation.
|
||||
type Address struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
Reference in New Issue
Block a user