From 7c8a2b562f3efaed5385959ba8f4c36ba90a3342 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Thu, 28 May 2026 12:41:36 +0200 Subject: [PATCH] feat(sca): handle 3ds checkout end to end with qr poll and race fix (#21) * feat(sca): handle 3ds checkout end to end with qr poll and race fix * fix(sca): drop unused pollOrderStatusCmd helper --- api/handlers/cart.go | 12 ++++-- api/handlers/webhook.go | 10 +++++ pkg/api/client.go | 39 +++++++++++++++-- pkg/tui/confirmation.go | 94 +++++++++++++++++++++++++++++++++++++++++ pkg/tui/model.go | 48 +++++++++++++++++++-- 5 files changed, 193 insertions(+), 10 deletions(-) diff --git a/api/handlers/cart.go b/api/handlers/cart.go index 95786fa..18deca0 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -529,10 +529,14 @@ func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models 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 { + // Persist the paymentIntent id AND reset status to pending. 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.OrderStatusPending, + }).Error; err != nil { utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil) return } diff --git a/api/handlers/webhook.go b/api/handlers/webhook.go index f67d4fa..06737c2 100644 --- a/api/handlers/webhook.go +++ b/api/handlers/webhook.go @@ -167,6 +167,16 @@ func (h *WebhookHandler) handlePaymentIntentFailed(ctx context.Context, event st return } + // authentication_required is recoverable — the cart handler re-confirms the + // PI on-session and the customer completes 3DS via redirect. Marking the + // order failed here would race with the recovery path and surface a + // momentarily-broken state in the TUI. + if pi.LastPaymentError != nil && string(pi.LastPaymentError.Code) == "authentication_required" { + webhookLog().Info("ignoring authentication_required payment_failed; handled via 3ds redirect", + "order_id", orderIDStr, "pi", pi.ID) + return + } + db := database.GetDB().WithContext(ctx) if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending). Update("status", models.OrderStatusFailed).Error; err != nil { diff --git a/pkg/api/client.go b/pkg/api/client.go index 20f2916..5009910 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -280,14 +280,31 @@ type CardResponse struct { } // ConvertCartResponse represents the cart conversion (checkout) API response. +// 200 carries Order populated; 202 (requires_action / 3DS) carries the +// redirect fields and OrderID instead. type ConvertCartResponse struct { Success bool `json:"success"` Data struct { - Order models.Order `json:"order"` + Order *models.Order `json:"order,omitempty"` + OrderID uint `json:"order_id,omitempty"` + PaymentIntentID string `json:"payment_intent_id,omitempty"` + Status string `json:"status,omitempty"` + RedirectToken string `json:"redirect_token,omitempty"` + RedirectURL string `json:"redirect_url,omitempty"` } `json:"data"` Error *APIError `json:"error,omitempty"` } +// CheckoutOutcome is the unified result of ConvertCart. RequiresAction=true +// means the customer needs to complete 3DS at RedirectURL; poll +// GetOrderStatus(OrderID) until the webhook flips the order to paid or failed. +type CheckoutOutcome struct { + Order *models.Order + OrderID uint + RequiresAction bool + RedirectURL string +} + // GetCart fetches the user's current cart. func (c *Client) GetCart() (*CartData, error) { url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL) @@ -467,7 +484,9 @@ func (c *Client) ClearCart() error { } // ConvertCart converts the cart into an order, charging the saved card. -func (c *Client) ConvertCart() (*models.Order, error) { +// Returns a CheckoutOutcome that either carries the completed Order or +// indicates the customer must complete 3DS authentication via RedirectURL. +func (c *Client) ConvertCart() (*CheckoutOutcome, error) { url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL) req, err := http.NewRequest("POST", url, nil) @@ -493,7 +512,21 @@ func (c *Client) ConvertCart() (*models.Order, error) { return nil, fmt.Errorf("failed to convert cart") } - return &convertResp.Data.Order, nil + if convertResp.Data.Status == "requires_action" { + return &CheckoutOutcome{ + OrderID: convertResp.Data.OrderID, + RequiresAction: true, + RedirectURL: convertResp.Data.RedirectURL, + }, nil + } + + if convertResp.Data.Order == nil { + return nil, fmt.Errorf("malformed convert response: missing order") + } + return &CheckoutOutcome{ + Order: convertResp.Data.Order, + OrderID: convertResp.Data.Order.ID, + }, nil } type OrderStatusResponse struct { diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index a0f106c..a40ce67 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -5,6 +5,7 @@ import ( "strings" "terminalShop/pkg/models" + "terminalShop/pkg/tui/qrfefe" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" @@ -169,6 +170,18 @@ func (m Model) ReviewView() string { if m.CheckingOut { return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...") } + if m.review.authFailed { + content := m.theme.TextError().Bold(true).Padding(1).Render("authentication failed — press esc to try again") + return lipgloss.Place( + m.widthContainer, + lipgloss.Height(content), + lipgloss.Center, lipgloss.Center, + content, + ) + } + if m.review.requiresAction { + return m.renderRequiresActionView() + } if m.review.success { content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!") return lipgloss.Place( @@ -187,6 +200,34 @@ func (m Model) ReviewView() string { ) } +// renderRequiresActionView shows a QR for the 3DS challenge URL while the TUI +// polls the order status. Mirrors the add-card QR layout in payment_form.go. +func (m Model) renderRequiresActionView() string { + base := m.theme.TextLabel() + accent := m.theme.TextHighlight() + header := m.theme.TextAccent().Bold(true).Render("your bank wants to verify this charge") + + if m.review.redirectURL == "" { + return base.Render(" waiting for authentication link...") + } + + qr, qrSize, err := qrfefe.Generate(m.review.redirectURL) + if err != nil || qrSize > m.widthContent { + body := header + "\n\n" + + base.Render("open this url on your phone to authenticate:") + "\n\n" + + accent.Render(m.review.redirectURL) + "\n\n" + + base.Render("waiting for completion...") + return lipgloss.Place(m.widthContainer, lipgloss.Height(body), lipgloss.Center, lipgloss.Center, body) + } + + // Same gotcha as payment_form.go: lipgloss layout helpers corrupt qrfefe ANSI. + return qr + + header + "\n" + + base.Render("scan or visit to complete authentication") + "\n" + + accent.Render(m.review.redirectURL) + "\n\n" + + base.Render("waiting for completion...") +} + func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) { switch msg := msg.(type) { case CheckoutResultMsg: @@ -199,6 +240,12 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) { m.payment.form = nil return m, nil } + if msg.RequiresAction { + m.review.requiresAction = true + m.review.redirectURL = msg.RedirectURL + m.review.orderID = msg.OrderID + return m, m.schedulePollOrderStatus(msg.OrderID) + } m.Cart = make(map[uint]*models.CartItem) m.cart.cursor = 0 m.ShippingInfo = nil @@ -206,10 +253,57 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) { m.review.success = true return m, nil + case OrderStatusMsg: + if !m.review.requiresAction || msg.OrderID != m.review.orderID { + return m, nil + } + if msg.Err != nil { + // Transient — keep polling. + return m, m.schedulePollOrderStatus(msg.OrderID) + } + switch msg.Status { + case "paid", "shipped", "delivered": + m.Cart = make(map[uint]*models.CartItem) + m.cart.cursor = 0 + m.ShippingInfo = nil + m.OrdersLoaded = false + m.review.requiresAction = false + m.review.redirectURL = "" + m.review.success = true + return m, nil + case "failed", "cancelled": + m.review.requiresAction = false + m.review.redirectURL = "" + m.review.authFailed = true + return m, nil + default: + return m, m.schedulePollOrderStatus(msg.OrderID) + } + case tea.KeyMsg: if m.CheckingOut { return m, nil } + if m.review.authFailed { + if msg.String() == "esc" || msg.String() == "enter" { + m.review.authFailed = false + m = m.SwitchPage(paymentPage) + m.payment.view = 0 + m.payment.form = nil + return m, nil + } + return m, nil + } + if m.review.requiresAction { + if msg.String() == "esc" { + m.review.requiresAction = false + m.review.redirectURL = "" + m = m.SwitchPage(paymentPage) + m.payment.view = 0 + return m, nil + } + return m, nil + } if m.review.success { m.review.success = false m.review.cardJustAdded = false diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 9fedead..bc9c568 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -202,6 +202,15 @@ type reviewState struct { cardJustAdded bool cardWasDuplicate bool success bool + + // 3DS / SCA flow: when the backend returns 202 requires_action, + // requiresAction is true and redirectURL is the Stripe-hosted challenge + // page. The TUI shows a QR and polls GetOrderStatus(orderID) until the + // order flips to paid or failed. + requiresAction bool + redirectURL string + orderID uint + authFailed bool } type confirmState struct { @@ -418,8 +427,18 @@ func (m Model) Init() tea.Cmd { } type CheckoutResultMsg struct { + OrderID uint + Total int + RequiresAction bool + RedirectURL string + Err error +} + +// OrderStatusMsg carries the result of a poll against /orders/{id}/status. +// Emitted on the 2s tick while waiting for 3DS authentication to complete. +type OrderStatusMsg struct { OrderID uint - Total int + Status string Err error } @@ -631,15 +650,38 @@ func (m Model) checkoutWithSavedCard() tea.Cmd { } // 3. Convert the cart to an order - order, err := m.APIClient.ConvertCart() + outcome, err := m.APIClient.ConvertCart() if err != nil { return CheckoutResultMsg{Err: err} } - return CheckoutResultMsg{OrderID: order.ID, Total: order.Total} + if outcome.RequiresAction { + return CheckoutResultMsg{ + OrderID: outcome.OrderID, + RequiresAction: true, + RedirectURL: outcome.RedirectURL, + } + } + + return CheckoutResultMsg{OrderID: outcome.Order.ID, Total: outcome.Order.Total} } } +// schedulePollOrderStatus sleeps 2s then polls /orders/{id}/status. Used while +// waiting for the customer to complete 3DS authentication. +func (m Model) schedulePollOrderStatus(orderID uint) tea.Cmd { + return tea.Tick(2*time.Second, func(time.Time) tea.Msg { + if m.APIClient == nil { + return OrderStatusMsg{OrderID: orderID, Err: fmt.Errorf("api client unavailable")} + } + status, err := m.APIClient.GetOrderStatus(orderID) + if err != nil { + return OrderStatusMsg{OrderID: orderID, Err: err} + } + return OrderStatusMsg{OrderID: orderID, Status: status} + }) +} + func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, tea.Cmd) { if m.APIClient == nil || m.User == nil { return m, nil