Compare commits

..

1 Commits

10 changed files with 40 additions and 257 deletions

View File

@@ -8,9 +8,9 @@ jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: actions/setup-go@v6
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
@@ -30,7 +30,6 @@ jobs:
fi
- name: Lint
if: ${{ !env.ACT }}
uses: golangci/golangci-lint-action@v7
with:
version: v2.4.0

View File

@@ -8,8 +8,8 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Run tests

View File

@@ -7,7 +7,7 @@ import (
"fmt"
"net/http"
"strconv"
"time"
"sync"
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
@@ -24,43 +24,35 @@ import (
stripeSession "github.com/stripe/stripe-go/v78/checkout/session"
)
const RedirectTTL = 10 * time.Minute
// payRedirects maps short tokens to full Stripe checkout URLs.
var (
payRedirects = map[string]string{}
payRedirectsMu sync.Mutex
)
func storeRedirect(stripeURL, purpose string) (string, error) {
func storeRedirect(stripeURL string) (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
token := hex.EncodeToString(b)
now := time.Now()
rec := models.PayRedirect{
Token: token,
URL: stripeURL,
Purpose: purpose,
CreatedAt: now,
ExpiresAt: now.Add(RedirectTTL),
}
if err := database.GetDB().Create(&rec).Error; err != nil {
return "", err
}
payRedirectsMu.Lock()
payRedirects[token] = stripeURL
payRedirectsMu.Unlock()
return token, nil
}
// PayRedirect resolves a short token and redirects to the Stripe checkout URL.
func PayRedirect(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
db := database.GetDB().WithContext(r.Context())
var rec models.PayRedirect
if err := db.Where("token = ?", token).First(&rec).Error; err != nil {
payRedirectsMu.Lock()
url, ok := payRedirects[token]
payRedirectsMu.Unlock()
if !ok {
http.NotFound(w, r)
return
}
if time.Now().After(rec.ExpiresAt) {
db.Where("token = ?", rec.Token).Delete(&models.PayRedirect{})
http.NotFound(w, r)
return
}
http.Redirect(w, r, rec.URL, http.StatusFound)
http.Redirect(w, r, url, http.StatusFound)
}
// CardHandler handles saved payment method CRUD.
@@ -176,7 +168,7 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
return
}
token, err := storeRedirect(sess.URL, models.RedirectPurposeAddCard)
token, err := storeRedirect(sess.URL)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
return

View File

@@ -425,7 +425,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur)
if stripeErr, ok := err.(*stripe.Error); ok {
if stripeErr.Code == stripe.ErrorCodeAuthenticationRequired && stripeErr.PaymentIntent != nil {
h.respondRequiresAction(w, &order, paymentMethodID, stripeErr.PaymentIntent)
h.respondRequiresAction(w, &order, stripeErr.PaymentIntent)
return
}
db.Model(&order).Update("status", models.OrderStatusFailed)
@@ -454,7 +454,7 @@ 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, paymentMethodID, pi)
h.respondRequiresAction(w, &order, pi)
return
}
@@ -503,18 +503,18 @@ 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, paymentMethodID string, pi *stripe.PaymentIntent) {
func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, pi *stripe.PaymentIntent) {
db := database.GetDB()
// After an off-session authentication_required decline, the PI sits in
// status=requires_payment_method with payment_method detached. Re-confirm
// on-session with the same PM so Stripe pushes the PI into requires_action
// and populates next_action.redirect_to_url with the 3DS challenge URL.
// status=requires_payment_method, not requires_action — there is no
// next_action to read. Re-confirm on-session so Stripe pushes the PI into
// requires_action and populates next_action.redirect_to_url with the 3DS
// challenge URL.
if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" {
confirmed, cerr := paymentintent.Confirm(pi.ID, &stripe.PaymentIntentConfirmParams{
PaymentMethod: stripe.String(paymentMethodID),
OffSession: stripe.Bool(false),
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
OffSession: stripe.Bool(false),
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
})
if cerr == nil && confirmed != nil {
pi = confirmed
@@ -529,19 +529,15 @@ func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models
return
}
// 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 {
// 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, models.RedirectPurposeCheckout3DS)
token, err := storeRedirect(pi.NextAction.RedirectToURL.URL)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to allocate redirect token", nil)
return

View File

@@ -167,16 +167,6 @@ 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 {

View File

@@ -280,31 +280,14 @@ 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,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"`
Order models.Order `json:"order"`
} `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)
@@ -484,9 +467,7 @@ func (c *Client) ClearCart() error {
}
// ConvertCart converts the cart into an order, charging the saved card.
// 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) {
func (c *Client) ConvertCart() (*models.Order, error) {
url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL)
req, err := http.NewRequest("POST", url, nil)
@@ -512,21 +493,7 @@ func (c *Client) ConvertCart() (*CheckoutOutcome, error) {
return nil, fmt.Errorf("failed to convert cart")
}
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
return &convertResp.Data.Order, nil
}
type OrderStatusResponse struct {

View File

@@ -3,7 +3,6 @@ package database
import (
"fmt"
"log"
"terminalShop/pkg/models"
"gorm.io/gorm"
@@ -25,7 +24,6 @@ func Migrate(db *gorm.DB) error {
&models.OrderItem{}, // Line items within orders
&models.Address{}, // Saved addresses
&models.OrderEvent{},
&models.PayRedirect{}, // short-token -> stripe url store (10 min TTL)
); err != nil {
return fmt.Errorf("migration failed: %w", err)
}

View File

@@ -1,23 +0,0 @@
package models
import "time"
// PayRedirect maps a short token to a stripe hosted url with a TTL.
// survives api restarts so a customer mid-3Ds authentication still
// lands on the bank-hosted challenge page after a deploy or crash
type PayRedirect struct {
Token string `gorm:"primaryKey;size:64" json:"token"`
URL string `gorm:"type:text;not null" json:"url"`
Purpose string `gorm:"size:32;not null" json:"purpose"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
}
const (
RedirectPurposeCheckout3DS = "checkout_3ds"
RedirectPurposeAddCard = "add_card"
)
func (PayRedirect) TableName() string {
return "pay_redirects"
}

View File

@@ -5,7 +5,6 @@ import (
"strings"
"terminalShop/pkg/models"
"terminalShop/pkg/tui/qrfefe"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
@@ -170,18 +169,6 @@ 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(
@@ -200,34 +187,6 @@ 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:
@@ -240,12 +199,6 @@ 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
@@ -253,57 +206,10 @@ 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

View File

@@ -202,15 +202,6 @@ 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 {
@@ -427,18 +418,8 @@ 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
Status string
Total int
Err error
}
@@ -650,38 +631,15 @@ func (m Model) checkoutWithSavedCard() tea.Cmd {
}
// 3. Convert the cart to an order
outcome, err := m.APIClient.ConvertCart()
order, err := m.APIClient.ConvertCart()
if err != nil {
return CheckoutResultMsg{Err: err}
}
if outcome.RequiresAction {
return CheckoutResultMsg{
OrderID: outcome.OrderID,
RequiresAction: true,
RedirectURL: outcome.RedirectURL,
}
}
return CheckoutResultMsg{OrderID: outcome.Order.ID, Total: outcome.Order.Total}
return CheckoutResultMsg{OrderID: order.ID, Total: 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