feat(shipping): shippo webhook fro tracking status updates

This commit is contained in:
2026-05-14 13:30:37 +02:00
parent 387758aa13
commit ee6e14a424
5 changed files with 145 additions and 4 deletions

View File

@@ -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
}

View File

@@ -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{

View File

@@ -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) {

View File

@@ -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()...)
}

View File

@@ -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 {