Compare commits
1 Commits
refactor/a
...
feat/sca-r
| Author | SHA1 | Date | |
|---|---|---|---|
| a22f0e84cd |
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user