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
This commit is contained in:
2026-05-28 12:41:36 +02:00
committed by GitHub
parent 1c5765d25b
commit 7c8a2b562f
5 changed files with 193 additions and 10 deletions

View File

@@ -529,10 +529,14 @@ func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models
return return
} }
// persist the paymentIntent id before responding so the reconciliation jon // Persist the paymentIntent id AND reset status to pending. The
// and webhook can match the eventual succeeded/failed event to this order // payment_intent.payment_failed webhook from the initial off-session decline
// even if the customer drops off mid-auth // often arrives before this response and flips the order to failed —
if err := db.Model(order).Update("stripe_payment_id", pi.ID).Error; err != nil { // 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) utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil)
return return
} }

View File

@@ -167,6 +167,16 @@ func (h *WebhookHandler) handlePaymentIntentFailed(ctx context.Context, event st
return 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) db := database.GetDB().WithContext(ctx)
if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending). if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
Update("status", models.OrderStatusFailed).Error; err != nil { Update("status", models.OrderStatusFailed).Error; err != nil {

View File

@@ -280,14 +280,31 @@ type CardResponse struct {
} }
// ConvertCartResponse represents the cart conversion (checkout) API response. // 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 { type ConvertCartResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { 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"` } `json:"data"`
Error *APIError `json:"error,omitempty"` 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. // GetCart fetches the user's current cart.
func (c *Client) GetCart() (*CartData, error) { func (c *Client) GetCart() (*CartData, error) {
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL) 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. // 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) url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL)
req, err := http.NewRequest("POST", url, nil) 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 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 { type OrderStatusResponse struct {

View File

@@ -5,6 +5,7 @@ import (
"strings" "strings"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"terminalShop/pkg/tui/qrfefe"
"charm.land/bubbles/v2/viewport" "charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
@@ -169,6 +170,18 @@ func (m Model) ReviewView() string {
if m.CheckingOut { if m.CheckingOut {
return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...") 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 { if m.review.success {
content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!") content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!")
return lipgloss.Place( 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) { func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case CheckoutResultMsg: case CheckoutResultMsg:
@@ -199,6 +240,12 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.payment.form = nil m.payment.form = nil
return m, 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 = make(map[uint]*models.CartItem)
m.cart.cursor = 0 m.cart.cursor = 0
m.ShippingInfo = nil m.ShippingInfo = nil
@@ -206,10 +253,57 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.review.success = true m.review.success = true
return m, nil 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: case tea.KeyMsg:
if m.CheckingOut { if m.CheckingOut {
return m, nil 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 { if m.review.success {
m.review.success = false m.review.success = false
m.review.cardJustAdded = false m.review.cardJustAdded = false

View File

@@ -202,6 +202,15 @@ type reviewState struct {
cardJustAdded bool cardJustAdded bool
cardWasDuplicate bool cardWasDuplicate bool
success 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 { type confirmState struct {
@@ -418,8 +427,18 @@ func (m Model) Init() tea.Cmd {
} }
type CheckoutResultMsg struct { 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 OrderID uint
Total int Status string
Err error Err error
} }
@@ -631,15 +650,38 @@ func (m Model) checkoutWithSavedCard() tea.Cmd {
} }
// 3. Convert the cart to an order // 3. Convert the cart to an order
order, err := m.APIClient.ConvertCart() outcome, err := m.APIClient.ConvertCart()
if err != nil { if err != nil {
return CheckoutResultMsg{Err: err} 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) { func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, tea.Cmd) {
if m.APIClient == nil || m.User == nil { if m.APIClient == nil || m.User == nil {
return m, nil return m, nil