447 lines
14 KiB
Go
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,
|
|
}
|
|
}
|