mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 02:52:20 +02:00
505 lines
18 KiB
Go
505 lines
18 KiB
Go
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)
|
|
}
|