2
0

Version 1

This commit is contained in:
MangoPig
2026-06-23 18:50:37 +01:00
commit 55d9772272
133 changed files with 21515 additions and 0 deletions
+446
View File
@@ -0,0 +1,446 @@
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,
}
}
@@ -0,0 +1,755 @@
package httpx
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"royal-pop-backend/internal/config"
"royal-pop-backend/internal/database"
"royal-pop-backend/internal/inventory"
"royal-pop-backend/internal/mailer"
"royal-pop-backend/internal/orders"
)
const clientSessionCookieName = "royal_pop_client_session"
type clientLoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type clientStockUpdateRequest struct {
Style string `json:"style"`
ColorwayID string `json:"colorwayId"`
FinishID string `json:"finishId"`
QuantityOnHand int64 `json:"quantityOnHand"`
Notes string `json:"notes"`
}
type clientOrderUpdateRequest struct {
FulfillmentStatus string `json:"fulfillmentStatus"`
ShippingCarrier string `json:"shippingCarrier"`
TrackingNumber string `json:"trackingNumber"`
FulfillmentNotes string `json:"fulfillmentNotes"`
ClearShippedAt bool `json:"clearShippedAt"`
MarkShippedAtNow bool `json:"markShippedAtNow"`
}
func handleClientReseed(db *database.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if db == nil {
WriteError(w, http.StatusServiceUnavailable, "reseed_unavailable", "Database reseed is not configured yet.")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
summary, err := db.BackupAndResetAppData(ctx)
if err != nil {
WriteError(w, http.StatusInternalServerError, "reseed_failed", "The database backup or reset could not be completed.")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"backupPath": summary.BackupPath,
"ordersCount": summary.OrdersCount,
"orderItemsCount": summary.OrderItemsCount,
"inventoryCount": summary.InventoryCount,
"message": "Backup created and the local database has been reset to an empty state.",
},
})
}
}
func handleListPublicInventory(stockStore *inventory.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if stockStore == nil {
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"inventory": []map[string]any{},
},
})
return
}
levels, err := stockStore.List(r.Context())
if err != nil {
WriteError(w, http.StatusInternalServerError, "inventory_list_failed", "The storefront could not load inventory availability.")
return
}
serialized := make([]map[string]any, 0, len(levels))
for _, level := range levels {
serialized = append(serialized, map[string]any{
"style": level.Style,
"colorwayId": level.ColorwayID,
"finishId": level.FinishID,
"quantityOnHand": level.QuantityOnHand,
"updatedAt": level.UpdatedAt.Format(time.RFC3339),
})
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"inventory": serialized,
},
})
}
}
func handleGetClientSession(cfg *config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !cfg.ClientDashboardEnabled() {
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"authenticated": false,
"configured": false,
},
})
return
}
username, expiresAt, ok := validateClientSessionCookie(cfg, r)
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"authenticated": ok,
"configured": true,
"username": username,
"expiresAt": expiresAt,
},
})
}
}
func handleClientLogin(cfg *config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !cfg.ClientDashboardEnabled() {
WriteError(w, http.StatusServiceUnavailable, "client_dashboard_not_configured", "Client dashboard credentials are not configured yet.")
return
}
var request clientLoginRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
WriteError(w, http.StatusBadRequest, "invalid_json", "Provide a valid username and password.")
return
}
if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(request.Username)), []byte(cfg.ClientDashboardUser)) != 1 ||
subtle.ConstantTimeCompare([]byte(request.Password), []byte(cfg.ClientDashboardPass)) != 1 {
WriteError(w, http.StatusUnauthorized, "invalid_client_credentials", "The client dashboard username or password is incorrect.")
return
}
expiresAt := time.Now().Add(cfg.ClientDashboardSessionTTL)
setClientSessionCookie(w, cfg, cfg.ClientDashboardUser, expiresAt)
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"authenticated": true,
"configured": true,
"username": cfg.ClientDashboardUser,
"expiresAt": expiresAt.Format(time.RFC3339),
},
})
}
}
func handleClientLogout(cfg *config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
clearClientSessionCookie(w, cfg)
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"authenticated": false,
},
})
}
}
func requireClientSession(cfg *config.Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !cfg.ClientDashboardEnabled() {
WriteError(w, http.StatusServiceUnavailable, "client_dashboard_not_configured", "Client dashboard credentials are not configured yet.")
return
}
if _, _, ok := validateClientSessionCookie(cfg, r); !ok {
WriteError(w, http.StatusUnauthorized, "client_dashboard_auth_required", "Please sign in to the client dashboard first.")
return
}
next.ServeHTTP(w, r)
})
}
}
func handleListClientOrders(orderStore *orders.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if orderStore == nil {
WriteError(w, http.StatusServiceUnavailable, "orders_unavailable", "Order storage is not configured yet.")
return
}
limit := 25
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
parsed, err := strconv.Atoi(raw)
if err == nil {
limit = parsed
}
}
search := strings.TrimSpace(r.URL.Query().Get("search"))
status := strings.TrimSpace(r.URL.Query().Get("status"))
ordersList, err := orderStore.ListForDashboard(r.Context(), orders.ListFilter{
Limit: limit,
Search: search,
FulfillmentStatus: status,
})
if err != nil {
WriteError(w, http.StatusInternalServerError, "orders_list_failed", "The client dashboard could not load recent orders.")
return
}
serialized := make([]map[string]any, 0, len(ordersList))
for _, order := range ordersList {
serialized = append(serialized, serializeClientOrderSummary(order))
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"orders": serialized,
},
})
}
}
func handleGetClientOrder(orderStore *orders.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if orderStore == nil {
WriteError(w, http.StatusServiceUnavailable, "orders_unavailable", "Order storage is not configured yet.")
return
}
orderID := strings.TrimSpace(chi.URLParam(r, "orderID"))
if orderID == "" {
WriteError(w, http.StatusBadRequest, "invalid_order_id", "Provide a valid order ID.")
return
}
order, err := orderStore.GetByID(r.Context(), orderID)
if err != nil {
if err == orders.ErrOrderNotFound {
WriteError(w, http.StatusNotFound, "order_not_found", "That order could not be found.")
return
}
WriteError(w, http.StatusInternalServerError, "order_load_failed", "The client dashboard could not load this order.")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"order": serializeClientOrderDetail(order),
},
})
}
}
func handleUpdateClientOrder(orderStore *orders.Store, fulfillmentMailer *mailer.ResendMailer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if orderStore == nil {
WriteError(w, http.StatusServiceUnavailable, "orders_unavailable", "Order storage is not configured yet.")
return
}
orderID := strings.TrimSpace(chi.URLParam(r, "orderID"))
if orderID == "" {
WriteError(w, http.StatusBadRequest, "invalid_order_id", "Provide a valid order ID.")
return
}
existingOrder, err := orderStore.GetByID(r.Context(), orderID)
if err != nil {
if err == orders.ErrOrderNotFound {
WriteError(w, http.StatusNotFound, "order_not_found", "That order could not be found.")
return
}
WriteError(w, http.StatusInternalServerError, "order_load_failed", "The client dashboard could not load this order.")
return
}
var request clientOrderUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
WriteError(w, http.StatusBadRequest, "invalid_json", "Provide a valid order update payload.")
return
}
updatedOrder, err := orderStore.UpdateFulfillment(r.Context(), orders.UpdateFulfillmentInput{
OrderID: orderID,
FulfillmentStatus: request.FulfillmentStatus,
ShippingCarrier: request.ShippingCarrier,
TrackingNumber: request.TrackingNumber,
FulfillmentNotes: request.FulfillmentNotes,
ClearShippedAt: request.ClearShippedAt,
MarkShippedAtNow: request.MarkShippedAtNow,
})
if err != nil {
switch err {
case orders.ErrOrderNotFound:
WriteError(w, http.StatusNotFound, "order_not_found", "That order could not be found.")
return
default:
if strings.Contains(err.Error(), "invalid fulfillment status") {
WriteError(w, http.StatusBadRequest, "invalid_fulfillment_status", "Choose a valid fulfillment status.")
return
}
WriteError(w, http.StatusInternalServerError, "order_update_failed", "The order update could not be saved.")
return
}
}
shipmentEmail := map[string]any{
"status": "skipped",
"message": "Fulfilment email was not sent.",
}
if fulfillmentMailer == nil || !fulfillmentMailer.Enabled() {
shipmentEmail["reason"] = "not_configured"
shipmentEmail["message"] = "Fulfilment email is not configured."
} else if shouldSendShipmentEmail(existingOrder, updatedOrder) {
if err := fulfillmentMailer.SendOrderShipped(r.Context(), updatedOrder); err != nil {
log.Printf("client order shipment email failed for %s: %v", updatedOrder.ID, err)
shipmentEmail["status"] = "failed"
shipmentEmail["message"] = "The order was updated, but the shipment email could not be sent."
shipmentEmail["reason"] = "send_failed"
} else {
shipmentEmail["status"] = "sent"
shipmentEmail["message"] = "Shipment email sent to the customer."
}
} else if shouldSendCancellationEmail(existingOrder, updatedOrder) {
if err := fulfillmentMailer.SendOrderCancelled(r.Context(), updatedOrder); err != nil {
log.Printf("client order cancellation email failed for %s: %v", updatedOrder.ID, err)
shipmentEmail["status"] = "failed"
shipmentEmail["message"] = "The order was updated, but the cancellation email could not be sent."
shipmentEmail["reason"] = "send_failed"
} else {
shipmentEmail["status"] = "sent"
shipmentEmail["message"] = "Cancellation email sent to the customer."
}
} else if shouldSendDeliveredEmail(existingOrder, updatedOrder) {
if err := fulfillmentMailer.SendOrderDelivered(r.Context(), updatedOrder); err != nil {
log.Printf("client order delivered email failed for %s: %v", updatedOrder.ID, err)
shipmentEmail["status"] = "failed"
shipmentEmail["message"] = "The order was updated, but the delivered email could not be sent."
shipmentEmail["reason"] = "send_failed"
} else {
shipmentEmail["status"] = "sent"
shipmentEmail["message"] = "Delivered email sent to the customer."
}
} else {
shipmentEmail["reason"] = shipmentEmailSkipReason(existingOrder, updatedOrder)
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"order": serializeClientOrderDetail(updatedOrder),
"shipmentEmail": shipmentEmail,
},
})
}
}
func shouldSendShipmentEmail(previousOrder, updatedOrder *orders.Order) bool {
if previousOrder == nil || updatedOrder == nil {
return false
}
if !strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentShipped) {
return false
}
if strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentShipped) {
return false
}
if strings.TrimSpace(updatedOrder.Email) == "" {
return false
}
if strings.TrimSpace(updatedOrder.ShippingCarrier) == "" || strings.TrimSpace(updatedOrder.TrackingNumber) == "" {
return false
}
return true
}
func shouldSendCancellationEmail(previousOrder, updatedOrder *orders.Order) bool {
if previousOrder == nil || updatedOrder == nil {
return false
}
if !strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentCancelled) {
return false
}
if strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentCancelled) {
return false
}
if strings.TrimSpace(updatedOrder.Email) == "" {
return false
}
return true
}
func shouldSendDeliveredEmail(previousOrder, updatedOrder *orders.Order) bool {
if previousOrder == nil || updatedOrder == nil {
return false
}
if !strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentDelivered) {
return false
}
if strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentDelivered) {
return false
}
if strings.TrimSpace(updatedOrder.Email) == "" {
return false
}
return true
}
func shipmentEmailSkipReason(previousOrder, updatedOrder *orders.Order) string {
if updatedOrder == nil {
return "order_unavailable"
}
if strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentShipped) {
if previousOrder != nil && strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentShipped) {
return "already_shipped"
}
if strings.TrimSpace(updatedOrder.Email) == "" {
return "missing_email"
}
if strings.TrimSpace(updatedOrder.ShippingCarrier) == "" || strings.TrimSpace(updatedOrder.TrackingNumber) == "" {
return "missing_tracking"
}
return "not_applicable"
}
if strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentCancelled) {
if previousOrder != nil && strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentCancelled) {
return "already_cancelled"
}
if strings.TrimSpace(updatedOrder.Email) == "" {
return "missing_email"
}
return "not_applicable"
}
if strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentDelivered) {
if previousOrder != nil && strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentDelivered) {
return "already_delivered"
}
if strings.TrimSpace(updatedOrder.Email) == "" {
return "missing_email"
}
return "not_applicable"
}
return "status_not_supported"
}
func handleListClientStock(stockStore *inventory.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if stockStore == nil {
WriteError(w, http.StatusServiceUnavailable, "stock_unavailable", "Stock storage is not configured yet.")
return
}
levels, err := stockStore.List(r.Context())
if err != nil {
WriteError(w, http.StatusInternalServerError, "stock_list_failed", "The client dashboard could not load stock levels.")
return
}
serialized := make([]map[string]any, 0, len(levels))
for _, level := range levels {
serialized = append(serialized, map[string]any{
"style": level.Style,
"colorwayId": level.ColorwayID,
"finishId": level.FinishID,
"quantityOnHand": level.QuantityOnHand,
"notes": level.Notes,
"createdAt": level.CreatedAt.Format(time.RFC3339),
"updatedAt": level.UpdatedAt.Format(time.RFC3339),
})
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"stock": serialized,
},
})
}
}
func handleUpsertClientStock(stockStore *inventory.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if stockStore == nil {
WriteError(w, http.StatusServiceUnavailable, "stock_unavailable", "Stock storage is not configured yet.")
return
}
var request clientStockUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
WriteError(w, http.StatusBadRequest, "invalid_json", "Provide a valid stock update payload.")
return
}
request.Style = strings.TrimSpace(strings.ToUpper(request.Style))
request.ColorwayID = strings.TrimSpace(request.ColorwayID)
request.FinishID = strings.TrimSpace(request.FinishID)
request.Notes = strings.TrimSpace(request.Notes)
if request.Style != "A" && request.Style != "B" {
WriteError(w, http.StatusBadRequest, "invalid_style", "Stock entries must use style A or B.")
return
}
if request.ColorwayID == "" || request.FinishID == "" {
WriteError(w, http.StatusBadRequest, "invalid_stock_key", "Stock entries need a colorway and finish.")
return
}
if request.QuantityOnHand < 0 {
WriteError(w, http.StatusBadRequest, "invalid_quantity", "Stock quantity cannot be negative.")
return
}
level, err := stockStore.Upsert(r.Context(), inventory.UpsertInput{
Style: request.Style,
ColorwayID: request.ColorwayID,
FinishID: request.FinishID,
QuantityOnHand: request.QuantityOnHand,
Notes: request.Notes,
})
if err != nil {
WriteError(w, http.StatusInternalServerError, "stock_save_failed", "The stock level could not be saved.")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"stock": map[string]any{
"style": level.Style,
"colorwayId": level.ColorwayID,
"finishId": level.FinishID,
"quantityOnHand": level.QuantityOnHand,
"notes": level.Notes,
"createdAt": level.CreatedAt.Format(time.RFC3339),
"updatedAt": level.UpdatedAt.Format(time.RFC3339),
},
},
})
}
}
func validateClientSessionCookie(cfg *config.Config, r *http.Request) (string, string, bool) {
if cfg == nil || r == nil || !cfg.ClientDashboardEnabled() {
return "", "", false
}
cookie, err := r.Cookie(clientSessionCookieName)
if err != nil || strings.TrimSpace(cookie.Value) == "" {
return "", "", false
}
decoded, err := base64.RawURLEncoding.DecodeString(cookie.Value)
if err != nil {
return "", "", false
}
parts := strings.Split(string(decoded), ".")
if len(parts) != 3 {
return "", "", false
}
username := parts[0]
expiresUnix, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return "", "", false
}
expiresAt := time.Unix(expiresUnix, 0).UTC()
if time.Now().After(expiresAt) {
return "", "", false
}
expectedSignature := signClientSession(cfg.ClientDashboardSigningSecret(), username, expiresUnix)
if subtle.ConstantTimeCompare([]byte(parts[2]), []byte(expectedSignature)) != 1 {
return "", "", false
}
if subtle.ConstantTimeCompare([]byte(username), []byte(cfg.ClientDashboardUser)) != 1 {
return "", "", false
}
return username, expiresAt.Format(time.RFC3339), true
}
func setClientSessionCookie(w http.ResponseWriter, cfg *config.Config, username string, expiresAt time.Time) {
expiresUnix := expiresAt.UTC().Unix()
token := fmt.Sprintf("%s.%d.%s", username, expiresUnix, signClientSession(cfg.ClientDashboardSigningSecret(), username, expiresUnix))
http.SetCookie(w, &http.Cookie{
Name: clientSessionCookieName,
Value: base64.RawURLEncoding.EncodeToString([]byte(token)),
Path: "/",
Expires: expiresAt,
MaxAge: int(time.Until(expiresAt).Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: !cfg.IsDevelopment(),
})
}
func clearClientSessionCookie(w http.ResponseWriter, cfg *config.Config) {
http.SetCookie(w, &http.Cookie{
Name: clientSessionCookieName,
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: !cfg.IsDevelopment(),
})
}
func signClientSession(secret, username string, expiresUnix int64) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(fmt.Sprintf("%s|%d", username, expiresUnix)))
return hex.EncodeToString(mac.Sum(nil))
}
func serializeClientOrderSummary(order orders.Order) map[string]any {
itemCount := int64(0)
items := make([]map[string]any, 0, len(order.Items))
for _, item := range order.Items {
itemCount += item.Quantity
items = append(items, map[string]any{
"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,
"fulfillmentStatus": order.FulfillmentStatus,
"email": order.Email,
"phone": order.Phone,
"firstName": order.FirstName,
"lastName": order.LastName,
"city": order.City,
"region": order.Region,
"postalCode": order.PostalCode,
"country": order.Country,
"notes": order.Notes,
"currency": order.Currency,
"amount": order.Amount,
"shippingCarrier": order.ShippingCarrier,
"trackingNumber": order.TrackingNumber,
"webhookStatus": order.WebhookStatus,
"webhookMessage": order.WebhookMessage,
"createdAt": order.CreatedAt.Format(time.RFC3339),
"updatedAt": order.UpdatedAt.Format(time.RFC3339),
"shippedAt": formatOptionalTime(order.ShippedAt),
"itemCount": itemCount,
"items": items,
}
}
func serializeClientOrderDetail(order *orders.Order) map[string]any {
if order == nil {
return map[string]any{}
}
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,
"fulfillmentStatus": order.FulfillmentStatus,
"email": order.Email,
"phone": order.Phone,
"firstName": order.FirstName,
"lastName": order.LastName,
"addressLine1": order.AddressLine1,
"addressLine2": order.AddressLine2,
"city": order.City,
"region": order.Region,
"postalCode": order.PostalCode,
"country": order.Country,
"notes": order.Notes,
"currency": order.Currency,
"amount": order.Amount,
"shippingCarrier": order.ShippingCarrier,
"trackingNumber": order.TrackingNumber,
"fulfillmentNotes": order.FulfillmentNotes,
"webhookStatus": order.WebhookStatus,
"webhookEventId": order.WebhookEventID,
"webhookEventType": order.WebhookEventType,
"webhookMessage": order.WebhookMessage,
"stripePaymentIntentId": order.StripePaymentIntentID,
"createdAt": order.CreatedAt.Format(time.RFC3339),
"updatedAt": order.UpdatedAt.Format(time.RFC3339),
"shippedAt": formatOptionalTime(order.ShippedAt),
"items": items,
}
}
func formatOptionalTime(value *time.Time) any {
if value == nil {
return nil
}
return value.UTC().Format(time.RFC3339)
}
+105
View File
@@ -0,0 +1,105 @@
package httpx
import (
"strings"
"sync"
"time"
)
type paymentIntentVerification struct {
PaymentIntentID string `json:"paymentIntentId"`
WebhookState string `json:"webhookState"`
WebhookEventID string `json:"webhookEventId,omitempty"`
WebhookEventType string `json:"webhookEventType,omitempty"`
Message string `json:"message"`
Verified bool `json:"verified"`
UpdatedAt time.Time `json:"updatedAt"`
CreatedAt time.Time `json:"createdAt"`
}
type paymentIntentStateStore struct {
mu sync.RWMutex
records map[string]paymentIntentVerification
}
func newPaymentIntentStateStore() *paymentIntentStateStore {
return &paymentIntentStateStore{records: make(map[string]paymentIntentVerification)}
}
func (s *paymentIntentStateStore) upsertCreated(paymentIntentID string) {
if strings.TrimSpace(paymentIntentID) == "" {
return
}
now := time.Now().UTC()
s.mu.Lock()
defer s.mu.Unlock()
record, exists := s.records[paymentIntentID]
if !exists {
record = paymentIntentVerification{
PaymentIntentID: paymentIntentID,
CreatedAt: now,
}
}
record.WebhookState = "awaiting_webhook"
record.Message = "Waiting for Stripe webhook verification."
record.Verified = false
record.UpdatedAt = now
s.records[paymentIntentID] = record
}
func (s *paymentIntentStateStore) applyWebhook(result *stripeWebhookResult) {
if result == nil || strings.TrimSpace(result.PaymentIntentID) == "" {
return
}
now := time.Now().UTC()
s.mu.Lock()
defer s.mu.Unlock()
record, exists := s.records[result.PaymentIntentID]
if !exists {
record = paymentIntentVerification{
PaymentIntentID: result.PaymentIntentID,
CreatedAt: now,
}
}
record.WebhookEventID = result.EventID
record.WebhookEventType = result.EventType
record.Message = result.Message
record.Verified = result.Verified
record.UpdatedAt = now
switch result.OrderState {
case "paid_verified_local_only", "paid_unrecorded":
record.WebhookState = "succeeded"
case "payment_failed_verified_local_only", "payment_failed_unrecorded":
record.WebhookState = "failed"
case "processing_verified_local_only", "processing_unrecorded":
record.WebhookState = "processing"
case "canceled_verified_local_only", "canceled_unrecorded":
record.WebhookState = "canceled"
case "ignored":
record.WebhookState = "ignored"
default:
record.WebhookState = "awaiting_webhook"
}
s.records[result.PaymentIntentID] = record
}
func (s *paymentIntentStateStore) get(paymentIntentID string) (paymentIntentVerification, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
record, exists := s.records[paymentIntentID]
return record, exists
}
var paymentIntentStates = newPaymentIntentStateStore()
+30
View File
@@ -0,0 +1,30 @@
package httpx
import (
"encoding/json"
"net/http"
)
type errorEnvelope struct {
Error errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
func WriteJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func WriteError(w http.ResponseWriter, status int, code, message string) {
WriteJSON(w, status, errorEnvelope{
Error: errorBody{
Code: code,
Message: message,
},
})
}
+132
View File
@@ -0,0 +1,132 @@
package httpx
import (
"context"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"royal-pop-backend/internal/config"
"royal-pop-backend/internal/database"
"royal-pop-backend/internal/inventory"
"royal-pop-backend/internal/mailer"
"royal-pop-backend/internal/orders"
)
type RouterConfig struct {
Config *config.Config
DB *database.DB
Orders *orders.Store
Stock *inventory.Store
Mailer *mailer.ResendMailer
}
func NewRouter(cfg RouterConfig) http.Handler {
router := chi.NewRouter()
router.Use(chimiddleware.RealIP)
router.Use(chimiddleware.RequestID)
router.Use(chimiddleware.Recoverer)
router.Use(chimiddleware.Timeout(30 * time.Second))
router.Use(corsMiddleware(cfg.Config.AllowedOrigins))
router.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
databaseStatus := "disabled"
if cfg.DB != nil {
healthCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := cfg.DB.Health(healthCtx); err != nil {
WriteJSON(w, http.StatusServiceUnavailable, map[string]any{
"service": cfg.Config.AppName,
"status": "degraded",
"database": "unreachable",
})
return
}
databaseStatus = "ok"
}
WriteJSON(w, http.StatusOK, map[string]any{
"service": cfg.Config.AppName,
"status": "ok",
"database": databaseStatus,
})
})
router.Post("/webhooks/stripe", handleStripeWebhook(cfg.Config, cfg.Orders, cfg.Mailer))
router.Route("/v1", func(api chi.Router) {
api.Get("/inventory", handleListPublicInventory(cfg.Stock))
api.Get("/storefront/pricing", handleGetStorefrontPricing(cfg.Config))
api.Get("/", func(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"service": cfg.Config.AppName,
"status": "scaffolded",
"version": "v1",
})
})
api.Post("/checkout/payment-intent", handleCreatePaymentIntent(cfg.Config, cfg.Orders))
api.Get("/checkout/payment-intent/{paymentIntentID}", handleGetPaymentIntentStatus(cfg.Config, cfg.Orders))
api.Get("/client/session", handleGetClientSession(cfg.Config))
api.Post("/client/login", handleClientLogin(cfg.Config))
api.Post("/client/logout", handleClientLogout(cfg.Config))
api.Group(func(client chi.Router) {
client.Use(requireClientSession(cfg.Config))
client.Get("/client/orders", handleListClientOrders(cfg.Orders))
client.Get("/client/orders/{orderID}", handleGetClientOrder(cfg.Orders))
client.Patch("/client/orders/{orderID}", handleUpdateClientOrder(cfg.Orders, cfg.Mailer))
client.Get("/client/stock", handleListClientStock(cfg.Stock))
client.Put("/client/stock", handleUpsertClientStock(cfg.Stock))
client.Post("/client/reseed", handleClientReseed(cfg.DB))
})
})
router.NotFound(func(w http.ResponseWriter, _ *http.Request) {
WriteError(w, http.StatusNotFound, "not_found", "The requested endpoint does not exist.")
})
return router
}
func corsMiddleware(allowedOrigins []string) func(http.Handler) http.Handler {
normalized := make([]string, 0, len(allowedOrigins))
for _, origin := range allowedOrigins {
trimmed := strings.TrimSpace(origin)
if trimmed == "" {
continue
}
normalized = append(normalized, trimmed)
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" {
for _, allowedOrigin := range normalized {
if allowedOrigin == "*" || strings.EqualFold(allowedOrigin, origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
break
}
}
}
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
+236
View File
@@ -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"
}
}