Compare commits

..

2 Commits

Author SHA1 Message Date
1c004dd838 fix(test): pin coffee price in cart-limit fixture
TestConvertCartLimit's quantities and expected totals were keyed to
the original 500-cent latte price. After the product price bump in
1ce63fb, coffee_id=4 became 2500 cents, so under_cap/at_cap subtests
overshot the $200 cap and over_cap's expected total magnitude was
wrong by 5x. Override coffee_id=4's price to 500 in test setup so the
cap math stays stable regardless of seed changes.
2026-06-05 23:45:44 +02:00
1ce63fbdb9 fix(products): update pricing on products 2026-06-05 23:38:54 +02:00
34 changed files with 294 additions and 1635 deletions

View File

@@ -42,7 +42,7 @@ func (h *AddressHandler) GetAddresses(w http.ResponseWriter, r *http.Request) {
var addresses []models.Address var addresses []models.Address
db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&addresses) db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&addresses)
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"addresses": addresses, "addresses": addresses,
}) })
} }
@@ -154,7 +154,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusCreated, map[string]any{ utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
"address": address, "address": address,
}) })
} }
@@ -183,7 +183,7 @@ func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "address successfully deleted", "message": "address successfully deleted",
}) })
} }
@@ -218,7 +218,7 @@ func (h *AddressHandler) SetDefaultAddress(w http.ResponseWriter, r *http.Reques
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"address": address, "address": address,
}) })
} }

View File

@@ -58,7 +58,7 @@ func TestGetAddressesEmpty(t *testing.T) {
var resp struct { var resp struct {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
Addresses []any `json:"addresses"` Addresses []interface{} `json:"addresses"`
} `json:"data"` } `json:"data"`
} }
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {

View File

@@ -108,7 +108,7 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
} }
middleware.RecordAuthAttempt("success") middleware.RecordAuthAttempt("success")
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"access_token": token, "access_token": token,
"token_type": "Bearer", "token_type": "Bearer",
"expires_in": 1800, // 30 minutes in seconds "expires_in": 1800, // 30 minutes in seconds
@@ -146,7 +146,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request)
} }
if len(errors) > 0 { if len(errors) > 0 {
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]any{ utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]interface{}{
"errors": errors, "errors": errors,
}) })
return return
@@ -173,7 +173,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request)
return return
} }
utils.RespondSuccess(w, http.StatusCreated, map[string]any{ utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
"user": user.ToPublic(), "user": user.ToPublic(),
}) })
} }
@@ -195,7 +195,7 @@ func (h *AuthHandler) GetUserBySSHKey(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"user": user.ToPublic(), "user": user.ToPublic(),
}) })
} }

View File

@@ -46,7 +46,7 @@ func TestGetCards_FiltersExpiredRows(t *testing.T) {
} }
} }
handler := NewCardHandler("") handler := NewCardHandler("", "")
req := authRequest("GET", "/api/v1/cards", nil, user.ID) req := authRequest("GET", "/api/v1/cards", nil, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.GetCards(w, req) handler.GetCards(w, req)
@@ -91,7 +91,7 @@ func TestSetDefaultCard_ExpiredReturns410(t *testing.T) {
t.Fatalf("seed card: %v", err) t.Fatalf("seed card: %v", err)
} }
handler := NewCardHandler("") handler := NewCardHandler("", "")
idStr := strconv.FormatUint(uint64(card.ID), 10) idStr := strconv.FormatUint(uint64(card.ID), 10)
req := authRequest("PUT", "/api/v1/cards/"+idStr+"/default", nil, user.ID) req := authRequest("PUT", "/api/v1/cards/"+idStr+"/default", nil, user.ID)
routeCtx := chi.NewRouteContext() routeCtx := chi.NewRouteContext()

View File

