Compare commits
1 Commits
fix/temp-d
...
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)
|
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()
|
||||||
|
|||||||
@@ -67,13 +67,13 @@ func PayRedirect(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// CardHandler handles saved payment method CRUD.
|
// CardHandler handles saved payment method CRUD.
|
||||||
type CardHandler struct {
|
type CardHandler struct {
|
||||||
appURL string
|
stripeKey string
|
||||||
|
appURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCardHandler creates a new card handler. Stripe credentials are wired
|
// NewCardHandler creates a new card handler with the Stripe secret key.
|
||||||
// once at startup via stripe.Key in api/main.go.
|
func NewCardHandler(stripeSecretKey string, appURL string) *CardHandler {
|
||||||
func NewCardHandler(appURL string) *CardHandler {
|
return &CardHandler{stripeKey: stripeSecretKey, appURL: appURL}
|
||||||
return &CardHandler{appURL: appURL}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCards returns all saved cards for the authenticated user.
|
// GetCards returns all saved cards for the authenticated user.
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"terminalShop/api/middleware"
|
|
||||||
"terminalShop/pkg/audit"
|
|
||||||
"terminalShop/pkg/database"
|
"terminalShop/pkg/database"
|
||||||
"terminalShop/pkg/models"
|
"terminalShop/pkg/models"
|
||||||
"terminalShop/pkg/notify"
|
"terminalShop/pkg/notify"
|
||||||
@@ -17,19 +14,15 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// paymentIntentGetFn lets tests stub the Stripe Get call without spinning
|
|
||||||
// up an httptest backend. Production callers go straight through to the
|
|
||||||
// real paymentintent.Get.
|
|
||||||
var paymentIntentGetFn = paymentintent.Get
|
|
||||||
|
|
||||||
func reconcileLog() *slog.Logger { return slog.With("component", "reconcile") }
|
func reconcileLog() *slog.Logger { return slog.With("component", "reconcile") }
|
||||||
|
|
||||||
// ReconcileOrders finds pending orders with no Stripe ID older than 10 minutes
|
// ReconcileOrders finds pending orders with no Stripe ID older than 10 minutes
|
||||||
// and checks whether Stripe has a succeeded PaymentIntent for them. This fixes
|
// and checks whether Stripe has a succeeded PaymentIntent for them. This fixes
|
||||||
// the case where the server crashed after charging but before the DB transaction
|
// the case where the server crashed after charging but before the DB transaction
|
||||||
// completed.
|
// completed.
|
||||||
func ReconcileOrders(ctx context.Context) {
|
func ReconcileOrders(stripeKey string) {
|
||||||
db := database.GetDB().WithContext(ctx)
|
stripe.Key = stripeKey
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
var orders []models.Order
|
var orders []models.Order
|
||||||
cutoff := time.Now().Add(-10 * time.Minute)
|
cutoff := time.Now().Add(-10 * time.Minute)
|
||||||
@@ -43,14 +36,9 @@ func ReconcileOrders(ctx context.Context) {
|
|||||||
reconcileLog().Info("checking pending orders", "count", len(orders))
|
reconcileLog().Info("checking pending orders", "count", len(orders))
|
||||||
|
|
||||||
for _, order := range orders {
|
for _, order := range orders {
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
reconcileLog().Info("reconcile orders cancelled mid-loop", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params := &stripe.PaymentIntentSearchParams{
|
params := &stripe.PaymentIntentSearchParams{
|
||||||
SearchParams: stripe.SearchParams{
|
SearchParams: stripe.SearchParams{
|
||||||
Context: ctx,
|
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
|
||||||
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
44
api/main.go
44
api/main.go
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
1
go.mod
@@ -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
14
main.go
@@ -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
|
||||||
|
|||||||
@@ -14,22 +14,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// EventOrderRequiresAction is the audit event-type string emitted whenever a
|
// EventOrderRequiresAction is the audit event-type string emitted whenever a
|
||||||
// PaymentIntent is pushed into requires_action. Exposed so cart-flow handler
|
// PaymentIntent is pushed into requires_action. explored so cart-flow handler
|
||||||
// can count retry attempts without hardcoding the string literal.
|
// can count retry attempts without hardcoding the string literal.
|
||||||
const EventOrderRequiresAction = "order_requires_action"
|
const EventOrderRequiresAction = "order_requires_action"
|
||||||
|
|
||||||
// EventOrder3DSAbandoned is the audit event-type string emitted by the
|
|
||||||
// abandoned-3DS sweep when it flips a stale requires_action row to failed.
|
|
||||||
// Distinct from EventOrderRequiresAction (caveat #17) so the retry-cap count
|
|
||||||
// stays decoupled from sweep activity
|
|
||||||
const EventOrder3DSAbandoned = "order_3ds_abandoned"
|
|
||||||
|
|
||||||
// EventCartRejected is the audit event-type string emitted when the per-order
|
|
||||||
// spend cap blocks a checkout BEFORE an order row is created. The event lives
|
|
||||||
// in slog only, persist() no-ops on zero order id, which is the correct shape
|
|
||||||
// here (cap rejection is upstream of order state). See caveat #13.
|
|
||||||
const EventCartRejected = "cart_rejected"
|
|
||||||
|
|
||||||
type eventType string
|
type eventType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -41,8 +29,6 @@ const (
|
|||||||
eventOrderShipped eventType = "order_shipped"
|
eventOrderShipped eventType = "order_shipped"
|
||||||
eventOrderFailed eventType = "order_failed"
|
eventOrderFailed eventType = "order_failed"
|
||||||
eventOrderRequiresAction eventType = EventOrderRequiresAction
|
eventOrderRequiresAction eventType = EventOrderRequiresAction
|
||||||
eventOrder3DSAbandoned eventType = EventOrder3DSAbandoned
|
|
||||||
eventCartRejected eventType = EventCartRejected
|
|
||||||
eventOrderRefunded eventType = "order_refunded"
|
eventOrderRefunded eventType = "order_refunded"
|
||||||
eventPaymentCritical eventType = "payment_critical"
|
eventPaymentCritical eventType = "payment_critical"
|
||||||
eventLabelPurchased eventType = "label_purchased"
|
eventLabelPurchased eventType = "label_purchased"
|
||||||
@@ -53,21 +39,19 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type event struct {
|
type event struct {
|
||||||
Event eventType
|
Event eventType
|
||||||
UserID uint
|
UserID uint
|
||||||
OrderID uint
|
OrderID uint
|
||||||
CardID uint
|
CardID uint
|
||||||
Amount int
|
Amount int
|
||||||
StripeID string
|
StripeID string
|
||||||
CardLast4 string
|
CardLast4 string
|
||||||
CardBrand string
|
CardBrand string
|
||||||
Carrier string
|
Carrier string
|
||||||
Tracking string
|
Tracking string
|
||||||
Status string
|
Status string
|
||||||
Error string
|
Error string
|
||||||
Actor string
|
Actor string
|
||||||
TotalCents int
|
|
||||||
CapCents int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e event) attrs() []any {
|
func (e event) attrs() []any {
|
||||||
@@ -108,12 +92,6 @@ func (e event) attrs() []any {
|
|||||||
if e.Actor != "" {
|
if e.Actor != "" {
|
||||||
a = append(a, "actor", e.Actor)
|
a = append(a, "actor", e.Actor)
|
||||||
}
|
}
|
||||||
if e.TotalCents != 0 {
|
|
||||||
a = append(a, "total_cents", e.TotalCents)
|
|
||||||
}
|
|
||||||
if e.CapCents != 0 {
|
|
||||||
a = append(a, "cap_cents", e.CapCents)
|
|
||||||
}
|
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,33 +328,3 @@ func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
|
|||||||
slog.Info("audit", e.attrs()...)
|
slog.Info("audit", e.attrs()...)
|
||||||
persist(orderID, eventOrderRequiresAction, e)
|
persist(orderID, eventOrderRequiresAction, e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Order3DSAbandoned records the abandoned-3DS sweep flipping a stale
|
|
||||||
// requires_action row to failed because Stripe still reports the underlying
|
|
||||||
// PaymentIntent as outstanding past the sweep threshold.
|
|
||||||
func Order3DSAbandoned(userID, orderID uint, paymentIntentID string) {
|
|
||||||
e := event{
|
|
||||||
Event: eventOrder3DSAbandoned,
|
|
||||||
UserID: userID,
|
|
||||||
OrderID: orderID,
|
|
||||||
StripeID: paymentIntentID,
|
|
||||||
}
|
|
||||||
slog.Info("audit", e.attrs()...)
|
|
||||||
persist(orderID, eventOrder3DSAbandoned, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CartRejected records the per-order cap blocking a checkout BEFORE an
|
|
||||||
// order row is created. Slog-only by design: cap rejection runs upstream
|
|
||||||
// of order creation so there is no order id to scope a persistent
|
|
||||||
// OrderEvent row. persist() is intentionally not called - future refactors
|
|
||||||
// of the persist() zero-id guard must not silently start writing rows here.
|
|
||||||
// Counterpart to the validation_over_limit Prometheus counter.
|
|
||||||
func CartRejected(userID uint, totalCents, capCents int) {
|
|
||||||
e := event{
|
|
||||||
Event: eventCartRejected,
|
|
||||||
UserID: userID,
|
|
||||||
TotalCents: totalCents,
|
|
||||||
CapCents: capCents,
|
|
||||||
}
|
|
||||||
slog.Info("audit", e.attrs()...)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -14,26 +13,25 @@ import (
|
|||||||
|
|
||||||
// Config holds all application configuration
|
// Config holds all application configuration
|
||||||
type Config struct {
|
type Config struct {
|
||||||
APIPort string
|
APIPort string
|
||||||
APIURL string
|
APIURL string
|
||||||
AppURL string
|
AppURL string
|
||||||
SSHPort string
|
SSHPort string
|
||||||
Environment string
|
Environment string
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
StripeSecretKey string
|
StripeSecretKey string
|
||||||
StripePublicKey string
|
StripePublicKey string
|
||||||
StripeWebhookSecret string
|
StripeWebhookSecret string
|
||||||
ShippoAPIKey string
|
ShippoAPIKey string
|
||||||
AuthFingerprintKey string
|
AuthFingerprintKey string
|
||||||
BringAPIUID string
|
BringAPIUID string
|
||||||
BringAPIKey string
|
BringAPIKey string
|
||||||
BringCustomerNumber string
|
BringCustomerNumber string
|
||||||
ShippoWebhookSecret string
|
ShippoWebhookSecret string
|
||||||
SlackSigningSecret string
|
SlackSigningSecret string
|
||||||
AdminAPIKey string
|
AdminAPIKey string
|
||||||
MaxOrderCents int
|
MaxOrderCents int
|
||||||
Abandoned3DSThresholdMinutes int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables and validates required secrets.
|
// Load reads configuration from environment variables and validates required secrets.
|
||||||
@@ -52,26 +50,25 @@ func Load() (*Config, error) {
|
|||||||
apiPort := getEnvOrDefault("API_PORT", "8080")
|
apiPort := getEnvOrDefault("API_PORT", "8080")
|
||||||
apiURL := getEnvOrDefault("API_URL", fmt.Sprintf("http://localhost:%s", apiPort))
|
apiURL := getEnvOrDefault("API_URL", fmt.Sprintf("http://localhost:%s", apiPort))
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
APIPort: apiPort,
|
APIPort: apiPort,
|
||||||
APIURL: apiURL,
|
APIURL: apiURL,
|
||||||
AppURL: getEnvOrDefault("APP_URL", apiURL),
|
AppURL: getEnvOrDefault("APP_URL", apiURL),
|
||||||
SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
|
SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
|
||||||
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
|
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
|
||||||
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
|
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
|
||||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||||
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||||
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
|
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
|
||||||
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||||
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
|
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
|
||||||
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
|
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
|
||||||
BringAPIUID: os.Getenv("BRING_API_UID"),
|
BringAPIUID: os.Getenv("BRING_API_UID"),
|
||||||
BringAPIKey: os.Getenv("BRING_API_KEY"),
|
BringAPIKey: os.Getenv("BRING_API_KEY"),
|
||||||
BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"),
|
BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"),
|
||||||
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
|
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
|
||||||
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
|
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
|
||||||
AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
|
AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
|
||||||
MaxOrderCents: loadMaxOrderCents(),
|
MaxOrderCents: loadMaxOrderCents(),
|
||||||
Abandoned3DSThresholdMinutes: loadAbandoned3DSThresholdMinutes(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cfg.validate(); err != nil {
|
if err := cfg.validate(); err != nil {
|
||||||
@@ -137,13 +134,11 @@ func getEnvOrDefault(key, defaultValue string) string {
|
|||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $500 — aligns with the
|
// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $200. Explicit 0 disables
|
||||||
// PSD2 TRA exemption ceiling (above this, Stripe always requests SCA, so the
|
// the cap. Garbage or negative values fall back to the default with a warning
|
||||||
// cap is only meaningful below it). Explicit 0 disables the cap. Garbage or
|
// so an operator typo cannot silently disable a fraud control.
|
||||||
// negative values fall back to the default with a warning so an operator
|
|
||||||
// typo cannot silently disable a fraud control.
|
|
||||||
func loadMaxOrderCents() int {
|
func loadMaxOrderCents() int {
|
||||||
const defaultCap = 50000
|
const defaultCap = 20000
|
||||||
raw := os.Getenv("MAX_ORDER_CENTS")
|
raw := os.Getenv("MAX_ORDER_CENTS")
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return defaultCap
|
return defaultCap
|
||||||
@@ -155,26 +150,3 @@ func loadMaxOrderCents() int {
|
|||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadAbandoned3DSThresholdMinutes read ABANDONED_3DS_THRESHOLD_MINUTES.
|
|
||||||
// The abandoned-3DS sweep only flips requires_action rows whose updated_at iconfigs
|
|
||||||
// older than this many minutes. Default 30 min. Values below 1 fall back to
|
|
||||||
// the default so a typo cannot collapse the grace window the TUI polls inside.
|
|
||||||
func loadAbandoned3DSThresholdMinutes() int {
|
|
||||||
const defaultMinutes = 30
|
|
||||||
raw := os.Getenv("ABANDONED_3DS_THRESHOLD_MINUTES")
|
|
||||||
if raw == "" {
|
|
||||||
return defaultMinutes
|
|
||||||
}
|
|
||||||
n, err := strconv.Atoi(raw)
|
|
||||||
if err != nil || n < 1 {
|
|
||||||
slog.Warn("invalid ABANDONED_3DS_THRESHOLD_MINUTES, falling back",
|
|
||||||
"raw", raw, "default", defaultMinutes)
|
|
||||||
return defaultMinutes
|
|
||||||
}
|
|
||||||
if n < 10 {
|
|
||||||
slog.Warn("ABANDONED_3DS_THRESHOLD_MINUTES below 10 may race in-flight 3ds challenges",
|
|
||||||
"value", n)
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,14 +10,13 @@ import (
|
|||||||
type OrderStatus string
|
type OrderStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
OrderStatusPending OrderStatus = "pending"
|
OrderStatusPending OrderStatus = "pending"
|
||||||
OrderStatusRequiresAction OrderStatus = "requires_action"
|
OrderStatusPaid OrderStatus = "paid"
|
||||||
OrderStatusPaid OrderStatus = "paid"
|
OrderStatusShipped OrderStatus = "shipped"
|
||||||
OrderStatusShipped OrderStatus = "shipped"
|
OrderStatusDelivered OrderStatus = "delivered"
|
||||||
OrderStatusDelivered OrderStatus = "delivered"
|
OrderStatusCancelled OrderStatus = "cancelled"
|
||||||
OrderStatusCancelled OrderStatus = "cancelled"
|
OrderStatusRefunded OrderStatus = "refunded"
|
||||||
OrderStatusRefunded OrderStatus = "refunded"
|
OrderStatusFailed OrderStatus = "failed"
|
||||||
OrderStatusFailed OrderStatus = "failed"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type TrackingStatus string
|
type TrackingStatus string
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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, "")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user