Files
terminalShop/api/handlers/cart.go

908 lines
34 KiB
Go

package handlers
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentintent"
"github.com/stripe/stripe-go/v78/paymentmethod"
"gorm.io/gorm"
"go.opentelemetry.io/otel"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/notify"
"terminalShop/pkg/utils"
)
type CartHandler struct {
appURL string
maxOrderCents int
}
// maxAuthIssuances caps how many times a single ordes's PaymentIntent can be
// pushed into requires_action; one initial challende plus up to three retries
// before we tell the customer to use a different card. Counted via existing
// audit rows (audit.EventOrderRequiresAction) so the cap stays migration-free.
const maxAuthIssuances = 4
// paymentIntentNewFn lets tests stub the Stripe create call without spinning
// up an httptest backend. Production callers go straight through to the real
// paymentintent.New. Mirrors paymentIntentGetFn in reconcile.go.
var paymentIntentNewFn = paymentintent.New
var (
paymentMethodNewFn = paymentmethod.New
paymentMethodDetachFn = paymentmethod.Detach
)
// NewCartHandler creates a new cart handler. Stripe credentials are wired
// once at startup via stripe.Key in api/main.go.
func NewCartHandler(appURL string, maxOrderCents int) *CartHandler {
return &CartHandler{appURL: appURL, maxOrderCents: maxOrderCents}
}
// getOrCreateCart finds the users cart or creates one.
func getOrCreateCart(ctx context.Context, userID uint) (*models.Cart, error) {
db := database.GetDB().WithContext(ctx)
var cart models.Cart
err := db.Where("user_id = ?", userID).First(&cart).Error
if err != nil {
cart = models.Cart{UserID: userID}
if createErr := db.Create(&cart).Error; createErr != nil {
return nil, fmt.Errorf("failed to create cart: %w", createErr)
}
}
return &cart, nil
}
func cartResponse(ctx context.Context, cart *models.Cart) map[string]any {
db := database.GetDB().WithContext(ctx)
var items []models.CartItem
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
subtotal := 0
itemsData := make([]map[string]any, 0, len(items))
for _, item := range items {
lineTotal := item.Coffee.Price * item.Quantity
subtotal += lineTotal
itemsData = append(itemsData, map[string]any{
"id": item.ID,
"coffee_id": item.CoffeeID,
"quantity": item.Quantity,
"subtotal": lineTotal, // already in cents
"coffee": item.Coffee,
})
}
return map[string]any{
"items": itemsData,
"subtotal": subtotal,
"address_id": cart.AddressID,
"card_id": cart.CardID,
"shipping_cost": cart.ShippingCost,
"shipping_service": cart.ShippingService,
}
}
func (h *CartHandler) GetCart(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromContext(r.Context())
cart, err := getOrCreateCart(r.Context(), userID)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"cart": cartResponse(r.Context(), cart),
})
}
type SetItemRequest struct {
CoffeeID uint `json:"coffee_id"`
Quantity int `json:"quantity"`
}
func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
var req SetItemRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
return
}
if req.CoffeeID == 0 {
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "coffee_id is required", nil)
return
}
var coffee models.Coffee
if err := db.First(&coffee, req.CoffeeID).Error; err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_PRODUCT", "product not found", nil)
return
}
cart, err := getOrCreateCart(r.Context(), userID)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
return
}
if req.Quantity <= 0 {
if err := db.Where("cart_id = ? AND coffee_id = ?", cart.ID, req.CoffeeID).Delete(&models.CartItem{}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete cart item", nil)
return
}
} else {
var item models.CartItem
result := db.Where("cart_id = ? AND coffee_id = ?", cart.ID, req.CoffeeID).First(&item)
if result.Error != nil {
item = models.CartItem{
CartID: cart.ID,
CoffeeID: req.CoffeeID,
Quantity: req.Quantity,
}
if err := db.Create(&item).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create item", nil)
return
}
} else {
if err := db.Model(&item).Update("quantity", req.Quantity).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update quantity", nil)
return
}
}
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"cart": cartResponse(r.Context(), cart),
})
}
type SetAddressRequest struct {
AddressID uint `json:"address_id"`
}
func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
var req SetAddressRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
return
}
var address models.Address
if err := db.Where("id = ? AND user_id = ?", req.AddressID, userID).First(&address).Error; err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "address not found", nil)
return
}
cart, err := getOrCreateCart(r.Context(), userID)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
return
}
if err := db.Model(cart).Update("address_id", req.AddressID).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to set address", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"message": "address set",
})
}
type SetCardRequest struct {
CardID uint `json:"card_id"`
}
func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
var req SetCardRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
return
}
var card models.Card
if err := db.Where("id = ? AND user_id = ?", req.CardID, userID).First(&card).Error; err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_CARD", "card not found", nil)
return
}
if card.IsStorageExpired(time.Now()) {
if err := expireStoredCard(db, &card); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
cart, err := getOrCreateCart(r.Context(), userID)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
return
}
if err := db.Model(cart).Update("card_id", req.CardID).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to set card", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"message": "card set",
})
}
func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
cart, err := getOrCreateCart(r.Context(), userID)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
return
}
if err := db.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete cart item", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"message": "cart cleared",
})
}
// ConvertCartRequest carries the optional privacy-checkout controls. The body
// is optional: a saved.card checkout sends nothing and both field stay zero.
// Ephemeral opts a single order into privacy mode (ont-time card, no Card row,
// PM detached after settle); CardToken supplies that one-time card. A user with
// PrivacyMode set is ephemeral regardless of the flag.
type ConvertCartRequest struct {
Ephemeral bool `json:"ephemeral"`
CardToken string `json:"card_token"`
}
func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
ctx, span := otel.Tracer("api").Start(r.Context(), "cart.convert")
defer span.End()
r = r.WithContext(ctx)
var req ConvertCartRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
return
}
cart, err := getOrCreateCart(r.Context(), userID)
if err != nil {
middleware.RecordCartConversion("cart_lookup_failed")
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
return
}
var items []models.CartItem
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
if len(items) == 0 {
middleware.RecordCartConversion("validation_empty_cart")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "cart is empty", nil)
return
}
if cart.AddressID == nil {
middleware.RecordCartConversion("validation_missing_address")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "shipping address is required", nil)
return
}
var address models.Address
if err := db.First(&address, *cart.AddressID).Error; err != nil {
middleware.RecordCartConversion("validation_invalid_address")
utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "shipping address not found", nil)
return
}
var user models.User
if err := db.First(&user, userID).Error; err != nil {
middleware.RecordCartConversion("user_not_found")
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
return
}
// Per-order intent drives the charge SHAPE: req.Ephemeral picks the one-time
// token path (no saved card). Privacy mode adds a second guarantee on the
// saved-card path — the live QR add-card flow has no "save anyway" prompt, so
// a privacy user who selects a (QR-collected) card still must not have it
// retained. forgetCard charges that card normally, then detaches the
// PaymentMethod and hard-deletes the local Card row once the order settles.
// It never applies to the token path, which has no local row.
ephemeral := req.Ephemeral
forgetCard := user.PrivacyMode && !ephemeral
var card models.Card
var paymentMethodID string
if ephemeral {
if req.CardToken == "" {
middleware.RecordCartConversion("validation_missing_card")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "card token is required for private checkout", nil)
return
}
pm, pmerr := paymentMethodNewFn(&stripe.PaymentMethodParams{
Type: stripe.String("card"),
Card: &stripe.PaymentMethodCardParams{
Token: stripe.String(req.CardToken),
},
})
if pmerr != nil {
middleware.RecordCartConversion("stripe_error")
utils.RespondError(w, http.StatusBadRequest, "STRIPE_ERROR", "failed to create payment method", nil)
return
}
paymentMethodID = pm.ID
} else {
if cart.CardID == nil {
middleware.RecordCartConversion("validation_missing_card")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "payment card is required", nil)
return
}
if err := db.First(&card, *cart.CardID).Error; err != nil {
middleware.RecordCartConversion("validation_invalid_card")
utils.RespondError(w, http.StatusBadRequest, "INVALID_CARD", "payment card not found", nil)
return
}
if card.IsStorageExpired(time.Now()) {
middleware.RecordCartConversion("validation_card_expired")
if err := expireStoredCard(db, &card); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
if err := ensureStripeCustomer(db, &user); err != nil {
middleware.RecordCartConversion("stripe_customer_failed")
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
return
}
paymentMethodID = card.StripePaymentID
if len(paymentMethodID) >= 3 && paymentMethodID[:3] == "tok" {
pm, pmErr := paymentmethod.New(&stripe.PaymentMethodParams{
Type: stripe.String("card"),
Card: &stripe.PaymentMethodCardParams{
Token: stripe.String(paymentMethodID),
},
})
if pmErr != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create payment method", nil)
return
}
if _, attachErr := paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{
Customer: stripe.String(user.StripeCustomerID),
}); attachErr != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to attach payment method", nil)
return
}
paymentMethodID = pm.ID
card.StripePaymentID = pm.ID
if err := db.Save(&card).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save card", nil)
return
}
}
}
var subtotal int
var orderItems []models.OrderItem
for _, item := range items {
lineTotal := item.Coffee.Price * item.Quantity
subtotal += lineTotal
orderItems = append(orderItems, models.OrderItem{
CoffeeID: item.CoffeeID,
Name: item.Coffee.Name,
Price: item.Coffee.Price,
Quantity: item.Quantity,
})
}
total := subtotal + cart.ShippingCost
// Per-user overridable spend cap. The override may raise OR lower the global
// cap — operators set it in both directions deliberately. A negative override
// is nonsense: ignore it and fall back to the global so an erroneous DB write
// cannot silently disable a fraud control (mirrors loadMaxOrderCents in
// pkg/config/config.go, which defaults a negative MAX_ORDER_CENTS). An explicit
// 0 is a valid per-user off-switch (same semantics as the global).
capCents := h.maxOrderCents
if user.MaxOrderCents != nil {
if *user.MaxOrderCents < 0 {
slog.Warn("ignoring negative per-user order cap, using global",
"user_id", user.ID,
"user_cap_cents", *user.MaxOrderCents,
"global_cap_cents", h.maxOrderCents)
} else {
capCents = *user.MaxOrderCents
}
}
// A user-set self-limit may only TIGHTEN the effective cap, never raise it.
// a compromised account must not be able to lift its own ceiling to drain a
// stolen card. Unlike the admin/global cap (where 0 is an off-switch), a
// present self-limit always activates a ceiling, a self-limit of 0 means
// "block everything", not "disable", so it folds in via min() and can only
// lower capCents. A negative value is ignored (mirrors the maxOrderCents
// guard above); this min() is the authoritative lower-only guard.
capActive := capCents > 0
if user.SelfLimitCents != nil && *user.SelfLimitCents >= 0 {
if self := *user.SelfLimitCents; !capActive || self < capCents {
capCents = self
}
capActive = true
}
if capActive && total > capCents {
middleware.RecordCartConversion("validation_over_limit")
audit.CartRejected(userID, total, capCents)
utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT",
fmt.Sprintf("order total must be at most $%.2f", float64(capCents)/100),
map[string]any{
"total_cents": total,
"limit_cents": capCents,
})
return
}
if total < 50 {
middleware.RecordCartConversion("validation_min_amount")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil)
return
}
// Create the order record BEFORE charging so we always have something to
// reconcile against if the server crashes mid-flight.
order := models.Order{
UserID: user.ID,
CardID: card.ID,
StripePaymentID: "",
Ephemeral: ephemeral || forgetCard,
Status: models.OrderStatusPending,
Subtotal: subtotal,
ShippingCost: cart.ShippingCost,
Total: total,
ShippingName: address.Name,
ShippingStreet: address.Street,
ShippingStreet2: address.Street2,
ShippingCity: address.City,
ShippingState: address.State,
ShippingZip: address.Zip,
ShippingCountry: address.Country,
ShippingPhone: address.Phone,
Items: orderItems,
}
if err := db.Create(&order).Error; err != nil {
middleware.RecordCartConversion("db_create_order_failed")
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create order", nil)
return
}
audit.OrderCreated(userID, order.ID, total)
// Charge the card. This is a Customer-Initiated Transaction (CIT): the
// customer is present in the TUI at checkout. We deliberately do NOT set
// OffSession — that flag marks a Merchant-Initiated Transaction (MIT),
// which under PSD2/SCA may only claim an exemption when a stored mandate
// authorises the merchant to charge while the customer is away. We capture
// no mandate and run no recurring billing, so MIT would misrepresent the
// transaction type. As a CIT, an SCA challenge surfaces as
// pi.Status == requires_action (handled below), not as an
// authentication_required error. See docs/sca-psd2-compliance.md "Gap 1".
// Reference: stripe-samples accept-a-payment custom-payment-flow —
// php/public/google-pay.php drives 3DS off status==requires_action and sets
// no off_session on a customer-present confirm.
// The idempotency key is tied to the order ID so retries never double charge.
piParams := &stripe.PaymentIntentParams{
Amount: stripe.Int64(int64(total)),
Currency: stripe.String(string(stripe.CurrencyUSD)),
PaymentMethod: stripe.String(paymentMethodID),
PaymentMethodTypes: stripe.StringSlice([]string{"card"}),
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
Confirm: stripe.Bool(true),
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
Shipping: &stripe.ShippingDetailsParams{
Name: stripe.String(address.Name),
Address: &stripe.AddressParams{
Line1: stripe.String(address.Street),
Line2: stripe.String(address.Street2),
City: stripe.String(address.City),
State: stripe.String(address.State),
PostalCode: stripe.String(address.Zip),
Country: stripe.String(address.Country),
},
},
}
piParams.SetIdempotencyKey(fmt.Sprintf("order-%d", order.ID))
piParams.Metadata = map[string]string{
"order_id": fmt.Sprintf("%d", order.ID),
}
if !ephemeral {
piParams.Customer = stripe.String(user.StripeCustomerID)
}
stripeStart := time.Now()
pi, err := paymentIntentNewFn(piParams)
stripeDur := time.Since(stripeStart).Seconds()
if err != nil {
middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur)
if stripeErr, ok := err.(*stripe.Error); ok {
// Defensive fallback: as CIT (no off_session) Stripe normally
// signals 3DS via status=requires_action below, not via this error.
// authentication_required can still appear if the saved card needs
// auth that could not run inline; respondRequiresAction re-Confirms
// on-session to recover. (Pre-CIT migration this was the main path.)
if stripeErr.Code == stripe.ErrorCodeAuthenticationRequired && stripeErr.PaymentIntent != nil {
h.respondRequiresAction(w, &order, paymentMethodID, stripeErr.PaymentIntent)
return
}
db.Model(&order).Update("status", models.OrderStatusFailed)
switch stripeErr.Type {
case stripe.ErrorTypeCard:
middleware.RecordCartConversion("card_declined")
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
utils.RespondError(w, http.StatusPaymentRequired, mapStripeCardErrCode(stripeErr.Code), stripeErr.Msg, nil)
case stripe.ErrorTypeInvalidRequest:
middleware.RecordCartConversion("stripe_invalid_request")
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
utils.RespondError(w, http.StatusBadRequest, "INVALID_PAYMENT", stripeErr.Msg, nil)
default:
middleware.RecordCartConversion("stripe_error")
audit.OrderFailed(userID, order.ID, total, stripeErr.Error())
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
}
return
}
db.Model(&order).Update("status", models.OrderStatusFailed)
middleware.RecordCartConversion("stripe_error")
audit.OrderFailed(userID, order.ID, total, err.Error())
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
return
}
middleware.ObserveStripeRequest("payment_intent_create", "success", stripeDur)
if pi.Status == stripe.PaymentIntentStatusRequiresAction {
h.respondRequiresAction(w, &order, paymentMethodID, pi)
return
}
// Commit payment ID and clear cart in a single transaction.
txErr := db.Transaction(func(tx *gorm.DB) error {
order.StripePaymentID = pi.ID
order.Status = models.OrderStatusPaid
if err := tx.Save(&order).Error; err != nil {
return err
}
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return err
}
return tx.Model(cart).Updates(map[string]any{
"address_id": nil,
"card_id": nil,
}).Error
})
if txErr != nil {
// Card was charged but we could not persist the outcome. This is the
// most dangerous failure mode: the customer paid but has no order.
// The reconciliation job will catch this within 5 minutes.
// The audit log entry below must be treated as a critical alert.
middleware.RecordCartConversion("payment_critical")
audit.PaymentCritical(order.ID, pi.ID, txErr)
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "payment succeeded but failed to record order — please contact support", nil)
return
}
if ephemeral || forgetCard {
if _, derr := paymentMethodDetachFn(paymentMethodID, nil); derr != nil {
slog.Warn("failed to detach ephemeral payment method", "order_id", order.ID, "error", derr)
}
}
if forgetCard {
// Privacy mode: the card was persisted only long enough to select and
// charge it. Null the order's reference and hard-delete the local row so
// no card data (last4/brand/token) survives the checkout. Unscoped because
// Card uses gorm soft-delete; a plain delete would leave the data on disk.
// Mirrors upstream Card.remove (terminal-shop-source core/src/card/index.ts):
// detach the PM, drop the FK, delete the row.
if err := db.Model(&order).Update("card_id", 0).Error; err != nil {
slog.Warn("failed to null forgotten card reference", "order_id", order.ID, "error", err)
}
if err := db.Unscoped().Delete(&models.Card{}, card.ID).Error; err != nil {
slog.Warn("failed to delete forgotten card", "order_id", order.ID, "card_id", card.ID, "error", err)
}
}
audit.OrderPaid(userID, order.ID, total, pi.ID)
middleware.RecordOrderCreated(string(order.Status))
middleware.RecordCartConversion("success")
middleware.ObserveOrderValueCents(total)
if err := db.Preload("Items").First(&order, order.ID).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to preload items", nil)
return
}
go notify.SlackOrderPaid(&order)
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"order": order,
})
}
// respondRequiresAction handles a PaymentIntent that needs 3DS auth. The order
// transitions to requires_action (see caveat #20 in sca-psd2-compliance.md);
// the payment_intent.succeeded webhook flips it to paid once the customer
// completes the bank-hosted challenge. The TUI polls /api/v1/orders/{id}/status.
func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, paymentMethodID string, pi *stripe.PaymentIntent) {
db := database.GetDB()
// After an off-session authentication_required decline, the PI sits in
// status=requires_payment_method with payment_method detached. Re-confirm
// on-session with the same PM so Stripe pushes the PI into requires_action
// and populates next_action.redirect_to_url with the 3DS challenge URL.
if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" {
confirmed, cerr := paymentintent.Confirm(pi.ID, &stripe.PaymentIntentConfirmParams{
PaymentMethod: stripe.String(paymentMethodID),
OffSession: stripe.Bool(false),
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
})
if cerr == nil && confirmed != nil {
pi = confirmed
}
}
if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" {
middleware.RecordCartConversion("sca_missing_next_action")
db.Model(order).Update("status", models.OrderStatusFailed)
audit.OrderFailed(order.UserID, order.ID, order.Total, "stripe returned requires_action without redirect url")
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "stripe did not return a 3ds url", nil)
return
}
// Persist the paymentIntent id AND set status to requires_action so list
// UIs can distinguish "charging" from "awaiting customer 3DS". The
// payment_intent.payment_failed webhook from the initial off-session
// decline often arrives before this response and flips the order to
// failed - override it back so the 3DS-in-flight order shows the
// correct state
if err := db.Model(order).Updates(map[string]any{
"stripe_payment_id": pi.ID,
"status": models.OrderStatusRequiresAction,
}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil)
return
}
token, err := storeRedirect(pi.NextAction.RedirectToURL.URL, models.RedirectPurposeCheckout3DS)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to allocate redirect token", nil)
return
}
middleware.RecordCartConversion("requires_action")
audit.OrderRequiresAction(order.UserID, order.ID, pi.ID)
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
"order_id": order.ID,
"payment_intent_id": pi.ID,
"status": "requires_action",
"redirect_token": token,
"redirect_url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
})
}
func mapStripeCardErrCode(code stripe.ErrorCode) string {
switch code {
case stripe.ErrorCodeAuthenticationRequired:
return "AUTHENTICATION_REQUIRED"
case "authentication_not_supported":
return "AUTHENTICATION_NOT_SUPPORTED"
case "card_declined", "":
return "CARD_DECLINED"
case "expired_card":
return "CARD_EXPIRED"
case "incorrect_cvc":
return "CARD_CVC_FAILED"
case "insufficient_funds":
return "CARD_INSUFFICIENT_FUNDS"
case "processing_error":
return "CARD_PROCESSING_ERROR"
}
return "CARD_DECLINED"
}
//nolint:unused // wired into Convert cart later
func appURLForHandler(r *http.Request) string {
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
host := r.Host
if fh := r.Header.Get("X-Forwarded-Host"); fh != "" {
host = fh
}
return fmt.Sprintf("%s://%s", scheme, host)
}
// RetryAuth re-Confirms an in-flight PaymentIntent that the customer failed
// or abandoned during the 3DS challenge, recovering the existing order rather
// than starting a new checkout. Bound to POST /api/v1/orders/{id}/retry-auth.
//
// Stripe's PI state machine bounces back to requires_payment_method after a
// failed or abandoned 3DS, so paymentintent.Confirm(pi.id, …) mints a fresh
// next_action.redirect_to_url — the same shape respondRequiresAction already
// handles. No idempotency key is set: Confirm mutates an existing PI and
// Stripe collapses by id (see sca-psd2-compliance.md caveat #5).
func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id")
id64, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
return
}
orderID := uint(id64)
var order models.Order
if err := db.Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
return
}
// Retryable source states:
// pending - initial attempt mid-flight (pre-PI confirm page)
// requires_action - customer abandoned or failed the 3DS challenge
// failed - webhook flipped after a non-recoverable error
// terminal states (paid, shipped, delivered, cancelled, refunded) refuse.
if order.Status != models.OrderStatusPending &&
order.Status != models.OrderStatusRequiresAction &&
order.Status != models.OrderStatusFailed {
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "order is already finalized", nil)
return
}
if order.StripePaymentID == "" {
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "order has no payment intent to retry", nil)
return
}
// Cap is best-effort: two concurrent retries at issuances=cap-1 will both
// pass this check and both Confirm before either writes a new event row.
// Per-user RateLimitByUser(10/min) on the route bounds the worst case.
// SQLite-WAL has no SELECT ... FOR UPDATE, so a stricter guard would need
// a serializing INSERT - not worth the cimplexity at current traffic.
var issuances int64
if err := db.Model(&models.OrderEvent{}).
Where("order_id = ? AND type = ?", order.ID, audit.EventOrderRequiresAction).
Count(&issuances).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to count auth attempts", nil)
return
}
if issuances >= maxAuthIssuances {
middleware.RecordCartConversion("retry_max_attempts")
utils.RespondError(w, http.StatusTooManyRequests, "RETRY_LIMIT", "too many authentication attempts, please use a different card", nil)
return
}
// scoped card lookup - match the initial-flow policy in ConvertCart so a
// card the user deleted (or that the storage-TTL reconciler removed)
// cannot be silently re-charged through retry. The ownership clause is
// defensive: order.CardID is enforced at SetCard time today but binding
// the retry path to that invariant explicitly costs nothing.
var card models.Card
if err := db.Where("id = ? AND user_id = ?", order.CardID, userID).First(&card).Error; err != nil {
utils.RespondError(w, http.StatusConflict, "CARD_NO_LONGER_AVAILABLE", "saved card is no longer available, add it again to retry", nil)
return
}
if card.IsStorageExpired(time.Now()) {
if err := expireStoredCard(db, &card); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
stripeStart := time.Now()
confirmed, cerr := paymentintent.Confirm(order.StripePaymentID, &stripe.PaymentIntentConfirmParams{
PaymentMethod: stripe.String(card.StripePaymentID),
OffSession: stripe.Bool(false),
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
})
stripeDur := time.Since(stripeStart).Seconds()
if cerr != nil {
middleware.ObserveStripeRequest("payment_intent_confirm", "error", stripeDur)
if stripeErr, ok := cerr.(*stripe.Error); ok && stripeErr.Type == stripe.ErrorTypeCard {
db.Model(&order).Update("status", models.OrderStatusFailed)
audit.OrderFailed(userID, order.ID, order.Total, stripeErr.Msg)
middleware.RecordCartConversion("retry_card_declined")
utils.RespondError(w, http.StatusPaymentRequired, mapStripeCardErrCode(stripeErr.Code), stripeErr.Msg, nil)
return
}
if stripeErr, ok := cerr.(*stripe.Error); ok && stripeErr.Code == "payment_intent_unexpected_state" {
actual, gerr := paymentintent.Get(order.StripePaymentID, nil)
if gerr != nil {
middleware.RecordCartConversion("retry_stripe_error")
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment failed", nil)
return
}
switch actual.Status {
case stripe.PaymentIntentStatusSucceeded:
middleware.RecordCartConversion("retry_race_already_done")
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
"order_id": order.ID,
"status": "succeeded",
})
return
case stripe.PaymentIntentStatusCanceled:
db.Model(&order).Update("status", models.OrderStatusFailed)
audit.OrderFailed(userID, order.ID, order.Total, "payment intent canceled")
middleware.RecordCartConversion("retry_pi_canceled")
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "payment intent is canceled, please start a new order", nil)
return
default:
middleware.RecordCartConversion("retry_pi_unexpected_state")
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment intent in unexpected state", nil)
return
}
}
middleware.RecordCartConversion("retry_stripe_error")
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment failed", nil)
return
}
middleware.ObserveStripeRequest("payment_intent_confirm", "success", stripeDur)
// frictionless 3DS on retry, stripe approved without a new challenge.
// the payment_intent.succeeded webhook owns the order.status=paid
// transition (matches the initial-flow contract); tell the TUI to keep
// polling /orders/{id}/status until it lands
if confirmed.Status == stripe.PaymentIntentStatusSucceeded {
middleware.RecordCartConversion("retry_frictionless")
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
"order_id": order.ID,
"status": "succeeded",
})
return
}
h.respondRequiresAction(w, &order, card.StripePaymentID, confirmed)
}