@@ -67,13 +67,13 @@ func PayRedirect(w http.ResponseWriter, r *http.Request) {
// CardHandler handles saved payment method CRUD. // CardHandler handles saved payment method CRUD.
type CardHandler struct { type CardHandler struct {
appURL string stripeKey string
appURL string
} }
// NewCardHandler creates a new card handler. Stripe credentials are wired // NewCardHandler creates a new card handler with the Stripe secret key.
// once at startup via stripe.Key in api/main.go. func NewCardHandler(stripeSecretKey string, appURL string) *CardHandler {
func NewCardHandler(appURL string) *CardHandler { return &CardHandler{stripeKey: stripeSecretKey, appURL: appURL}
return &CardHandler{appURL: appURL}
} }
// GetCards returns all saved cards for the authenticated user. // GetCards returns all saved cards for the authenticated user.
@@ -89,12 +89,12 @@ func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) {
userID, time.Now(), userID, time.Now(),
).Order("is_default DESC, created_at DESC").Find(&cards) ).Order("is_default DESC, created_at DESC").Find(&cards)
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"cards": cards, "cards": cards,
}) })
} }
// func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]any) // func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]interface{})
// GetCard returns a single saved card by ID. // GetCard returns a single saved card by ID.
func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) { func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
@@ -114,7 +114,7 @@ func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"card": card, "card": card,
}) })
} }
@@ -137,7 +137,7 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
} }
if card.IsStorageExpired(time.Now()) { if card.IsStorageExpired(time.Now()) {
if err := expireStoredCard(db, &card); err != nil { if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil) utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return return
} }
@@ -156,7 +156,7 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"card": card, "card": card,
}) })
} }
@@ -171,6 +171,8 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
return return
} }
stripe.Key = h.stripeKey
if err := ensureStripeCustomer(db, &user); err != nil { if err := ensureStripeCustomer(db, &user); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil) utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
return return
@@ -195,7 +197,7 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil) utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"url": fmt.Sprintf("%s/pay/%s", h.appURL, token), "url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
}) })
} }
@@ -232,6 +234,8 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
return return
} }
stripe.Key = h.stripeKey
// Ensure the user has a Stripe customer, creating one if needed. // Ensure the user has a Stripe customer, creating one if needed.
if err := ensureStripeCustomer(db, &user); err != nil { if err := ensureStripeCustomer(db, &user); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil) utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
@@ -298,7 +302,7 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
audit.CardAdded(userID, card.ID, card.Brand, card.Last4) audit.CardAdded(userID, card.ID, card.Brand, card.Last4)
utils.RespondSuccess(w, http.StatusCreated, map[string]any{ utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
"card": card, "card": card,
}) })
} }
@@ -331,6 +335,7 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
// Detach from Stripe (best-effort; don't block deletion on Stripe errors). // Detach from Stripe (best-effort; don't block deletion on Stripe errors).
if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" { if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" {
stripe.Key = h.stripeKey
_, _ = paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{}) _, _ = paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{})
} }
@@ -342,7 +347,7 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
audit.CardDeleted(userID, card.ID) audit.CardDeleted(userID, card.ID)
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "card deleted", "message": "card deleted",
}) })
} }
@@ -350,10 +355,8 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
// expireStoredCard removes a card whose retention deadline elapsed. // expireStoredCard removes a card whose retention deadline elapsed.
// Local state (cart references + card row) is committed atomically; // Local state (cart references + card row) is committed atomically;
// Stripe detach and audit run only after the local commit so retries // Stripe detach and audit run only after the local commit so retries
// cannot double-fire the audit event. Stripe detach is gated on // cannot double-fire the audit event.
// stripe.Key being non-empty so tests (which never init the SDK key) func expireStoredCard(db *gorm.DB, card *models.Card, stripeKey string) error {
// skip the real network call without an explicit flag.
func expireStoredCard(db *gorm.DB, card *models.Card) error {
if err := db.Transaction(func(tx *gorm.DB) error { if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", card.ID, card.UserID).Update("card_id", nil).Error; err != nil { if err := tx.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", card.ID, card.UserID).Update("card_id", nil).Error; err != nil {
return fmt.Errorf("failed to clear expired card from cart: %w", err) return fmt.Errorf("failed to clear expired card from cart: %w", err)
@@ -366,7 +369,8 @@ func expireStoredCard(db *gorm.DB, card *models.Card) error {
return err return err
} }
if stripe.Key != "" && strings.HasPrefix(card.StripePaymentID, "pm_") { if stripeKey != "" && strings.HasPrefix(card.StripePaymentID, "pm_") {
stripe.Key = stripeKey
if _, err := paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{}); err != nil { if _, err := paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{}); err != nil {
slog.Warn("failed to detach expired card", "card_id", card.ID, "error", err) slog.Warn("failed to detach expired card", "card_id", card.ID, "error", err)
} }

View File

@@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"context"
"os" "os"
"testing" "testing"
"time" "time"
@@ -59,7 +58,7 @@ func TestReconcileExpiredCards_RemovesPastDeadlineOnly(t *testing.T) {
t.Fatalf("seed cart: %v", err) t.Fatalf("seed cart: %v", err)
} }
ReconcileExpiredCards(context.Background()) ReconcileExpiredCards("")
var remaining []models.Card var remaining []models.Card
if err := db.Where("user_id = ?", user.ID).Find(&remaining).Error; err != nil { if err := db.Where("user_id = ?", user.ID).Find(&remaining).Error; err != nil {
@@ -118,7 +117,7 @@ func TestReconcileExpiredCards_SweepsAcrossUsers(t *testing.T) {
} }
} }
ReconcileExpiredCards(context.Background()) ReconcileExpiredCards("")
var count int64 var count int64
if err := db.Model(&models.Card{}).Count(&count).Error; err != nil { if err := db.Model(&models.Card{}).Count(&count).Error; err != nil {

View File

@@ -25,6 +25,7 @@ import (
) )
type CartHandler struct { type CartHandler struct {
stripeKey string
appURL string appURL string
maxOrderCents int maxOrderCents int
} }
@@ -35,10 +36,9 @@ type CartHandler struct {
// audit rows (audit.EventOrderRequiresAction) so the cap stays migration-free. // audit rows (audit.EventOrderRequiresAction) so the cap stays migration-free.
const maxAuthIssuances = 4 const maxAuthIssuances = 4
// NewCartHandler creates a new cart handler. Stripe credentials are wired // NewCartHandler creates a new cart handler with the Stripe secret key
// once at startup via stripe.Key in api/main.go. func NewCartHandler(stripeSecretKey, appURL string, maxOrderCents int) *CartHandler {
func NewCartHandler(appURL string, maxOrderCents int) *CartHandler { return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL, maxOrderCents: maxOrderCents}
return &CartHandler{appURL: appURL, maxOrderCents: maxOrderCents}
} }
// getOrCreateCart finds the users cart or creates one. // getOrCreateCart finds the users cart or creates one.
@@ -55,18 +55,18 @@ func getOrCreateCart(ctx context.Context, userID uint) (*models.Cart, error) {
return &cart, nil return &cart, nil
} }
func cartResponse(ctx context.Context, cart *models.Cart) map[string]any { func cartResponse(ctx context.Context, cart *models.Cart) map[string]interface{} {
db := database.GetDB().WithContext(ctx) db := database.GetDB().WithContext(ctx)
var items []models.CartItem var items []models.CartItem
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items) db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
subtotal := 0 subtotal := 0
itemsData := make([]map[string]any, 0, len(items)) itemsData := make([]map[string]interface{}, 0, len(items))
for _, item := range items { for _, item := range items {
lineTotal := item.Coffee.Price * item.Quantity lineTotal := item.Coffee.Price * item.Quantity
subtotal += lineTotal subtotal += lineTotal
itemsData = append(itemsData, map[string]any{ itemsData = append(itemsData, map[string]interface{}{
"id": item.ID, "id": item.ID,
"coffee_id": item.CoffeeID, "coffee_id": item.CoffeeID,
"quantity": item.Quantity, "quantity": item.Quantity,
@@ -75,7 +75,7 @@ func cartResponse(ctx context.Context, cart *models.Cart) map[string]any {
}) })
} }
return map[string]any{ return map[string]interface{}{
"items": itemsData, "items": itemsData,
"subtotal": subtotal, "subtotal": subtotal,
"address_id": cart.AddressID, "address_id": cart.AddressID,
@@ -94,7 +94,7 @@ func (h *CartHandler) GetCart(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"cart": cartResponse(r.Context(), cart), "cart": cartResponse(r.Context(), cart),
}) })
} }
@@ -157,7 +157,7 @@ func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
} }
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"cart": cartResponse(r.Context(), cart), "cart": cartResponse(r.Context(), cart),
}) })
} }
@@ -193,7 +193,7 @@ func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "address set", "message": "address set",
}) })
} }
@@ -219,7 +219,7 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
} }
if card.IsStorageExpired(time.Now()) { if card.IsStorageExpired(time.Now()) {
if err := expireStoredCard(db, &card); err != nil { if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil) utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return return
} }
@@ -238,7 +238,7 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "card set", "message": "card set",
}) })
} }
@@ -258,7 +258,7 @@ func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "cart cleared", "message": "cart cleared",
}) })
} }
@@ -315,7 +315,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
if card.IsStorageExpired(time.Now()) { if card.IsStorageExpired(time.Now()) {
middleware.RecordCartConversion("validation_card_expired") middleware.RecordCartConversion("validation_card_expired")
if err := expireStoredCard(db, &card); err != nil { if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil) utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return return
} }
@@ -330,6 +330,8 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
return return
} }
stripe.Key = h.stripeKey
// Ensure the user has a Stripe customer (shared helper with cards handler). // Ensure the user has a Stripe customer (shared helper with cards handler).
if err := ensureStripeCustomer(db, &user); err != nil { if err := ensureStripeCustomer(db, &user); err != nil {
middleware.RecordCartConversion("stripe_customer_failed") middleware.RecordCartConversion("stripe_customer_failed")
@@ -382,10 +384,9 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
if h.maxOrderCents > 0 && total > h.maxOrderCents { if h.maxOrderCents > 0 && total > h.maxOrderCents {
middleware.RecordCartConversion("validation_over_limit") middleware.RecordCartConversion("validation_over_limit")
audit.CartRejected(userID, total, h.maxOrderCents)
utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT", utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT",
fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100), fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100),
map[string]any{ map[string]interface{}{
"total_cents": total, "total_cents": total,
"limit_cents": h.maxOrderCents, "limit_cents": h.maxOrderCents,
}) })
@@ -506,7 +507,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil { if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return err return err
} }
return tx.Model(cart).Updates(map[string]any{ return tx.Model(cart).Updates(map[string]interface{}{
"address_id": nil, "address_id": nil,
"card_id": nil, "card_id": nil,
}).Error }).Error
@@ -533,15 +534,14 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
} }
go notify.SlackOrderPaid(&order) go notify.SlackOrderPaid(&order)
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"order": order, "order": order,
}) })
} }
// respondRequiresAction handles a PaymentIntent that needs 3DS auth. The order // respondRequiresAction handles a PaymentIntent that needs 3DS auth
// transitions to requires_action (see caveat #20 in sca-psd2-compliance.md); // The order stays pending. the webhook flips it to paid once the customer
// the payment_intent.succeeded webhook flips it to paid once the customer // completes the bank-hosted challenge, the tui polls /api/v1/orders/{id}/status
// 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, paymentMethodID string, pi *stripe.PaymentIntent) {
db := database.GetDB() db := database.GetDB()
@@ -568,15 +568,13 @@ func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models
return return
} }
// Persist the paymentIntent id AND set status to requires_action so list // Persist the paymentIntent id AND reset status to pending. The
// UIs can distinguish "charging" from "awaiting customer 3DS". The // payment_intent.payment_failed webhook from the initial off-session decline
// payment_intent.payment_failed webhook from the initial off-session // often arrives before this response and flips the order to failed —
// decline often arrives before this response and flips the order to // override it back so the 3DS-in-flight order shows the correct state.
// failed - override it back so the 3DS-in-flight order shows the
// correct state
if err := db.Model(order).Updates(map[string]any{ if err := db.Model(order).Updates(map[string]any{
"stripe_payment_id": pi.ID, "stripe_payment_id": pi.ID,
"status": models.OrderStatusRequiresAction, "status": models.OrderStatusPending,
}).Error; err != nil { }).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
@@ -660,14 +658,10 @@ func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
return return
} }
// Retryable source states: // Only pending (initial attempt mid-flight) and failed (webhook flipped it
// pending - initial attempt mid-flight (pre-PI confirm page) // after a non-recoverable error) are retryable. paid, refundedn shipped,
// requires_action - customer abandoned or failed the 3DS challenge // delivered, cancelled are terminal - refuse
// failed - webhook flipped after a non-recoverable error if order.Status != models.OrderStatusPending && order.Status != models.OrderStatusFailed {
// terminal states (paid, shipped, delivered, cancelled, refunded) refuse.
if order.Status != models.OrderStatusPending &&
order.Status != models.OrderStatusRequiresAction &&
order.Status != models.OrderStatusFailed {
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "order is already finalized", nil) utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "order is already finalized", nil)
return return
} }
@@ -705,7 +699,7 @@ func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
return return
} }
if card.IsStorageExpired(time.Now()) { if card.IsStorageExpired(time.Now()) {
if err := expireStoredCard(db, &card); err != nil { if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil) utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return return
} }
@@ -713,6 +707,8 @@ func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
return return
} }
stripe.Key = h.stripeKey
stripeStart := time.Now() stripeStart := time.Now()
confirmed, cerr := paymentintent.Confirm(order.StripePaymentID, &stripe.PaymentIntentConfirmParams{ confirmed, cerr := paymentintent.Confirm(order.StripePaymentID, &stripe.PaymentIntentConfirmParams{
PaymentMethod: stripe.String(card.StripePaymentID), PaymentMethod: stripe.String(card.StripePaymentID),

View File

@@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -12,7 +11,6 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
"terminalShop/api/middleware" "terminalShop/api/middleware"
"terminalShop/pkg/audit" "terminalShop/pkg/audit"
@@ -70,7 +68,7 @@ func TestGetCartEmpty(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
req := authRequest("GET", "/api/v1/cart", nil, user.ID) req := authRequest("GET", "/api/v1/cart", nil, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -85,8 +83,8 @@ func TestGetCartEmpty(t *testing.T) {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
Cart struct { Cart struct {
Items []any `json:"items"` Items []interface{} `json:"items"`
Subtotal int `json:"subtotal"` Subtotal int `json:"subtotal"`
} `json:"cart"` } `json:"cart"`
} `json:"data"` } `json:"data"`
} }
@@ -109,10 +107,10 @@ func TestSetItemAndGetCart(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Add item (CoffeeID=1, Quantity=2) // Add item (CoffeeID=1, Quantity=2)
body, _ := json.Marshal(map[string]any{ body, _ := json.Marshal(map[string]interface{}{
"coffee_id": 1, "coffee_id": 1,
"quantity": 2, "quantity": 2,
}) })
@@ -165,16 +163,16 @@ func TestSetItemUpdateQuantity(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Add item // Add item
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 2}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
// Update quantity // Update quantity
body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5}) body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5})
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID) req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -210,16 +208,16 @@ func TestSetItemRemove(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Add item // Add item
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 3}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
// Remove item (quantity 0) // Remove item (quantity 0)
body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 0}) body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 0})
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID) req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -233,7 +231,7 @@ func TestSetItemRemove(t *testing.T) {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
Cart struct { Cart struct {
Items []any `json:"items"` Items []interface{} `json:"items"`
} `json:"cart"` } `json:"cart"`
} `json:"data"` } `json:"data"`
} }
@@ -249,9 +247,9 @@ func TestSetItemInvalidProduct(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"coffee_id": 999, "quantity": 1}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -266,9 +264,9 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"quantity": 1}) body, _ := json.Marshal(map[string]interface{}{"quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -283,11 +281,11 @@ func TestClearCart(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Add two items // Add two items
for _, coffeeID := range []uint{1, 2} { for _, coffeeID := range []uint{1, 2} {
body, _ := json.Marshal(map[string]any{"coffee_id": coffeeID, "quantity": 1}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": coffeeID, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -311,7 +309,7 @@ func TestClearCart(t *testing.T) {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
Cart struct { Cart struct {
Items []any `json:"items"` Items []interface{} `json:"items"`
} `json:"cart"` } `json:"cart"`
} `json:"data"` } `json:"data"`
} }
@@ -339,9 +337,9 @@ func TestSetAddress(t *testing.T) {
} }
db.Create(&addr) db.Create(&addr)
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"address_id": addr.ID}) body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetAddress(w, req) handler.SetAddress(w, req)
@@ -393,9 +391,9 @@ func TestSetAddressWrongUser(t *testing.T) {
} }
db.Create(&addr) db.Create(&addr)
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"address_id": addr.ID}) body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetAddress(w, req) handler.SetAddress(w, req)
@@ -421,9 +419,9 @@ func TestSetCard(t *testing.T) {
} }
db.Create(&card) db.Create(&card)
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"card_id": card.ID}) body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetCard(w, req) handler.SetCard(w, req)
@@ -473,9 +471,9 @@ func TestSetCardWrongUser(t *testing.T) {
} }
db.Create(&card) db.Create(&card)
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"card_id": card.ID}) body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetCard(w, req) handler.SetCard(w, req)
@@ -490,10 +488,10 @@ func TestConvertCartMissingAddress(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Add item but no address or card // Add item but no address or card
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -513,18 +511,18 @@ func TestConvertCartMissingCard(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
db := database.GetDB() db := database.GetDB()
// Add item and address // Add item and address
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
addr := models.Address{UserID: user.ID, Name: "Test", Street: "123 St", City: "PDX", State: "OR", Zip: "97201", Country: "US"} addr := models.Address{UserID: user.ID, Name: "Test", Street: "123 St", City: "PDX", State: "OR", Zip: "97201", Country: "US"}
db.Create(&addr) db.Create(&addr)
body, _ = json.Marshal(map[string]any{"address_id": addr.ID}) body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID})
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID) req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
handler.SetAddress(w, req) handler.SetAddress(w, req)
@@ -544,7 +542,7 @@ func TestConvertCartEmpty(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Empty cart convert // Empty cart convert
req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID) req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
@@ -561,11 +559,11 @@ func TestMultipleItems(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Add three different items // Add three different items
for i := uint(1); i <= 3; i++ { for i := uint(1); i <= 3; i++ {
body, _ := json.Marshal(map[string]any{"coffee_id": i, "quantity": int(i)}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": i, "quantity": int(i)})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -583,8 +581,8 @@ func TestMultipleItems(t *testing.T) {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
Cart struct { Cart struct {
Items []any `json:"items"` Items []interface{} `json:"items"`
Subtotal int `json:"subtotal"` Subtotal int `json:"subtotal"`
} `json:"cart"` } `json:"cart"`
} `json:"data"` } `json:"data"`
} }
@@ -610,19 +608,19 @@ func TestCartIsolation(t *testing.T) {
} }
db.Create(&user2) db.Create(&user2)
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
r := chi.NewRouter() r := chi.NewRouter()
r.Put("/cart/item", handler.SetItem) r.Put("/cart/item", handler.SetItem)
r.Get("/cart", handler.GetCart) r.Get("/cart", handler.GetCart)
// User 1 adds item // User 1 adds item
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5})
req := authRequest("PUT", "/cart/item", body, user1.ID) req := authRequest("PUT", "/cart/item", body, user1.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
// User 2 adds different item // User 2 adds different item
body, _ = json.Marshal(map[string]any{"coffee_id": 2, "quantity": 3}) body, _ = json.Marshal(map[string]interface{}{"coffee_id": 2, "quantity": 3})
req = authRequest("PUT", "/cart/item", body, user2.ID) req = authRequest("PUT", "/cart/item", body, user2.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
@@ -719,9 +717,9 @@ func TestConvertCartLimit(t *testing.T) {
t.Fatalf("pin coffee price: %v", err) t.Fatalf("pin coffee price: %v", err)
} }
handler := NewCartHandler("", tc.capCents) handler := NewCartHandler("", "", tc.capCents)
body, _ := json.Marshal(map[string]any{"coffee_id": 4, "quantity": tc.quantity}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": 4, "quantity": tc.quantity})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -730,7 +728,7 @@ func TestConvertCartLimit(t *testing.T) {
} }
// set address // set address
body, _ = json.Marshal(map[string]any{"address_id": addr.ID}) body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID})
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID) req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
handler.SetAddress(w, req) handler.SetAddress(w, req)
@@ -739,7 +737,7 @@ func TestConvertCartLimit(t *testing.T) {
} }
// set card // set card
body, _ = json.Marshal(map[string]any{"card_id": card.ID}) body, _ = json.Marshal(map[string]interface{}{"card_id": card.ID})
req = authRequest("PUT", "/api/v1/cart/card", body, user.ID) req = authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
handler.SetCard(w, req) handler.SetCard(w, req)
@@ -747,19 +745,6 @@ func TestConvertCartLimit(t *testing.T) {
t.Fatalf("SetCard: %d %s", w.Code, w.Body.String()) t.Fatalf("SetCard: %d %s", w.Code, w.Body.String())
} }
// Capture audit slog ONLY for ConvertCart's emit window. Placed
// here (not at top of t.Run) so setup INFO logs from
// database.Connect/Migrate/Seed don't pollute the buffer with
// non-audit records - json.Unmarshal below requires a single
// JSON object, not multi-document input. slog.Default is
// process-global - do NOT add t.Parallel() to subtests here.
var auditBuf bytes.Buffer
if tc.expectOverCap {
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&auditBuf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
}
// Convert // Convert
req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID) req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
@@ -768,9 +753,9 @@ func TestConvertCartLimit(t *testing.T) {
var resp struct { var resp struct {
Success bool `json:"success"` Success bool `json:"success"`
Error struct { Error struct {
Code string `json:"code"` Code string `json:"code"`
Message string `json:"message"` Message string `json:"message"`
Details map[string]any `json:"details"` Details map[string]interface{} `json:"details"`
} `json:"error"` } `json:"error"`
} }
_ = json.NewDecoder(w.Body).Decode(&resp) _ = json.NewDecoder(w.Body).Decode(&resp)
@@ -786,27 +771,6 @@ func TestConvertCartLimit(t *testing.T) {
if got, _ := resp.Error.Details["total_cents"].(float64); int(got) != tc.quantity*pinnedPrice { if got, _ := resp.Error.Details["total_cents"].(float64); int(got) != tc.quantity*pinnedPrice {
t.Errorf("total_cents in details: want %d, got %v", tc.quantity*pinnedPrice, resp.Error.Details["total_cents"]) t.Errorf("total_cents in details: want %d, got %v", tc.quantity*pinnedPrice, resp.Error.Details["total_cents"])
} }
if auditBuf.Len() == 0 {
t.Fatalf("audit: expected cart_rejected slog line, got nothing")
}
var rec map[string]any
if err := json.Unmarshal(auditBuf.Bytes(), &rec); err != nil {
t.Fatalf("audit: decode slog record: %v\nraw: %s", err, auditBuf.String())
}
if got := rec["event"]; got != "cart_rejected" {
t.Errorf("audit event: want cart_rejected, got %v", got)
}
if n, _ := rec["user_id"].(float64); uint(n) != user.ID {
t.Errorf("audit user_id: want %d, got %v", user.ID, rec["user_id"])
}
if n, _ := rec["total_cents"].(float64); int(n) != tc.quantity*pinnedPrice {
t.Errorf("audit total_cents: want %d, got %v", tc.quantity*pinnedPrice, rec["total_cents"])
}
if n, _ := rec["cap_cents"].(float64); int(n) != tc.capCents {
t.Errorf("audit cap_cents: want %d, got %v", tc.capCents, rec["cap_cents"])
}
} }
}) })
} }
@@ -830,8 +794,8 @@ func TestSetCard_ExpiredReturns410(t *testing.T) {
t.Fatalf("seed card: %v", err) t.Fatalf("seed card: %v", err)
} }
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"card_id": card.ID}) body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetCard(w, req) handler.SetCard(w, req)
@@ -868,20 +832,20 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
db := database.GetDB() db := database.GetDB()
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
req := authRequest("PUT", "/api/v1/vart/item", body, user.ID) req := authRequest("PUT", "/api/v1/vart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
NewCartHandler("", 0).SetItem(w, req) NewCartHandler("", "", 0).SetItem(w, req)
addr := models.Address{ addr := models.Address{
UserID: user.ID, Name: "Test", Street: "123 St", UserID: user.ID, Name: "Test", Street: "123 St",
City: "PDX", State: "OR", Zip: "97201", Country: "US", City: "PDX", State: "OR", Zip: "97201", Country: "US",
} }
db.Create(&addr) db.Create(&addr)
body, _ = json.Marshal(map[string]any{"address_id": addr.ID}) body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID})
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID) req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
NewCartHandler("", 0).SetAddress(w, req) NewCartHandler("", "", 0).SetAddress(w, req)
past := time.Now().Add(-1 * time.Hour) past := time.Now().Add(-1 * time.Hour)
card := models.Card{ card := models.Card{
@@ -897,7 +861,7 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID) req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
NewCartHandler("", 0).ConvertCart(w, req) NewCartHandler("", "", 0).ConvertCart(w, req)
if w.Code != http.StatusGone { if w.Code != http.StatusGone {
t.Fatalf("want 410, got %d, body: %s", w.Code, w.Body.String()) t.Fatalf("want 410, got %d, body: %s", w.Code, w.Body.String())
@@ -919,9 +883,8 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
// succeeded) hit the live Stripe API and aren't covered here — see // succeeded) hit the live Stripe API and aren't covered here — see
// sca-psd2-compliance.md "Test plan" for manual coverage with the // sca-psd2-compliance.md "Test plan" for manual coverage with the
// 4000 0027 6000 3184 test card. Caveat #14 in that doc still applies: // 4000 0027 6000 3184 test card. Caveat #14 in that doc still applies:
// NewCartHandler takes 2 args (appURL, maxOrderCents) and the seeded user // NewCartHandler takes 3 args and the seeded user must have a non-empty
// must have a non-empty StripeCustomerID so the Convert/Retry paths stay // StripeCustomerID so the Convert/Retry paths stay hermetic.
// hermetic.
func TestRetryAuth(t *testing.T) { func TestRetryAuth(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
@@ -956,12 +919,6 @@ func TestRetryAuth(t *testing.T) {
seedPayment: "pi_test_refunded", seedEvents: 1, seedPayment: "pi_test_refunded", seedEvents: 1,
wantStatus: http.StatusConflict, wantErrCode: "INVALID_STATE", wantStatus: http.StatusConflict, wantErrCode: "INVALID_STATE",
}, },
{
name: "requires_action honors retry cap",
ownedByUser: true, seedStatus: models.OrderStatusRequiresAction,
seedPayment: "pi_test_req_action_cap", seedEvents: maxAuthIssuances,
wantStatus: http.StatusTooManyRequests, wantErrCode: "RETRY_LIMIT",
},
{ {
name: "retry cap reached returns 429", name: "retry cap reached returns 429",
ownedByUser: true, seedStatus: models.OrderStatusFailed, ownedByUser: true, seedStatus: models.OrderStatusFailed,
@@ -1028,7 +985,7 @@ func TestRetryAuth(t *testing.T) {
} }
} }
handler := NewCartHandler("http://localhost", 0) handler := NewCartHandler("", "http://localhost", 0)
req := orderRequest("POST", req := orderRequest("POST",
fmt.Sprintf("/api/v1/orders/%d/retry-auth", order.ID), nil, user.ID, fmt.Sprintf("%d", order.ID)) fmt.Sprintf("/api/v1/orders/%d/retry-auth", order.ID), nil, user.ID, fmt.Sprintf("%d", order.ID))
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -1056,7 +1013,7 @@ func TestRetryAuth_OrderNotFound(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("http://localhost", 0) handler := NewCartHandler("", "http://localhost", 0)
req := orderRequest("POST", "/api/v1/orders/9999/retry-auth", nil, user.ID, "9999") req := orderRequest("POST", "/api/v1/orders/9999/retry-auth", nil, user.ID, "9999")
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.RetryAuth(w, req) handler.RetryAuth(w, req)
@@ -1065,137 +1022,3 @@ func TestRetryAuth_OrderNotFound(t *testing.T) {
t.Fatalf("want 404, got %d body=%s", w.Code, w.Body.String()) t.Fatalf("want 404, got %d body=%s", w.Code, w.Body.String())
} }
} }
func TestRespondRequiresAction_SetStatusRequiresAction(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_test_req_action",
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
order := models.Order{
UserID: user.ID, CardID: card.ID,
Status: models.OrderStatusPending,
Subtotal: 500, Total: 500,
ShippingName: "Test", ShippingStreet: "1 Main St",
ShippingCity: "PDX", ShippingState: "OR",
ShippingZip: "97201", ShippingCountry: "US",
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
pi := &stripe.PaymentIntent{
ID: "pi_test_action_set",
NextAction: &stripe.PaymentIntentNextAction{
RedirectToURL: &stripe.PaymentIntentNextActionRedirectToURL{
URL: "https://hooks.stripe.com/redirect/test/test_3ds",
},
},
}
handler := NewCartHandler("http://localhost", 0)
w := httptest.NewRecorder()
handler.respondRequiresAction(w, &order, card.StripePaymentID, pi)
if w.Code != http.StatusAccepted {
t.Fatalf("want 202, got %d body=%s", w.Code, w.Body.String())
}
var resp struct {
Data struct {
OrderID uint `json:"order_id"`
PaymentIntentID string `json:"payment_intent_id"`
Status string `json:"status"`
RedirectURL string `json:"redirect_url"`
} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Data.Status != "requires_action" {
t.Fatalf("response status: %q, got %q", "requires_action", resp.Data.Status)
}
if resp.Data.RedirectURL == "" {
t.Fatalf("expected redirect url, got empty")
}
if resp.Data.PaymentIntentID != pi.ID {
t.Fatalf("payment intent id: want %q, got %q", pi.ID, resp.Data.PaymentIntentID)
}
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("order.Status: want %q, got %q", models.OrderStatusRequiresAction, reloaded.Status)
}
if reloaded.StripePaymentID != pi.ID {
t.Fatalf("order.StripePaymentID: want %q, got %q", pi.ID, reloaded.StripePaymentID)
}
}
// TestRespondRequiresAction_OverridesFailedFromWebhookRace simulates the race
// described in sca-psd2-compliance.md note #3 / caveat #4: the
// payment_intent.payment_failed webhook arrives BEFORE respondRequiresAction
// returns and flips the row to OrderStatusFailed. The helper must override
// that back to OrderStatusRequiresAction so the in-flight 3DS state surfaces
// correctly to the TUI.
func TestRespondRequiresAction_OverridesFailedFromWebhookRace(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_test_race",
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
order := models.Order{
UserID: user.ID, CardID: card.ID,
Status: models.OrderStatusFailed,
Subtotal: 500, Total: 500,
ShippingName: "Test", ShippingStreet: "1 Main St",
ShippingCity: "PDX", ShippingState: "OR",
ShippingZip: "97201", ShippingCountry: "US",
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
pi := &stripe.PaymentIntent{
ID: "pi_test_race",
NextAction: &stripe.PaymentIntentNextAction{
RedirectToURL: &stripe.PaymentIntentNextActionRedirectToURL{
URL: "https://hooks.stripe.com/redirect/test_race",
},
},
}
handler := NewCartHandler("http://localhost", 0)
w := httptest.NewRecorder()
handler.respondRequiresAction(w, &order, card.StripePaymentID, pi)
if w.Code != http.StatusAccepted {
t.Fatalf("want 202, got %d body=%s", w.Code, w.Body.String())
}
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("override failed: want %q, got %q", models.OrderStatusRequiresAction, reloaded.Status)
}
}

View File

@@ -28,6 +28,7 @@ import (
) )
type OrderHandler struct { type OrderHandler struct {
stripeKey string
bringAPIUID string bringAPIUID string
bringAPIKey string bringAPIKey string
bringCustomerNumber string bringCustomerNumber string
@@ -43,10 +44,9 @@ type refundRequestPayload struct {
// repeated submissions from spamming the operator's Slack channel. // repeated submissions from spamming the operator's Slack channel.
const refundRequestCooldown = 15 * time.Minute const refundRequestCooldown = 15 * time.Minute
// NewOrderHandler creates an order handler. Stripe credentials are wired func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
// once at startup via stripe.Key in api/main.go.
func NewOrderHandler(bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
return &OrderHandler{ return &OrderHandler{
stripeKey: stripeSecretKey,
bringAPIUID: bringAPIUID, bringAPIUID: bringAPIUID,
bringAPIKey: bringAPIKey, bringAPIKey: bringAPIKey,
bringCustomerNumber: bringCustomerNumber, bringCustomerNumber: bringCustomerNumber,
@@ -200,6 +200,8 @@ func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
return return
} }
stripe.Key = h.stripeKey
rf, err := refund.New(&stripe.RefundParams{ rf, err := refund.New(&stripe.RefundParams{
PaymentIntent: stripe.String(order.StripePaymentID), PaymentIntent: stripe.String(order.StripePaymentID),
}) })
@@ -219,7 +221,7 @@ func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
audit.OrderRefunded(userID, order.ID, order.Total, rf.ID) audit.OrderRefunded(userID, order.ID, order.Total, rf.ID)
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"refund_id": rf.ID, "refund_id": rf.ID,
"status": "refunded", "status": "refunded",
}) })
@@ -285,7 +287,7 @@ func (h *OrderHandler) UpdateTracking(w http.ResponseWriter, r *http.Request) {
audit.OrderShipped(order.UserID, order.ID, req.Carrier, req.TrackingNumber) audit.OrderShipped(order.UserID, order.ID, req.Carrier, req.TrackingNumber)
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"order_id": order.ID, "order_id": order.ID,
"carrier": req.Carrier, "carrier": req.Carrier,
"tracking_number": req.TrackingNumber, "tracking_number": req.TrackingNumber,

View File

@@ -60,7 +60,7 @@ func contextWithRoute(ctx context.Context, routeCtx *chi.Context) context.Contex
func TestCreateRefundRequestRequiresMessageForOther(t *testing.T) { func TestCreateRefundRequestRequiresMessageForOther(t *testing.T) {
user := setupOrderTestDB(t) user := setupOrderTestDB(t)
handler := NewOrderHandler("", "", "") handler := NewOrderHandler("", "", "", "")
body := []byte(`{"reason":"Other","message":" "}`) body := []byte(`{"reason":"Other","message":" "}`)
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, "1") req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, "1")
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -100,7 +100,7 @@ func TestCreateRefundRequestRejectsInvalidOrderState(t *testing.T) {
t.Fatalf("failed to create order: %v", err) t.Fatalf("failed to create order: %v", err)
} }
handler := NewOrderHandler("", "", "") handler := NewOrderHandler("", "", "", "")
body := []byte(`{"reason":"Quality issue","message":""}`) body := []byte(`{"reason":"Quality issue","message":""}`)
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID)) req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -142,7 +142,7 @@ func TestCreateRefundRequestEnforcesCooldown(t *testing.T) {
t.Fatalf("failed to create order: %v", err) t.Fatalf("failed to create order: %v", err)
} }
handler := NewOrderHandler("", "", "") handler := NewOrderHandler("", "", "", "")
body := []byte(`{"reason":"Quality issue","message":""}`) body := []byte(`{"reason":"Quality issue","message":""}`)
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID)) req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
w := httptest.NewRecorder() w := httptest.NewRecorder()

