237 lines
8.1 KiB
Go
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"
|
|
}
|
|
}
|