Compare commits
1 Commits
feat/sca-a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b5ad61eca6 |
@@ -46,7 +46,7 @@ func TestGetCards_FiltersExpiredRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
handler := NewCardHandler("")
|
||||
handler := NewCardHandler("", "")
|
||||
req := authRequest("GET", "/api/v1/cards", nil, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.GetCards(w, req)
|
||||
@@ -91,7 +91,7 @@ func TestSetDefaultCard_ExpiredReturns410(t *testing.T) {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
|
||||
handler := NewCardHandler("")
|
||||
handler := NewCardHandler("", "")
|
||||
idStr := strconv.FormatUint(uint64(card.ID), 10)
|
||||
req := authRequest("PUT", "/api/v1/cards/"+idStr+"/default", nil, user.ID)
|
||||
routeCtx := chi.NewRouteContext()
|
||||
|
||||
@@ -67,13 +67,13 @@ func PayRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// CardHandler handles saved payment method CRUD.
|
||||
type CardHandler struct {
|
||||
appURL string
|
||||
stripeKey string
|
||||
appURL string
|
||||
}
|
||||
|
||||
// NewCardHandler creates a new card handler. Stripe credentials are wired
|
||||
// once at startup via stripe.Key in api/main.go.
|
||||
func NewCardHandler(appURL string) *CardHandler {
|
||||
return &CardHandler{appURL: appURL}
|
||||
// NewCardHandler creates a new card handler with the Stripe secret key.
|
||||
func NewCardHandler(stripeSecretKey string, appURL string) *CardHandler {
|
||||
return &CardHandler{stripeKey: stripeSecretKey, appURL: appURL}
|
||||
}
|
||||
|
||||
// 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 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)
|
||||
return
|
||||
}
|
||||
@@ -171,6 +171,8 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
if err := ensureStripeCustomer(db, &user); err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
|
||||
return
|
||||
@@ -232,6 +234,8 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
// Ensure the user has a Stripe customer, creating one if needed.
|
||||
if err := ensureStripeCustomer(db, &user); err != 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).
|
||||
if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" {
|
||||
stripe.Key = h.stripeKey
|
||||
_, _ = 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.
|
||||
// Local state (cart references + card row) is committed atomically;
|
||||
// Stripe detach and audit run only after the local commit so retries
|
||||
// cannot double-fire the audit event. Stripe detach is gated on
|
||||
// stripe.Key being non-empty so tests (which never init the SDK key)
|
||||
// skip the real network call without an explicit flag.
|
||||
func expireStoredCard(db *gorm.DB, card *models.Card) error {
|
||||
// cannot double-fire the audit event.
|
||||
func expireStoredCard(db *gorm.DB, card *models.Card, stripeKey string) 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 {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
slog.Warn("failed to detach expired card", "card_id", card.ID, "error", err)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestReconcileExpiredCards_RemovesPastDeadlineOnly(t *testing.T) {
|
||||
t.Fatalf("seed cart: %v", err)
|
||||
}
|
||||
|
||||
ReconcileExpiredCards()
|
||||
ReconcileExpiredCards("")
|
||||
|
||||
var remaining []models.Card
|
||||
if err := db.Where("user_id = ?", user.ID).Find(&remaining).Error; err != nil {
|
||||
@@ -117,7 +117,7 @@ func TestReconcileExpiredCards_SweepsAcrossUsers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
ReconcileExpiredCards()
|
||||
ReconcileExpiredCards("")
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&models.Card{}).Count(&count).Error; err != nil {
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
)
|
||||
|
||||
type CartHandler struct {
|
||||
stripeKey string
|
||||
appURL string
|
||||
maxOrderCents int
|
||||
}
|
||||
@@ -35,10 +36,9 @@ type CartHandler struct {
|
||||
// audit rows (audit.EventOrderRequiresAction) so the cap stays migration-free.
|
||||
const maxAuthIssuances = 4
|
||||
|
||||
// NewCartHandler creates a new cart handler. Stripe credentials are wired
|
||||
// once at startup via stripe.Key in api/main.go.
|
||||
func NewCartHandler(appURL string, maxOrderCents int) *CartHandler {
|
||||
return &CartHandler{appURL: appURL, maxOrderCents: maxOrderCents}
|
||||
// NewCartHandler creates a new cart handler with the Stripe secret key
|
||||
func NewCartHandler(stripeSecretKey, appURL string, maxOrderCents int) *CartHandler {
|
||||
return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL, maxOrderCents: maxOrderCents}
|
||||
}
|
||||
|
||||
// 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 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)
|
||||
return
|
||||
}
|
||||
@@ -315,7 +315,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if card.IsStorageExpired(time.Now()) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -330,6 +330,8 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
// Ensure the user has a Stripe customer (shared helper with cards handler).
|
||||
if err := ensureStripeCustomer(db, &user); err != nil {
|
||||
middleware.RecordCartConversion("stripe_customer_failed")
|
||||
@@ -537,10 +539,9 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// respondRequiresAction handles a PaymentIntent that needs 3DS auth. The order
|
||||
// transitions to requires_action (see caveat #20 in sca-psd2-compliance.md);
|
||||
// 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.
|
||||
// respondRequiresAction handles a PaymentIntent that needs 3DS auth
|
||||
// The order stays pending. the webhook flips it to paid once the customer
|
||||
// completes the bank-hosted challenge, the tui polls /api/v1/orders/{id}/status
|
||||
func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, paymentMethodID string, pi *stripe.PaymentIntent) {
|
||||
db := database.GetDB()
|
||||
|
||||
@@ -567,15 +568,13 @@ func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models
|
||||
return
|
||||
}
|
||||
|
||||
// Persist the paymentIntent id AND set status to requires_action so list
|
||||
// UIs can distinguish "charging" from "awaiting customer 3DS". The
|
||||
// payment_intent.payment_failed webhook from the initial off-session
|
||||
// decline often arrives before this response and flips the order to
|
||||
// failed - override it back so the 3DS-in-flight order shows the
|
||||
// correct state
|
||||
// Persist the paymentIntent id AND reset status to pending. The
|
||||
// payment_intent.payment_failed webhook from the initial off-session decline
|
||||
// often arrives before this response and flips the order to failed —
|
||||
// override it back so the 3DS-in-flight order shows the correct state.
|
||||
if err := db.Model(order).Updates(map[string]any{
|
||||
"stripe_payment_id": pi.ID,
|
||||
"status": models.OrderStatusRequiresAction,
|
||||
"status": models.OrderStatusPending,
|
||||
}).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil)
|
||||
return
|
||||
@@ -659,14 +658,10 @@ func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Retryable source states:
|
||||
// pending - initial attempt mid-flight (pre-PI confirm page)
|
||||
// requires_action - customer abandoned or failed the 3DS challenge
|
||||
// failed - webhook flipped after a non-recoverable error
|
||||
// terminal states (paid, shipped, delivered, cancelled, refunded) refuse.
|
||||
if order.Status != models.OrderStatusPending &&
|
||||
order.Status != models.OrderStatusRequiresAction &&
|
||||
order.Status != models.OrderStatusFailed {
|
||||
// Only pending (initial attempt mid-flight) and failed (webhook flipped it
|
||||
// after a non-recoverable error) are retryable. paid, refundedn shipped,
|
||||
// delivered, cancelled are terminal - refuse
|
||||
if order.Status != models.OrderStatusPending && order.Status != models.OrderStatusFailed {
|
||||
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "order is already finalized", nil)
|
||||
return
|
||||
}
|
||||
@@ -704,7 +699,7 @@ func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -712,6 +707,8 @@ func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
stripeStart := time.Now()
|
||||
confirmed, cerr := paymentintent.Confirm(order.StripePaymentID, &stripe.PaymentIntentConfirmParams{
|
||||
PaymentMethod: stripe.String(card.StripePaymentID),
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/audit"
|
||||
@@ -69,7 +68,7 @@ func TestGetCartEmpty(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", 0)
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
req := authRequest("GET", "/api/v1/cart", nil, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -108,7 +107,7 @@ func TestSetItemAndGetCart(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", 0)
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
// Add item (CoffeeID=1, Quantity=2)
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
@@ -164,7 +163,7 @@ func TestSetItemUpdateQuantity(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", 0)
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
// Add item
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 2})
|
||||
@@ -209,7 +208,7 @@ func TestSetItemRemove(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", 0)
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
// Add item
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 3})
|
||||
@@ -248,7 +247,7 @@ func TestSetItemInvalidProduct(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", 0)
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 999, "quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
@@ -265,7 +264,7 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", 0)
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
@@ -282,7 +281,7 @@ func TestClearCart(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", 0)
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
// Add two items
|
||||
for _, coffeeID := range []uint{1, 2} {
|
||||
@@ -338,7 +337,7 @@ func TestSetAddress(t *testing.T) {
|
||||
}
|
||||
db.Create(&addr)
|
||||
|
||||
handler := NewCartHandler("", 0)
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"address_id": addr.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
@@ -392,7 +391,7 @@ func TestSetAddressWrongUser(t *testing.T) {
|
||||
}
|
||||
db.Create(&addr)
|
||||
|
||||
handler := NewCartHandler("", 0)
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"address_id": addr.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
@@ -420,7 +419,7 @@ func TestSetCard(t *testing.T) {
|
||||
}
|
||||
db.Create(&card)
|
||||
|
||||
handler := NewCartHandler("", 0)
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||
@@ -472,7 +471,7 @@ func TestSetCardWrongUser(t *testing.T) {
|
||||
}
|
||||
db.Create(&card)
|
||||
|
||||
handler := NewCartHandler("", 0)
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||
@@ -489,7 +488,7 @@ func TestConvertCartMissingAddress(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", 0)
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
// Add item but no address or card
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
|
||||
@@ -512,7 +511,7 @@ func TestConvertCartMissingCard(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", 0)
|
||||
handler := NewCartHandler("", "", 0)
|
||||
db := database.GetDB()
|
||||
|
||||
// Add item and address
|
||||
@@ -543,7 +542,7 @@ func TestConvertCartEmpty(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", 0)
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
// Empty cart convert
|
||||
req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
|
||||
@@ -560,7 +559,7 @@ func TestMultipleItems(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", 0)
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
// Add three different items
|
||||
for i := uint(1); i <= 3; i++ {
|
||||
@@ -609,7 +608,7 @@ func TestCartIsolation(t *testing.T) {
|
||||
}
|
||||
db.Create(&user2)
|
||||
|
||||
handler := NewCartHandler("", 0)
|
||||
handler := NewCartHandler("", "", 0)
|
||||
r := chi.NewRouter()
|
||||
r.Put("/cart/item", handler.SetItem)
|
||||
r.Get("/cart", handler.GetCart)
|
||||
@@ -718,7 +717,7 @@ func TestConvertCartLimit(t *testing.T) {
|
||||
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})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
@@ -795,7 +794,7 @@ func TestSetCard_ExpiredReturns410(t *testing.T) {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
|
||||
handler := NewCartHandler("", 0)
|
||||
handler := NewCartHandler("", "", 0)
|
||||
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -836,7 +835,7 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/vart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
NewCartHandler("", 0).SetItem(w, req)
|
||||
NewCartHandler("", "", 0).SetItem(w, req)
|
||||
|
||||
addr := models.Address{
|
||||
UserID: user.ID, Name: "Test", Street: "123 St",
|
||||
@@ -846,7 +845,7 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
|
||||
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
|
||||
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
NewCartHandler("", 0).SetAddress(w, req)
|
||||
NewCartHandler("", "", 0).SetAddress(w, req)
|
||||
|
||||
past := time.Now().Add(-1 * time.Hour)
|
||||
card := models.Card{
|
||||
@@ -862,7 +861,7 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
|
||||
|
||||
req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
NewCartHandler("", 0).ConvertCart(w, req)
|
||||
NewCartHandler("", "", 0).ConvertCart(w, req)
|
||||
|
||||
if w.Code != http.StatusGone {
|
||||
t.Fatalf("want 410, got %d, body: %s", w.Code, w.Body.String())
|
||||
@@ -884,9 +883,8 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
|
||||
// succeeded) hit the live Stripe API and aren't covered here — see
|
||||
// sca-psd2-compliance.md "Test plan" for manual coverage with the
|
||||
// 4000 0027 6000 3184 test card. Caveat #14 in that doc still applies:
|
||||
// NewCartHandler takes 2 args (appURL, maxOrderCents) and the seeded user
|
||||
// must have a non-empty StripeCustomerID so the Convert/Retry paths stay
|
||||
// hermetic.
|
||||
// NewCartHandler takes 3 args and the seeded user must have a non-empty
|
||||
// StripeCustomerID so the Convert/Retry paths stay hermetic.
|
||||
func TestRetryAuth(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -921,12 +919,6 @@ func TestRetryAuth(t *testing.T) {
|
||||
seedPayment: "pi_test_refunded", seedEvents: 1,
|
||||
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",
|
||||
ownedByUser: true, seedStatus: models.OrderStatusFailed,
|
||||
@@ -993,7 +985,7 @@ func TestRetryAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
handler := NewCartHandler("http://localhost", 0)
|
||||
handler := NewCartHandler("", "http://localhost", 0)
|
||||
req := orderRequest("POST",
|
||||
fmt.Sprintf("/api/v1/orders/%d/retry-auth", order.ID), nil, user.ID, fmt.Sprintf("%d", order.ID))
|
||||
w := httptest.NewRecorder()
|
||||
@@ -1021,7 +1013,7 @@ func TestRetryAuth_OrderNotFound(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
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")
|
||||
w := httptest.NewRecorder()
|
||||
handler.RetryAuth(w, req)
|
||||
@@ -1030,137 +1022,3 @@ func TestRetryAuth_OrderNotFound(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
)
|
||||
|
||||
type OrderHandler struct {
|
||||
stripeKey string
|
||||
bringAPIUID string
|
||||
bringAPIKey string
|
||||
bringCustomerNumber string
|
||||
@@ -43,10 +44,9 @@ type refundRequestPayload struct {
|
||||
// repeated submissions from spamming the operator's Slack channel.
|
||||
const refundRequestCooldown = 15 * time.Minute
|
||||
|
||||
// NewOrderHandler creates an order handler. Stripe credentials are wired
|
||||
// once at startup via stripe.Key in api/main.go.
|
||||
func NewOrderHandler(bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
|
||||
func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
|
||||
return &OrderHandler{
|
||||
stripeKey: stripeSecretKey,
|
||||
bringAPIUID: bringAPIUID,
|
||||
bringAPIKey: bringAPIKey,
|
||||
bringCustomerNumber: bringCustomerNumber,
|
||||
@@ -200,6 +200,8 @@ func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
rf, err := refund.New(&stripe.RefundParams{
|
||||
PaymentIntent: stripe.String(order.StripePaymentID),
|
||||
})
|
||||
|
||||
@@ -60,7 +60,7 @@ func contextWithRoute(ctx context.Context, routeCtx *chi.Context) context.Contex
|
||||
func TestCreateRefundRequestRequiresMessageForOther(t *testing.T) {
|
||||
user := setupOrderTestDB(t)
|
||||
|
||||
handler := NewOrderHandler("", "", "")
|
||||
handler := NewOrderHandler("", "", "", "")
|
||||
body := []byte(`{"reason":"Other","message":" "}`)
|
||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, "1")
|
||||
w := httptest.NewRecorder()
|
||||
@@ -100,7 +100,7 @@ func TestCreateRefundRequestRejectsInvalidOrderState(t *testing.T) {
|
||||
t.Fatalf("failed to create order: %v", err)
|
||||
}
|
||||
|
||||
handler := NewOrderHandler("", "", "")
|
||||
handler := NewOrderHandler("", "", "", "")
|
||||
body := []byte(`{"reason":"Quality issue","message":""}`)
|
||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
|
||||
w := httptest.NewRecorder()
|
||||
@@ -142,7 +142,7 @@ func TestCreateRefundRequestEnforcesCooldown(t *testing.T) {
|
||||
t.Fatalf("failed to create order: %v", err)
|
||||
}
|
||||
|
||||
handler := NewOrderHandler("", "", "")
|
||||
handler := NewOrderHandler("", "", "", "")
|
||||
body := []byte(`{"reason":"Quality issue","message":""}`)
|
||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/notify"
|
||||
@@ -15,18 +14,14 @@ import (
|
||||
"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") }
|
||||
|
||||
// ReconcileOrders finds pending orders with no Stripe ID older than 10 minutes
|
||||
// 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
|
||||
// completed.
|
||||
func ReconcileOrders() {
|
||||
func ReconcileOrders(stripeKey string) {
|
||||
stripe.Key = stripeKey
|
||||
db := database.GetDB()
|
||||
|
||||
var orders []models.Order
|
||||
@@ -82,103 +77,11 @@ func ReconcileOrders() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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(threshold time.Duration) {
|
||||
db := database.GetDB()
|
||||
|
||||
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))
|
||||
|
||||
for i := range orders {
|
||||
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)
|
||||
continue
|
||||
}
|
||||
actual, err := paymentIntentGetFn(order.StripePaymentID, nil)
|
||||
if err != nil {
|
||||
reconcileLog().Error("stripe get failed",
|
||||
"order_id", order.ID, "pi", order.StripePaymentID, "error", err)
|
||||
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)
|
||||
case stripe.PaymentIntentStatusProcessing,
|
||||
stripe.PaymentIntentStatusRequiresCapture:
|
||||
reconcileLog().Info("payment intent still transitioning; skipping",
|
||||
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", string(actual.Status))
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
reconcileLog().Info("stale 3ds row already transitioned; sweep no-op",
|
||||
"order_id", order.ID)
|
||||
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)
|
||||
}
|
||||
|
||||
// ReconcileExpiredCards removes saved cards whose retention deadline elapsed.
|
||||
// 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
|
||||
// handlers never block on Stripe API calls during a card sweep.
|
||||
func ReconcileExpiredCards() {
|
||||
func ReconcileExpiredCards(stripeKey string) {
|
||||
db := database.GetDB()
|
||||
|
||||
var cards []models.Card
|
||||
@@ -195,7 +98,7 @@ func ReconcileExpiredCards() {
|
||||
|
||||
reconcileLog().Info("expiring inactive saved cards", "count", len(cards))
|
||||
for i := range cards {
|
||||
if err := expireStoredCard(db, &cards[i]); err != nil {
|
||||
if err := expireStoredCard(db, &cards[i], stripeKey); err != nil {
|
||||
reconcileLog().Error("expire card failed", "card_id", cards[i].ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,429 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(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))
|
||||
}
|
||||
}
|
||||
|
||||
// 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(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
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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(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(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
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -28,13 +28,13 @@ func webhookLog() *slog.Logger { return slog.With("component", "webhook") }
|
||||
// WebhookHandler handles inbound Stripe webhook events.
|
||||
type WebhookHandler struct {
|
||||
webhookSecret string
|
||||
stripeKey string
|
||||
shippoSecret string
|
||||
}
|
||||
|
||||
// NewWebhookHandler creates a new webhook handler. Stripe credentials are
|
||||
// wired once at startup via stripe.Key in api/main.go.
|
||||
func NewWebhookHandler(webhookSecret, shippoSecret string) *WebhookHandler {
|
||||
return &WebhookHandler{webhookSecret: webhookSecret, shippoSecret: shippoSecret}
|
||||
// NewWebhookHandler creates a new webhook handler.
|
||||
func NewWebhookHandler(webhookSecret, stripeKey, shippoSecret string) *WebhookHandler {
|
||||
return &WebhookHandler{webhookSecret: webhookSecret, stripeKey: stripeKey, shippoSecret: shippoSecret}
|
||||
}
|
||||
|
||||
// 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)
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
si, err := setupintent.Get(sess.SetupIntent.ID, nil)
|
||||
if err != nil {
|
||||
webhookLog().Error("failed to get setup intent", "setup_intent", sess.SetupIntent.ID, "error", err)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
28
api/main.go
28
api/main.go
@@ -19,8 +19,6 @@ import (
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/observability"
|
||||
"terminalShop/pkg/stripeclient"
|
||||
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
)
|
||||
|
||||
const version = "v0.1.0"
|
||||
@@ -51,12 +49,6 @@ func main() {
|
||||
}
|
||||
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
|
||||
if err := database.Seed(db); err != nil {
|
||||
log.Fatalf("Failed to seed database: %v", err)
|
||||
@@ -79,7 +71,7 @@ func main() {
|
||||
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
|
||||
|
||||
// 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
|
||||
server := &http.Server{
|
||||
@@ -114,7 +106,7 @@ func main() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileOrders()
|
||||
handlers.ReconcileOrders(cfg.StripeSecretKey)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -145,21 +137,7 @@ func main() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileExpiredCards()
|
||||
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(threshold)
|
||||
handlers.ReconcileExpiredCards(cfg.StripeSecretKey)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
// SetupRoutes configures all API routes
|
||||
func SetupRoutes(
|
||||
version string,
|
||||
stripeSecretKey string,
|
||||
stripeWebhookSecret string,
|
||||
jwtManager *auth.JWTManager,
|
||||
authFingerprintKey string,
|
||||
@@ -46,12 +47,12 @@ func SetupRoutes(
|
||||
healthHandler := handlers.NewHealthHandler(version)
|
||||
productHandler := handlers.NewProductHandler()
|
||||
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
|
||||
cartHandler := handlers.NewCartHandler(appURL, maxOrderCents)
|
||||
cardHandler := handlers.NewCardHandler(appURL)
|
||||
orderHandler := handlers.NewOrderHandler(bringAPIUID, bringAPIKey, bringCustomerNumber)
|
||||
cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL, maxOrderCents)
|
||||
cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
|
||||
orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber)
|
||||
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
|
||||
viewHandler := handlers.NewViewHandler()
|
||||
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, shippoWebhookSecret)
|
||||
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, stripeSecretKey, shippoWebhookSecret)
|
||||
slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort, shippoWebhookSecret)
|
||||
|
||||
// Short payment redirect and success page — no auth required.
|
||||
|
||||
14
main.go
14
main.go
@@ -15,7 +15,6 @@ import (
|
||||
"charm.land/wish/v2/bubbletea"
|
||||
"charm.land/wish/v2/logging"
|
||||
"github.com/charmbracelet/ssh"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
|
||||
"terminalShop/pkg/auth"
|
||||
"terminalShop/pkg/config"
|
||||
@@ -32,6 +31,7 @@ func listenHost() string {
|
||||
var (
|
||||
apiURL string
|
||||
authFingerprintKey string
|
||||
stripePublicKey string
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey)
|
||||
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -57,15 +57,7 @@ func main() {
|
||||
// Set API URL and auth key from config for TUI connections
|
||||
apiURL = cfg.APIURL
|
||||
authFingerprintKey = cfg.AuthFingerprintKey
|
||||
|
||||
// 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
|
||||
|
||||
stripePublicKey = cfg.StripePublicKey
|
||||
log.Printf("TUI will connect to API at: %s", apiURL)
|
||||
|
||||
// Create SSH server
|
||||
|
||||
@@ -14,16 +14,10 @@ import (
|
||||
)
|
||||
|
||||
// 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.
|
||||
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"
|
||||
|
||||
type eventType string
|
||||
|
||||
const (
|
||||
@@ -35,7 +29,6 @@ const (
|
||||
eventOrderShipped eventType = "order_shipped"
|
||||
eventOrderFailed eventType = "order_failed"
|
||||
eventOrderRequiresAction eventType = EventOrderRequiresAction
|
||||
eventOrder3DSAbandoned eventType = EventOrder3DSAbandoned
|
||||
eventOrderRefunded eventType = "order_refunded"
|
||||
eventPaymentCritical eventType = "payment_critical"
|
||||
eventLabelPurchased eventType = "label_purchased"
|
||||
@@ -335,17 +328,3 @@ func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
|
||||
slog.Info("audit", e.attrs()...)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -14,26 +13,25 @@ import (
|
||||
|
||||
// Config holds all application configuration
|
||||
type Config struct {
|
||||
APIPort string
|
||||
APIURL string
|
||||
AppURL string
|
||||
SSHPort string
|
||||
Environment string
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
StripeSecretKey string
|
||||
StripePublicKey string
|
||||
StripeWebhookSecret string
|
||||
ShippoAPIKey string
|
||||
AuthFingerprintKey string
|
||||
BringAPIUID string
|
||||
BringAPIKey string
|
||||
BringCustomerNumber string
|
||||
ShippoWebhookSecret string
|
||||
SlackSigningSecret string
|
||||
AdminAPIKey string
|
||||
MaxOrderCents int
|
||||
Abandoned3DSThresholdMinutes int
|
||||
APIPort string
|
||||
APIURL string
|
||||
AppURL string
|
||||
SSHPort string
|
||||
Environment string
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
StripeSecretKey string
|
||||
StripePublicKey string
|
||||
StripeWebhookSecret string
|
||||
ShippoAPIKey string
|
||||
AuthFingerprintKey string
|
||||
BringAPIUID string
|
||||
BringAPIKey string
|
||||
BringCustomerNumber string
|
||||
ShippoWebhookSecret string
|
||||
SlackSigningSecret string
|
||||
AdminAPIKey string
|
||||
MaxOrderCents int
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables and validates required secrets.
|
||||
@@ -52,26 +50,25 @@ func Load() (*Config, error) {
|
||||
apiPort := getEnvOrDefault("API_PORT", "8080")
|
||||
apiURL := getEnvOrDefault("API_URL", fmt.Sprintf("http://localhost:%s", apiPort))
|
||||
cfg := &Config{
|
||||
APIPort: apiPort,
|
||||
APIURL: apiURL,
|
||||
AppURL: getEnvOrDefault("APP_URL", apiURL),
|
||||
SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
|
||||
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
|
||||
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
|
||||
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
|
||||
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
|
||||
BringAPIUID: os.Getenv("BRING_API_UID"),
|
||||
BringAPIKey: os.Getenv("BRING_API_KEY"),
|
||||
BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"),
|
||||
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
|
||||
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
|
||||
AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
|
||||
MaxOrderCents: loadMaxOrderCents(),
|
||||
Abandoned3DSThresholdMinutes: loadAbandoned3DSThresholdMinutes(),
|
||||
APIPort: apiPort,
|
||||
APIURL: apiURL,
|
||||
AppURL: getEnvOrDefault("APP_URL", apiURL),
|
||||
SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
|
||||
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
|
||||
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
|
||||
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
|
||||
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
|
||||
BringAPIUID: os.Getenv("BRING_API_UID"),
|
||||
BringAPIKey: os.Getenv("BRING_API_KEY"),
|
||||
BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"),
|
||||
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
|
||||
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
|
||||
AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
|
||||
MaxOrderCents: loadMaxOrderCents(),
|
||||
}
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
@@ -153,26 +150,3 @@ func loadMaxOrderCents() int {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,30 +1,16 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"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) {
|
||||
cases := []struct {
|
||||
name string
|
||||
env string // "" means unset
|
||||
want int
|
||||
}{
|
||||
{"empty falls back to default", "", 20000},
|
||||
{"unset uses default", "", 20000},
|
||||
{"explicit zero disables", "0", 0},
|
||||
{"valid override", "50000", 50000},
|
||||
{"negative falls back to default", "-1", 20000},
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,13 @@ import (
|
||||
type OrderStatus string
|
||||
|
||||
const (
|
||||
OrderStatusPending OrderStatus = "pending"
|
||||
OrderStatusRequiresAction OrderStatus = "requires_action"
|
||||
OrderStatusPaid OrderStatus = "paid"
|
||||
OrderStatusShipped OrderStatus = "shipped"
|
||||
OrderStatusDelivered OrderStatus = "delivered"
|
||||
OrderStatusCancelled OrderStatus = "cancelled"
|
||||
OrderStatusRefunded OrderStatus = "refunded"
|
||||
OrderStatusFailed OrderStatus = "failed"
|
||||
OrderStatusPending OrderStatus = "pending"
|
||||
OrderStatusPaid OrderStatus = "paid"
|
||||
OrderStatusShipped OrderStatus = "shipped"
|
||||
OrderStatusDelivered OrderStatus = "delivered"
|
||||
OrderStatusCancelled OrderStatus = "cancelled"
|
||||
OrderStatusRefunded OrderStatus = "refunded"
|
||||
OrderStatusFailed OrderStatus = "failed"
|
||||
)
|
||||
|
||||
type TrackingStatus string
|
||||
|
||||
@@ -35,8 +35,6 @@ func (order *Order) DisplayState() string {
|
||||
switch order.Status {
|
||||
case OrderStatusPending:
|
||||
return "Awaiting payment"
|
||||
case OrderStatusRequiresAction:
|
||||
return "Awaiting authentication"
|
||||
case OrderStatusPaid:
|
||||
if order.TrackingNumber == "" {
|
||||
return "Paid * awaiting label"
|
||||
@@ -79,8 +77,6 @@ func (order *Order) DisplayKind() DisplayKind {
|
||||
switch order.Status {
|
||||
case OrderStatusPending:
|
||||
return DisplayKindNeutral
|
||||
case OrderStatusRequiresAction:
|
||||
return DisplayKindNeutral
|
||||
case OrderStatusPaid:
|
||||
return DisplayKindAccent
|
||||
case OrderStatusShipped:
|
||||
|
||||
@@ -14,7 +14,6 @@ func TestOrderDisplayState(t *testing.T) {
|
||||
kind DisplayKind
|
||||
}{
|
||||
{"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 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},
|
||||
|
||||
@@ -75,6 +75,7 @@ type Model struct {
|
||||
Fingerprint string
|
||||
AccessToken string
|
||||
AuthFingerprintKey string // shared secret for /auth/token refresh
|
||||
StripePublicKey string
|
||||
|
||||
// One-shop auth command - capture pubKeyStr in closure, not stored on Model
|
||||
authCmd tea.Cmd
|
||||
@@ -735,6 +736,7 @@ func (m Model) saveCardOnlyCmd(form PaymentFormCompleteMsg) tea.Cmd {
|
||||
if m.APIClient == nil {
|
||||
return CardSavedForReviewMsg{Err: fmt.Errorf("API client not available")}
|
||||
}
|
||||
stripe.Key = m.StripePublicKey
|
||||
tok, err := stripetoken.New(&stripe.TokenParams{
|
||||
Card: &stripe.CardParams{
|
||||
Name: stripe.String(form.CardName),
|
||||
@@ -903,12 +905,11 @@ func NewModel(username string) Model {
|
||||
}
|
||||
|
||||
// NewModelWithAuth creates a new model with user authentication context.
|
||||
// The publishable Stripe key is wired into stripe.Key once at startup in
|
||||
// the SSH binary's main, so it is not threaded through here.
|
||||
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string) Model {
|
||||
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
|
||||
m := NewModel("")
|
||||
m.Fingerprint = fingerprint
|
||||
m.AuthFingerprintKey = clientSecret
|
||||
m.StripePublicKey = stripePublicKey
|
||||
m.currentPage = splashPage
|
||||
if apiURL != "" {
|
||||
m.APIClient = api.NewClient(apiURL, "")
|
||||
|
||||
Reference in New Issue
Block a user