diff --git a/api/handlers/orders.go b/api/handlers/orders.go index 634ca4f..e040110 100644 --- a/api/handlers/orders.go +++ b/api/handlers/orders.go @@ -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, + }) +} diff --git a/api/routes/routes.go b/api/routes/routes.go index 1711d38..0b41aaa 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -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) }) }) diff --git a/pkg/audit/audit.go b/pkg/audit/audit.go index 5e305d1..7ae7da4 100644 --- a/pkg/audit/audit.go +++ b/pkg/audit/audit.go @@ -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()...) +} diff --git a/pkg/models/order.go b/pkg/models/order.go index f753332..90a92f6 100644 --- a/pkg/models/order.go +++ b/pkg/models/order.go @@ -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 diff --git a/pkg/shippo/shippo.go b/pkg/shippo/shippo.go index 3acccc6..385a6f8 100644 --- a/pkg/shippo/shippo.go +++ b/pkg/shippo/shippo.go @@ -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"`