From a22f0e84cd87475bc7e42507dde461f6a4edf5f0 Mon Sep 17 00:00:00 2001 From: marcusDenslow Date: Thu, 28 May 2026 11:16:26 +0200 Subject: [PATCH] feat(sca): return 202 + 3ds redirect token when checkout requires auth --- api/handlers/cart.go | 57 ++++++++++++++++++++++++++++++++++++--- api/handlers/cart_test.go | 32 +++++++++++----------- api/routes/routes.go | 2 +- 3 files changed, 71 insertions(+), 20 deletions(-) diff --git a/api/handlers/cart.go b/api/handlers/cart.go index 09b61ec..ecd4415 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -24,11 +24,12 @@ import ( type CartHandler struct { stripeKey string + appURL string } // NewCartHandler creates a new cart handler with the Stripe secret key -func NewCartHandler(stripeSecretKey string) *CartHandler { - return &CartHandler{stripeKey: stripeSecretKey} +func NewCartHandler(stripeSecretKey, appURL string) *CartHandler { + return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL} } // getOrCreateCart finds the users cart or creates one. @@ -424,8 +425,12 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { stripeDur := time.Since(stripeStart).Seconds() if err != nil { middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur) - db.Model(&order).Update("status", models.OrderStatusFailed) if stripeErr, ok := err.(*stripe.Error); ok { + if stripeErr.Code == stripe.ErrorCodeAuthenticationRequired && stripeErr.PaymentIntent != nil { + h.respondRequiresAction(w, &order, stripeErr.PaymentIntent) + return + } + db.Model(&order).Update("status", models.OrderStatusFailed) switch stripeErr.Type { case stripe.ErrorTypeCard: middleware.RecordCartConversion("card_declined") @@ -442,6 +447,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { } 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) @@ -449,6 +455,11 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { } middleware.ObserveStripeRequest("payment_intent_create", "success", stripeDur) + if pi.Status == stripe.PaymentIntentStatusRequiresAction { + h.respondRequiresAction(w, &order, pi) + return + } + // Commit payment ID and clear cart in a single transaction. txErr := db.Transaction(func(tx *gorm.DB) error { order.StripePaymentID = pi.ID @@ -491,6 +502,46 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { }) } +// respondRequiresAction handles a PaymentIntent that needs 3DS auth +// The order stays pending. the 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, pi *stripe.PaymentIntent) { + db := database.GetDB() + + 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 before responding so the reconciliation jon + // and webhook can match the eventual succeeded/failed event to this order + // even if the customer drops off mid-auth + if err := db.Model(order).Update("stripe_payment_id", pi.ID).Error; err != nil { + utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil) + return + } + + token, err := storeRedirect(pi.NextAction.RedirectToURL.URL) + 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: diff --git a/api/handlers/cart_test.go b/api/handlers/cart_test.go index 819f42c..04559ea 100644 --- a/api/handlers/cart_test.go +++ b/api/handlers/cart_test.go @@ -65,7 +65,7 @@ func TestGetCartEmpty(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") req := authRequest("GET", "/api/v1/cart", nil, user.ID) w := httptest.NewRecorder() @@ -104,7 +104,7 @@ func TestSetItemAndGetCart(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") // Add item (CoffeeID=1, Quantity=2) body, _ := json.Marshal(map[string]interface{}{ @@ -160,7 +160,7 @@ func TestSetItemUpdateQuantity(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") // Add item body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2}) @@ -205,7 +205,7 @@ func TestSetItemRemove(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") // Add item body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3}) @@ -244,7 +244,7 @@ func TestSetItemInvalidProduct(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1}) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) @@ -261,7 +261,7 @@ func TestSetItemMissingCoffeeID(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") body, _ := json.Marshal(map[string]interface{}{"quantity": 1}) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) @@ -278,7 +278,7 @@ func TestClearCart(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") // Add two items for _, coffeeID := range []uint{1, 2} { @@ -334,7 +334,7 @@ func TestSetAddress(t *testing.T) { } db.Create(&addr) - handler := NewCartHandler("") + handler := NewCartHandler("", "") body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID}) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) @@ -388,7 +388,7 @@ func TestSetAddressWrongUser(t *testing.T) { } db.Create(&addr) - handler := NewCartHandler("") + handler := NewCartHandler("", "") body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID}) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) @@ -416,7 +416,7 @@ func TestSetCard(t *testing.T) { } db.Create(&card) - handler := NewCartHandler("") + handler := NewCartHandler("", "") body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID}) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) @@ -468,7 +468,7 @@ func TestSetCardWrongUser(t *testing.T) { } db.Create(&card) - handler := NewCartHandler("") + handler := NewCartHandler("", "") body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID}) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) @@ -485,7 +485,7 @@ func TestConvertCartMissingAddress(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") // Add item but no address or card body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1}) @@ -508,7 +508,7 @@ func TestConvertCartMissingCard(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") db := database.GetDB() // Add item and address @@ -539,7 +539,7 @@ func TestConvertCartEmpty(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") // Empty cart convert req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID) @@ -556,7 +556,7 @@ func TestMultipleItems(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") // Add three different items for i := uint(1); i <= 3; i++ { @@ -605,7 +605,7 @@ func TestCartIsolation(t *testing.T) { } db.Create(&user2) - handler := NewCartHandler("") + handler := NewCartHandler("", "") r := chi.NewRouter() r.Put("/cart/item", handler.SetItem) r.Get("/cart", handler.GetCart) diff --git a/api/routes/routes.go b/api/routes/routes.go index 6f050d5..144fd79 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -46,7 +46,7 @@ func SetupRoutes( healthHandler := handlers.NewHealthHandler(version) productHandler := handlers.NewProductHandler() authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey) - cartHandler := handlers.NewCartHandler(stripeSecretKey) + cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL) cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL) orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber) addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)