Compare commits

..

1 Commits

Author SHA1 Message Date
b5ad61eca6 chore/any-pragmatism (#1)
Some checks failed
Deploy / deploy (push) Has been cancelled
Reviewed-on: http://gitea/marcusDenslow/terminalShop/pulls/1
2026-06-06 13:02:02 +00:00
26 changed files with 207 additions and 1548 deletions

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 {
stripeKey string
appURL 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.
@@ -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
} }
@@ -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
@@ -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)
@@ -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{})
} }
@@ -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.
@@ -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
} }
@@ -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,7 +384,6 @@ 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]any{
@@ -538,10 +539,9 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
}) })
} }
// 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()
@@ -109,7 +107,7 @@ 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]any{
@@ -165,7 +163,7 @@ 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]any{"coffee_id": 1, "quantity": 2})
@@ -210,7 +208,7 @@ 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]any{"coffee_id": 1, "quantity": 3})
@@ -249,7 +247,7 @@ 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]any{"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)
@@ -266,7 +264,7 @@ 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]any{"quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
@@ -283,7 +281,7 @@ 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} {
@@ -339,7 +337,7 @@ 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]any{"address_id": addr.ID})
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
@@ -393,7 +391,7 @@ 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]any{"address_id": addr.ID})
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
@@ -421,7 +419,7 @@ 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]any{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
@@ -473,7 +471,7 @@ 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]any{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
@@ -490,7 +488,7 @@ 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]any{"coffee_id": 1, "quantity": 1})
@@ -513,7 +511,7 @@ 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
@@ -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,7 +559,7 @@ 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++ {
@@ -610,7 +608,7 @@ 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)
@@ -719,7 +717,7 @@ 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]any{"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)
@@ -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()
@@ -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,7 +794,7 @@ 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]any{"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()
@@ -871,7 +835,7 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1}) body, _ := json.Marshal(map[string]any{"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",
@@ -881,7 +845,7 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
body, _ = json.Marshal(map[string]any{"address_id": addr.ID}) body, _ = json.Marshal(map[string]any{"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),
}) })

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

@@ -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,13 +36,8 @@ 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),
}, },
} }
@@ -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

@@ -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.
@@ -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

@@ -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"
@@ -66,8 +52,6 @@ type event struct {
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

@@ -3,7 +3,6 @@ package config
import ( import (
"errors" "errors"
"fmt" "fmt"
"log/slog"
"net/url" "net/url"
"os" "os"
"strconv" "strconv"
@@ -33,7 +32,6 @@ type Config struct {
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.
@@ -71,7 +69,6 @@ func Load() (*Config, error) {
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

@@ -11,7 +11,6 @@ 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"

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")
}
}