Compare commits

...

7 Commits

Author SHA1 Message Date
9be048b303 refactor(stripe): drop dead stripeKey fields and pre-call sdk params 2026-06-07 18:08:16 +02:00
3131f91d62 fix(stripe): wire key once at init drop per-call writes 2026-06-07 16:11:03 +02:00
7901666c8b fix(sca): address pr review on abandoned 3ds sweep
- Drop stripe.Key write in ReconcileStale3DSOrders; wire once in api/main.go after cfg.Load so concurrent reconcilers don't
race the SDK global.
- Add empty-PI short-circuit before paymentintent.Get to bury a future regression in a slog.Warn instead of a Stripe 4xx.
- Swap fmt.Printf for slog.Warn with k/v attrs in loadAbandoned3DSThresholdMinutes so Loki sees structured fields instead of
 literal %q/%d.
- Comment the updated_at predicate (RetryAuth bumps it via GORM auto-stamp, intentional grace-window extension) + the
conditional-UPDATE/map-form choice.
- Tests: processing/requires_capture skip, unknown-status skip, stripe-error skip, empty-PI skip, full env-parse table for
loadAbandoned3DSThresholdMinutes.
2026-06-07 15:38:55 +02:00
ecb494de9a feat(sca): sweep abandoned 3ds requires_action orders to failed 2026-06-07 14:57:44 +02:00
8b753e4f4b feat(sca): add requires_action orde status for 3ds in flight sate (#36)
* feat(sca): add requires_action orde status for 3ds in flight sate

* fix(sca): fix stuff the review commented on
2026-06-06 21:06:25 +02:00
90645958e5 chore(repo): change all instances of interface{} to any as of newer go standards (#35) 2026-06-06 13:59:09 +02:00
135f1361e4 fix(products): update pricing on products (#34)
* fix(products): update pricing on products

* fix(test): pin coffee price in cart-limit fixture

TestConvertCartLimit's quantities and expected totals were keyed to
the original 500-cent latte price. After the product price bump in
1ce63fb, coffee_id=4 became 2500 cents, so under_cap/at_cap subtests
overshot the $200 cap and over_cap's expected total magnitude was
wrong by 5x. Override coffee_id=4's price to 500 in test setup so the
cap math stays stable regardless of seed changes.
2026-06-05 23:49:03 +02:00
30 changed files with 1265 additions and 244 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
@@ -68,7 +69,7 @@ func TestGetCartEmpty(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "", 0)
handler := NewCartHandler("", 0)
req := authRequest("GET", "/api/v1/cart", nil, user.ID)
w := httptest.NewRecorder()
@@ -83,8 +84,8 @@ func TestGetCartEmpty(t *testing.T) {
Success bool `json:"success"`
Data struct {
Cart struct {
Items []interface{} `json:"items"`
Subtotal int `json:"subtotal"`
Items []any `json:"items"`
Subtotal int `json:"subtotal"`
} `json:"cart"`
} `json:"data"`
}
@@ -107,10 +108,10 @@ func TestSetItemAndGetCart(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "", 0)
handler := NewCartHandler("", 0)
// Add item (CoffeeID=1, Quantity=2)
body, _ := json.Marshal(map[string]interface{}{
body, _ := json.Marshal(map[string]any{
"coffee_id": 1,
"quantity": 2,
})
@@ -163,16 +164,16 @@ func TestSetItemUpdateQuantity(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "", 0)
handler := NewCartHandler("", 0)
// Add item
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2})
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 2})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
// Update quantity
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5})
body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5})
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w = httptest.NewRecorder()
handler.SetItem(w, req)
@@ -208,16 +209,16 @@ func TestSetItemRemove(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "", 0)
handler := NewCartHandler("", 0)
// Add item
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3})
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 3})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
// Remove item (quantity 0)
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 0})
body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 0})
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w = httptest.NewRecorder()
handler.SetItem(w, req)
@@ -231,7 +232,7 @@ func TestSetItemRemove(t *testing.T) {
Success bool `json:"success"`
Data struct {
Cart struct {
Items []interface{} `json:"items"`
Items []any `json:"items"`
} `json:"cart"`
} `json:"data"`
}
@@ -247,9 +248,9 @@ func TestSetItemInvalidProduct(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "", 0)
handler := NewCartHandler("", 0)
body, _ := json.Marshal(map[string]interface{}{"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)
w := httptest.NewRecorder()
handler.SetItem(w, req)
@@ -264,9 +265,9 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "", 0)
handler := NewCartHandler("", 0)
body, _ := json.Marshal(map[string]interface{}{"quantity": 1})
body, _ := json.Marshal(map[string]any{"quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
@@ -281,11 +282,11 @@ func TestClearCart(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "", 0)
handler := NewCartHandler("", 0)
// Add two items
for _, coffeeID := range []uint{1, 2} {
body, _ := json.Marshal(map[string]interface{}{"coffee_id": coffeeID, "quantity": 1})
body, _ := json.Marshal(map[string]any{"coffee_id": coffeeID, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
@@ -309,7 +310,7 @@ func TestClearCart(t *testing.T) {
Success bool `json:"success"`
Data struct {
Cart struct {
Items []interface{} `json:"items"`
Items []any `json:"items"`
} `json:"cart"`
} `json:"data"`
}
@@ -337,9 +338,9 @@ func TestSetAddress(t *testing.T) {
}
db.Create(&addr)
handler := NewCartHandler("", "", 0)
handler := NewCartHandler("", 0)
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
body, _ := json.Marshal(map[string]any{"address_id": addr.ID})
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w := httptest.NewRecorder()
handler.SetAddress(w, req)
@@ -391,9 +392,9 @@ func TestSetAddressWrongUser(t *testing.T) {
}
db.Create(&addr)
handler := NewCartHandler("", "", 0)
handler := NewCartHandler("", 0)
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
body, _ := json.Marshal(map[string]any{"address_id": addr.ID})
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w := httptest.NewRecorder()
handler.SetAddress(w, req)
@@ -419,9 +420,9 @@ func TestSetCard(t *testing.T) {
}
db.Create(&card)
handler := NewCartHandler("", "", 0)
handler := NewCartHandler("", 0)
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w := httptest.NewRecorder()
handler.SetCard(w, req)
@@ -471,9 +472,9 @@ func TestSetCardWrongUser(t *testing.T) {
}
db.Create(&card)
handler := NewCartHandler("", "", 0)
handler := NewCartHandler("", 0)
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w := httptest.NewRecorder()
handler.SetCard(w, req)
@@ -488,10 +489,10 @@ func TestConvertCartMissingAddress(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "", 0)
handler := NewCartHandler("", 0)
// Add item but no address or card
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
@@ -511,18 +512,18 @@ func TestConvertCartMissingCard(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "", 0)
handler := NewCartHandler("", 0)
db := database.GetDB()
// Add item and address
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
addr := models.Address{UserID: user.ID, Name: "Test", Street: "123 St", City: "PDX", State: "OR", Zip: "97201", Country: "US"}
db.Create(&addr)
body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID})
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w = httptest.NewRecorder()
handler.SetAddress(w, req)
@@ -542,7 +543,7 @@ func TestConvertCartEmpty(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "", 0)
handler := NewCartHandler("", 0)
// Empty cart convert
req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
@@ -559,11 +560,11 @@ func TestMultipleItems(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "", 0)
handler := NewCartHandler("", 0)
// Add three different items
for i := uint(1); i <= 3; i++ {
body, _ := json.Marshal(map[string]interface{}{"coffee_id": i, "quantity": int(i)})
body, _ := json.Marshal(map[string]any{"coffee_id": i, "quantity": int(i)})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
@@ -581,8 +582,8 @@ func TestMultipleItems(t *testing.T) {
Success bool `json:"success"`
Data struct {
Cart struct {
Items []interface{} `json:"items"`
Subtotal int `json:"subtotal"`
Items []any `json:"items"`
Subtotal int `json:"subtotal"`
} `json:"cart"`
} `json:"data"`
}
@@ -608,19 +609,19 @@ func TestCartIsolation(t *testing.T) {
}
db.Create(&user2)
handler := NewCartHandler("", "", 0)
handler := NewCartHandler("", 0)
r := chi.NewRouter()
r.Put("/cart/item", handler.SetItem)
r.Get("/cart", handler.GetCart)
// User 1 adds item
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5})
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5})
req := authRequest("PUT", "/cart/item", body, user1.ID)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// User 2 adds different item
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 2, "quantity": 3})
body, _ = json.Marshal(map[string]any{"coffee_id": 2, "quantity": 3})
req = authRequest("PUT", "/cart/item", body, user2.ID)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -709,9 +710,17 @@ func TestConvertCartLimit(t *testing.T) {
t.Fatalf("seed card: %v", err)
}
handler := NewCartHandler("", "", tc.capCents)
// Pin coffee_id=4's price so cart-cap math stays stable across
// seed-price changes. The cap test is about (price * quantity)
// vs cap, not which coffee or what the menu currently charges.
const pinnedPrice = 500
if err := db.Model(&models.Coffee{}).Where("id = ?", 4).Update("price", pinnedPrice).Error; err != nil {
t.Fatalf("pin coffee price: %v", err)
}
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 4, "quantity": tc.quantity})
handler := NewCartHandler("", tc.capCents)
body, _ := json.Marshal(map[string]any{"coffee_id": 4, "quantity": tc.quantity})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
@@ -720,7 +729,7 @@ func TestConvertCartLimit(t *testing.T) {
}
// set address
body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID})
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w = httptest.NewRecorder()
handler.SetAddress(w, req)
@@ -729,7 +738,7 @@ func TestConvertCartLimit(t *testing.T) {
}
// set card
body, _ = json.Marshal(map[string]interface{}{"card_id": card.ID})
body, _ = json.Marshal(map[string]any{"card_id": card.ID})
req = authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w = httptest.NewRecorder()
handler.SetCard(w, req)
@@ -745,9 +754,9 @@ func TestConvertCartLimit(t *testing.T) {
var resp struct {
Success bool `json:"success"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]interface{} `json:"details"`
Code string `json:"code"`
Message string `json:"message"`
Details map[string]any `json:"details"`
} `json:"error"`
}
_ = json.NewDecoder(w.Body).Decode(&resp)
@@ -760,8 +769,8 @@ func TestConvertCartLimit(t *testing.T) {
if got, _ := resp.Error.Details["limit_cents"].(float64); int(got) != tc.capCents {
t.Errorf("limit_cents in details: want %d, got %v", tc.capCents, resp.Error.Details["limit_cents"])
}
if got, _ := resp.Error.Details["total_cents"].(float64); int(got) != tc.quantity*500 {
t.Errorf("total_cents in details: want %d, got %v", tc.quantity*500, resp.Error.Details["total_cents"])
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"])
}
}
})
@@ -786,8 +795,8 @@ func TestSetCard_ExpiredReturns410(t *testing.T) {
t.Fatalf("seed card: %v", err)
}
handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
handler := NewCartHandler("", 0)
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w := httptest.NewRecorder()
handler.SetCard(w, req)
@@ -824,20 +833,20 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
db := database.GetDB()
body, _ := json.Marshal(map[string]interface{}{"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)
w := httptest.NewRecorder()
NewCartHandler("", "", 0).SetItem(w, req)
NewCartHandler("", 0).SetItem(w, req)
addr := models.Address{
UserID: user.ID, Name: "Test", Street: "123 St",
City: "PDX", State: "OR", Zip: "97201", Country: "US",
}
db.Create(&addr)
body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID})
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w = httptest.NewRecorder()
NewCartHandler("", "", 0).SetAddress(w, req)
NewCartHandler("", 0).SetAddress(w, req)
past := time.Now().Add(-1 * time.Hour)
card := models.Card{
@@ -853,7 +862,7 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
w = httptest.NewRecorder()
NewCartHandler("", "", 0).ConvertCart(w, req)
NewCartHandler("", 0).ConvertCart(w, req)
if w.Code != http.StatusGone {
t.Fatalf("want 410, got %d, body: %s", w.Code, w.Body.String())
@@ -875,8 +884,9 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) {
// succeeded) hit the live Stripe API and aren't covered here — see
// sca-psd2-compliance.md "Test plan" for manual coverage with the
// 4000 0027 6000 3184 test card. Caveat #14 in that doc still applies:
// NewCartHandler takes 3 args and the seeded user must have a non-empty
// StripeCustomerID so the Convert/Retry paths stay hermetic.
// NewCartHandler takes 2 args (appURL, maxOrderCents) and the seeded user
// must have a non-empty StripeCustomerID so the Convert/Retry paths stay
// hermetic.
func TestRetryAuth(t *testing.T) {
cases := []struct {
name string
@@ -911,6 +921,12 @@ func TestRetryAuth(t *testing.T) {
seedPayment: "pi_test_refunded", seedEvents: 1,
wantStatus: http.StatusConflict, wantErrCode: "INVALID_STATE",
},
{
name: "requires_action honors retry cap",
ownedByUser: true, seedStatus: models.OrderStatusRequiresAction,
seedPayment: "pi_test_req_action_cap", seedEvents: maxAuthIssuances,
wantStatus: http.StatusTooManyRequests, wantErrCode: "RETRY_LIMIT",
},
{
name: "retry cap reached returns 429",
ownedByUser: true, seedStatus: models.OrderStatusFailed,
@@ -977,7 +993,7 @@ func TestRetryAuth(t *testing.T) {
}
}
handler := NewCartHandler("", "http://localhost", 0)
handler := NewCartHandler("http://localhost", 0)
req := orderRequest("POST",
fmt.Sprintf("/api/v1/orders/%d/retry-auth", order.ID), nil, user.ID, fmt.Sprintf("%d", order.ID))
w := httptest.NewRecorder()
@@ -1005,7 +1021,7 @@ func TestRetryAuth_OrderNotFound(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "http://localhost", 0)
handler := NewCartHandler("http://localhost", 0)
req := orderRequest("POST", "/api/v1/orders/9999/retry-auth", nil, user.ID, "9999")
w := httptest.NewRecorder()
handler.RetryAuth(w, req)
@@ -1014,3 +1030,137 @@ func TestRetryAuth_OrderNotFound(t *testing.T) {
t.Fatalf("want 404, got %d body=%s", w.Code, w.Body.String())
}
}
func TestRespondRequiresAction_SetStatusRequiresAction(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_test_req_action",
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
order := models.Order{
UserID: user.ID, CardID: card.ID,
Status: models.OrderStatusPending,
Subtotal: 500, Total: 500,
ShippingName: "Test", ShippingStreet: "1 Main St",
ShippingCity: "PDX", ShippingState: "OR",
ShippingZip: "97201", ShippingCountry: "US",
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
pi := &stripe.PaymentIntent{
ID: "pi_test_action_set",
NextAction: &stripe.PaymentIntentNextAction{
RedirectToURL: &stripe.PaymentIntentNextActionRedirectToURL{
URL: "https://hooks.stripe.com/redirect/test/test_3ds",
},
},
}
handler := NewCartHandler("http://localhost", 0)
w := httptest.NewRecorder()
handler.respondRequiresAction(w, &order, card.StripePaymentID, pi)
if w.Code != http.StatusAccepted {
t.Fatalf("want 202, got %d body=%s", w.Code, w.Body.String())
}
var resp struct {
Data struct {
OrderID uint `json:"order_id"`
PaymentIntentID string `json:"payment_intent_id"`
Status string `json:"status"`
RedirectURL string `json:"redirect_url"`
} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Data.Status != "requires_action" {
t.Fatalf("response status: %q, got %q", "requires_action", resp.Data.Status)
}
if resp.Data.RedirectURL == "" {
t.Fatalf("expected redirect url, got empty")
}
if resp.Data.PaymentIntentID != pi.ID {
t.Fatalf("payment intent id: want %q, got %q", pi.ID, resp.Data.PaymentIntentID)
}
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("order.Status: want %q, got %q", models.OrderStatusRequiresAction, reloaded.Status)
}
if reloaded.StripePaymentID != pi.ID {
t.Fatalf("order.StripePaymentID: want %q, got %q", pi.ID, reloaded.StripePaymentID)
}
}
// TestRespondRequiresAction_OverridesFailedFromWebhookRace simulates the race
// described in sca-psd2-compliance.md note #3 / caveat #4: the
// payment_intent.payment_failed webhook arrives BEFORE respondRequiresAction
// returns and flips the row to OrderStatusFailed. The helper must override
// that back to OrderStatusRequiresAction so the in-flight 3DS state surfaces
// correctly to the TUI.
func TestRespondRequiresAction_OverridesFailedFromWebhookRace(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_test_race",
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
order := models.Order{
UserID: user.ID, CardID: card.ID,
Status: models.OrderStatusFailed,
Subtotal: 500, Total: 500,
ShippingName: "Test", ShippingStreet: "1 Main St",
ShippingCity: "PDX", ShippingState: "OR",
ShippingZip: "97201", ShippingCountry: "US",
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
pi := &stripe.PaymentIntent{
ID: "pi_test_race",
NextAction: &stripe.PaymentIntentNextAction{
RedirectToURL: &stripe.PaymentIntentNextActionRedirectToURL{
URL: "https://hooks.stripe.com/redirect/test_race",
},
},
}
handler := NewCartHandler("http://localhost", 0)
w := httptest.NewRecorder()
handler.respondRequiresAction(w, &order, card.StripePaymentID, pi)
if w.Code != http.StatusAccepted {
t.Fatalf("want 202, got %d body=%s", w.Code, w.Body.String())
}
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("override failed: want %q, got %q", models.OrderStatusRequiresAction, reloaded.Status)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,8 @@ import (
"terminalShop/pkg/database"
"terminalShop/pkg/observability"
"terminalShop/pkg/stripeclient"
"github.com/stripe/stripe-go/v78"
)
const version = "v0.1.0"
@@ -49,6 +51,12 @@ func main() {
}
audit.SetDB(db)
// wire the stripe SDK's package-level key once at startup so concurrent
// reconcilers and the request path don't race on stripe.Key writes.
// NOTE: assumes single-process — if you ever embed both the SSH and API
// binaries together, the last writer wins and one of them silently breaks.
stripe.Key = cfg.StripeSecretKey
// Seed database with initial data
if err := database.Seed(db); err != nil {
log.Fatalf("Failed to seed database: %v", err)
@@ -71,7 +79,7 @@ func main() {
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
// Setup routes
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)
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)
// Create HTTP server
server := &http.Server{
@@ -106,7 +114,7 @@ func main() {
for {
select {
case <-ticker.C:
handlers.ReconcileOrders(cfg.StripeSecretKey)
handlers.ReconcileOrders()
case <-ctx.Done():
return
}
@@ -137,7 +145,21 @@ func main() {
for {
select {
case <-ticker.C:
handlers.ReconcileExpiredCards(cfg.StripeSecretKey)
handlers.ReconcileExpiredCards()
case <-ctx.Done():
return
}
}
}()
go func() {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
threshold := time.Duration(cfg.Abandoned3DSThresholdMinutes) * time.Minute
for {
select {
case <-ticker.C:
handlers.ReconcileStale3DSOrders(threshold)
case <-ctx.Done():
return
}

View File

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

14
main.go
View File

@@ -15,6 +15,7 @@ import (
"charm.land/wish/v2/bubbletea"
"charm.land/wish/v2/logging"
"github.com/charmbracelet/ssh"
"github.com/stripe/stripe-go/v78"
"terminalShop/pkg/auth"
"terminalShop/pkg/config"
@@ -31,7 +32,6 @@ func listenHost() string {
var (
apiURL string
authFingerprintKey string
stripePublicKey string
)
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
@@ -43,7 +43,7 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
pubKeyStr = auth.FormatSSHPublicKey(pubKey)
}
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey)
return m, nil
}
@@ -57,7 +57,15 @@ func main() {
// Set API URL and auth key from config for TUI connections
apiURL = cfg.APIURL
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)
// Create SSH server

View File

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

View File

@@ -14,10 +14,16 @@ import (
)
// EventOrderRequiresAction is the audit event-type string emitted whenever a
// PaymentIntent is pushed into requires_action. explored so cart-flow handler
// PaymentIntent is pushed into requires_action. Exposed so cart-flow handler
// can count retry attempts without hardcoding the string literal.
const EventOrderRequiresAction = "order_requires_action"
// EventOrder3DSAbandoned is the audit event-type string emitted by the
// abandoned-3DS sweep when it flips a stale requires_action row to failed.
// Distinct from EventOrderRequiresAction (caveat #17) so the retry-cap count
// stays decoupled from sweep activity
const EventOrder3DSAbandoned = "order_3ds_abandoned"
type eventType string
const (
@@ -29,6 +35,7 @@ const (
eventOrderShipped eventType = "order_shipped"
eventOrderFailed eventType = "order_failed"
eventOrderRequiresAction eventType = EventOrderRequiresAction
eventOrder3DSAbandoned eventType = EventOrder3DSAbandoned
eventOrderRefunded eventType = "order_refunded"
eventPaymentCritical eventType = "payment_critical"
eventLabelPurchased eventType = "label_purchased"
@@ -328,3 +335,17 @@ func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
slog.Info("audit", e.attrs()...)
persist(orderID, eventOrderRequiresAction, e)
}
// Order3DSAbandoned records the abandoned-3DS sweep flipping a stale
// requires_action row to failed because Stripe still reports the underlying
// PaymentIntent as outstanding past the sweep threshold.
func Order3DSAbandoned(userID, orderID uint, paymentIntentID string) {
e := event{
Event: eventOrder3DSAbandoned,
UserID: userID,
OrderID: orderID,
StripeID: paymentIntentID,
}
slog.Info("audit", e.attrs()...)
persist(orderID, eventOrder3DSAbandoned, e)
}

View File

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

View File

@@ -3,6 +3,7 @@ package config
import (
"errors"
"fmt"
"log/slog"
"net/url"
"os"
"strconv"
@@ -13,25 +14,26 @@ import (
// Config holds all application configuration
type Config struct {
APIPort string
APIURL string
AppURL string
SSHPort string
Environment string
DatabaseURL string
JWTSecret string
StripeSecretKey string
StripePublicKey string
StripeWebhookSecret string
ShippoAPIKey string
AuthFingerprintKey string
BringAPIUID string
BringAPIKey string
BringCustomerNumber string
ShippoWebhookSecret string
SlackSigningSecret string
AdminAPIKey string
MaxOrderCents int
APIPort string
APIURL string
AppURL string
SSHPort string
Environment string
DatabaseURL string
JWTSecret string
StripeSecretKey string
StripePublicKey string
StripeWebhookSecret string
ShippoAPIKey string
AuthFingerprintKey string
BringAPIUID string
BringAPIKey string
BringCustomerNumber string
ShippoWebhookSecret string
SlackSigningSecret string
AdminAPIKey string
MaxOrderCents int
Abandoned3DSThresholdMinutes int
}
// Load reads configuration from environment variables and validates required secrets.
@@ -50,25 +52,26 @@ func Load() (*Config, error) {
apiPort := getEnvOrDefault("API_PORT", "8080")
apiURL := getEnvOrDefault("API_URL", fmt.Sprintf("http://localhost:%s", apiPort))
cfg := &Config{
APIPort: apiPort,
APIURL: apiURL,
AppURL: getEnvOrDefault("APP_URL", apiURL),
SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
JWTSecret: os.Getenv("JWT_SECRET"),
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
BringAPIUID: os.Getenv("BRING_API_UID"),
BringAPIKey: os.Getenv("BRING_API_KEY"),
BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"),
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
MaxOrderCents: loadMaxOrderCents(),
APIPort: apiPort,
APIURL: apiURL,
AppURL: getEnvOrDefault("APP_URL", apiURL),
SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
JWTSecret: os.Getenv("JWT_SECRET"),
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
BringAPIUID: os.Getenv("BRING_API_UID"),
BringAPIKey: os.Getenv("BRING_API_KEY"),
BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"),
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
MaxOrderCents: loadMaxOrderCents(),
Abandoned3DSThresholdMinutes: loadAbandoned3DSThresholdMinutes(),
}
if err := cfg.validate(); err != nil {
@@ -150,3 +153,26 @@ func loadMaxOrderCents() int {
}
return n
}
// loadAbandoned3DSThresholdMinutes read ABANDONED_3DS_THRESHOLD_MINUTES.
// The abandoned-3DS sweep only flips requires_action rows whose updated_at iconfigs
// older than this many minutes. Default 30 min. Values below 1 fall back to
// the default so a typo cannot collapse the grace window the TUI polls inside.
func loadAbandoned3DSThresholdMinutes() int {
const defaultMinutes = 30
raw := os.Getenv("ABANDONED_3DS_THRESHOLD_MINUTES")
if raw == "" {
return defaultMinutes
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
slog.Warn("invalid ABANDONED_3DS_THRESHOLD_MINUTES, falling back",
"raw", raw, "default", defaultMinutes)
return defaultMinutes
}
if n < 10 {
slog.Warn("ABANDONED_3DS_THRESHOLD_MINUTES below 10 may race in-flight 3ds challenges",
"value", n)
}
return n
}

View File

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

View File

@@ -4,8 +4,9 @@ import (
"fmt"
"log"
"gorm.io/gorm"
"terminalShop/pkg/models"
"gorm.io/gorm"
)
// Seed populates the database with initial data
@@ -27,7 +28,7 @@ func Seed(db *gorm.DB) error {
RoastType: "Dark Roast",
Ounces: 2,
BeanType: "Arabica",
Price: 350, // $3.50
Price: 1800, // $18.00
Color: "#8B4513",
Description: "Bold and intense coffee with a rich crema",
},
@@ -36,7 +37,7 @@ func Seed(db *gorm.DB) error {
RoastType: "Medium Roast",
Ounces: 8,
BeanType: "Arabica",
Price: 400, // $4.00
Price: 2200, // $22.00
Color: "#A0522D",
Description: "Smooth and balanced espresso diluted with hot water",
},
@@ -45,7 +46,7 @@ func Seed(db *gorm.DB) error {
RoastType: "Medium Roast",
Ounces: 6,
BeanType: "Arabica",
Price: 450, // $4.50
Price: 2200, // $22.00
Color: "#C4A574",
Description: "Espresso with steamed milk and a thick layer of foam",
},
@@ -54,7 +55,7 @@ func Seed(db *gorm.DB) error {
RoastType: "Medium Roast",
Ounces: 12,
BeanType: "Arabica",
Price: 500, // $5.00
Price: 2500, // $25.00
Color: "#D2B48C",
Description: "Creamy espresso with plenty of steamed milk and a touch of foam",
},
@@ -63,7 +64,7 @@ func Seed(db *gorm.DB) error {
RoastType: "Dark Roast",
Ounces: 12,
BeanType: "Arabica",
Price: 550, // $5.50
Price: 3000, // $30.00
Color: "#8B4513",
Description: "Chocolate and espresso combined with steamed milk. Sweet and indulgent.",
},
@@ -72,7 +73,7 @@ func Seed(db *gorm.DB) error {
RoastType: "Dark Roast",
Ounces: 3,
BeanType: "Robusta Blend",
Price: 425, // $4.25
Price: 1800, // $18.00
Color: "#DAA520",
Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.",
},

View File

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

View File

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

View File

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

View File

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

View File

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