Version 1
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user