package httpx import ( "encoding/json" "fmt" "net/http" "regexp" "sort" "strconv" "strings" "github.com/go-chi/chi/v5" "github.com/stripe/stripe-go/v83" "github.com/stripe/stripe-go/v83/paymentintent" stripeprice "github.com/stripe/stripe-go/v83/price" "royal-pop-backend/internal/config" "royal-pop-backend/internal/orders" ) type createPaymentIntentRequest struct { Items []checkoutItem `json:"items"` PaymentMethod string `json:"paymentMethod"` Customer checkoutCustomerDetails `json:"customer"` } type checkoutItem struct { ID string `json:"id"` Style string `json:"style"` ColorwayID string `json:"colorwayId"` FinishID string `json:"finishId"` Quantity int64 `json:"quantity,omitempty"` } type checkoutCustomerDetails struct { Email string `json:"email"` Phone string `json:"phone"` FirstName string `json:"firstName"` LastName string `json:"lastName"` AddressLine1 string `json:"addressLine1"` AddressLine2 string `json:"addressLine2"` City string `json:"city"` Region string `json:"region"` PostalCode string `json:"postalCode"` Country string `json:"country"` Notes string `json:"notes"` } type storefrontPricing struct { PriceID string Currency string UnitAmount int64 RetailAmount int64 } var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) func handleCreatePaymentIntent(cfg *config.Config, orderStore *orders.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if cfg.StripeSecretKey == "" { WriteError(w, http.StatusInternalServerError, "stripe_not_configured", "Missing STRIPE_SECRET_KEY for backend Stripe calls.") return } var request createPaymentIntentRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { WriteError(w, http.StatusBadRequest, "invalid_request", "The payment intent payload could not be parsed.") return } itemCount := countItems(request.Items) if itemCount <= 0 { WriteError(w, http.StatusBadRequest, "empty_cart", "Add at least one Royal Pop kit before creating a payment intent.") return } customerDetails, err := validateCustomerDetails(request.Customer) if err != nil { WriteError(w, http.StatusBadRequest, "invalid_customer_details", err.Error()) return } pricing := resolveStorefrontPricing(cfg) stripe.Key = cfg.StripeSecretKey amount := itemCount * pricing.UnitAmount orderID := orders.NewOrderID() metadata := buildPaymentIntentMetadata(cfg, request, itemCount, amount, orderID) params := &stripe.PaymentIntentParams{ Amount: stripe.Int64(amount), Currency: stripe.String(pricing.Currency), AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{ Enabled: stripe.Bool(true), AllowRedirects: stripe.String("never"), }, Description: stripe.String(fmt.Sprintf("Royal Pop preorder (%d kit%s)", itemCount, pluralize(itemCount))), } for key, value := range metadata { params.AddMetadata(key, value) } intent, err := paymentintent.New(params) if err != nil { WriteError(w, http.StatusBadGateway, "stripe_payment_intent_failed", err.Error()) return } orderRecord, err := orderStore.CreatePendingOrder(r.Context(), orders.CreateOrderInput{ OrderID: orderID, Customer: orders.CustomerDetails{ Email: customerDetails.Email, Phone: customerDetails.Phone, FirstName: customerDetails.FirstName, LastName: customerDetails.LastName, AddressLine1: customerDetails.AddressLine1, AddressLine2: customerDetails.AddressLine2, City: customerDetails.City, Region: customerDetails.Region, PostalCode: customerDetails.PostalCode, Country: customerDetails.Country, Notes: customerDetails.Notes, }, Items: buildOrderItems(request.Items, pricing.UnitAmount), Currency: pricing.Currency, Amount: amount, StripePriceID: cfg.StripePriceID, StripePaymentIntentID: intent.ID, }) if err != nil { WriteError(w, http.StatusInternalServerError, "order_persist_failed", "The payment session was created, but the order could not be saved. Please try again.") return } paymentIntentStates.upsertCreated(intent.ID) WriteJSON(w, http.StatusCreated, map[string]any{ "data": map[string]any{ "orderId": orderRecord.ID, "paymentIntentId": intent.ID, "clientSecret": intent.ClientSecret, "amount": intent.Amount, "currency": intent.Currency, "itemCount": itemCount, "priceId": cfg.StripePriceID, "unitAmount": pricing.UnitAmount, "submitEnabled": true, "message": paymentIntentCreatedMessage(cfg), }, }) } } func handleGetStorefrontPricing(cfg *config.Config) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { pricing := resolveStorefrontPricing(cfg) WriteJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "priceId": pricing.PriceID, "currency": pricing.Currency, "unitAmount": pricing.UnitAmount, "retailAmount": pricing.RetailAmount, }, }) } } func handleGetPaymentIntentStatus(cfg *config.Config, orderStore *orders.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if cfg.StripeSecretKey == "" { WriteError(w, http.StatusInternalServerError, "stripe_not_configured", "Missing STRIPE_SECRET_KEY for backend Stripe calls.") return } paymentIntentID := strings.TrimSpace(chi.URLParam(r, "paymentIntentID")) if paymentIntentID == "" { WriteError(w, http.StatusBadRequest, "missing_payment_intent_id", "The payment intent ID is required.") return } stripe.Key = cfg.StripeSecretKey intent, err := paymentintent.Get(paymentIntentID, nil) if err != nil { WriteError(w, http.StatusBadGateway, "stripe_payment_intent_lookup_failed", err.Error()) return } verification, exists := paymentIntentStates.get(intent.ID) orderRecord, orderErr := orderStore.GetByPaymentIntentID(r.Context(), intent.ID) if orderErr == nil { verification = paymentIntentVerification{ PaymentIntentID: orderRecord.StripePaymentIntentID, WebhookState: orderRecord.WebhookStatus, WebhookEventID: orderRecord.WebhookEventID, WebhookEventType: orderRecord.WebhookEventType, Message: orderRecord.WebhookMessage, Verified: orderRecord.Status == orders.StatusPaid, } exists = true } if !exists { verification = paymentIntentVerification{ PaymentIntentID: intent.ID, WebhookState: "awaiting_webhook", Message: "Waiting for Stripe webhook verification.", } } message := verification.Message if message == "" { message = defaultStatusMessage(intent.Status, verification.Verified) } WriteJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "orderId": orderIDFromOrder(orderRecord), "orderStatus": orderStatusFromOrder(orderRecord), "order": serializeOrder(orderRecord), "paymentIntentId": intent.ID, "stripeStatus": string(intent.Status), "amount": intent.Amount, "currency": intent.Currency, "webhookStatus": verification.WebhookState, "webhookEventId": verification.WebhookEventID, "webhookEventType": verification.WebhookEventType, "verified": verification.Verified, "message": message, }, }) } } func countItems(items []checkoutItem) int64 { var total int64 for _, item := range items { quantity := item.Quantity if quantity <= 0 { quantity = 1 } total += quantity } return total } func buildPaymentIntentMetadata(cfg *config.Config, request createPaymentIntentRequest, itemCount int64, amount int64, orderID string) map[string]string { pricing := resolveStorefrontPricing(cfg) colorways := make([]string, 0, len(request.Items)) styles := make([]string, 0, len(request.Items)) for _, item := range request.Items { if item.ColorwayID != "" { colorways = append(colorways, item.ColorwayID) } if item.Style != "" { styles = append(styles, item.Style) } } sort.Strings(colorways) sort.Strings(styles) return map[string]string{ "order_id": orderID, "source": "royal-pop-storefront", "stripe_price_id": cfg.StripePriceID, "payment_method_ui": strings.TrimSpace(request.PaymentMethod), "item_count": fmt.Sprintf("%d", itemCount), "unit_amount": fmt.Sprintf("%d", pricing.UnitAmount), "amount_total": fmt.Sprintf("%d", amount), "colorways": strings.Join(colorways, ","), "styles": strings.Join(styles, ","), } } func resolveStorefrontPricing(cfg *config.Config) storefrontPricing { pricing := storefrontPricing{ PriceID: strings.TrimSpace(cfg.StripePriceID), Currency: strings.ToLower(strings.TrimSpace(cfg.StripeCurrency)), UnitAmount: cfg.RoyalPopUnitAmount, RetailAmount: cfg.RoyalPopRetailAmount, } if pricing.Currency == "" { pricing.Currency = "gbp" } if strings.TrimSpace(cfg.StripeSecretKey) == "" || pricing.PriceID == "" { return pricing } stripe.Key = cfg.StripeSecretKey priceRecord, err := stripeprice.Get(pricing.PriceID, nil) if err != nil || priceRecord == nil { return pricing } if priceRecord.UnitAmount > 0 { pricing.UnitAmount = priceRecord.UnitAmount } if currency := strings.ToLower(string(priceRecord.Currency)); currency != "" { pricing.Currency = currency } for _, key := range []string{"retail_amount", "compare_at_amount", "compareAtAmount"} { if raw := strings.TrimSpace(priceRecord.Metadata[key]); raw != "" { if parsed, err := strconv.ParseInt(raw, 10, 64); err == nil && parsed > 0 { pricing.RetailAmount = parsed break } } } return pricing } func buildOrderItems(items []checkoutItem, unitAmount int64) []orders.LineItem { result := make([]orders.LineItem, 0, len(items)) for _, item := range items { quantity := item.Quantity if quantity <= 0 { quantity = 1 } result = append(result, orders.LineItem{ ItemID: strings.TrimSpace(item.ID), Style: strings.TrimSpace(item.Style), ColorwayID: strings.TrimSpace(item.ColorwayID), FinishID: strings.TrimSpace(item.FinishID), Quantity: quantity, UnitAmount: unitAmount, }) } return result } func validateCustomerDetails(input checkoutCustomerDetails) (checkoutCustomerDetails, error) { normalized := checkoutCustomerDetails{ Email: strings.TrimSpace(input.Email), Phone: strings.TrimSpace(input.Phone), FirstName: strings.TrimSpace(input.FirstName), LastName: strings.TrimSpace(input.LastName), AddressLine1: strings.TrimSpace(input.AddressLine1), AddressLine2: strings.TrimSpace(input.AddressLine2), City: strings.TrimSpace(input.City), Region: strings.TrimSpace(input.Region), PostalCode: strings.TrimSpace(input.PostalCode), Country: strings.TrimSpace(input.Country), Notes: strings.TrimSpace(input.Notes), } if !emailPattern.MatchString(normalized.Email) { return normalized, fmt.Errorf("Enter a valid email address before continuing to payment.") } if len(normalized.Phone) < 7 { return normalized, fmt.Errorf("Enter a valid phone number before continuing to payment.") } if len(normalized.FirstName) < 2 || len(normalized.LastName) < 2 { return normalized, fmt.Errorf("Enter the full shipping name before continuing to payment.") } if len(normalized.AddressLine1) < 5 || len(normalized.City) < 2 || len(normalized.Region) < 2 || len(normalized.PostalCode) < 3 || len(normalized.Country) < 2 { return normalized, fmt.Errorf("Complete the full shipping address before continuing to payment.") } return normalized, nil } func orderIDFromOrder(order *orders.Order) string { if order == nil { return "" } return order.ID } func orderStatusFromOrder(order *orders.Order) string { if order == nil { return "" } return order.Status } func pluralize(value int64) string { if value == 1 { return "" } return "s" } func paymentIntentCreatedMessage(cfg *config.Config) string { if cfg.IsDevelopment() { return "Payment session created. Complete the payment form, then wait for Stripe webhook verification to confirm the order." } return "Payment session created. Complete the payment form, then wait for Stripe webhook verification to confirm the order." } func defaultStatusMessage(status stripe.PaymentIntentStatus, verified bool) string { if verified { return "Stripe confirmed your payment and updated the order record." } switch status { case stripe.PaymentIntentStatusSucceeded: return "Stripe reports this payment as succeeded, but the backend is still waiting for the webhook confirmation." case stripe.PaymentIntentStatusProcessing: return "Stripe is still processing this payment intent." case stripe.PaymentIntentStatusCanceled: return "Stripe marked this payment intent as canceled." case stripe.PaymentIntentStatusRequiresPaymentMethod: return "Stripe needs a valid payment method before this payment can complete." case stripe.PaymentIntentStatusRequiresAction: return "Stripe requires additional customer action before this payment can complete." default: return "Waiting for the next Stripe payment update." } } func serializeOrder(order *orders.Order) map[string]any { if order == nil { return nil } items := make([]map[string]any, 0, len(order.Items)) for _, item := range order.Items { items = append(items, map[string]any{ "itemId": item.ItemID, "style": item.Style, "colorwayId": item.ColorwayID, "finishId": item.FinishID, "quantity": item.Quantity, "unitAmount": item.UnitAmount, }) } return map[string]any{ "id": order.ID, "status": order.Status, "currency": order.Currency, "amount": order.Amount, "webhookStatus": order.WebhookStatus, "items": items, } }