package handlers import ( "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "os" "strings" "time" "github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78/paymentmethod" "github.com/stripe/stripe-go/v78/setupintent" "github.com/stripe/stripe-go/v78/webhook" "gorm.io/gorm" "terminalShop/pkg/audit" "terminalShop/pkg/database" "terminalShop/pkg/models" "terminalShop/pkg/notify" "terminalShop/pkg/utils" ) func webhookLog() *slog.Logger { return slog.With("component", "webhook") } // WebhookHandler handles inbound Stripe webhook events. type WebhookHandler struct { webhookSecret string shippoSecret string } // NewWebhookHandler creates a new webhook handler. Stripe credentials are // wired once at startup via stripe.Key in api/main.go. func NewWebhookHandler(webhookSecret, shippoSecret string) *WebhookHandler { return &WebhookHandler{webhookSecret: webhookSecret, shippoSecret: shippoSecret} } // HandleStripe validates the Stripe signature and routes the event. // This endpoint is intentionally unauthenticated — authentication is // provided by the webhook signature (HMAC-SHA256). func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) { // Read the entire body up front; webhook.ConstructEvent requires it. 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 } // Verify the Stripe signature. Reject anything that doesn't match. if h.webhookSecret == "" { // Fail closed in production. config.validate() requires STRIPE_WEBHOOK_SECRET // in production, so the server should never boot into this state; this is // defense in depth against a misconfigured deploy. Mirrors the Shippo and // Slack handlers, which already refuse to run without their secret. if os.Getenv("ENVIRONMENT") == "production" { webhookLog().Error("stripe webhook secret not set in production; refusing to process event") utils.RespondError(w, http.StatusServiceUnavailable, "WEBHOOK_NOT_CONFIGURED", "webhook verification not configured", nil) return } // Development only; warn loudly but still process so local testing works. webhookLog().Warn("stripe webhook secret not set; skipping signature verification (development only)") } else { sig := r.Header.Get("Stripe-Signature") if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{ IgnoreAPIVersionMismatch: true, }); err != nil { webhookLog().Warn("signature verification failed", "error", err) utils.RespondError(w, http.StatusUnauthorized, "INVALID_SIGNATURE", "webhook signature verification failed", nil) return } } var event stripe.Event if err := json.Unmarshal(body, &event); err != nil { utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "could not parse event", nil) return } switch event.Type { case "checkout.session.completed": h.handleCheckoutSessionCompleted(r.Context(), event) // payment_intent.succeeded fires when a charge completes asynchronously // (e.g. 3DS, bank redirects). For confirm=true intents this is normally // already handled by ConvertCart, but the webhook is the authoritative source. case "payment_intent.succeeded": h.handlePaymentIntentSucceeded(r.Context(), event) // payment_intent.payment_failed fires when a payment fails after initial // confirmation (e.g. bank decline on a deferred 3DS flow). case "payment_intent.payment_failed": h.handlePaymentIntentFailed(r.Context(), event) // Sync local card state when Stripe's side changes. case "payment_method.attached", "payment_method.detached", "payment_method.updated": // Handled by the reconcile job and direct API calls; log only. webhookLog().Info("payment method event received", "event_type", string(event.Type)) } // Always return 200 so Stripe doesn't retry. w.WriteHeader(http.StatusOK) } func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event stripe.Event) { var pi stripe.PaymentIntent if err := json.Unmarshal(event.Data.Raw, &pi); err != nil { webhookLog().Error("failed to unmarshal payment_intent", "error", err, "event", "payment_intent.succeeded") return } orderIDStr, ok := pi.Metadata["order_id"] if !ok { // PaymentIntent not tied to a local order — nothing to do. return } db := database.GetDB().WithContext(ctx) var order models.Order if err := db.Preload("Items").Where("id = ?", orderIDStr).First(&order).Error; err != nil { webhookLog().Warn("order not found for payment intent", "order_id", orderIDStr, "pi", pi.ID) return } if order.Status == models.OrderStatusPaid { // Already recorded — idempotent. return } // Defense in depth: even behind a valid signature, refuse to mark an order // paid unless the PaymentIntent's amount and currency match what we charged. // Under ConvertCart these always match (Amount == order.Total at creation), so // a mismatch means a malformed or unexpected event we should not trust. if pi.Amount != int64(order.Total) { webhookLog().Error("payment intent amount mismatch; refusing to mark paid", "order_id", orderIDStr, "pi", pi.ID, "pi_amount", pi.Amount, "order_total", order.Total) return } if pi.Currency != "" && pi.Currency != stripe.CurrencyUSD { webhookLog().Error("payment intent currency mismatch; refusing to mark paid", "order_id", orderIDStr, "pi", pi.ID, "currency", string(pi.Currency)) return } txErr := db.Transaction(func(tx *gorm.DB) error { if err := tx.Model(&order).Updates(map[string]any{ "status": models.OrderStatusPaid, "stripe_payment_id": pi.ID, }).Error; err != nil { return err } // Clear the user's cart if it hasn't been cleared yet. var cart models.Cart if err := tx.Where("user_id = ?", order.UserID).First(&cart).Error; err != nil { return nil // Cart already gone. } if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil { return fmt.Errorf("failed to delete cart item: %w", err) } return tx.Model(&cart).Updates(map[string]any{ "address_id": nil, "card_id": nil, }).Error }) if txErr != nil { webhookLog().Error("failed to record paid status", "order_id", orderIDStr, "pi", pi.ID, "error", txErr) return } // A retained (non-ephemeral) card gets its storage TTL refreshed on use. // Ephemeral orders are about to lose their card entirely, so skip the refresh. if order.CardID != 0 && !order.Ephemeral { if err := refreshCardStorageTTL(db, order.CardID, time.Now()); err != nil { webhookLog().Warn("failed to refresh card storage ttl", "card_id", order.CardID, "error", err) } } if order.Ephemeral && pi.PaymentMethod != nil && pi.PaymentMethod.ID != "" { if _, derr := paymentMethodDetachFn(pi.PaymentMethod.ID, nil); derr != nil { webhookLog().Warn("failed to detach ephemeral payment method", "order_id", order.ID, "pi", pi.ID, "error", derr) } } // Deferred (3DS) twin of the inline forget-card cleanup in ConvertCart: a // privacy-mode order that selected a saved/QR card carries CardID != 0. Now // that it is paid, hard-delete the local row (Unscoped: Card soft-deletes by // default). Token-ephemeral orders have CardID == 0 and skip this. if order.Ephemeral && order.CardID != 0 { if err := db.Unscoped().Delete(&models.Card{}, order.CardID).Error; err != nil { webhookLog().Warn("failed to delete forgotten card", "order_id", order.ID, "card_id", order.CardID, "error", err) } else if err := db.Model(&order).Update("card_id", 0).Error; err != nil { webhookLog().Warn("failed to null forgotten card reference", "order_id", order.ID, "error", err) } } audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID) go notify.SlackOrderPaid(&order) webhookLog().Info("order marked paid", "order_id", orderIDStr, "pi", pi.ID) } func (h *WebhookHandler) handlePaymentIntentFailed(ctx context.Context, event stripe.Event) { var pi stripe.PaymentIntent if err := json.Unmarshal(event.Data.Raw, &pi); err != nil { webhookLog().Error("failed to unmarshal payment_intent", "error", err, "event", "payment_intent.payment_failed") return } orderIDStr, ok := pi.Metadata["order_id"] if !ok { return } // authentication_required is recoverable — the cart handler re-confirms the // PI on-session and the customer completes 3DS via redirect. Marking the // order failed here would race with the recovery path and surface a // momentarily-broken state in the TUI. if pi.LastPaymentError != nil && string(pi.LastPaymentError.Code) == "authentication_required" { webhookLog().Info("ignoring authentication_required payment_failed; handled via 3ds redirect", "order_id", orderIDStr, "pi", pi.ID) return } db := database.GetDB().WithContext(ctx) if err := db.Model(&models.Order{}).Where("id = ? AND status IN (?, ?)", orderIDStr, models.OrderStatusPending, models.OrderStatusRequiresAction). Update("status", models.OrderStatusFailed).Error; err != nil { webhookLog().Error("failed to mark order failed", "order_id", orderIDStr, "error", err) } errMsg := "unknown" if pi.LastPaymentError != nil { errMsg = pi.LastPaymentError.Msg } webhookLog().Warn("payment failed", "order_id", orderIDStr, "pi", pi.ID, "reason", errMsg) } func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, event stripe.Event) { var sess stripe.CheckoutSession if err := json.Unmarshal(event.Data.Raw, &sess); err != nil { webhookLog().Error("failed to unmarshal checkout session", "error", err) return } if sess.Mode != stripe.CheckoutSessionModeSetup { return } if sess.SetupIntent == nil || sess.Customer == nil { webhookLog().Warn("checkout session missing setup_intent or customer") return } si, err := setupintent.Get(sess.SetupIntent.ID, nil) if err != nil { webhookLog().Error("failed to get setup intent", "setup_intent", sess.SetupIntent.ID, "error", err) return } if si.PaymentMethod == nil { webhookLog().Warn("setup intent has no payment method", "setup_intent", si.ID) return } pm, err := paymentmethod.Get(si.PaymentMethod.ID, nil) if err != nil { webhookLog().Error("failed to get payment method", "payment_method", si.PaymentMethod.ID, "error", err) return } db := database.GetDB().WithContext(ctx) var user models.User if err := db.Where("stripe_customer_id = ?", sess.Customer.ID).First(&user).Error; err != nil { webhookLog().Warn("user not found for customer", "customer", sess.Customer.ID, "error", err) return } // Idempotency: skip if this exact PaymentMethod is already in use var existing models.Card if err := db.Where("user_id = ? AND stripe_payment_id = ?", user.ID, pm.ID).First(&existing).Error; err == nil { return } // dedup against local cards table, matching last4 + brand + expiry means // the user is re-adding the same physical card. detach the new pm from // stripe and skip the local insert. Local DB is the source of truth // the stripe customers paymentmethods list may contain orphans from before // this fix, which would cause false-positive dedup hits if pm.Card != nil { var dup models.Card err := db.Where( "user_id = ? AND last4 = ? AND brand = ? AND exp_month = ? AND exp_year = ?", user.ID, pm.Card.Last4, string(pm.Card.Brand), int(pm.Card.ExpMonth), int(pm.Card.ExpYear), ).First(&dup).Error if err == nil { if _, derr := paymentmethod.Detach(pm.ID, nil); derr != nil { webhookLog().Warn("failed to detach duplicate pm", "user_id", user.ID, "error", derr) } // Bump UpdatedAt so the TUI poll signal can detect that the webhook // fired even though no new row was inserted. storage_expires_at is // reset because the user just re-added the card; this is a card-add // path, not a payment-success refresh, so last_used_at stays as-is. now := time.Now() if uerr := db.Model(&dup).Updates(map[string]any{ "updated_at": now, "storage_expires_at": models.CardStorageExpiresAt(now), }).Error; uerr != nil { webhookLog().Warn("failed to bump dup card updated_at", "card_id", dup.ID, "error", uerr) } webhookLog().Info("skipped duplicate card via local-db match", "user_id", user.ID, "existing_card_id", dup.ID) return } } card := models.Card{ UserID: user.ID, StripePaymentID: pm.ID, Last4: pm.Card.Last4, Brand: string(pm.Card.Brand), ExpMonth: int(pm.Card.ExpMonth), ExpYear: int(pm.Card.ExpYear), } card.InitializeStorageTTL(time.Now()) if err := db.Create(&card).Error; err != nil { webhookLog().Error("failed to save card", "user_id", user.ID, "error", err) return } 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) writeShippoResponse(w, map[string]any{ "received": true, "skipped": "non-tracking event", "event": payload.Event, }) 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) writeShippoResponse(w, map[string]any{ "received": true, "skipped": "unrecognized status", "raw_status": payload.Data.TrackingStatus.Status, }) 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) writeShippoResponse(w, map[string]any{ "received": true, "skipped": "order not found", "tracking_number": payload.Data.TrackingNumber, }) return } if order.TrackingStatusUpdatedAt != nil && !payload.Data.TrackingStatus.StatusDate.After(*order.TrackingStatusUpdatedAt) { writeShippoResponse(w, map[string]any{ "received": true, "skipped": "stale status_date", "order_id": order.ID, "current_tracking_status": string(order.TrackingStatus), }) 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)) go func() { _ = notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails)) }() } if deliveredTransition { audit.OrderDelivered(order.ID, payload.Data.TrackingNumber) } writeShippoResponse(w, map[string]any{ "received": true, "order_id": order.ID, "tracking_status": string(newStatus), "previous_tracking_status": string(previousTrackingStatus), "lifecycle_status": string(order.Status), "delivered_transition": deliveredTransition, "status_details": payload.Data.TrackingStatus.StatusDetails, }) } func formatTrackingUpdate(status models.TrackingStatus, details string) string { var icon, label string switch status { case models.TrackingStatusPreTransit: icon, label = ":label:", "Label created" case models.TrackingStatusTransit: icon, label = ":truck:", "In transit" case models.TrackingStatusDelivered: icon, label = ":white_check_mark:", "Delivered" case models.TrackingStatusReturned: icon, label = ":leftwards_arrow_with_hook:", "Returned to sender" case models.TrackingStatusFailure: icon, label = ":warning:", "Delivery exception" default: icon, label = ":package:", string(status) } if details != "" { return fmt.Sprintf("%s *%s* — %s", icon, label, details) } return fmt.Sprintf("%s *%s*", icon, label) } 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 } func writeShippoResponse(w http.ResponseWriter, payload map[string]any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(payload) }