2
0
Files
Royal-Pop-Accessory/Backend/internal/httpx/webhook_routes.go
T
2026-06-23 18:50:37 +01:00

237 lines
8.1 KiB
Go

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