package httpx import ( "context" "encoding/json" "errors" "fmt" "io" "log" "net/http" "strings" "time" "github.com/stripe/stripe-go/v83" stripewebhook "github.com/stripe/stripe-go/v83/webhook" "royal-pop-backend/internal/config" "royal-pop-backend/internal/mailer" "royal-pop-backend/internal/orders" ) const maxStripeWebhookBodyBytes int64 = 64 * 1024 type stripeWebhookResult struct { EventID string `json:"eventId"` EventType string `json:"eventType"` PaymentIntentID string `json:"paymentIntentId,omitempty"` OrderState string `json:"orderState"` Persistence string `json:"persistence"` Message string `json:"message"` Verified bool `json:"verified"` } func handleStripeWebhook(cfg *config.Config, orderStore *orders.Store, fulfillmentMailer *mailer.ResendMailer) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if cfg.StripeWebhookSecret == "" { WriteError(w, http.StatusInternalServerError, "stripe_webhook_not_configured", "Missing STRIPE_WEBHOOK_SECRET for Stripe webhook verification.") return } r.Body = http.MaxBytesReader(w, r.Body, maxStripeWebhookBodyBytes) payload, err := io.ReadAll(r.Body) if err != nil { WriteError(w, http.StatusBadRequest, "invalid_webhook_payload", "The Stripe webhook payload could not be read.") return } signature := r.Header.Get("Stripe-Signature") if strings.TrimSpace(signature) == "" { WriteError(w, http.StatusBadRequest, "missing_stripe_signature", "Missing Stripe-Signature header.") return } constructOptions := stripewebhook.ConstructEventOptions{} constructOptions.IgnoreAPIVersionMismatch = true event, err := stripewebhook.ConstructEventWithOptions(payload, signature, cfg.StripeWebhookSecret, constructOptions) if err != nil { status := http.StatusBadRequest if errors.Is(err, stripewebhook.ErrTooOld) { status = http.StatusUnauthorized } log.Printf("stripe webhook rejected: status=%d error=%v", status, err) WriteError(w, status, "invalid_stripe_signature", err.Error()) return } result, err := processStripeWebhookEvent(event) if err != nil { WriteError(w, http.StatusBadRequest, "unsupported_webhook_payload", err.Error()) return } paymentIntentStates.applyWebhook(result) if orderStore != nil && result.PaymentIntentID != "" { updateCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() previousOrder, err := orderStore.GetByPaymentIntentID(updateCtx, result.PaymentIntentID) if err != nil && !errors.Is(err, orders.ErrOrderNotFound) { WriteError(w, http.StatusInternalServerError, "order_lookup_failed", "Stripe webhook verification succeeded, but the current order record could not be loaded.") return } if err := orderStore.UpdateFromWebhook(updateCtx, result.PaymentIntentID, orders.WebhookUpdate{ Status: orderStatusForWebhookResult(result.OrderState), WebhookStatus: webhookStatusForWebhookResult(result.OrderState), WebhookEventID: result.EventID, WebhookEventType: result.EventType, WebhookMessage: result.Message, }); err != nil && !errors.Is(err, orders.ErrOrderNotFound) { WriteError(w, http.StatusInternalServerError, "order_webhook_update_failed", "Stripe webhook verification succeeded, but the order record could not be updated.") return } if fulfillmentMailer != nil && fulfillmentMailer.Enabled() { updatedOrder, err := orderStore.GetByPaymentIntentID(updateCtx, result.PaymentIntentID) if err != nil && !errors.Is(err, orders.ErrOrderNotFound) { WriteError(w, http.StatusInternalServerError, "order_reload_failed", "Stripe webhook verification succeeded, but the updated order record could not be loaded.") return } if shouldSendPaidEmail(previousOrder, updatedOrder) { if err := fulfillmentMailer.SendOrderPaid(updateCtx, updatedOrder); err != nil { log.Printf("paid email failed for order %s: %v", updatedOrder.ID, err) } if err := fulfillmentMailer.SendOrderCreated(updateCtx, updatedOrder); err != nil { log.Printf("order created reminder email failed for order %s: %v", updatedOrder.ID, err) } } } } log.Printf("stripe webhook received: event_id=%s type=%s payment_intent=%s order_state=%s", result.EventID, result.EventType, result.PaymentIntentID, result.OrderState) WriteJSON(w, http.StatusOK, map[string]any{ "received": true, "data": result, }) } } func shouldSendPaidEmail(previousOrder, updatedOrder *orders.Order) bool { if previousOrder == nil || updatedOrder == nil { return false } if updatedOrder.Status != orders.StatusPaid { return false } if previousOrder.Status == orders.StatusPaid { return false } return strings.TrimSpace(updatedOrder.Email) != "" } func processStripeWebhookEvent(event stripe.Event) (*stripeWebhookResult, error) { intent, err := decodePaymentIntentFromEvent(event) if err != nil { return nil, err } result := &stripeWebhookResult{ EventID: event.ID, EventType: string(event.Type), PaymentIntentID: intent.ID, Persistence: "not_persisted_yet", } switch event.Type { case stripe.EventTypePaymentIntentSucceeded: result.OrderState = "paid_verified_local_only" result.Verified = true result.Message = "Stripe confirmed the payment and updated the order record." case stripe.EventTypePaymentIntentPaymentFailed: result.OrderState = "payment_failed_verified_local_only" result.Verified = true result.Message = buildPaymentIntentFailureMessage(intent) case stripe.EventTypePaymentIntentProcessing: result.OrderState = "processing_verified_local_only" result.Verified = true result.Message = "Stripe marked the payment intent as processing. The order record has been updated and is waiting for the next payment event." case stripe.EventTypePaymentIntentCanceled: result.OrderState = "canceled_verified_local_only" result.Verified = true result.Message = "Stripe marked the payment intent as canceled and the order record has been updated." default: result.OrderState = "ignored" result.Verified = false result.Message = fmt.Sprintf("Received Stripe event %s. Signature verified, but no persistence action is configured for this event yet.", event.Type) } return result, nil } func decodePaymentIntentFromEvent(event stripe.Event) (*stripe.PaymentIntent, error) { if len(event.Data.Raw) == 0 { return nil, fmt.Errorf("stripe event %s did not include an object payload", event.Type) } var intent stripe.PaymentIntent if err := json.Unmarshal(event.Data.Raw, &intent); err != nil { return nil, fmt.Errorf("stripe event %s could not be parsed as a payment intent: %w", event.Type, err) } return &intent, nil } func buildPaymentIntentFailureMessage(intent *stripe.PaymentIntent) string { if intent == nil || intent.LastPaymentError == nil { return "Stripe reported that the payment intent failed and the order record has been updated." } message := strings.TrimSpace(intent.LastPaymentError.Msg) if message == "" { return "Stripe reported that the payment intent failed and the order record has been updated." } return fmt.Sprintf("Stripe reported that the payment intent failed: %s", message) } func orderStatusForWebhookResult(orderState string) string { switch orderState { case "paid_verified_local_only", "paid_unrecorded": return orders.StatusPaid case "payment_failed_verified_local_only", "payment_failed_unrecorded": return orders.StatusFailed case "processing_verified_local_only", "processing_unrecorded": return orders.StatusProcessing case "canceled_verified_local_only", "canceled_unrecorded": return orders.StatusCanceled default: return orders.StatusPending } } func webhookStatusForWebhookResult(orderState string) string { switch orderState { case "paid_verified_local_only", "paid_unrecorded": return "succeeded" case "payment_failed_verified_local_only", "payment_failed_unrecorded": return "failed" case "processing_verified_local_only", "processing_unrecorded": return "processing" case "canceled_verified_local_only", "canceled_unrecorded": return "canceled" case "ignored": return "ignored" default: return "awaiting_webhook" } }