View File

@@ -30,7 +30,7 @@ func (h *ProductHandler) GetProducts(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"products": products, "products": products,
}) })
} }
@@ -53,7 +53,7 @@ func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"product": product, "product": product,
}) })
} }

View File

@@ -1,13 +1,10 @@
package handlers package handlers
import ( import (
"context"
"fmt" "fmt"
"log/slog" "log/slog"
"time" "time"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database" "terminalShop/pkg/database"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"terminalShop/pkg/notify" "terminalShop/pkg/notify"
@@ -17,19 +14,15 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// paymentIntentGetFn lets tests stub the Stripe Get call without spinning
// up an httptest backend. Production callers go straight through to the
// real paymentintent.Get.
var paymentIntentGetFn = paymentintent.Get
func reconcileLog() *slog.Logger { return slog.With("component", "reconcile") } func reconcileLog() *slog.Logger { return slog.With("component", "reconcile") }
// ReconcileOrders finds pending orders with no Stripe ID older than 10 minutes // ReconcileOrders finds pending orders with no Stripe ID older than 10 minutes
// and checks whether Stripe has a succeeded PaymentIntent for them. This fixes // and checks whether Stripe has a succeeded PaymentIntent for them. This fixes
// the case where the server crashed after charging but before the DB transaction // the case where the server crashed after charging but before the DB transaction
// completed. // completed.
func ReconcileOrders(ctx context.Context) { func ReconcileOrders(stripeKey string) {
db := database.GetDB().WithContext(ctx) stripe.Key = stripeKey
db := database.GetDB()
var orders []models.Order var orders []models.Order
cutoff := time.Now().Add(-10 * time.Minute) cutoff := time.Now().Add(-10 * time.Minute)
@@ -43,14 +36,9 @@ func ReconcileOrders(ctx context.Context) {
reconcileLog().Info("checking pending orders", "count", len(orders)) reconcileLog().Info("checking pending orders", "count", len(orders))
for _, order := range orders { for _, order := range orders {
if err := ctx.Err(); err != nil {
reconcileLog().Info("reconcile orders cancelled mid-loop", "error", err)
return
}
params := &stripe.PaymentIntentSearchParams{ params := &stripe.PaymentIntentSearchParams{
SearchParams: stripe.SearchParams{ SearchParams: stripe.SearchParams{
Context: ctx, Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
}, },
} }
@@ -61,7 +49,7 @@ func ReconcileOrders(ctx context.Context) {
"order_id", order.ID, "pi", pi.ID) "order_id", order.ID, "pi", pi.ID)
txErr := db.Transaction(func(tx *gorm.DB) error { txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]any{ if err := tx.Model(&order).Updates(map[string]interface{}{
"status": models.OrderStatusPaid, "status": models.OrderStatusPaid,
"stripe_payment_id": pi.ID, "stripe_payment_id": pi.ID,
}).Error; err != nil { }).Error; err != nil {
@@ -74,7 +62,7 @@ func ReconcileOrders(ctx context.Context) {
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil { if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return err return err
} }
return tx.Model(&cart).Updates(map[string]any{ return tx.Model(&cart).Updates(map[string]interface{}{
"address_id": nil, "address_id": nil,
"card_id": nil, "card_id": nil,
}).Error }).Error
@@ -89,117 +77,12 @@ func ReconcileOrders(ctx context.Context) {
} }
} }
// ReconcileStale3DSOrders flips requires_action orders that have been
// awaiting the customer's 3DS challenge longer than threshold to failed,
// after re-checking the PaymentIntent with Stripe so a late
// payment_intent.succeeded webhook is respected. Deliberately separate
// from ReconcileOrders: the empty-PI predicate there is load-bearing for
// the crash-mid-create case (see sca-psd2-compliance.md caveat #20).
// stripe.Key is wired once at startup in api/main.go.
func ReconcileStale3DSOrders(ctx context.Context, threshold time.Duration) {
db := database.GetDB().WithContext(ctx)
var orders []models.Order
// Predicate keys off updated_at, not created_at: respondRequiresAction
// and RetryAuth both bump updated_at via GORM auto-stamping, so every
// customer-driven retry extends the grace window. Intentional - sweeping
// a row the customer just re-armed would race the new challenge.
cutoff := time.Now().Add(-threshold)
if err := db.Where("status = ? AND updated_at < ?",
models.OrderStatusRequiresAction, cutoff).Find(&orders).Error; err != nil {
reconcileLog().Error("stale 3ds scan failed", "error", err)
return
}
if len(orders) == 0 {
return
}
reconcileLog().Info("checking stale requires_action orders", "count", len(orders))
piParams := &stripe.PaymentIntentParams{Params: stripe.Params{Context: ctx}}
for i := range orders {
if err := ctx.Err(); err != nil {
reconcileLog().Info("stale 3ds sweep cancelled mid-loop", "error", err)
return
}
order := &orders[i]
if order.StripePaymentID == "" {
// respondRequiresAction writes stripe_payment_id and status in
// one UPDATE so this should be unreachable; guard anyway so a
// future regression noisily logs instead of 4xx'ing Stripe.
reconcileLog().Warn("requires_action row missing PI id; skipping", "order_id", order.ID)
middleware.RecordAbandoned3DSSweep("missing_pi")
continue
}
actual, err := paymentIntentGetFn(order.StripePaymentID, piParams)
if err != nil {
reconcileLog().Error("stripe get failed",
"order_id", order.ID, "pi", order.StripePaymentID, "error", err)
middleware.RecordAbandoned3DSSweep("stripe_error")
continue
}
switch actual.Status {
case stripe.PaymentIntentStatusSucceeded:
// Stripe says paid but our row still says requires_action, the
// payment_intent.succeeded webhook has not landed yet (or was
// dropped). Defer to handlePaymentIntentsucceeded; it owns the
// paid transition + cart cleanup atomically. The next sweep
// tick re-checks and surfaces the dropped webhook if it persists
reconcileLog().Warn("stripe reports succeeded for requires_action row; deferring to webhook",
"order_id", order.ID, "pi", order.StripePaymentID)
middleware.RecordAbandoned3DSSweep("succeeded_skip")
case stripe.PaymentIntentStatusProcessing,
stripe.PaymentIntentStatusRequiresCapture:
reconcileLog().Info("payment intent still transitioning; skipping",
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", string(actual.Status))
middleware.RecordAbandoned3DSSweep("processing_skip")
case stripe.PaymentIntentStatusRequiresAction,
stripe.PaymentIntentStatusRequiresPaymentMethod,
stripe.PaymentIntentStatusRequiresConfirmation,
stripe.PaymentIntentStatusCanceled:
flipStale3DSToFailed(db, order, string(actual.Status))
default:
reconcileLog().Warn("unrecognized stripe status; skipping",
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", string(actual.Status))
middleware.RecordAbandoned3DSSweep("unrecognized_status")
}
}
}
// flipStale3DSToFailed transitions a single stale requires_action row
// to failed. The UPDATE is conditional on status = requires_action so a
// payment_intent.succeeded webhook that landed between the stripe get and
// this write (caveat #20 race) wins and we silently no-op
func flipStale3DSToFailed(db *gorm.DB, order *models.Order, stripeStatus string) {
// Map form so the single-field UPDATE is explicit at the call site.
// GORM still auto-stamps updated_at; the row exits the sweep predicate
// naturally on the next tick because status no longer matches anyway.
res := db.Model(&models.Order{}).
Where("id = ? AND status = ?", order.ID, models.OrderStatusRequiresAction).
Updates(map[string]any{"status": models.OrderStatusFailed})
if res.Error != nil {
reconcileLog().Error("flip stale 3ds failed", "order_id", order.ID, "error", res.Error)
middleware.RecordAbandoned3DSSweep("flip_failed")
return
}
if res.RowsAffected == 0 {
reconcileLog().Info("stale 3ds row already transitioned; sweep no-op",
"order_id", order.ID)
middleware.RecordAbandoned3DSSweep("flip_noop")
return
}
audit.Order3DSAbandoned(order.UserID, order.ID, order.StripePaymentID)
reconcileLog().Info("3ds abandoned order flipped to failed",
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", stripeStatus)
middleware.RecordAbandoned3DSSweep("flipped")
}
// ReconcileExpiredCards removes saved cards whose retention deadline elapsed. // ReconcileExpiredCards removes saved cards whose retention deadline elapsed.
// Read paths filter out expired rows so users never see them; this job drives // Read paths filter out expired rows so users never see them; this job drives
// the Stripe detach + audit + physical row delete out of band so request // the Stripe detach + audit + physical row delete out of band so request
// handlers never block on Stripe API calls during a card sweep. // handlers never block on Stripe API calls during a card sweep.
func ReconcileExpiredCards(ctx context.Context) { func ReconcileExpiredCards(stripeKey string) {
db := database.GetDB().WithContext(ctx) db := database.GetDB()
var cards []models.Card var cards []models.Card
if err := db.Where( if err := db.Where(
@@ -215,20 +98,16 @@ func ReconcileExpiredCards(ctx context.Context) {
reconcileLog().Info("expiring inactive saved cards", "count", len(cards)) reconcileLog().Info("expiring inactive saved cards", "count", len(cards))
for i := range cards { for i := range cards {
if err := ctx.Err(); err != nil { if err := expireStoredCard(db, &cards[i], stripeKey); err != nil {
reconcileLog().Info("card sweep cancelled mid-loop", "error", err)
return
}
if err := expireStoredCard(db, &cards[i]); err != nil {
reconcileLog().Error("expire card failed", "card_id", cards[i].ID, "error", err) reconcileLog().Error("expire card failed", "card_id", cards[i].ID, "error", err)
} }
} }
} }
// ReconcileUnshipped finds paid orders older than 24h with no tracking // ReconcileUnshipped finds paid order orlder than 24h with no tracking
// number and posts a single Slack reminder. Pure read + notify. // number and posts single Slack reminder. Pure read + notif
func ReconcileUnshipped(ctx context.Context) { func ReconcileUnshipped() {
db := database.GetDB().WithContext(ctx) db := database.GetDB()
var orders []models.Order var orders []models.Order
cutoff := time.Now().Add(-24 * time.Hour) cutoff := time.Now().Add(-24 * time.Hour)

View File

@@ -1,520 +0,0 @@
package handlers
import (
"context"
"fmt"
"os"
"testing"
"time"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stripe/stripe-go/v78"
)
// TestReconcileStale3DSOrders_FlipsAbandonedToFailed verifies the predicate
// and Stripe-Get branching: a row past the threshold whose PaymentIntent
// Stripe still reports as requires_payment_method (the customer never
// completed the challenge) transitions to failed and emits the new
// order_3ds_abandoned audit row.
func TestReconcileStale3DSOrders_FlipsAbandonedToFailed(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_stale_abandoned",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
flipped := middleware.Abandoned3DSSweepCounter().WithLabelValues("flipped")
before := testutil.ToFloat64(flipped)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusFailed {
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
}
var events []models.OrderEvent
if err := db.Where("order_id = ? AND type = ?",
order.ID, audit.EventOrder3DSAbandoned).Find(&events).Error; err != nil {
t.Fatalf("list events: %v", err)
}
if len(events) != 1 {
t.Fatalf("want 1 order_3ds_abandoned audit row, got %d", len(events))
}
if got := testutil.ToFloat64(flipped) - before; got != 1 {
t.Fatalf("want 1 flipped sweep counter increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_SkipsRecent verifies the threshold predicate
// excludes rows still within the grace window. A fresh requires_action row
// must not even reach the Stripe Get call.
func TestReconcileStale3DSOrders_SkipsRecent(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_fresh_3ds",
time.Now().Add(-5*time.Minute))
called := false
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
called = true
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
if called {
t.Fatalf("stripe Get called for in-window row")
}
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("status changed unexpectedly: %q", reloaded.Status)
}
}
// TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded covers the
// webhook-race guard from caveat #20. If Stripe-side already says succeeded,
// the sweep must defer to handlePaymentIntentSucceeded rather than overwrite
// the authoritative end state.
func TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_succeeded_late_webhook",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusSucceeded,
}, nil
}
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("succeeded_skip")
before := testutil.ToFloat64(skip)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("must defer to webhook on succeeded; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(skip) - before; got != 1 {
t.Fatalf("want 1 succeeded_skip sweep counter increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_GuardsAgainstWebhookRace covers the conditional
// UPDATE. If the row flipped to paid between the Stripe Get and the flip,
// the WHERE status = requires_action clause prevents clobber.
func TestReconcileStale3DSOrders_GuardsAgainstWebhookRace(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_race_paid",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
db.Model(&models.Order{}).Where("id = ?", order.ID).
Update("status", models.OrderStatusPaid)
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
noop := middleware.Abandoned3DSSweepCounter().WithLabelValues("flip_noop")
before := testutil.ToFloat64(noop)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusPaid {
t.Fatalf("conditional update must preserve paid; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(noop) - before; got != 1 {
t.Fatalf("want 1 flip_noop sweep counter increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_FlipsCanceledPI covers the canceled-PI branch:
// Stripe-terminal-canceled maps to OrderStatusFailed (matches RetryAuth's
// existing precedent — see api/handlers/cart.go RetryAuth canceled branch).
func TestReconcileStale3DSOrders_FlipsCanceledPI(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_canceled_stale",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusCanceled,
}, nil
}
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusFailed {
t.Fatalf("canceled PI must flip to failed; got %q", reloaded.Status)
}
}
// TestReconcileStale3DSOrders_DoesNotConsumeRetryCap regression-tests the
// caveat #17 decoupling: the new sweep emits EventOrder3DSAbandoned, never
// EventOrderRequiresAction, so a swept-then-stuck order does not silently
// shrink the retry ceiling for any future user-driven retry attempts.
func TestReconcileStale3DSOrders_DoesNotConsumeRetryCap(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_decoupled_cap",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var retryCount int64
if err := db.Model(&models.OrderEvent{}).
Where("order_id = ? AND type = ?", order.ID, audit.EventOrderRequiresAction).
Count(&retryCount).Error; err != nil {
t.Fatalf("count retry events: %v", err)
}
if retryCount != 0 {
t.Fatalf("sweep must not emit EventOrderRequiresAction; got %d", retryCount)
}
}
// TestReconcileStale3DSOrders_SkipsTransitioningStatuses covers the
// processing and requires_capture branches: Stripe still owns the PI's
// next move, so the sweep must not flip even past the threshold.
func TestReconcileStale3DSOrders_SkipsTransitioningStatuses(t *testing.T) {
cases := []struct {
name string
status stripe.PaymentIntentStatus
}{
{"processing", stripe.PaymentIntentStatusProcessing},
{"requires_capture", stripe.PaymentIntentStatusRequiresCapture},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID,
"pi_transitioning_"+tc.name, time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{ID: id, Status: tc.status}, nil
}
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("processing_skip")
before := testutil.ToFloat64(skip)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("%s must stay requires_action; got %q", tc.name, reloaded.Status)
}
if got := testutil.ToFloat64(skip) - before; got != 1 {
t.Fatalf("want 1 processing_skip increment, got %v", got)
}
})
}
}
// TestReconcileStale3DSOrders_SkipsUnknownStatus covers the switch's default
// arm: an unrecognized Stripe status logs and skips rather than guessing a
// transition. Defends against new PI statuses appearing in future SDKs.
func TestReconcileStale3DSOrders_SkipsUnknownStatus(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_unknown_status",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatus("some_new_future_status"),
}, nil
}
unknown := middleware.Abandoned3DSSweepCounter().WithLabelValues("unrecognized_status")
before := testutil.ToFloat64(unknown)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("unknown status must not flip; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(unknown) - before; got != 1 {
t.Fatalf("want 1 unrecognized_status increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_SkipsOnStripeError verifies a Stripe Get
// failure logs and leaves the row alone. Transient network errors must not
// look like an abandoned 3DS.
func TestReconcileStale3DSOrders_SkipsOnStripeError(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_stripe_error",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(_ string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return nil, fmt.Errorf("simulated stripe outage")
}
stripeErr := middleware.Abandoned3DSSweepCounter().WithLabelValues("stripe_error")
before := testutil.ToFloat64(stripeErr)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("stripe error must not flip; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(stripeErr) - before; got != 1 {
t.Fatalf("want 1 stripe_error sweep counter increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID guards the
// defensive empty-PI short-circuit: even past the threshold, a row missing
// stripe_payment_id must not reach paymentintent.Get (which would 4xx).
func TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_will_be_blanked",
time.Now().Add(-1*time.Hour))
if err := db.Model(&order).UpdateColumn("stripe_payment_id", "").Error; err != nil {
t.Fatalf("blank PI id: %v", err)
}
called := false
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(_ string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
called = true
return &stripe.PaymentIntent{Status: stripe.PaymentIntentStatusRequiresPaymentMethod}, nil
}
missing := middleware.Abandoned3DSSweepCounter().WithLabelValues("missing_pi")
before := testutil.ToFloat64(missing)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
if called {
t.Fatalf("stripe Get called for empty-PI row")
}
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("empty-PI guard must leave status alone; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(missing) - before; got != 1 {
t.Fatalf("want 1 missing_pi increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_BailsMidLoopOnCancel verifies the per-iteration
// ctx.Err() check stops the sweep once the supervising context cancels, so a
// SIGTERM during a large stale-3DS batch does not block the binary past the
// 30s graceful-shutdown window in api/main.go
func TestReconcileStale3DSOrders_BailsMidLoopOnCancel(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
seedRequiresActionOrder(t, user.ID, "pi_first", time.Now().Add(-1*time.Hour))
seedRequiresActionOrder(t, user.ID, "pi_second", time.Now().Add(-1*time.Hour))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
calls := 0
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
calls++
if calls == 1 {
cancel()
}
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
ReconcileStale3DSOrders(ctx, 30*time.Minute)
if calls != 1 {
t.Fatalf("want exactly 1 stripe Get call (loop bails before row 2); got %d", calls)
}
var stillPending int64
if err := db.Model(&models.Order{}).Where("status = ?", models.OrderStatusRequiresAction).Count(&stillPending).Error; err != nil {
t.Fatalf("count requires_action rows: %v", err)
}
if stillPending < 1 {
t.Fatalf("want at least 1 row left in requires_action after mid-loop cancel; got %d", stillPending)
}
}
// seedRequiresActionOrder inserts a card + order pair in the requires_action
// state. updatedAt is forced via UpdateColumn so GORM's auto-stamping of
// UpdatedAt on Create does not bump the row out of the sweep predicate.
func seedRequiresActionOrder(t *testing.T, userID uint, piID string, updatedAt time.Time) models.Order {
t.Helper()
db := database.GetDB()
card := models.Card{
UserID: userID,
StripePaymentID: "pm_test_" + piID,
Last4: "4242", Brand: "Visa",
ExpMonth: 12, ExpYear: 2030,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
order := models.Order{
UserID: userID, CardID: card.ID,
StripePaymentID: piID,
Status: models.OrderStatusRequiresAction,
Subtotal: 500, Total: 500,
ShippingName: "Test", ShippingStreet: "1 Main St",
ShippingCity: "PDX", ShippingState: "OR",
ShippingZip: "97201", ShippingCountry: "US",
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
if err := db.Model(&order).UpdateColumn("updated_at", updatedAt).Error; err != nil {
t.Fatalf("backdate updated_at: %v", err)
}
return order
}

View File

@@ -54,7 +54,7 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems) db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems)
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"user": user.ToPublic(), "user": user.ToPublic(),
"products": products, "products": products,
"cart": cartItems, "cart": cartItems,

View File

@@ -28,13 +28,13 @@ func webhookLog() *slog.Logger { return slog.With("component", "webhook") }
// WebhookHandler handles inbound Stripe webhook events. // WebhookHandler handles inbound Stripe webhook events.
type WebhookHandler struct { type WebhookHandler struct {
webhookSecret string webhookSecret string
stripeKey string
shippoSecret string shippoSecret string
} }
// NewWebhookHandler creates a new webhook handler. Stripe credentials are // NewWebhookHandler creates a new webhook handler.
// wired once at startup via stripe.Key in api/main.go. func NewWebhookHandler(webhookSecret, stripeKey, shippoSecret string) *WebhookHandler {
func NewWebhookHandler(webhookSecret, shippoSecret string) *WebhookHandler { return &WebhookHandler{webhookSecret: webhookSecret, stripeKey: stripeKey, shippoSecret: shippoSecret}
return &WebhookHandler{webhookSecret: webhookSecret, shippoSecret: shippoSecret}
} }
// HandleStripe validates the Stripe signature and routes the event. // HandleStripe validates the Stripe signature and routes the event.
@@ -125,7 +125,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
} }
txErr := db.Transaction(func(tx *gorm.DB) error { txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]any{ if err := tx.Model(&order).Updates(map[string]interface{}{
"status": models.OrderStatusPaid, "status": models.OrderStatusPaid,
"stripe_payment_id": pi.ID, "stripe_payment_id": pi.ID,
}).Error; err != nil { }).Error; err != nil {
@@ -139,7 +139,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil { if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return fmt.Errorf("failed to delete cart item: %w", err) return fmt.Errorf("failed to delete cart item: %w", err)
} }
return tx.Model(&cart).Updates(map[string]any{ return tx.Model(&cart).Updates(map[string]interface{}{
"address_id": nil, "address_id": nil,
"card_id": nil, "card_id": nil,
}).Error }).Error
@@ -184,7 +184,7 @@ func (h *WebhookHandler) handlePaymentIntentFailed(ctx context.Context, event st
} }
db := database.GetDB().WithContext(ctx) db := database.GetDB().WithContext(ctx)
if err := db.Model(&models.Order{}).Where("id = ? AND status IN (?, ?)", orderIDStr, models.OrderStatusPending, models.OrderStatusRequiresAction). 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 {
webhookLog().Error("failed to mark order failed", "order_id", orderIDStr, "error", err) webhookLog().Error("failed to mark order failed", "order_id", orderIDStr, "error", err)
} }
@@ -212,6 +212,8 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, eve
return return
} }
stripe.Key = h.stripeKey
si, err := setupintent.Get(sess.SetupIntent.ID, nil) si, err := setupintent.Get(sess.SetupIntent.ID, nil)
if err != nil { if err != nil {
webhookLog().Error("failed to get setup intent", "setup_intent", sess.SetupIntent.ID, "error", err) webhookLog().Error("failed to get setup intent", "setup_intent", sess.SetupIntent.ID, "error", err)

View File

@@ -1,224 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"os"
"testing"
"github.com/stripe/stripe-go/v78"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// buildPaymentFailedEvent constructs a stripe.Event whose Data.Raw decodes to
// a PaymentIntent referencing the given order via metadata. errCode is set on
// last_payment_error to drive the authentication_required short-circuit
// branch in handlePaymentIntentFailed (pass "" to omit the error block).
func buildPaymentFailedEvent(t *testing.T, piID string, orderID uint, errCode string) stripe.Event {
t.Helper()
pi := map[string]any{
"id": piID,
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", orderID)},
}
if errCode != "" {
pi["last_payment_error"] = map[string]any{"code": errCode}
}
raw, err := json.Marshal(pi)
if err != nil {
t.Fatalf("marshal pi: %v", err)
}
return stripe.Event{
Type: "payment_intent.payment_failed",
Data: &stripe.EventData{Raw: raw},
}
}
func seedOrderForWebhook(t *testing.T, userID uint, status models.OrderStatus, piID string) models.Order {
t.Helper()
db := database.GetDB()
card := models.Card{
UserID: userID,
StripePaymentID: fmt.Sprintf("pm_test_%s", piID),
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
order := models.Order{
UserID: userID, CardID: card.ID,
StripePaymentID: piID,
Status: status,
Subtotal: 500, Total: 500,
ShippingName: "Test", ShippingStreet: "1 Main St",
ShippingCity: "PDX", ShippingState: "OR",
ShippingZip: "97201", ShippingCountry: "US",
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
return order
}
// TestHandlePaymentIntentFailed_RequiresActionFlipsToFailed verifies the
// widened predicate (IN (pending, requires_action)) catches a row in the new
// requires_action state. Without this case, a card_declined webhook arriving
// after respondRequiresAction wrote requires_action would leave the row
// stuck forever.
func TestHandlePaymentIntentFailed_RequiresActionFlipsToFailed(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_failed")
h := NewWebhookHandler("", "")
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
h.handlePaymentIntentFailed(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusFailed {
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
}
}
// TestHandlePaymentIntentFailed_RequiresActionAuthRequiredShortCircuits
// verifies the existing short-circuit (note #3 in sca-psd2-compliance.md)
// still fires when the row is in requires_action and the error code is
// authentication_required. Without the short-circuit, the recovery flow
// would race the webhook and surface a momentarily-failed state to the TUI.
func TestHandlePaymentIntentFailed_RequiresActionAuthRequiredShortCircuits(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_authreq")
h := NewWebhookHandler("", "")
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "authentication_required")
h.handlePaymentIntentFailed(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("auth_required must not flip requires_action; got %q", reloaded.Status)
}
}
// TestHandlePaymentIntentFailed_PendingStillFlips guards the pre-existing
// pending -> failed transition against a regression where the widened
// predicate accidentally dropped pending from the source set.
func TestHandlePaymentIntentFailed_PendingStillFlips(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusPending, "pi_test_pending_failed")
h := NewWebhookHandler("", "")
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
h.handlePaymentIntentFailed(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusFailed {
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
}
}
// TestHandlePaymentIntentFailed_PaidNotTouched locks the upper boundary of
// the predicate: a paid row must NEVER be flipped to failed by a stale
// payment_failed webhook. Without this guard, a future maintainer could
// widen the IN clause to include paid (e.g. for some refund-retry flow)
// and silently break idempotency.
func TestHandlePaymentIntentFailed_PaidNotTouched(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusPaid, "pi_test_paid_untouched")
h := NewWebhookHandler("", "")
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
h.handlePaymentIntentFailed(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusPaid {
t.Fatalf("paid order was modified by payment_failed webhook: got %q", reloaded.Status)
}
}
// TestHandlePaymentIntentFailed_MissingMetadataNoOp covers the no-order
// short-circuit so the test file exercises every early-return branch in
// handlePaymentIntentFailed touched by the predicate work.
func TestHandlePaymentIntentFailed_MissingMetadataNoOp(t *testing.T) {
testDB, _ := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
pi := map[string]any{"id": "pi_test_no_meta"}
raw, err := json.Marshal(pi)
if err != nil {
t.Fatalf("marshal pi: %v", err)
}
evt := stripe.Event{
Type: "payment_intent.payment_failed",
Data: &stripe.EventData{Raw: raw},
}
h := NewWebhookHandler("", "")
h.handlePaymentIntentFailed(context.Background(), evt) // must not panic
}
func TestHandlePaymentIntentSucceeded_RequiresActionFlipsToPaid(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_paid")
pi := map[string]any{
"id": order.StripePaymentID,
"amount": order.Total,
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
}
raw, err := json.Marshal(pi)
if err != nil {
t.Fatalf("marshal pi: %v", err)
}
evt := stripe.Event{
Type: "payment_intent.succeeded",
Data: &stripe.EventData{Raw: raw},
}
h := NewWebhookHandler("", "")
h.handlePaymentIntentSucceeded(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusPaid {
t.Fatalf("status: want %q, got %q", models.OrderStatusPaid, reloaded.Status)
}
if reloaded.StripePaymentID != order.StripePaymentID {
t.Fatalf("stripe_payment_id: want %q, got %q", order.StripePaymentID, reloaded.StripePaymentID)
}
}

View File

@@ -19,8 +19,6 @@ import (
"terminalShop/pkg/database" "terminalShop/pkg/database"
"terminalShop/pkg/observability" "terminalShop/pkg/observability"
"terminalShop/pkg/stripeclient" "terminalShop/pkg/stripeclient"
"github.com/stripe/stripe-go/v78"
) )
const version = "v0.1.0" const version = "v0.1.0"
@@ -51,12 +49,6 @@ func main() {
} }
audit.SetDB(db) audit.SetDB(db)
// wire the stripe SDK's package-level key once at startup so concurrent
// reconcilers and the request path don't race on stripe.Key writes.
// NOTE: assumes single-process — if you ever embed both the SSH and API
// binaries together, the last writer wins and one of them silently breaks.
stripe.Key = cfg.StripeSecretKey
// Seed database with initial data // Seed database with initial data
if err := database.Seed(db); err != nil { if err := database.Seed(db); err != nil {
log.Fatalf("Failed to seed database: %v", err) log.Fatalf("Failed to seed database: %v", err)
@@ -79,7 +71,7 @@ func main() {
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute) jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
// Setup routes // Setup routes
router := routes.SetupRoutes(version, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL, cfg.SlackSigningSecret, cfg.AdminAPIKey, cfg.APIPort, cfg.MaxOrderCents) router := routes.SetupRoutes(version, cfg.StripeSecretKey, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL, cfg.SlackSigningSecret, cfg.AdminAPIKey, cfg.APIPort, cfg.MaxOrderCents)
// Create HTTP server // Create HTTP server
server := &http.Server{ server := &http.Server{
@@ -114,7 +106,7 @@ func main() {
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
handlers.ReconcileOrders(ctx) handlers.ReconcileOrders(cfg.StripeSecretKey)
case <-ctx.Done(): case <-ctx.Done():
return return
} }
@@ -122,18 +114,14 @@ func main() {
}() }()
go func() { go func() {
select { time.Sleep(30 * time.Second)
case <-time.After(30 * time.Second): handlers.ReconcileUnshipped()
case <-ctx.Done():
return
}
handlers.ReconcileUnshipped(ctx)
ticker := time.NewTicker(24 * time.Hour) ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
handlers.ReconcileUnshipped(ctx) handlers.ReconcileUnshipped()
case <-ctx.Done(): case <-ctx.Done():
return return
} }
@@ -149,21 +137,7 @@ func main() {
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
handlers.ReconcileExpiredCards(ctx) handlers.ReconcileExpiredCards(cfg.StripeSecretKey)
case <-ctx.Done():
return
}
}
}()
go func() {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
threshold := time.Duration(cfg.Abandoned3DSThresholdMinutes) * time.Minute
for {
select {
case <-ticker.C:
handlers.ReconcileStale3DSOrders(ctx, threshold)
case <-ctx.Done(): case <-ctx.Done():
return return
} }
@@ -178,10 +152,10 @@ func main() {
log.Println("Server is shutting down...") log.Println("Server is shutting down...")
// Graceful shutdown with 30 second timeout // Graceful shutdown with 30 second timeout
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel() defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil { if err := server.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err) log.Fatalf("Server forced to shutdown: %v", err)
} }

View File

@@ -70,14 +70,6 @@ var (
[]string{"outcome"}, []string{"outcome"},
) )
abandoned3DSSweepTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "abandoned_3ds_sweep_total",
Help: "ReconcileStale3DSOrders outcomes per row processed (flipped, flip_noop, flip_failed, succeeded_skip, processing_skip, stripe_error, missing_pi, unrecognized_status)",
},
[]string{"outcome"},
)
orderValueDollars = promauto.NewHistogram( orderValueDollars = promauto.NewHistogram(
prometheus.HistogramOpts{ prometheus.HistogramOpts{
Name: "order_value_dollars", Name: "order_value_dollars",
@@ -181,19 +173,6 @@ func ObserveOrderValueCents(cents int) {
orderValueDollars.Observe(float64(cents) / 100.0) orderValueDollars.Observe(float64(cents) / 100.0)
} }
// RecordAbandoned3DSSweep records the outcome of one row processed by
// ReconcileStale3DSOrders. Bounded enumeration documented on the counter's
// Help string.
func RecordAbandoned3DSSweep(outcome string) {
abandoned3DSSweepTotal.WithLabelValues(outcome).Inc()
}
// Abandoned3DSSweepCounter exposes the sweep counter so tests can snapshot
// per-label values via prometheus testutil. Not used in production code.
func Abandoned3DSSweepCounter() *prometheus.CounterVec {
return abandoned3DSSweepTotal
}
// ObserveStripeRequest records latency of a Stripe API call. // ObserveStripeRequest records latency of a Stripe API call.
func ObserveStripeRequest(op, outcome string, durSec float64) { func ObserveStripeRequest(op, outcome string, durSec float64) {
stripeRequestDuration.WithLabelValues(op, outcome).Observe(durSec) stripeRequestDuration.WithLabelValues(op, outcome).Observe(durSec)

View File

@@ -16,6 +16,7 @@ import (
// SetupRoutes configures all API routes // SetupRoutes configures all API routes
func SetupRoutes( func SetupRoutes(
version string, version string,
stripeSecretKey string,
stripeWebhookSecret string, stripeWebhookSecret string,
jwtManager *auth.JWTManager, jwtManager *auth.JWTManager,
authFingerprintKey string, authFingerprintKey string,
@@ -46,12 +47,12 @@ func SetupRoutes(
healthHandler := handlers.NewHealthHandler(version) healthHandler := handlers.NewHealthHandler(version)
productHandler := handlers.NewProductHandler() productHandler := handlers.NewProductHandler()
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey) authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
cartHandler := handlers.NewCartHandler(appURL, maxOrderCents) cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL, maxOrderCents)
cardHandler := handlers.NewCardHandler(appURL) cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
orderHandler := handlers.NewOrderHandler(bringAPIUID, bringAPIKey, bringCustomerNumber) orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber)
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey) addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
viewHandler := handlers.NewViewHandler() viewHandler := handlers.NewViewHandler()
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, shippoWebhookSecret) webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, stripeSecretKey, shippoWebhookSecret)
slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort, shippoWebhookSecret) slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort, shippoWebhookSecret)
// Short payment redirect and success page — no auth required. // Short payment redirect and success page — no auth required.

1
go.mod
View File

@@ -81,7 +81,6 @@ require (
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect

14
main.go
View File

@@ -15,7 +15,6 @@ import (
"charm.land/wish/v2/bubbletea" "charm.land/wish/v2/bubbletea"
"charm.land/wish/v2/logging" "charm.land/wish/v2/logging"
"github.com/charmbracelet/ssh" "github.com/charmbracelet/ssh"
"github.com/stripe/stripe-go/v78"
"terminalShop/pkg/auth" "terminalShop/pkg/auth"
"terminalShop/pkg/config" "terminalShop/pkg/config"
@@ -32,6 +31,7 @@ func listenHost() string {
var ( var (
apiURL string apiURL string
authFingerprintKey string authFingerprintKey string
stripePublicKey string
) )
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
@@ -43,7 +43,7 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
pubKeyStr = auth.FormatSSHPublicKey(pubKey) pubKeyStr = auth.FormatSSHPublicKey(pubKey)
} }
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey) m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
return m, nil return m, nil
} }
@@ -57,15 +57,7 @@ func main() {
// Set API URL and auth key from config for TUI connections // Set API URL and auth key from config for TUI connections
apiURL = cfg.APIURL apiURL = cfg.APIURL
authFingerprintKey = cfg.AuthFingerprintKey authFingerprintKey = cfg.AuthFingerprintKey
stripePublicKey = cfg.StripePublicKey
// Wire the publishable key into stripe-go's package-level global once.
// TUI uses the legacy Tokens API (stripetoken.New) which accepts the
// publishable key; per-session writes would race across concurrent SSH
// sessions even though they'd all write the same value.
// NOTE: assumes single-process — if you ever embed both the SSH and API
// binaries together, the last writer wins and one of them silently breaks.
stripe.Key = cfg.StripePublicKey
log.Printf("TUI will connect to API at: %s", apiURL) log.Printf("TUI will connect to API at: %s", apiURL)
// Create SSH server // Create SSH server

View File

@@ -104,16 +104,16 @@ func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error)
// APIResponse represents the standard API response format // APIResponse represents the standard API response format
type APIResponse struct { type APIResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Data map[string]any `json:"data"` Data map[string]interface{} `json:"data"`
Error *APIError `json:"error,omitempty"` Error *APIError `json:"error,omitempty"`
} }
// APIError represents an error from the API // APIError represents an error from the API
type APIError struct { type APIError struct {
Code string `json:"code"` Code string `json:"code"`
Message string `json:"message"` Message string `json:"message"`
Details map[string]any `json:"details,omitempty"` Details map[string]interface{} `json:"details,omitempty"`
} }
// ProductsResponse represents the products API response // ProductsResponse represents the products API response
@@ -339,7 +339,7 @@ func (c *Client) GetCart() (*CartData, error) {
func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) { func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
url := fmt.Sprintf("%s/api/v1/cart/item", c.BaseURL) url := fmt.Sprintf("%s/api/v1/cart/item", c.BaseURL)
body := map[string]any{ body := map[string]interface{}{
"coffee_id": coffeeID, "coffee_id": coffeeID,
"quantity": quantity, "quantity": quantity,
} }
@@ -379,7 +379,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
func (c *Client) SetCartAddress(addressID uint) error { func (c *Client) SetCartAddress(addressID uint) error {
url := fmt.Sprintf("%s/api/v1/cart/address", c.BaseURL) url := fmt.Sprintf("%s/api/v1/cart/address", c.BaseURL)
body := map[string]any{ body := map[string]interface{}{
"address_id": addressID, "address_id": addressID,
} }
jsonData, err := json.Marshal(body) jsonData, err := json.Marshal(body)
@@ -418,7 +418,7 @@ func (c *Client) SetCartAddress(addressID uint) error {
func (c *Client) SetCartCard(cardID uint) error { func (c *Client) SetCartCard(cardID uint) error {
url := fmt.Sprintf("%s/api/v1/cart/card", c.BaseURL) url := fmt.Sprintf("%s/api/v1/cart/card", c.BaseURL)
body := map[string]any{ body := map[string]interface{}{
"card_id": cardID, "card_id": cardID,
} }
jsonData, err := json.Marshal(body) jsonData, err := json.Marshal(body)

View File

@@ -14,22 +14,10 @@ import (
) )
// EventOrderRequiresAction is the audit event-type string emitted whenever a // EventOrderRequiresAction is the audit event-type string emitted whenever a
// PaymentIntent is pushed into requires_action. Exposed so cart-flow handler // PaymentIntent is pushed into requires_action. explored so cart-flow handler
// can count retry attempts without hardcoding the string literal. // can count retry attempts without hardcoding the string literal.
const EventOrderRequiresAction = "order_requires_action" const EventOrderRequiresAction = "order_requires_action"
// EventOrder3DSAbandoned is the audit event-type string emitted by the
// abandoned-3DS sweep when it flips a stale requires_action row to failed.
// Distinct from EventOrderRequiresAction (caveat #17) so the retry-cap count
// stays decoupled from sweep activity
const EventOrder3DSAbandoned = "order_3ds_abandoned"
// EventCartRejected is the audit event-type string emitted when the per-order
// spend cap blocks a checkout BEFORE an order row is created. The event lives
// in slog only, persist() no-ops on zero order id, which is the correct shape
// here (cap rejection is upstream of order state). See caveat #13.
const EventCartRejected = "cart_rejected"
type eventType string type eventType string
const ( const (
@@ -41,8 +29,6 @@ const (
eventOrderShipped eventType = "order_shipped" eventOrderShipped eventType = "order_shipped"
eventOrderFailed eventType = "order_failed" eventOrderFailed eventType = "order_failed"
eventOrderRequiresAction eventType = EventOrderRequiresAction eventOrderRequiresAction eventType = EventOrderRequiresAction
eventOrder3DSAbandoned eventType = EventOrder3DSAbandoned
eventCartRejected eventType = EventCartRejected
eventOrderRefunded eventType = "order_refunded" eventOrderRefunded eventType = "order_refunded"
eventPaymentCritical eventType = "payment_critical" eventPaymentCritical eventType = "payment_critical"
eventLabelPurchased eventType = "label_purchased" eventLabelPurchased eventType = "label_purchased"
@@ -53,21 +39,19 @@ const (
) )
type event struct { type event struct {
Event eventType Event eventType
UserID uint UserID uint
OrderID uint OrderID uint
CardID uint CardID uint
Amount int Amount int
StripeID string StripeID string
CardLast4 string CardLast4 string
CardBrand string CardBrand string
Carrier string Carrier string
Tracking string Tracking string
Status string Status string
Error string Error string
Actor string Actor string
TotalCents int
CapCents int
} }
func (e event) attrs() []any { func (e event) attrs() []any {
@@ -108,12 +92,6 @@ func (e event) attrs() []any {
if e.Actor != "" { if e.Actor != "" {
a = append(a, "actor", e.Actor) a = append(a, "actor", e.Actor)
} }
if e.TotalCents != 0 {
a = append(a, "total_cents", e.TotalCents)
}
if e.CapCents != 0 {
a = append(a, "cap_cents", e.CapCents)
}
return a return a
} }
@@ -350,33 +328,3 @@ func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
slog.Info("audit", e.attrs()...) slog.Info("audit", e.attrs()...)
persist(orderID, eventOrderRequiresAction, e) persist(orderID, eventOrderRequiresAction, e)
} }
// Order3DSAbandoned records the abandoned-3DS sweep flipping a stale
// requires_action row to failed because Stripe still reports the underlying
// PaymentIntent as outstanding past the sweep threshold.
func Order3DSAbandoned(userID, orderID uint, paymentIntentID string) {
e := event{
Event: eventOrder3DSAbandoned,
UserID: userID,
OrderID: orderID,
StripeID: paymentIntentID,
}
slog.Info("audit", e.attrs()...)
persist(orderID, eventOrder3DSAbandoned, e)
}
// CartRejected records the per-order cap blocking a checkout BEFORE an
// order row is created. Slog-only by design: cap rejection runs upstream
// of order creation so there is no order id to scope a persistent
// OrderEvent row. persist() is intentionally not called - future refactors
// of the persist() zero-id guard must not silently start writing rows here.
// Counterpart to the validation_over_limit Prometheus counter.
func CartRejected(userID uint, totalCents, capCents int) {
e := event{
Event: eventCartRejected,
UserID: userID,
TotalCents: totalCents,
CapCents: capCents,
}
slog.Info("audit", e.attrs()...)
}

View File

@@ -1,41 +0,0 @@
package audit
import (
"bytes"
"encoding/json"
"log/slog"
"strings"
"testing"
)
func TestCartRejected(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
CartRejected(42, 25000, 20000)
raw := strings.TrimSpace(buf.String())
if raw == "" {
t.Fatalf("CartRejected: no slog output captured")
}
var rec map[string]any
if err := json.Unmarshal([]byte(raw), &rec); err != nil {
t.Fatalf("decode slog record: %v\nraw: %s", err, raw)
}
if got, want := rec["event"], "cart_rejected"; got != want {
t.Errorf("event: want %q, got %v", want, got)
}
if n, _ := rec["user_id"].(float64); int(n) != 42 {
t.Errorf("user_id want 42, got %v", rec["user_id"])
}
if n, _ := rec["total_cents"].(float64); int(n) != 25000 {
t.Errorf("total_cents: want 25000, got %v", rec["total_cents"])
}
if n, _ := rec["cap_cents"].(float64); int(n) != 20000 {
t.Errorf("cap_cents: want 20000, got %v", rec["cap_cents"])
}
}

View File

@@ -53,7 +53,7 @@ func (m *JWTManager) GenerateToken(userID uint, email, username string) (string,
// ValidateToken validates a JWT token and returns the claims // ValidateToken validates a JWT token and returns the claims
func (m *JWTManager) ValidateToken(tokenString string) (*Claims, error) { func (m *JWTManager) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
// Verify signing method // Verify signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])

View File

@@ -3,7 +3,6 @@ package config
import ( import (
"errors" "errors"
"fmt" "fmt"
"log/slog"
"net/url" "net/url"
"os" "os"
"strconv" "strconv"
@@ -14,26 +13,25 @@ import (
// Config holds all application configuration // Config holds all application configuration
type Config struct { type Config struct {
APIPort string APIPort string
APIURL string APIURL string
AppURL string AppURL string
SSHPort string SSHPort string
Environment string Environment string
DatabaseURL string DatabaseURL string
JWTSecret string JWTSecret string
StripeSecretKey string StripeSecretKey string
StripePublicKey string StripePublicKey string
StripeWebhookSecret string StripeWebhookSecret string
ShippoAPIKey string ShippoAPIKey string
AuthFingerprintKey string AuthFingerprintKey string
BringAPIUID string BringAPIUID string
BringAPIKey string BringAPIKey string
BringCustomerNumber string BringCustomerNumber string
ShippoWebhookSecret string ShippoWebhookSecret string
SlackSigningSecret string SlackSigningSecret string
AdminAPIKey string AdminAPIKey string
MaxOrderCents int MaxOrderCents int
Abandoned3DSThresholdMinutes int
} }
// Load reads configuration from environment variables and validates required secrets. // Load reads configuration from environment variables and validates required secrets.
@@ -52,26 +50,25 @@ func Load() (*Config, error) {
apiPort := getEnvOrDefault("API_PORT", "8080") apiPort := getEnvOrDefault("API_PORT", "8080")
apiURL := getEnvOrDefault("API_URL", fmt.Sprintf("http://localhost:%s", apiPort)) apiURL := getEnvOrDefault("API_URL", fmt.Sprintf("http://localhost:%s", apiPort))
cfg := &Config{ cfg := &Config{
APIPort: apiPort, APIPort: apiPort,
APIURL: apiURL, APIURL: apiURL,
AppURL: getEnvOrDefault("APP_URL", apiURL), AppURL: getEnvOrDefault("APP_URL", apiURL),
SSHPort: getEnvOrDefault("SSH_PORT", "23457"), SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
Environment: getEnvOrDefault("ENVIRONMENT", "development"), Environment: getEnvOrDefault("ENVIRONMENT", "development"),
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"), DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
JWTSecret: os.Getenv("JWT_SECRET"), JWTSecret: os.Getenv("JWT_SECRET"),
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"), StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"), ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"), AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
BringAPIUID: os.Getenv("BRING_API_UID"), BringAPIUID: os.Getenv("BRING_API_UID"),
BringAPIKey: os.Getenv("BRING_API_KEY"), BringAPIKey: os.Getenv("BRING_API_KEY"),
BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"), BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"),
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"), ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"), SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
AdminAPIKey: os.Getenv("ADMIN_API_KEY"), AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
MaxOrderCents: loadMaxOrderCents(), MaxOrderCents: loadMaxOrderCents(),
Abandoned3DSThresholdMinutes: loadAbandoned3DSThresholdMinutes(),
} }
if err := cfg.validate(); err != nil { if err := cfg.validate(); err != nil {
@@ -137,13 +134,11 @@ func getEnvOrDefault(key, defaultValue string) string {
return defaultValue return defaultValue
} }
// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $500 — aligns with the // loadMaxOrderCents reads MAX_ORDER_CENTS. Default $200. Explicit 0 disables
// PSD2 TRA exemption ceiling (above this, Stripe always requests SCA, so the // the cap. Garbage or negative values fall back to the default with a warning
// cap is only meaningful below it). Explicit 0 disables the cap. Garbage or // so an operator typo cannot silently disable a fraud control.
// negative values fall back to the default with a warning so an operator
// typo cannot silently disable a fraud control.
func loadMaxOrderCents() int { func loadMaxOrderCents() int {
const defaultCap = 50000 const defaultCap = 20000
raw := os.Getenv("MAX_ORDER_CENTS") raw := os.Getenv("MAX_ORDER_CENTS")
if raw == "" { if raw == "" {
return defaultCap return defaultCap
@@ -155,26 +150,3 @@ func loadMaxOrderCents() int {
} }
return n return n
} }
// loadAbandoned3DSThresholdMinutes read ABANDONED_3DS_THRESHOLD_MINUTES.
// The abandoned-3DS sweep only flips requires_action rows whose updated_at iconfigs
// older than this many minutes. Default 30 min. Values below 1 fall back to
// the default so a typo cannot collapse the grace window the TUI polls inside.
func loadAbandoned3DSThresholdMinutes() int {
const defaultMinutes = 30
raw := os.Getenv("ABANDONED_3DS_THRESHOLD_MINUTES")
if raw == "" {
return defaultMinutes
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
slog.Warn("invalid ABANDONED_3DS_THRESHOLD_MINUTES, falling back",
"raw", raw, "default", defaultMinutes)
return defaultMinutes
}
if n < 10 {
slog.Warn("ABANDONED_3DS_THRESHOLD_MINUTES below 10 may race in-flight 3ds challenges",
"value", n)
}
return n
}

View File

@@ -1,35 +1,21 @@
package config package config
import ( import (
"io"
"log/slog"
"os"
"testing" "testing"
) )
// TestMain silences the package's slog warnings so the negative/garbage
// env-var cases plus the sub-10 valid-minimum case don't spam test output.
// The actual fall-back behavior is still asserted via the case tables.
func TestMain(m *testing.M) {
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
code := m.Run()
slog.SetDefault(prev)
os.Exit(code)
}
func TestLoadMaxOrderCents(t *testing.T) { func TestLoadMaxOrderCents(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
env string // "" means unset env string // "" means unset
want int want int
}{ }{
{"empty falls back to default", "", 50000}, {"unset uses default", "", 20000},
{"explicit zero disables", "0", 0}, {"explicit zero disables", "0", 0},
{"valid override", "30000", 30000}, {"valid override", "50000", 50000},
{"negative falls back to default", "-1", 50000}, {"negative falls back to default", "-1", 20000},
{"garbage falls back to default", "two hundred", 50000}, {"garbage falls back to default", "two hundred", 20000},
{"empty after trim falls back to default", "", 50000}, {"empty after trim falls back to default", "", 20000},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -46,33 +32,3 @@ func TestLoadMaxOrderCents(t *testing.T) {
}) })
} }
} }
// TestLoadAbandoned3DSThresholdMinutes covers the env-parsing surface for
// the sweep threshold. Zero and negative fall back to the default (unlike
// MaxOrderCents where zero is a deliberate off-switch) because a zero
// threshold would sweep every requires_action row on the first tick and
// race the customer's in-progress challenge.
func TestLoadAbandoned3DSThresholdMinutes(t *testing.T) {
cases := []struct {
name string
env string // "" means unset
want int
}{
{"empty falls back to default", "", 30},
{"valid override", "45", 45},
{"zero falls back to default", "0", 30},
{"negative falls back to default", "-5", 30},
{"garbage falls back to default", "half an hour", 30},
{"valid minimum (warns)", "1", 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("ABANDONED_3DS_THRESHOLD_MINUTES", tc.env)
got := loadAbandoned3DSThresholdMinutes()
if got != tc.want {
t.Errorf("loadAbandoned3DSThresholdMinutes() = %d, want %d", got, tc.want)
}
})
}
}

View File

@@ -10,14 +10,13 @@ import (
type OrderStatus string type OrderStatus string
const ( const (
OrderStatusPending OrderStatus = "pending" OrderStatusPending OrderStatus = "pending"
OrderStatusRequiresAction OrderStatus = "requires_action" OrderStatusPaid OrderStatus = "paid"
OrderStatusPaid OrderStatus = "paid" OrderStatusShipped OrderStatus = "shipped"
OrderStatusShipped OrderStatus = "shipped" OrderStatusDelivered OrderStatus = "delivered"
OrderStatusDelivered OrderStatus = "delivered" OrderStatusCancelled OrderStatus = "cancelled"
OrderStatusCancelled OrderStatus = "cancelled" OrderStatusRefunded OrderStatus = "refunded"
OrderStatusRefunded OrderStatus = "refunded" OrderStatusFailed OrderStatus = "failed"
OrderStatusFailed OrderStatus = "failed"
) )
type TrackingStatus string type TrackingStatus string

View File

@@ -35,8 +35,6 @@ func (order *Order) DisplayState() string {
switch order.Status { switch order.Status {
case OrderStatusPending: case OrderStatusPending:
return "Awaiting payment" return "Awaiting payment"
case OrderStatusRequiresAction:
return "Awaiting authentication"
case OrderStatusPaid: case OrderStatusPaid:
if order.TrackingNumber == "" { if order.TrackingNumber == "" {
return "Paid * awaiting label" return "Paid * awaiting label"
@@ -79,8 +77,6 @@ func (order *Order) DisplayKind() DisplayKind {
switch order.Status { switch order.Status {
case OrderStatusPending: case OrderStatusPending:
return DisplayKindNeutral return DisplayKindNeutral
case OrderStatusRequiresAction:
return DisplayKindNeutral
case OrderStatusPaid: case OrderStatusPaid:
return DisplayKindAccent return DisplayKindAccent
case OrderStatusShipped: case OrderStatusShipped:

View File

@@ -14,7 +14,6 @@ func TestOrderDisplayState(t *testing.T) {
kind DisplayKind kind DisplayKind
}{ }{
{"pending", Order{Status: OrderStatusPending}, "Awaiting payment", DisplayKindNeutral}, {"pending", Order{Status: OrderStatusPending}, "Awaiting payment", DisplayKindNeutral},
{"requires_action", Order{Status: OrderStatusRequiresAction}, "Awaiting authentication", DisplayKindNeutral},
{"paid no tracking", Order{Status: OrderStatusPaid}, "Paid * awaiting label", DisplayKindAccent}, {"paid no tracking", Order{Status: OrderStatusPaid}, "Paid * awaiting label", DisplayKindAccent},
{"paid with tracking, no event", Order{Status: OrderStatusPaid, TrackingNumber: "12999"}, "Paid", DisplayKindAccent}, {"paid with tracking, no event", Order{Status: OrderStatusPaid, TrackingNumber: "12999"}, "Paid", DisplayKindAccent},
{"Paid pre-transit", Order{Status: OrderStatusPaid, TrackingNumber: "12999", TrackingStatus: TrackingStatusPreTransit}, "Paid * ready to ship", DisplayKindAccent}, {"Paid pre-transit", Order{Status: OrderStatusPaid, TrackingNumber: "12999", TrackingStatus: TrackingStatusPreTransit}, "Paid * ready to ship", DisplayKindAccent},

View File

@@ -75,6 +75,7 @@ type Model struct {
Fingerprint string Fingerprint string
AccessToken string AccessToken string
AuthFingerprintKey string // shared secret for /auth/token refresh AuthFingerprintKey string // shared secret for /auth/token refresh
StripePublicKey string
// One-shop auth command - capture pubKeyStr in closure, not stored on Model // One-shop auth command - capture pubKeyStr in closure, not stored on Model
authCmd tea.Cmd authCmd tea.Cmd
@@ -735,6 +736,7 @@ func (m Model) saveCardOnlyCmd(form PaymentFormCompleteMsg) tea.Cmd {
if m.APIClient == nil { if m.APIClient == nil {
return CardSavedForReviewMsg{Err: fmt.Errorf("API client not available")} return CardSavedForReviewMsg{Err: fmt.Errorf("API client not available")}
} }
stripe.Key = m.StripePublicKey
tok, err := stripetoken.New(&stripe.TokenParams{ tok, err := stripetoken.New(&stripe.TokenParams{
Card: &stripe.CardParams{ Card: &stripe.CardParams{
Name: stripe.String(form.CardName), Name: stripe.String(form.CardName),
@@ -903,12 +905,11 @@ func NewModel(username string) Model {
} }
// NewModelWithAuth creates a new model with user authentication context. // NewModelWithAuth creates a new model with user authentication context.
// The publishable Stripe key is wired into stripe.Key once at startup in func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
// the SSH binary's main, so it is not threaded through here.
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string) Model {
m := NewModel("") m := NewModel("")
m.Fingerprint = fingerprint m.Fingerprint = fingerprint
m.AuthFingerprintKey = clientSecret m.AuthFingerprintKey = clientSecret
m.StripePublicKey = stripePublicKey
m.currentPage = splashPage m.currentPage = splashPage
if apiURL != "" { if apiURL != "" {
m.APIClient = api.NewClient(apiURL, "") m.APIClient = api.NewClient(apiURL, "")

View File

@@ -355,16 +355,11 @@ func (m Model) RenderCardList() string {
boxes = append(boxes, box) boxes = append(boxes, box)
} }
// SSH inline card-add is dormant until i have a norwegian org sshFocused := m.payment.cardCursor == len(m.SavedCards)
// number wired to stripe, something i dont have since its $300+. Keep the row visible so the option is sshBox := m.CreateBox(m.formatListItem("+ add card via ssh", sshFocused), sshFocused)
// discoverable, but render it dimmed and exclude it from cursor space
// (handled in PaymentUpdate below) so it can not be picked
dim := m.theme.TextDim().Render
sshLabel := dim("+ add card via ssh") + " " + dim("(WIP)")
sshBox := m.CreateBox(m.formatListItem(sshLabel, false), false)
boxes = append(boxes, sshBox) boxes = append(boxes, sshBox)
httpsFocused := m.payment.cardCursor == len(m.SavedCards) httpsFocused := m.payment.cardCursor == len(m.SavedCards)+1
httpsBox := m.CreateBox(m.formatListItem("+ add payment via browser", httpsFocused), httpsFocused) httpsBox := m.CreateBox(m.formatListItem("+ add payment via browser", httpsFocused), httpsFocused)
boxes = append(boxes, httpsBox) boxes = append(boxes, httpsBox)
@@ -506,7 +501,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.payment.cardCursor-- m.payment.cardCursor--
} }
case "down", "j": case "down", "j":
if m.payment.cardCursor < len(m.SavedCards) { if m.payment.cardCursor < len(m.SavedCards)+1 {
m.payment.cardCursor++ m.payment.cardCursor++
} }
@@ -516,8 +511,17 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.SelectedCard = &selected m.SelectedCard = &selected
return m.ReviewSwitch() return m.ReviewSwitch()
} }
// Browser flow, the only reachable add-card path while the SSH if m.payment.cardCursor == len(m.SavedCards) {
// inline form is dormant (see RenderCardList) m.payment.view = 1
m.payment.form = m.InitPaymentForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, m.payment.form.form.Init()
}
// Browser payment
m.payment.view = 2 m.payment.view = 2
m.payment.collectURL = nil m.payment.collectURL = nil
m.payment.collectTimeOut = false m.payment.collectTimeOut = false
@@ -542,6 +546,16 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 { if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 {
m.payment.cardCursor-- m.payment.cardCursor--
} }
if len(m.SavedCards) == 0 {
m.payment.view = 1
m.payment.form = m.InitPaymentForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, tea.Batch(m.payment.form.form.Init(), m.deleteCardCmd(card.ID))
}
return m, m.deleteCardCmd(card.ID) return m, m.deleteCardCmd(card.ID)
} }
} }

View File

@@ -1,91 +0,0 @@
package tui
import (
"strings"
"testing"
"terminalShop/pkg/models"
tea "charm.land/bubbletea/v2"
)
// newPaymentCardListModel parks a Model on the payment page card-list view
// with n synthetic saved cards. Baseline for assertint that the dormant
// "+ add card via ssh" row is dimmed and unreachable by cursor navigation.
func newPaymentCardListModel(t *testing.T, n int) Model {
t.Helper()
m := NewModel("test")
m = m.updateLayout(120, 40)
m.currentPage = paymentPage
m.payment.view = 0
m.payment.form = nil
m.payment.cardCursor = 0
for i := range n {
m.SavedCards = append(m.SavedCards, models.Card{
ID: uint(i + 1),
Brand: "visa",
Last4: "4242",
ExpMonth: 12,
ExpYear: 2030,
})
}
return m
}
func keyEnter() tea.KeyMsg { return tea.KeyPressMsg{Code: tea.KeyEnter} }
func TestRenderCardList_SshOptionComingSoon(t *testing.T) {
m := newPaymentCardListModel(t, 1)
out := m.RenderCardList()
if !strings.Contains(out, "add card via ssh") {
t.Fatalf("ssh option missing from card list output:\n%s", out)
}
if !strings.Contains(out, "WIP") {
t.Fatalf("want 'WIP' marked on ssh option:\n%s", out)
}
}
func TestPaymentUpdate_DownNavigationStopsAtBrowserSlot(t *testing.T) {
m := newPaymentCardListModel(t, 2)
for range 10 {
var cmd tea.Cmd
m, cmd = m.PaymentUpdate(keyJ())
_ = cmd
}
want := len(m.SavedCards) // browser slot; ssh slot in unreachable
if m.payment.cardCursor != want {
t.Fatalf("cursor stopped at %d, want %d (browser slot)", m.payment.cardCursor, want)
}
}
func TestPaymentUpdate_EnterOnBrowserSlotLaunchesBrowserFlow(t *testing.T) {
m := newPaymentCardListModel(t, 1)
m.payment.cardCursor = len(m.SavedCards) // browser slot
newM, cmd := m.PaymentUpdate(keyEnter())
if newM.payment.view != 2 {
t.Fatalf("payment.view = %d, want 2 (browser flow)", newM.payment.view)
}
if newM.payment.form != nil {
t.Fatalf("payment.form must be nil after enter; ssh inline form is dormant")
}
if cmd == nil {
t.Fatalf("expected collectCardCmd, got nil")
}
}
func TestPaymentUpdate_EnterWithZeroCardsLaunchesBrowserFlow(t *testing.T) {
m := newPaymentCardListModel(t, 0)
if m.payment.cardCursor != 0 {
t.Fatalf("default cursor = %d, want 0 ", m.payment.cardCursor)
}
newM, cmd := m.PaymentUpdate(keyEnter())
if newM.payment.view != 2 {
t.Fatalf("payment.view = %d, want 2 (browser flow)", newM.payment.view)
}
if newM.payment.form != nil {
t.Fatalf("payment.form must be nil; ssh inline form is dormant")
}
if cmd == nil {
t.Fatalf("expected collectCardCmd, got nil")
}
}

View File

@@ -8,8 +8,8 @@ import (
// SuccessResponse represents a successful API response // SuccessResponse represents a successful API response
type SuccessResponse struct { type SuccessResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Data any `json:"data"` Data interface{} `json:"data"`
} }
// ErrorResponse represents an error API response // ErrorResponse represents an error API response
@@ -20,13 +20,13 @@ type ErrorResponse struct {
// Error contains error details // Error contains error details
type Error struct { type Error struct {
Code string `json:"code"` Code string `json:"code"`
Message string `json:"message"` Message string `json:"message"`
Details map[string]any `json:"details,omitempty"` Details map[string]interface{} `json:"details,omitempty"`
} }
// RespondSuccess sends a successful JSON response // RespondSuccess sends a successful JSON response
func RespondSuccess(w http.ResponseWriter, statusCode int, data any) { func RespondSuccess(w http.ResponseWriter, statusCode int, data interface{}) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode) w.WriteHeader(statusCode)
@@ -39,7 +39,7 @@ func RespondSuccess(w http.ResponseWriter, statusCode int, data any) {
} }
// RespondError sends an error JSON response // RespondError sends an error JSON response
func RespondError(w http.ResponseWriter, statusCode int, code, message string, details map[string]any) { func RespondError(w http.ResponseWriter, statusCode int, code, message string, details map[string]interface{}) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode) w.WriteHeader(statusCode)