mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 02:52:20 +02:00
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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user