Compare commits
2 Commits
feat/roast
...
feat/sca-3
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f139cba7d | |||
| 0367d18dc1 |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user