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

447 lines
14 KiB
Go

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