From ee6e14a42413d6c044dba1b5e823e715e6efa8bb Mon Sep 17 00:00:00 2001 From: marcusDenslow Date: Thu, 14 May 2026 13:30:37 +0200 Subject: [PATCH] feat(shipping): shippo webhook fro tracking status updates --- api/handlers/webhook.go | 116 +++++++++++++++++++++++++++++++++++++++- api/main.go | 2 +- api/routes/routes.go | 5 +- pkg/audit/audit.go | 24 +++++++++ pkg/config/config.go | 2 + 5 files changed, 145 insertions(+), 4 deletions(-) diff --git a/api/handlers/webhook.go b/api/handlers/webhook.go index 5de8a66..357b669 100644 --- a/api/handlers/webhook.go +++ b/api/handlers/webhook.go @@ -7,6 +7,8 @@ import ( "io" "log/slog" "net/http" + "strings" + "time" "github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78/paymentmethod" @@ -26,11 +28,12 @@ func webhookLog() *slog.Logger { return slog.With("component", "webhook") } type WebhookHandler struct { webhookSecret string stripeKey string + shippoSecret string } // NewWebhookHandler creates a new webhook handler. -func NewWebhookHandler(webhookSecret string, stripeKey string) *WebhookHandler { - return &WebhookHandler{webhookSecret: webhookSecret, stripeKey: stripeKey} +func NewWebhookHandler(webhookSecret, stripeKey, shippoSecret string) *WebhookHandler { + return &WebhookHandler{webhookSecret: webhookSecret, stripeKey: stripeKey, shippoSecret: shippoSecret} } // HandleStripe validates the Stripe signature and routes the event. @@ -240,3 +243,112 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, eve audit.CardAdded(user.ID, card.ID, card.Brand, card.Last4) webhookLog().Info("card saved via checkout.session.completed", "user_id", user.ID) } + +func (h *WebhookHandler) HandleShippo(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + if h.shippoSecret == "" || token != h.shippoSecret { + webhookLog().Warn("shippo webhook: token missing or invalid") + utils.RespondError(w, http.StatusUnauthorized, "INVALID_TOKEN", "token missing or invalid", nil) + return + } + + const maxBodyBytes = 65536 + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + body, err := io.ReadAll(r.Body) + if err != nil { + utils.RespondError(w, http.StatusBadRequest, "READ_ERROR", "could not read request body", nil) + return + } + + var payload struct { + Event string `json:"event"` + Data struct { + TrackingNumber string `json:"tracking_number"` + Carrier string `json:"carrier"` + TrackingStatus struct { + Status string `json:"status"` + StatusDetails string `json:"status_details"` + StatusDate time.Time `json:"status_date"` + } `json:"tracking_status"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "could not parse event", nil) + return + } + + if payload.Event != "track_updated" { + webhookLog().Info("shippo webhook: ignoring no-tracking event", "event", payload.Event) + w.WriteHeader(http.StatusOK) + return + } + + newStatus, known := mapShippoStatus(payload.Data.TrackingStatus.Status) + if !known { + webhookLog().Warn("shippo webhook: unrecognized status, skipping", + "status", payload.Data.TrackingStatus.Status, + "tracking_number", payload.Data.TrackingNumber) + w.WriteHeader(http.StatusOK) + return + } + + db := database.GetDB().WithContext(r.Context()) + var order models.Order + if err := db.Where("tracking_number = ?", payload.Data.TrackingNumber).First(&order).Error; err != nil { + webhookLog().Warn("shippo webhook: order not found", + "tracking_number", payload.Data.TrackingNumber) + w.WriteHeader(http.StatusOK) + return + } + + if order.TrackingStatusUpdatedAt != nil && !payload.Data.TrackingStatus.StatusDate.After(*order.TrackingStatusUpdatedAt) { + w.WriteHeader(http.StatusOK) + return + } + + statusDate := payload.Data.TrackingStatus.StatusDate + previousTrackingStatus := order.TrackingStatus + updates := map[string]any{ + "tracking_status": newStatus, + "tracking_status_details": payload.Data.TrackingStatus.StatusDetails, + "tracking_status_updated_at": &statusDate, + } + deliveredTransition := newStatus == models.TrackingStatusDelivered && + order.Status != models.OrderStatusDelivered + if deliveredTransition { + updates["status"] = models.OrderStatusDelivered + } + + if err := db.Model(&order).Updates(updates).Error; err != nil { + webhookLog().Error("shippo webhook: failed to persist tracking update", + "order_id", order.ID, "error", err) + utils.RespondError(w, http.StatusInternalServerError, "DB_ERROR", "failed to persist", nil) + return + } + + if previousTrackingStatus != newStatus { + audit.TrackingUpdated(order.ID, order.Carrier, payload.Data.TrackingNumber, string(newStatus)) + } + if deliveredTransition { + audit.OrderDelivered(order.ID, payload.Data.TrackingNumber) + } + w.WriteHeader(http.StatusOK) +} + +func mapShippoStatus(s string) (models.TrackingStatus, bool) { + switch strings.ToUpper(strings.TrimSpace(s)) { + case "UNKNOWN": + return models.TrackingStatusUnknown, true + case "PRE_TRANSIT": + return models.TrackingStatusPreTransit, true + case "TRANSIT": + return models.TrackingStatusTransit, true + case "DELIVERED": + return models.TrackingStatusDelivered, true + case "RETURNED": + return models.TrackingStatusReturned, true + case "FAILURE": + return models.TrackingStatusFailure, true + } + return models.TrackingStatusUnknown, false +} diff --git a/api/main.go b/api/main.go index 6d6f75f..f51643b 100644 --- a/api/main.go +++ b/api/main.go @@ -68,7 +68,7 @@ func main() { jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute) // Setup routes - router := routes.SetupRoutes(version, cfg.StripeSecretKey, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.AppURL) + router := routes.SetupRoutes(version, cfg.StripeSecretKey, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL) // Create HTTP server server := &http.Server{ diff --git a/api/routes/routes.go b/api/routes/routes.go index 6f121cf..cf24c7a 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -24,6 +24,7 @@ func SetupRoutes( bringAPIUID string, bringAPIKey string, bringCustomerNumber string, + shippoWebhookSecret string, appURL string, ) *chi.Mux { r := chi.NewRouter() @@ -47,7 +48,7 @@ func SetupRoutes( orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber) addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey) viewHandler := handlers.NewViewHandler() - webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, stripeSecretKey) + webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, stripeSecretKey, shippoWebhookSecret) // Short payment redirect and success page — no auth required. r.Get("/pay/{token}", handlers.PayRedirect) @@ -68,6 +69,8 @@ func SetupRoutes( // Apply a generous IP rate limit here (Stripe retries from many IPs). r.With(middleware.RateLimitByIP(60, time.Minute)). Post("/webhooks/stripe", webhookHandler.HandleStripe) + r.With(middleware.RateLimitByIP(60, time.Minute)). + Post("/webhooks/shippo", webhookHandler.HandleShippo) // Auth — stricter IP rate limit to protect against brute-force. r.Group(func(r chi.Router) { diff --git a/pkg/audit/audit.go b/pkg/audit/audit.go index 7ae7da4..c2412ca 100644 --- a/pkg/audit/audit.go +++ b/pkg/audit/audit.go @@ -20,6 +20,8 @@ const ( eventPaymentCritical eventType = "payment_critical" eventLabelPurchased eventType = "label_purchased" eventLabelOrphaned eventType = "label_orphaned" + eventTrackingUpdated eventType = "tracking_updated" + eventOrderDelivered eventType = "order_delivered" ) type event struct { @@ -33,6 +35,7 @@ type event struct { CardBrand string Carrier string Tracking string + Status string Error string } @@ -65,6 +68,9 @@ func (e event) attrs() []any { if e.Tracking != "" { a = append(a, "tracking", e.Tracking) } + if e.Status != "" { + a = append(a, "status", e.Status) + } if e.Error != "" { a = append(a, "error", e.Error) } @@ -177,3 +183,21 @@ func LabelOrphaned(orderID uint, shippoTxID, trackingNumber string, err error) { Error: err.Error(), }.attrs()...) } + +func TrackingUpdated(orderID uint, carrier, trackingNumber, status string) { + slog.Info("audit", event{ + Event: eventTrackingUpdated, + OrderID: orderID, + Carrier: carrier, + Tracking: trackingNumber, + Status: status, + }.attrs()...) +} + +func OrderDelivered(orderID uint, trackingNumber string) { + slog.Info("audit", event{ + Event: eventOrderDelivered, + OrderID: orderID, + Tracking: trackingNumber, + }.attrs()...) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 4384ae5..e4bcfe9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -27,6 +27,7 @@ type Config struct { BringAPIUID string BringAPIKey string BringCustomerNumber string + ShippoWebhookSecret string } // Load reads configuration from environment variables and validates required secrets. @@ -60,6 +61,7 @@ func Load() (*Config, error) { BringAPIUID: os.Getenv("BRING_API_UID"), BringAPIKey: os.Getenv("BRING_API_KEY"), BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"), + ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"), } if err := cfg.validate(); err != nil {