Compare commits

..

1 Commits

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

View File

@@ -35,5 +35,31 @@ jobs:
with: with:
version: v2.4.0 version: v2.4.0
- name: Install tparse
run: go install github.com/mfridman/tparse@v0.18.0
- name: Test (race detector) - name: Test (race detector)
run: go test -race ./... run: |
set +e
go tool gotestsum \
--format pkgname \
--format-icons hivis \
--hide-summary=skipped,output,errors,failed \
--jsonfile test-output.json \
--junitfile test-report.xml \
--packages="./..." \
-- -count=1 -race -covermode=atomic -coverprofile=coverage.out
rc=$?
echo
tparse -all -notests -slow 10 -file=test-output.json || true
exit $rc
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: test-report
path: |
test-output.json
test-report.xml
coverage.out

2
.gitignore vendored
View File

@@ -33,6 +33,8 @@ api/api-server
*.out *.out
coverage.txt coverage.txt
coverage.html coverage.html
test-output.json
test-report.xml
# Dependency directories # Dependency directories
vendor/ vendor/

55
Makefile Normal file
View File

@@ -0,0 +1,55 @@
.PHONY: test test-pretty test-watch test-ci test-summary cover
PKGS ?= ./...
JSON_OUT ?= test-output.json
JUNIT ?= test-report.xml
COVER ?= coverage.out
# Plain go test. Fastest, no extra deps loaded
test:
go test $(PKGS)
# Live pretty output. Groups by test name, reruns flakes once.
# Hides gotestsum's plain-text summary; tparse renders failures instead.
test-pretty:
@set +e; \
go tool gotestsum \
--format testname \
--format-icons hivis \
--hide-summary=skipped,output,errors,failed \
--rerun-fails=1 \
--jsonfile $(JSON_OUT) \
--packages="$(PKGS)" \
-- -count=1; \
rc=$$?; \
echo; \
tparse -all -notests -file=$(JSON_OUT) || true; \
exit $$rc
# TDD loop: re-runs affected packages on file save.
test-watch:
go tool gotestsum --watch --format testname --packages="$(PKGS)"
# CI / pre-push: race + coverage + JSON + JUnit + tparse summary table
test-ci:
@set +e; \
go tool gotestsum \
--format pkgname \
--format-icons hivis \
--hide-summary=skipped,output,errors,failed \
--jsonfile $(JSON_OUT) \
--junitfile $(JUNIT) \
--packages="$(PKGS)" \
-- -count=1 -race -covermode=atomic -coverprofile=$(COVER); \
rc=$$?; \
echo; \
tparse -all -notests -slow 10 -file=$(JSON_OUT) || true; \
exit $$rc
# Replay last summary without re-running tests
test-summary:
tparse -all -notests -file=$(JSON_OUT)
# Open HTML coverage report from last `test-ci` run
cover:
go tool cover -html=$(COVER)

View File

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

View File

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

View File

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

156
api/handlers/card_test.go Normal file
View File

@@ -0,0 +1,156 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strconv"
"testing"
"time"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"github.com/go-chi/chi/v5"
)
// TestGetCards_FiltersExpiredRows drives the SQL WHERE claude used by
// GetCards. a column-name typo (e.g used_id instead of user_id) surfaces
// here as a sqlite error, not in prod
func TestGetCards_FiltersExpiredRows(t *testing.T) {
testDB, user := setupCardsExpireTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
now := time.Now()
past := now.Add(-1 * time.Hour)
future := now.Add(24 * time.Hour)
expired := models.Card{
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
}
fresh := models.Card{
UserID: user.ID, StripePaymentID: "pm_fresh", Last4: "2222",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &future,
}
legacy := models.Card{
UserID: user.ID, StripePaymentID: "pm_legacy", Last4: "3333",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030,
}
for _, card := range []*models.Card{&expired, &fresh, &legacy} {
if err := db.Create(card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
}
handler := NewCardHandler("", "")
req := authRequest("GET", "/api/v1/cards", nil, user.ID)
w := httptest.NewRecorder()
handler.GetCards(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GetCards status %d, body %s", w.Code, w.Body.String())
}
var resp struct {
Success bool `json:"success"`
Data struct {
Cards []models.Card `json:"cards"`
} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Data.Cards) != 2 {
t.Fatalf("want 2 visible cards (fresh + legacy NULL), got %d", len(resp.Data.Cards))
}
for _, card := range resp.Data.Cards {
if card.ID == expired.ID {
t.Fatalf("expired card leaked into GetCards response")
}
}
}
// TestSetDefaultCard_ExpiredReturns410 verifies the sync-expire branch when
// the user acts on a card whose retention deadline already elapsed
func TestSetDefaultCard_ExpiredReturns410(t *testing.T) {
testDB, user := setupCardsExpireTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
past := time.Now().Add(-1 * time.Hour)
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
handler := NewCardHandler("", "")
idStr := strconv.FormatUint(uint64(card.ID), 10)
req := authRequest("PUT", "/api/v1/cards/"+idStr+"/default", nil, user.ID)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", idStr)
req = req.WithContext(contextWithRoute(req.Context(), routeCtx))
w := httptest.NewRecorder()
handler.SetDefaultCard(w, req)
if w.Code != http.StatusGone {
t.Fatalf("want 410 gone, got %d, body %s", w.Code, w.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp.Error.Code != "CARD_STORAGE_EXPIRED" {
t.Fatalf("want CARD_STORAGE_EXPIRED code, got %q", resp.Error.Code)
}
var soft models.Card
if err := db.Unscoped().First(&soft, card.ID).Error; err != nil {
t.Fatalf("reload card unscoped: %v", err)
}
if !soft.DeletedAt.Valid {
t.Fatalf("expired card was not soft-deleted")
}
}
// TestRefreshCardStorageTTL_BumpLastUsedAndExpiry exercises the SQL Updates
// map used by both the payment_intent.succeede webhook and the duplicate-card
// add path. Column-name typos in the map surface here
func TestRefreshCardStorageTTL_BumpLastUsedAndExpiry(t *testing.T) {
testDB, user := setupCardsExpireTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
old := time.Now().Add(-30 * 24 * time.Hour)
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_refresh", Last4: "4242",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &old,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
now := time.Now()
if err := refreshCardStorageTTL(db, card.ID, now); err != nil {
t.Fatalf("refreshCardStorageTTL: %v", err)
}
var reloaded models.Card
if err := db.First(&reloaded, card.ID).Error; err != nil {
t.Fatalf("reload card: %v", err)
}
if reloaded.LastUsedAt == nil || !reloaded.StorageExpiresAt.After(now.Add(179*24*time.Hour)) {
t.Fatalf("storage_expires_at not extended, got %v", reloaded.StorageExpiresAt)
}
}

View File

@@ -5,8 +5,10 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -75,19 +77,24 @@ func NewCardHandler(stripeSecretKey string, appURL string) *CardHandler {
} }
// GetCards returns all saved cards for the authenticated user. // GetCards returns all saved cards for the authenticated user.
// Cards past their retention deadline are filtered out at read time;
// physical deletion runs in handlers.ReconcileExpiredCards.
func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) { func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context()) db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context()) userID := middleware.UserIDFromContext(r.Context())
var cards []models.Card var cards []models.Card
db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&cards) db.Where(
"user_id = ? AND (storage_expires_at IS NULL OR storage_expires_at > ?)",
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, "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. // GetCard returns a single saved card by ID.
func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) { func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
@@ -107,7 +114,7 @@ func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"card": card, "card": card,
}) })
} }
@@ -129,6 +136,15 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
return return
} }
if card.IsStorageExpired(time.Now()) {
if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
if err := db.Model(&models.Card{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil { if err := db.Model(&models.Card{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update card", nil) utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update card", nil)
return return
@@ -140,7 +156,7 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"card": card, "card": card,
}) })
} }
@@ -181,7 +197,7 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil) utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"url": fmt.Sprintf("%s/pay/%s", h.appURL, token), "url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
}) })
} }
@@ -286,7 +302,7 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
audit.CardAdded(userID, card.ID, card.Brand, card.Last4) audit.CardAdded(userID, card.ID, card.Brand, card.Last4)
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{ utils.RespondSuccess(w, http.StatusCreated, map[string]any{
"card": card, "card": card,
}) })
} }
@@ -331,11 +347,56 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
audit.CardDeleted(userID, card.ID) audit.CardDeleted(userID, card.ID)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"message": "card deleted", "message": "card deleted",
}) })
} }
// 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 {
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)
}
if err := tx.Delete(card).Error; err != nil {
return fmt.Errorf("failed to delete expired card: %w", err)
}
return nil
}); err != nil {
return err
}
if stripeKey != "" && strings.HasPrefix(card.StripePaymentID, "pm_") {
stripe.Key = stripeKey
if _, err := paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{}); err != nil {
slog.Warn("failed to detach expired card", "card_id", card.ID, "error", err)
}
}
audit.CardExpired(card.UserID, card.ID)
return nil
}
func refreshCardStorageTTL(db *gorm.DB, cardID uint, now time.Time) error {
return db.Model(&models.Card{}).Where("id = ?", cardID).Updates(map[string]any{
"last_used_at": now,
"storage_expires_at": models.CardStorageExpiresAt(now),
}).Error
}
func respondCardStorageExpired(w http.ResponseWriter) {
utils.RespondError(
w,
http.StatusGone,
"CARD_STORAGE_EXPIRED",
"saved card expired, add it again",
nil,
)
}
// ensureStripeCustomer creates a Stripe Customer for the user if they don't // ensureStripeCustomer creates a Stripe Customer for the user if they don't
// already have one and persists the customer ID. Safe to call concurrently — // already have one and persists the customer ID. Safe to call concurrently —
// a duplicate Stripe customer is harmless; the local record reflects the first // a duplicate Stripe customer is harmless; the local record reflects the first

View File

@@ -0,0 +1,155 @@
package handlers
import (
"os"
"testing"
"time"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// TestReconcileExpiredCards_RemovesPastDeadlineOnly drives the real SQL path
// so a column-name typo in the WHERE clause surfaces here, not in production.
func TestReconcileExpiredCards_RemovesPastDeadlineOnly(t *testing.T) {
testDB, user := setupCardsExpireTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
now := time.Now()
past := now.Add(-1 * time.Hour)
future := now.Add(24 * time.Hour)
expired := models.Card{
UserID: user.ID,
StripePaymentID: "tok_expired",
Last4: "1111",
Brand: "Visa",
ExpMonth: 1,
ExpYear: 2030,
StorageExpiresAt: &past,
}
fresh := models.Card{
UserID: user.ID,
StripePaymentID: "tok_fresh",
Last4: "2222",
Brand: "Visa",
ExpMonth: 1,
ExpYear: 2030,
StorageExpiresAt: &future,
}
noTTL := models.Card{
UserID: user.ID,
StripePaymentID: "tok_legacy",
Last4: "3333",
Brand: "Visa",
ExpMonth: 1,
ExpYear: 2030,
}
for _, c := range []*models.Card{&expired, &fresh, &noTTL} {
if err := db.Create(c).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
}
cart := models.Cart{UserID: user.ID, CardID: &expired.ID}
if err := db.Create(&cart).Error; err != nil {
t.Fatalf("seed cart: %v", err)
}
ReconcileExpiredCards("")
var remaining []models.Card
if err := db.Where("user_id = ?", user.ID).Find(&remaining).Error; err != nil {
t.Fatalf("list cards: %v", err)
}
if len(remaining) != 2 {
t.Fatalf("want 2 cards remaining, got %d", len(remaining))
}
for _, c := range remaining {
if c.ID == expired.ID {
t.Fatalf("expired card was not deleted")
}
}
var refreshed models.Cart
if err := db.First(&refreshed, cart.ID).Error; err != nil {
t.Fatalf("reload cart: %v", err)
}
if refreshed.CardID != nil {
t.Fatalf("cart still references expired card: %v", refreshed.CardID)
}
}
// TestReconcileExpiredCards_SweepsAcrossUsers verifies the sweeper is not
// scoped to one user; it must collect every past-deadline row in one pass.
func TestReconcileExpiredCards_SweepsAcrossUsers(t *testing.T) {
testDB, user := setupCardsExpireTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
past := time.Now().Add(-1 * time.Hour)
other := models.User{
SSHKeyFingerprint: "SHA256:other",
SSHPublicKey: "ssh-ed25519 AAAA other",
Name: "Other",
Email: "other@example.com",
}
if err := db.Create(&other).Error; err != nil {
t.Fatalf("seed other user: %v", err)
}
for _, uid := range []uint{user.ID, other.ID} {
c := models.Card{
UserID: uid,
StripePaymentID: "tok_expired",
Last4: "9999",
Brand: "Visa",
ExpMonth: 1,
ExpYear: 2030,
StorageExpiresAt: &past,
}
if err := db.Create(&c).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
}
ReconcileExpiredCards("")
var count int64
if err := db.Model(&models.Card{}).Count(&count).Error; err != nil {
t.Fatalf("count cards: %v", err)
}
if count != 0 {
t.Fatalf("want 0 cards after sweep, got %d", count)
}
}
func setupCardsExpireTestDB(t *testing.T) (string, models.User) {
t.Helper()
testDB := "test_cards_expire.db"
_ = os.Remove(testDB)
database.ResetForTesting()
db, err := database.Connect(testDB)
if err != nil {
t.Fatalf("connect: %v", err)
}
if err := database.Migrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
user := models.User{
SSHKeyFingerprint: "SHA256:cards-expire",
SSHPublicKey: "ssh-ed25519 AAAA cardsexpire",
Name: "Test User",
Email: "cards-expire@example.com",
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("seed user: %v", err)
}
return testDB, user
}

View File

@@ -5,8 +5,10 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"time" "time"
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentintent" "github.com/stripe/stripe-go/v78/paymentintent"
"github.com/stripe/stripe-go/v78/paymentmethod" "github.com/stripe/stripe-go/v78/paymentmethod"
@@ -28,6 +30,12 @@ type CartHandler struct {
maxOrderCents int maxOrderCents int
} }
// maxAuthIssuances caps how many times a single ordes's PaymentIntent can be
// pushed into requires_action; one initial challende plus up to three retries
// before we tell the customer to use a different card. Counted via existing
// audit rows (audit.EventOrderRequiresAction) so the cap stays migration-free.
const maxAuthIssuances = 4
// NewCartHandler creates a new cart handler with the Stripe secret key // NewCartHandler creates a new cart handler with the Stripe secret key
func NewCartHandler(stripeSecretKey, appURL string, maxOrderCents int) *CartHandler { func NewCartHandler(stripeSecretKey, appURL string, maxOrderCents int) *CartHandler {
return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL, maxOrderCents: maxOrderCents} return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL, maxOrderCents: maxOrderCents}
@@ -47,18 +55,18 @@ func getOrCreateCart(ctx context.Context, userID uint) (*models.Cart, error) {
return &cart, nil return &cart, nil
} }
func cartResponse(ctx context.Context, cart *models.Cart) map[string]interface{} { func cartResponse(ctx context.Context, cart *models.Cart) map[string]any {
db := database.GetDB().WithContext(ctx) db := database.GetDB().WithContext(ctx)
var items []models.CartItem var items []models.CartItem
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items) db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
subtotal := 0 subtotal := 0
itemsData := make([]map[string]interface{}, 0, len(items)) itemsData := make([]map[string]any, 0, len(items))
for _, item := range items { for _, item := range items {
lineTotal := item.Coffee.Price * item.Quantity lineTotal := item.Coffee.Price * item.Quantity
subtotal += lineTotal subtotal += lineTotal
itemsData = append(itemsData, map[string]interface{}{ itemsData = append(itemsData, map[string]any{
"id": item.ID, "id": item.ID,
"coffee_id": item.CoffeeID, "coffee_id": item.CoffeeID,
"quantity": item.Quantity, "quantity": item.Quantity,
@@ -67,7 +75,7 @@ func cartResponse(ctx context.Context, cart *models.Cart) map[string]interface{}
}) })
} }
return map[string]interface{}{ return map[string]any{
"items": itemsData, "items": itemsData,
"subtotal": subtotal, "subtotal": subtotal,
"address_id": cart.AddressID, "address_id": cart.AddressID,
@@ -86,7 +94,7 @@ func (h *CartHandler) GetCart(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"cart": cartResponse(r.Context(), cart), "cart": cartResponse(r.Context(), cart),
}) })
} }
@@ -149,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), "cart": cartResponse(r.Context(), cart),
}) })
} }
@@ -185,7 +193,7 @@ func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"message": "address set", "message": "address set",
}) })
} }
@@ -210,6 +218,15 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
return return
} }
if card.IsStorageExpired(time.Now()) {
if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
cart, err := getOrCreateCart(r.Context(), userID) cart, err := getOrCreateCart(r.Context(), userID)
if err != nil { if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil) utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
@@ -221,7 +238,7 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"message": "card set", "message": "card set",
}) })
} }
@@ -241,7 +258,7 @@ func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"message": "cart cleared", "message": "cart cleared",
}) })
} }
@@ -296,6 +313,16 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
return return
} }
if card.IsStorageExpired(time.Now()) {
middleware.RecordCartConversion("validation_card_expired")
if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
var user models.User var user models.User
if err := db.First(&user, userID).Error; err != nil { if err := db.First(&user, userID).Error; err != nil {
middleware.RecordCartConversion("user_not_found") middleware.RecordCartConversion("user_not_found")
@@ -359,7 +386,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
middleware.RecordCartConversion("validation_over_limit") middleware.RecordCartConversion("validation_over_limit")
utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT", utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT",
fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100), fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100),
map[string]interface{}{ map[string]any{
"total_cents": total, "total_cents": total,
"limit_cents": h.maxOrderCents, "limit_cents": h.maxOrderCents,
}) })
@@ -480,7 +507,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil { if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return err return err
} }
return tx.Model(cart).Updates(map[string]interface{}{ return tx.Model(cart).Updates(map[string]any{
"address_id": nil, "address_id": nil,
"card_id": nil, "card_id": nil,
}).Error }).Error
@@ -507,7 +534,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
} }
go notify.SlackOrderPaid(&order) go notify.SlackOrderPaid(&order)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"order": order, "order": order,
}) })
} }
@@ -603,3 +630,147 @@ func appURLForHandler(r *http.Request) string {
} }
return fmt.Sprintf("%s://%s", scheme, host) return fmt.Sprintf("%s://%s", scheme, host)
} }
// RetryAuth re-Confirms an in-flight PaymentIntent that the customer failed
// or abandoned during the 3DS challenge, recovering the existing order rather
// than starting a new checkout. Bound to POST /api/v1/orders/{id}/retry-auth.
//
// Stripe's PI state machine bounces back to requires_payment_method after a
// failed or abandoned 3DS, so paymentintent.Confirm(pi.id, …) mints a fresh
// next_action.redirect_to_url — the same shape respondRequiresAction already
// handles. No idempotency key is set: Confirm mutates an existing PI and
// Stripe collapses by id (see sca-psd2-compliance.md caveat #5).
func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id")
id64, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
return
}
orderID := uint(id64)
var order models.Order
if err := db.Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
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 {
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "order is already finalized", nil)
return
}
if order.StripePaymentID == "" {
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "order has no payment intent to retry", nil)
return
}
// Cap is best-effort: two concurrent retries at issuances=cap-1 will both
// pass this check and both Confirm before either writes a new event row.
// Per-user RateLimitByUser(10/min) on the route bounds the worst case.
// SQLite-WAL has no SELECT ... FOR UPDATE, so a stricter guard would need
// a serializing INSERT - not worth the cimplexity at current traffic.
var issuances int64
if err := db.Model(&models.OrderEvent{}).
Where("order_id = ? AND type = ?", order.ID, audit.EventOrderRequiresAction).
Count(&issuances).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to count auth attempts", nil)
return
}
if issuances >= maxAuthIssuances {
middleware.RecordCartConversion("retry_max_attempts")
utils.RespondError(w, http.StatusTooManyRequests, "RETRY_LIMIT", "too many authentication attempts, please use a different card", nil)
return
}
// scoped card lookup - match the initial-flow policy in ConvertCart so a
// card the user deleted (or that the storage-TTL reconciler removed)
// cannot be silently re-charged through retry. The ownership clause is
// defensive: order.CardID is enforced at SetCard time today but binding
// the retry path to that invariant explicitly costs nothing.
var card models.Card
if err := db.Where("id = ? AND user_id = ?", order.CardID, userID).First(&card).Error; err != nil {
utils.RespondError(w, http.StatusConflict, "CARD_NO_LONGER_AVAILABLE", "saved card is no longer available, add it again to retry", nil)
return
}
if card.IsStorageExpired(time.Now()) {
if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
stripe.Key = h.stripeKey
stripeStart := time.Now()
confirmed, cerr := paymentintent.Confirm(order.StripePaymentID, &stripe.PaymentIntentConfirmParams{
PaymentMethod: stripe.String(card.StripePaymentID),
OffSession: stripe.Bool(false),
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
})
stripeDur := time.Since(stripeStart).Seconds()
if cerr != nil {
middleware.ObserveStripeRequest("payment_intent_confirm", "error", stripeDur)
if stripeErr, ok := cerr.(*stripe.Error); ok && stripeErr.Type == stripe.ErrorTypeCard {
db.Model(&order).Update("status", models.OrderStatusFailed)
audit.OrderFailed(userID, order.ID, order.Total, stripeErr.Msg)
middleware.RecordCartConversion("retry_card_declined")
utils.RespondError(w, http.StatusPaymentRequired, mapStripeCardErrCode(stripeErr.Code), stripeErr.Msg, nil)
return
}
if stripeErr, ok := cerr.(*stripe.Error); ok && stripeErr.Code == "payment_intent_unexpected_state" {
actual, gerr := paymentintent.Get(order.StripePaymentID, nil)
if gerr != nil {
middleware.RecordCartConversion("retry_stripe_error")
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment failed", nil)
return
}
switch actual.Status {
case stripe.PaymentIntentStatusSucceeded:
middleware.RecordCartConversion("retry_race_already_done")
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
"order_id": order.ID,
"status": "succeeded",
})
return
case stripe.PaymentIntentStatusCanceled:
db.Model(&order).Update("status", models.OrderStatusFailed)
audit.OrderFailed(userID, order.ID, order.Total, "payment intent canceled")
middleware.RecordCartConversion("retry_pi_canceled")
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "payment intent is canceled, please start a new order", nil)
return
default:
middleware.RecordCartConversion("retry_pi_unexpected_state")
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment intent in unexpected state", nil)
return
}
}
middleware.RecordCartConversion("retry_stripe_error")
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment failed", nil)
return
}
middleware.ObserveStripeRequest("payment_intent_confirm", "success", stripeDur)
// frictionless 3DS on retry, stripe approved without a new challenge.
// the payment_intent.succeeded webhook owns the order.status=paid
// transition (matches the initial-flow contract); tell the TUI to keep
// polling /orders/{id}/status until it lands
if confirmed.Status == stripe.PaymentIntentStatusSucceeded {
middleware.RecordCartConversion("retry_frictionless")
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
"order_id": order.ID,
"status": "succeeded",
})
return
}
h.respondRequiresAction(w, &order, card.StripePaymentID, confirmed)
}

View File

@@ -3,14 +3,17 @@ package handlers
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"testing" "testing"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"terminalShop/api/middleware" "terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database" "terminalShop/pkg/database"
"terminalShop/pkg/models" "terminalShop/pkg/models"
) )
@@ -80,8 +83,8 @@ func TestGetCartEmpty(t *testing.T) {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
Cart struct { Cart struct {
Items []interface{} `json:"items"` Items []any `json:"items"`
Subtotal int `json:"subtotal"` Subtotal int `json:"subtotal"`
} `json:"cart"` } `json:"cart"`
} `json:"data"` } `json:"data"`
} }
@@ -107,7 +110,7 @@ func TestSetItemAndGetCart(t *testing.T) {
handler := NewCartHandler("", "", 0) handler := NewCartHandler("", "", 0)
// Add item (CoffeeID=1, Quantity=2) // Add item (CoffeeID=1, Quantity=2)
body, _ := json.Marshal(map[string]interface{}{ body, _ := json.Marshal(map[string]any{
"coffee_id": 1, "coffee_id": 1,
"quantity": 2, "quantity": 2,
}) })
@@ -163,13 +166,13 @@ func TestSetItemUpdateQuantity(t *testing.T) {
handler := NewCartHandler("", "", 0) handler := NewCartHandler("", "", 0)
// Add item // 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) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
// Update quantity // Update quantity
body, _ = json.Marshal(map[string]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) req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -208,13 +211,13 @@ func TestSetItemRemove(t *testing.T) {
handler := NewCartHandler("", "", 0) handler := NewCartHandler("", "", 0)
// Add item // 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) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
// Remove item (quantity 0) // Remove item (quantity 0)
body, _ = json.Marshal(map[string]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) req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -228,7 +231,7 @@ func TestSetItemRemove(t *testing.T) {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
Cart struct { Cart struct {
Items []interface{} `json:"items"` Items []any `json:"items"`
} `json:"cart"` } `json:"cart"`
} `json:"data"` } `json:"data"`
} }
@@ -246,7 +249,7 @@ func TestSetItemInvalidProduct(t *testing.T) {
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) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -263,7 +266,7 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
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) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -282,7 +285,7 @@ func TestClearCart(t *testing.T) {
// Add two items // Add two items
for _, coffeeID := range []uint{1, 2} { 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) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -306,7 +309,7 @@ func TestClearCart(t *testing.T) {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
Cart struct { Cart struct {
Items []interface{} `json:"items"` Items []any `json:"items"`
} `json:"cart"` } `json:"cart"`
} `json:"data"` } `json:"data"`
} }
@@ -336,7 +339,7 @@ func TestSetAddress(t *testing.T) {
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) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetAddress(w, req) handler.SetAddress(w, req)
@@ -390,7 +393,7 @@ func TestSetAddressWrongUser(t *testing.T) {
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) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetAddress(w, req) handler.SetAddress(w, req)
@@ -418,7 +421,7 @@ func TestSetCard(t *testing.T) {
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) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetCard(w, req) handler.SetCard(w, req)
@@ -470,7 +473,7 @@ func TestSetCardWrongUser(t *testing.T) {
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) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetCard(w, req) handler.SetCard(w, req)
@@ -488,7 +491,7 @@ func TestConvertCartMissingAddress(t *testing.T) {
handler := NewCartHandler("", "", 0) handler := NewCartHandler("", "", 0)
// Add item but no address or card // Add item but no address or card
body, _ := json.Marshal(map[string]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) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -512,14 +515,14 @@ func TestConvertCartMissingCard(t *testing.T) {
db := database.GetDB() db := database.GetDB()
// Add item and address // 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) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
addr := models.Address{UserID: user.ID, Name: "Test", Street: "123 St", City: "PDX", State: "OR", Zip: "97201", Country: "US"} addr := models.Address{UserID: user.ID, Name: "Test", Street: "123 St", City: "PDX", State: "OR", Zip: "97201", Country: "US"}
db.Create(&addr) db.Create(&addr)
body, _ = json.Marshal(map[string]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) req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
handler.SetAddress(w, req) handler.SetAddress(w, req)
@@ -560,7 +563,7 @@ func TestMultipleItems(t *testing.T) {
// Add three different items // Add three different items
for i := uint(1); i <= 3; i++ { for i := uint(1); i <= 3; i++ {
body, _ := json.Marshal(map[string]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) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -578,8 +581,8 @@ func TestMultipleItems(t *testing.T) {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
Cart struct { Cart struct {
Items []interface{} `json:"items"` Items []any `json:"items"`
Subtotal int `json:"subtotal"` Subtotal int `json:"subtotal"`
} `json:"cart"` } `json:"cart"`
} `json:"data"` } `json:"data"`
} }
@@ -611,13 +614,13 @@ func TestCartIsolation(t *testing.T) {
r.Get("/cart", handler.GetCart) r.Get("/cart", handler.GetCart)
// User 1 adds item // 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) req := authRequest("PUT", "/cart/item", body, user1.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
// User 2 adds different item // User 2 adds different item
body, _ = json.Marshal(map[string]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) req = authRequest("PUT", "/cart/item", body, user2.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
@@ -706,9 +709,17 @@ func TestConvertCartLimit(t *testing.T) {
t.Fatalf("seed card: %v", err) t.Fatalf("seed card: %v", err)
} }
// 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)
}
handler := NewCartHandler("", "", tc.capCents) handler := NewCartHandler("", "", tc.capCents)
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 4, "quantity": tc.quantity}) body, _ := json.Marshal(map[string]any{"coffee_id": 4, "quantity": tc.quantity})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -717,7 +728,7 @@ func TestConvertCartLimit(t *testing.T) {
} }
// set address // 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) req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
handler.SetAddress(w, req) handler.SetAddress(w, req)
@@ -726,7 +737,7 @@ func TestConvertCartLimit(t *testing.T) {
} }
// set card // 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) req = authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
handler.SetCard(w, req) handler.SetCard(w, req)
@@ -742,9 +753,9 @@ func TestConvertCartLimit(t *testing.T) {
var resp struct { var resp struct {
Success bool `json:"success"` Success bool `json:"success"`
Error struct { Error struct {
Code string `json:"code"` Code string `json:"code"`
Message string `json:"message"` Message string `json:"message"`
Details map[string]interface{} `json:"details"` Details map[string]any `json:"details"`
} `json:"error"` } `json:"error"`
} }
_ = json.NewDecoder(w.Body).Decode(&resp) _ = json.NewDecoder(w.Body).Decode(&resp)
@@ -757,10 +768,257 @@ func TestConvertCartLimit(t *testing.T) {
if got, _ := resp.Error.Details["limit_cents"].(float64); int(got) != tc.capCents { 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"]) 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 { 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*500, resp.Error.Details["total_cents"]) t.Errorf("total_cents in details: want %d, got %v", tc.quantity*pinnedPrice, resp.Error.Details["total_cents"])
} }
} }
}) })
} }
} }
// TestSetCard_ExpiredReturns410 verifies that selecting an already-expired
// card on the cart returns 410 + CARD_STORAGE_EXPIRED and soft-deletes the
// card. Exercises cart.go SetCard expired-card branch
func TestSetCard_ExpiredReturns410(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
past := time.Now().Add(-1 * time.Hour)
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
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)
if w.Code != http.StatusGone {
t.Fatalf("want 410, got %d, body: %s", w.Code, w.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp.Error.Code != "CARD_STORAGE_EXPIRED" {
t.Fatalf("want CARD_STORAGE_EXPIRED, got %q", resp.Error.Code)
}
var soft models.Card
if err := db.Unscoped().First(&soft, card.ID).Error; err != nil {
t.Fatalf("reload card unscoped: %v", err)
}
if !soft.DeletedAt.Valid {
t.Fatalf("expired card was not soft-deleted")
}
}
// TestConvertCart_ExpiredReturns410 verifies that attempting checkout with an
// expired saved card shor-circuits at the validation step with 410 +
// CARD_STORAGE_EXPIRED before any stripe interaction
func TestConvertCart_ExpiredReturns410(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
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)
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]any{"address_id": addr.ID})
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w = httptest.NewRecorder()
NewCartHandler("", "", 0).SetAddress(w, req)
past := time.Now().Add(-1 * time.Hour)
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
if err := db.Model(&models.Cart{}).Where("user_id = ?", user.ID).Update("card_id", card.ID).Error; err != nil {
t.Fatalf("attach card to card: %v", err)
}
req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
w = httptest.NewRecorder()
NewCartHandler("", "", 0).ConvertCart(w, req)
if w.Code != http.StatusGone {
t.Fatalf("want 410, got %d, body: %s", w.Code, w.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp.Error.Code != "CARD_STORAGE_EXPIRED" {
t.Fatalf("want CARD_STORAGE_EXPIRED, got %q", resp.Error.Code)
}
}
// TestRetryAuth exercises the pre-Stripe validation branches of
// CartHandler.RetryAuth. The happy paths (Confirm → requires_action /
// 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.
func TestRetryAuth(t *testing.T) {
cases := []struct {
name string
ownedByUser bool
seedStatus models.OrderStatus
seedPayment string
seedEvents int
wantStatus int
wantErrCode string
}{
{
name: "wrong user returns not found",
ownedByUser: false, seedStatus: models.OrderStatusFailed,
seedPayment: "pi_test_wrong_user", seedEvents: 1,
wantStatus: http.StatusNotFound, wantErrCode: "ORDER_NOT_FOUND",
},
{
name: "missing payment intent rejects",
ownedByUser: true, seedStatus: models.OrderStatusPending,
seedPayment: "", seedEvents: 0,
wantStatus: http.StatusConflict, wantErrCode: "INVALID_STATE",
},
{
name: "paid order is not retryable",
ownedByUser: true, seedStatus: models.OrderStatusPaid,
seedPayment: "pi_test_paid", seedEvents: 1,
wantStatus: http.StatusConflict, wantErrCode: "INVALID_STATE",
},
{
name: "refunded order is not retryable",
ownedByUser: true, seedStatus: models.OrderStatusRefunded,
seedPayment: "pi_test_refunded", seedEvents: 1,
wantStatus: http.StatusConflict, wantErrCode: "INVALID_STATE",
},
{
name: "retry cap reached returns 429",
ownedByUser: true, seedStatus: models.OrderStatusFailed,
seedPayment: "pi_test_capped", seedEvents: maxAuthIssuances,
wantStatus: http.StatusTooManyRequests, wantErrCode: "RETRY_LIMIT",
},
}
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()
user.StripeCustomerID = "cus_test_retry"
if err := db.Save(&user).Error; err != nil {
t.Fatalf("save user: %v", err)
}
ownerID := user.ID
if !tc.ownedByUser {
other := models.User{
SSHKeyFingerprint: "SHA256:retryother",
SSHPublicKey: "ssh-ed25519 AAAA retryother",
}
if err := db.Create(&other).Error; err != nil {
t.Fatalf("seed other user: %v", err)
}
ownerID = other.ID
}
card := models.Card{
UserID: ownerID, StripePaymentID: "pm_test_retry",
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: ownerID,
CardID: card.ID,
StripePaymentID: tc.seedPayment,
Status: tc.seedStatus,
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)
}
for i := 0; i < tc.seedEvents; i++ {
evt := models.OrderEvent{
OrderID: order.ID,
Type: audit.EventOrderRequiresAction,
Actor: fmt.Sprintf("user:%d", ownerID),
Payload: `{}`,
}
if err := db.Create(&evt).Error; err != nil {
t.Fatalf("seed event: %v", err)
}
}
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()
handler.RetryAuth(w, req)
if w.Code != tc.wantStatus {
t.Fatalf("status: want %d, got %d body=%s", tc.wantStatus, w.Code, w.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp.Error.Code != tc.wantErrCode {
t.Fatalf("err code: want %q, got %q, body=%s", tc.wantErrCode, resp.Error.Code, w.Body.String())
}
})
}
}
func TestRetryAuth_OrderNotFound(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
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)
if w.Code != http.StatusNotFound {
t.Fatalf("want 404, got %d body=%s", w.Code, w.Body.String())
}
}

View File

@@ -221,7 +221,7 @@ func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
audit.OrderRefunded(userID, order.ID, order.Total, rf.ID) audit.OrderRefunded(userID, order.ID, order.Total, rf.ID)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"refund_id": rf.ID, "refund_id": rf.ID,
"status": "refunded", "status": "refunded",
}) })
@@ -287,7 +287,7 @@ func (h *OrderHandler) UpdateTracking(w http.ResponseWriter, r *http.Request) {
audit.OrderShipped(order.UserID, order.ID, req.Carrier, req.TrackingNumber) audit.OrderShipped(order.UserID, order.ID, req.Carrier, req.TrackingNumber)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"order_id": order.ID, "order_id": order.ID,
"carrier": req.Carrier, "carrier": req.Carrier,
"tracking_number": req.TrackingNumber, "tracking_number": req.TrackingNumber,

View File

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

View File

@@ -49,7 +49,7 @@ func ReconcileOrders(stripeKey string) {
"order_id", order.ID, "pi", pi.ID) "order_id", order.ID, "pi", pi.ID)
txErr := db.Transaction(func(tx *gorm.DB) error { txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]interface{}{ if err := tx.Model(&order).Updates(map[string]any{
"status": models.OrderStatusPaid, "status": models.OrderStatusPaid,
"stripe_payment_id": pi.ID, "stripe_payment_id": pi.ID,
}).Error; err != nil { }).Error; err != nil {
@@ -62,7 +62,7 @@ func ReconcileOrders(stripeKey string) {
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil { if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return err return err
} }
return tx.Model(&cart).Updates(map[string]interface{}{ return tx.Model(&cart).Updates(map[string]any{
"address_id": nil, "address_id": nil,
"card_id": nil, "card_id": nil,
}).Error }).Error
@@ -77,6 +77,33 @@ func ReconcileOrders(stripeKey string) {
} }
} }
// 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) {
db := database.GetDB()
var cards []models.Card
if err := db.Where(
"storage_expires_at IS NOT NULL AND storage_expires_at <= ?",
time.Now(),
).Find(&cards).Error; err != nil {
reconcileLog().Error("expired card scan failed", "error", err)
return
}
if len(cards) == 0 {
return
}
reconcileLog().Info("expiring inactive saved cards", "count", len(cards))
for i := range cards {
if err := expireStoredCard(db, &cards[i], stripeKey); err != nil {
reconcileLog().Error("expire card failed", "card_id", cards[i].ID, "error", err)
}
}
}
// ReconcileUnshipped finds paid order orlder than 24h with no tracking // ReconcileUnshipped finds paid order orlder than 24h with no tracking
// number and posts single Slack reminder. Pure read + notif // number and posts single Slack reminder. Pure read + notif
func ReconcileUnshipped() { func ReconcileUnshipped() {

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"net/http" "net/http"
"time"
"terminalShop/api/middleware" "terminalShop/api/middleware"
"terminalShop/pkg/database" "terminalShop/pkg/database"
@@ -11,6 +12,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// ViewHandler handles bulk TUI bootstrap data
type ViewHandler struct{} type ViewHandler struct{}
func NewViewHandler() *ViewHandler { func NewViewHandler() *ViewHandler {
@@ -37,7 +39,10 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
db.Where("user_id = ?", userID).Find(&addresses) db.Where("user_id = ?", userID).Find(&addresses)
var cards []models.Card var cards []models.Card
db.Where("user_id = ?", userID).Find(&cards) db.Where(
"user_id = ? AND (storage_expires_at IS NULL OR storage_expires_at > ?)",
userID, time.Now(),
).Order("is_default DESC, created_at DESC").Find(&cards)
var orders []models.Order var orders []models.Order
db.Where("user_id = ?", userID). db.Where("user_id = ?", userID).
@@ -49,7 +54,7 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems) db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems)
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"user": user.ToPublic(), "user": user.ToPublic(),
"products": products, "products": products,
"cart": cartItems, "cart": cartItems,

70
api/handlers/view_test.go Normal file
View File

@@ -0,0 +1,70 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// TestGetViewInit_FiltersExpiredCardsAndOrders drives the WHERE + ORDER BY
// clauses in GetViewInit. a column-name typo in either clause (e.g.
// id_default instead of is_default) surfaces here as a sqlite error
func TestGetViewInit_FiltersExpiredCardsAndOrders(t *testing.T) {
testDB, user := setupCardsExpireTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
now := time.Now()
past := now.Add(-1 * time.Hour)
future := now.Add((24 * time.Hour))
expired := models.Card{
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
}
freshDefault := models.Card{
UserID: user.ID, StripePaymentID: "pm_fresh_d", Last4: "2222",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &future,
IsDefault: true,
}
freshOther := models.Card{
UserID: user.ID, StripePaymentID: "pm_fresh_o", Last4: "3333",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &future,
}
for _, card := range []*models.Card{&expired, &freshDefault, &freshOther} {
if err := db.Create(card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
}
handler := NewViewHandler()
req := authRequest("GET", "/api/v1/view/init", nil, user.ID)
w := httptest.NewRecorder()
handler.GetViewInit(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GetViewInit status %d, body: %s", w.Code, w.Body.String())
}
var resp struct {
Data struct {
Cards []models.Card `json:"cards"`
} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Data.Cards) != 2 {
t.Fatalf("want 2 visible cards, got %d", len(resp.Data.Cards))
}
if resp.Data.Cards[0].ID != freshDefault.ID {
t.Fatalf("default card should sort first, got id=%d (want %d)", resp.Data.Cards[0].ID, freshDefault.ID)
}
}

View File

@@ -125,7 +125,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
} }
txErr := db.Transaction(func(tx *gorm.DB) error { txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]interface{}{ if err := tx.Model(&order).Updates(map[string]any{
"status": models.OrderStatusPaid, "status": models.OrderStatusPaid,
"stripe_payment_id": pi.ID, "stripe_payment_id": pi.ID,
}).Error; err != nil { }).Error; err != nil {
@@ -139,7 +139,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil { if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return fmt.Errorf("failed to delete cart item: %w", err) return fmt.Errorf("failed to delete cart item: %w", err)
} }
return tx.Model(&cart).Updates(map[string]interface{}{ return tx.Model(&cart).Updates(map[string]any{
"address_id": nil, "address_id": nil,
"card_id": nil, "card_id": nil,
}).Error }).Error
@@ -150,6 +150,12 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
return return
} }
if order.CardID != 0 {
if err := refreshCardStorageTTL(db, order.CardID, time.Now()); err != nil {
webhookLog().Warn("failed to refresh card storage ttl", "card_id", order.CardID, "error", err)
}
}
audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID) audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID)
go notify.SlackOrderPaid(&order) go notify.SlackOrderPaid(&order)
webhookLog().Info("order marked paid", "order_id", orderIDStr, "pi", pi.ID) webhookLog().Info("order marked paid", "order_id", orderIDStr, "pi", pi.ID)
@@ -255,8 +261,14 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, eve
webhookLog().Warn("failed to detach duplicate pm", "user_id", user.ID, "error", derr) webhookLog().Warn("failed to detach duplicate pm", "user_id", user.ID, "error", derr)
} }
// Bump UpdatedAt so the TUI poll signal can detect that the webhook // Bump UpdatedAt so the TUI poll signal can detect that the webhook
// fired even though no new row was inserted. // fired even though no new row was inserted. storage_expires_at is
if uerr := db.Model(&dup).Update("updated_at", time.Now()).Error; uerr != nil { // reset because the user just re-added the card; this is a card-add
// path, not a payment-success refresh, so last_used_at stays as-is.
now := time.Now()
if uerr := db.Model(&dup).Updates(map[string]any{
"updated_at": now,
"storage_expires_at": models.CardStorageExpiresAt(now),
}).Error; uerr != nil {
webhookLog().Warn("failed to bump dup card updated_at", "card_id", dup.ID, "error", uerr) webhookLog().Warn("failed to bump dup card updated_at", "card_id", dup.ID, "error", uerr)
} }
webhookLog().Info("skipped duplicate card via local-db match", "user_id", user.ID, "existing_card_id", dup.ID) webhookLog().Info("skipped duplicate card via local-db match", "user_id", user.ID, "existing_card_id", dup.ID)
@@ -272,6 +284,7 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, eve
ExpMonth: int(pm.Card.ExpMonth), ExpMonth: int(pm.Card.ExpMonth),
ExpYear: int(pm.Card.ExpYear), ExpYear: int(pm.Card.ExpYear),
} }
card.InitializeStorageTTL(time.Now())
if err := db.Create(&card).Error; err != nil { if err := db.Create(&card).Error; err != nil {
webhookLog().Error("failed to save card", "user_id", user.ID, "error", err) webhookLog().Error("failed to save card", "user_id", user.ID, "error", err)

View File

@@ -128,6 +128,22 @@ func main() {
} }
}() }()
// Sweep saved cards whose retention deadline elapsed. Read paths filter
// expired rows from query results, so this sweeper only drives the Stripe
// detach + audit + physical row delete asynchronously.
go func() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
handlers.ReconcileExpiredCards(cfg.StripeSecretKey)
case <-ctx.Done():
return
}
}
}()
log.Println("API server started. Press Ctrl+C to shutdown.") log.Println("API server started. Press Ctrl+C to shutdown.")
// Block until signal received // Block until signal received

View File

@@ -132,6 +132,7 @@ func SetupRoutes(
// Orders // Orders
r.Get("/orders", orderHandler.GetOrders) r.Get("/orders", orderHandler.GetOrders)
r.Get("/orders/{id}/status", orderHandler.GetOrderStatus) r.Get("/orders/{id}/status", orderHandler.GetOrderStatus)
r.With(middleware.RateLimitByUser(10, time.Minute)).Post("/orders/{id}/retry-auth", cartHandler.RetryAuth)
r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest) r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest)
r.Post("/orders/{id}/refund", orderHandler.RefundOrder) r.Post("/orders/{id}/refund", orderHandler.RefundOrder)

17
go.mod
View File

@@ -39,6 +39,7 @@ require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bitfield/gotestdox v0.2.2 // indirect
github.com/catppuccin/go v0.3.0 // indirect github.com/catppuccin/go v0.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -56,15 +57,20 @@ require (
github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/creack/pty v1.1.24 // indirect github.com/creack/pty v1.1.24 // indirect
github.com/dnephin/pflag v1.0.7 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect github.com/go-faster/errors v0.7.1 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gotesttools/gotestfmt/v2 v2.5.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect github.com/hashicorp/go-version v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -76,6 +82,8 @@ require (
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
@@ -98,10 +106,13 @@ require (
go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.52.0 // indirect golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.43.0 // indirect
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.43.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect google.golang.org/grpc v1.80.0 // indirect
@@ -109,4 +120,10 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/clickhouse v0.7.0 // indirect gorm.io/driver/clickhouse v0.7.0 // indirect
gorm.io/driver/mysql v1.5.7 // indirect gorm.io/driver/mysql v1.5.7 // indirect
gotest.tools/gotestsum v1.13.0 // indirect
)
tool (
github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt
gotest.tools/gotestsum
) )

27
go.sum
View File

@@ -26,6 +26,8 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE=
github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
@@ -69,12 +71,18 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk=
github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ= github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ=
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c/go.mod h1:0ghtL4RhKBjyWMkHCWosp2aHSEXShQeQMLQ0qBCytfU= github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c/go.mod h1:0ghtL4RhKBjyWMkHCWosp2aHSEXShQeQMLQ0qBCytfU=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
@@ -105,8 +113,12 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gotesttools/gotestfmt/v2 v2.5.0 h1:fSU3MnR+E+fvuXdw1l8xbufKhDxY3Tfjsjx/I1WerB4=
github.com/gotesttools/gotestfmt/v2 v2.5.0/go.mod h1:oQJg2KZ2aGoqEbMC2PDaAeBYm0tOkocgixK9FzsCdp4=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
@@ -143,6 +155,11 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
@@ -239,6 +256,8 @@ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -259,6 +278,8 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -274,6 +295,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -310,5 +333,9 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc= gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc=
gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y= gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y=
gotest.tools/gotestsum v1.13.0 h1:+Lh454O9mu9AMG1APV4o0y7oDYKyik/3kBOiCqiEpRo=
gotest.tools/gotestsum v1.13.0/go.mod h1:7f0NS5hFb0dWr4NtcsAsF0y1kzjEFfAil0HiBQJE03Q=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=

View File

@@ -8,7 +8,6 @@ import (
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time" "time"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"

View File

@@ -104,16 +104,16 @@ func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error)
// APIResponse represents the standard API response format // APIResponse represents the standard API response format
type APIResponse struct { type APIResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Data map[string]interface{} `json:"data"` Data map[string]any `json:"data"`
Error *APIError `json:"error,omitempty"` Error *APIError `json:"error,omitempty"`
} }
// APIError represents an error from the API // APIError represents an error from the API
type APIError struct { type APIError struct {
Code string `json:"code"` Code string `json:"code"`
Message string `json:"message"` Message string `json:"message"`
Details map[string]interface{} `json:"details,omitempty"` Details map[string]any `json:"details,omitempty"`
} }
// ProductsResponse represents the products API response // ProductsResponse represents the products API response
@@ -339,7 +339,7 @@ func (c *Client) GetCart() (*CartData, error) {
func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) { func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
url := fmt.Sprintf("%s/api/v1/cart/item", c.BaseURL) url := fmt.Sprintf("%s/api/v1/cart/item", c.BaseURL)
body := map[string]interface{}{ body := map[string]any{
"coffee_id": coffeeID, "coffee_id": coffeeID,
"quantity": quantity, "quantity": quantity,
} }
@@ -379,7 +379,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
func (c *Client) SetCartAddress(addressID uint) error { func (c *Client) SetCartAddress(addressID uint) error {
url := fmt.Sprintf("%s/api/v1/cart/address", c.BaseURL) url := fmt.Sprintf("%s/api/v1/cart/address", c.BaseURL)
body := map[string]interface{}{ body := map[string]any{
"address_id": addressID, "address_id": addressID,
} }
jsonData, err := json.Marshal(body) jsonData, err := json.Marshal(body)
@@ -418,7 +418,7 @@ func (c *Client) SetCartAddress(addressID uint) error {
func (c *Client) SetCartCard(cardID uint) error { func (c *Client) SetCartCard(cardID uint) error {
url := fmt.Sprintf("%s/api/v1/cart/card", c.BaseURL) url := fmt.Sprintf("%s/api/v1/cart/card", c.BaseURL)
body := map[string]interface{}{ body := map[string]any{
"card_id": cardID, "card_id": cardID,
} }
jsonData, err := json.Marshal(body) jsonData, err := json.Marshal(body)
@@ -563,6 +563,58 @@ func (c *Client) GetOrderStatus(id uint) (string, error) {
return sResp.Data.Status, nil return sResp.Data.Status, nil
} }
// RetryAuthOutcome is the result of POST /orders/{id}/retry-auth.
// status=="requires_action" -> RedirectURL is a fresh QR target; resume polling.
// Status=="succeeded" -> stripe approved frictionless; keep polling for the
// webhook to flip status=paid (RedirectURL will be empty)
type RetryAuthOutcome struct {
OrderID uint
Status string
RedirectURL string
}
type retryAuthResponse struct {
Success bool `json:"success"`
Data struct {
OrderID uint `json:"order_id"`
Status string `json:"status"`
RedirectURL string `json:"redirect_url,omitempty"`
} `json:"data"`
Error *APIError `json:"error,omitempty"`
}
// RetryAuth asks the server to re-Confirm the existing PaymentIntent for the
// order, producing a new 3DS challenge URL. Used by the TUI's authFailed
// screen so the customer can recover without re-entering the checkout flow.
func (c *Client) RetryAuth(orderID uint) (*RetryAuthOutcome, error) {
url := fmt.Sprintf("%s/api/v1/orders/%d/retry-auth", c.BaseURL, orderID)
req, err := http.NewRequest("POST", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create retry request: %w", err)
}
resp, err := c.doRequest(req)
if err != nil {
return nil, fmt.Errorf("failed to send retry request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
var rResp retryAuthResponse
if err := json.NewDecoder(resp.Body).Decode(&rResp); err != nil {
return nil, fmt.Errorf("failed to decode retry response: %w", err)
}
if !rResp.Success {
if rResp.Error != nil {
return nil, fmt.Errorf("%s: %s", rResp.Error.Code, rResp.Error.Message)
}
return nil, fmt.Errorf("failed to retry authentication")
}
return &RetryAuthOutcome{
OrderID: rResp.Data.OrderID,
Status: rResp.Data.Status,
RedirectURL: rResp.Data.RedirectURL,
}, nil
}
// GetCards fetches all saved cards for the user. // GetCards fetches all saved cards for the user.
func (c *Client) GetCards() ([]models.Card, error) { func (c *Client) GetCards() ([]models.Card, error) {
url := fmt.Sprintf("%s/api/v1/cards", c.BaseURL) url := fmt.Sprintf("%s/api/v1/cards", c.BaseURL)

View File

@@ -13,16 +13,22 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// EventOrderRequiresAction is the audit event-type string emitted whenever a
// PaymentIntent is pushed into requires_action. explored so cart-flow handler
// can count retry attempts without hardcoding the string literal.
const EventOrderRequiresAction = "order_requires_action"
type eventType string type eventType string
const ( const (
eventCardAdded eventType = "card_added" eventCardAdded eventType = "card_added"
eventCardDeleted eventType = "card_deleted" eventCardDeleted eventType = "card_deleted"
eventCardExpired eventType = "card_expired"
eventOrderCreated eventType = "order_created" eventOrderCreated eventType = "order_created"
eventOrderPaid eventType = "order_paid" eventOrderPaid eventType = "order_paid"
eventOrderShipped eventType = "order_shipped" eventOrderShipped eventType = "order_shipped"
eventOrderFailed eventType = "order_failed" eventOrderFailed eventType = "order_failed"
eventOrderRequiresAction eventType = "order_requires_action" eventOrderRequiresAction eventType = EventOrderRequiresAction
eventOrderRefunded eventType = "order_refunded" eventOrderRefunded eventType = "order_refunded"
eventPaymentCritical eventType = "payment_critical" eventPaymentCritical eventType = "payment_critical"
eventLabelPurchased eventType = "label_purchased" eventLabelPurchased eventType = "label_purchased"
@@ -113,6 +119,17 @@ func CardDeleted(userID, cardID uint) {
persist(0, eventCardDeleted, e) persist(0, eventCardDeleted, e)
} }
// CardExpired records that a saved payment method expired from inactivity
func CardExpired(userID, cardID uint) {
e := event{
Event: eventCardExpired,
UserID: userID,
CardID: cardID,
}
slog.Info("audit", e.attrs()...)
persist(0, eventCardExpired, e)
}
// OrderCreated records that an order record was created (before payment). // OrderCreated records that an order record was created (before payment).
func OrderCreated(userID, orderID uint, amountCents int) { func OrderCreated(userID, orderID uint, amountCents int) {
e := event{ e := event{
@@ -301,7 +318,6 @@ func attrsToMap(a []any) map[string]any {
return m return m
} }
//nolint:unused // called from ConvertCart in the next update
func OrderRequiresAction(userID, orderID uint, paymentIntentID string) { func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
e := event{ e := event{
Event: eventOrderRequiresAction, Event: eventOrderRequiresAction,

View File

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

View File

@@ -3,6 +3,7 @@ package database
import ( import (
"fmt" "fmt"
"log" "log"
"time"
"terminalShop/pkg/models" "terminalShop/pkg/models"
@@ -30,6 +31,18 @@ func Migrate(db *gorm.DB) error {
return fmt.Errorf("migration failed: %w", err) return fmt.Errorf("migration failed: %w", err)
} }
if err := backfillCardStorageExpiration(db); err != nil {
return err
}
log.Println("Database migrations completed successfully") log.Println("Database migrations completed successfully")
return nil return nil
} }
func backfillCardStorageExpiration(db *gorm.DB) error {
expiresAt := models.CardStorageExpiresAt(time.Now())
if err := db.Model(&models.Card{}).Where("storage_expires_at IS NULL").Update("storage_expires_at", expiresAt).Error; err != nil {
return fmt.Errorf("card storage ttl backfill failed: %w", err)
}
return nil
}

View File

@@ -0,0 +1,67 @@
package database
import (
"os"
"testing"
"time"
"terminalShop/pkg/models"
)
func TestBackfillCardStorageExpiration_FillsNullRowsOnly(t *testing.T) {
testDB := "test_backfill.db"
defer func() { _ = os.Remove(testDB) }()
defer ResetForTesting()
db, err := Connect(testDB)
if err != nil {
t.Fatalf("connect: %v", err)
}
if err := Migrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
user := models.User{
SSHKeyFingerprint: "SHA256:backfill",
SSHPublicKey: "ssh-ed25519 AAAA backfill",
Name: "Backfill",
Email: "backfill@example.com",
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("seed user: %v", err)
}
legacy := models.Card{
UserID: user.ID, StripePaymentID: "pm_legacy", Last4: "0000",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030,
}
if err := db.Create(&legacy).Error; err != nil {
t.Fatalf("seed legacy: %v", err)
}
fixed := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
preset := models.Card{
UserID: user.ID, StripePaymentID: "pm_preset", Last4: "9999",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &fixed,
}
if err := db.Create(&preset).Error; err != nil {
t.Fatalf("ssed preset: %v", err)
}
if err := backfillCardStorageExpiration(db); err != nil {
t.Fatalf("seed preset: %v", err)
}
var reloadedLegacy, reloadedPreset models.Card
if err := db.First(&reloadedLegacy, legacy.ID).Error; err != nil {
t.Fatalf("reload legacy: %v", err)
}
if err := db.First(&reloadedPreset, preset.ID).Error; err != nil {
t.Fatalf("reload preset: %v", err)
}
if reloadedLegacy.StorageExpiresAt == nil {
t.Fatalf("legacy card storage_expires_at still NULL after backfill")
}
if reloadedPreset.StorageExpiresAt == nil || !reloadedPreset.StorageExpiresAt.Equal(fixed) {
t.Fatalf("preset card storage_expires_at was overwritten: %v", reloadedPreset.StorageExpiresAt)
}
}

View File

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

View File

@@ -6,21 +6,25 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
const cardStorageTTL = 180 * 24 * time.Hour
// Card represents a saved payment method linked to a Stripe token. // Card represents a saved payment method linked to a Stripe token.
// The actual card details live on Stripe's servers — we only store // The actual card details live on Stripe's servers — we only store
// a reference and display info (last 4 digits, brand). // a reference and display info (last 4 digits, brand).
type Card struct { type Card struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"` UserID uint `gorm:"not null;index" json:"user_id"`
StripePaymentID string `gorm:"size:255;not null" json:"-"` // Stripe payment method or token ID StripePaymentID string `gorm:"size:255;not null" json:"-"` // Stripe payment method or token ID
Last4 string `gorm:"size:4;not null" json:"last4"` // Last 4 digits for display Last4 string `gorm:"size:4;not null" json:"last4"` // Last 4 digits for display
Brand string `gorm:"size:50" json:"brand"` // Visa, Mastercard, etc. Brand string `gorm:"size:50" json:"brand"` // Visa, Mastercard, etc.
ExpMonth int `gorm:"not null" json:"exp_month"` ExpMonth int `gorm:"not null" json:"exp_month"`
ExpYear int `gorm:"not null" json:"exp_year"` ExpYear int `gorm:"not null" json:"exp_year"`
IsDefault bool `gorm:"default:false" json:"is_default"` // User's preferred card IsDefault bool `gorm:"default:false" json:"is_default"` // User's preferred card
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
StorageExpiresAt *time.Time `gorm:"index" json:"storage_expires_at,omitempty"`
// Associations // Associations
User User `gorm:"foreignKey:UserID" json:"-"` User User `gorm:"foreignKey:UserID" json:"-"`
@@ -30,3 +34,20 @@ type Card struct {
func (Card) TableName() string { func (Card) TableName() string {
return "cards" return "cards"
} }
// CardStorageExpiresAt returns the inactivity expiration for a saved card
func CardStorageExpiresAt(now time.Time) *time.Time {
expiresAt := now.Add(cardStorageTTL)
return &expiresAt
}
// InitializeStorageTTL sets the initial retention deadline without marking
// the card as used for payment
func (c *Card) InitializeStorageTTL(now time.Time) {
c.StorageExpiresAt = CardStorageExpiresAt(now)
}
// IsStorageExpired reports whether the saved card retention deadline elapsed
func (c *Card) IsStorageExpired(now time.Time) bool {
return c.StorageExpiresAt != nil && !c.StorageExpiresAt.After(now)
}

26
pkg/models/card_test.go Normal file
View File

@@ -0,0 +1,26 @@
package models
import (
"testing"
"time"
)
func TestCardStorageTTL(t *testing.T) {
now := time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC)
var card Card
card.InitializeStorageTTL(now)
if card.LastUsedAt != nil {
t.Fatalf("InitializeStorageTTL should not mark card as used")
}
if card.StorageExpiresAt == nil || !card.StorageExpiresAt.Equal(now.Add(cardStorageTTL)) {
t.Fatalf("unexpected initial expiration: %v", card.StorageExpiresAt)
}
if card.IsStorageExpired(now.Add(cardStorageTTL - time.Second)) {
t.Fatalf("card expired before ttl elapsed")
}
if !card.IsStorageExpired(now.Add(cardStorageTTL)) {
t.Fatalf("card should expire at ttl boundary")
}
}

View File

@@ -170,8 +170,20 @@ func (m Model) ReviewView() string {
if m.CheckingOut { if m.CheckingOut {
return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...") return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...")
} }
if m.review.retrying {
return m.theme.TextAccent().Bold(true).Padding(1).Render(" retrying authentication...")
}
if m.review.retryExhausted {
content := m.theme.TextError().Bold(true).Padding(1).Render("too many authentication attempts") + "\n" + m.theme.TextLabel().Padding(0, 1).Render("press esc to start a new order")
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(content),
lipgloss.Center, lipgloss.Center,
content,
)
}
if m.review.authFailed { if m.review.authFailed {
content := m.theme.TextError().Bold(true).Padding(1).Render("authentication failed — press esc to try again") content := m.theme.TextError().Bold(true).Padding(1).Render("authentication failed — press r to retry, esc to go back")
return lipgloss.Place( return lipgloss.Place(
m.widthContainer, m.widthContainer,
lipgloss.Height(content), lipgloss.Height(content),
@@ -208,7 +220,7 @@ func (m Model) renderRequiresActionView() string {
header := m.theme.TextAccent().Bold(true).Render("your bank wants to verify this charge") header := m.theme.TextAccent().Bold(true).Render("your bank wants to verify this charge")
if m.review.redirectURL == "" { if m.review.redirectURL == "" {
return base.Render(" waiting for authentication link...") return base.Render(" verifying with your bank...")
} }
qr, qrSize, err := qrfefe.Generate(m.review.redirectURL) qr, qrSize, err := qrfefe.Generate(m.review.redirectURL)
@@ -233,12 +245,11 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
case CheckoutResultMsg: case CheckoutResultMsg:
m.CheckingOut = false m.CheckingOut = false
if msg.Err != nil { if msg.Err != nil {
m = m.SwitchPage(paymentPage) var cmd tea.Cmd
m, cmd = m.switchForCheckoutError(msg.Err)
m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)} m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)}
m.review.cardJustAdded = false m.review.cardJustAdded = false
m.payment.view = 0 return m, cmd
m.payment.form = nil
return m, nil
} }
if msg.RequiresAction { if msg.RequiresAction {
m.review.requiresAction = true m.review.requiresAction = true
@@ -275,17 +286,65 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.review.requiresAction = false m.review.requiresAction = false
m.review.redirectURL = "" m.review.redirectURL = ""
m.review.authFailed = true m.review.authFailed = true
m.footer = []footerCommand{{key: "r", value: "retry"}, {key: "esc", value: "back"}}
return m, nil return m, nil
default: default:
return m, m.schedulePollOrderStatus(msg.OrderID) return m, m.schedulePollOrderStatus(msg.OrderID)
} }
case RetryAuthResultMsg:
if m.review.orderID != 0 && msg.OrderID != m.review.orderID {
return m, nil
}
m.review.retrying = false
if msg.Err != nil {
s := msg.Err.Error()
switch {
case strings.Contains(s, "RETRY_LIMIT"):
m.review.authFailed = false
m.review.retryExhausted = true
m.footer = []footerCommand{{key: "esc", value: "back"}}
return m, nil
case strings.Contains(s, "CARD_NO_LONGER_AVAILABLE"):
m.review.authFailed = false
m.review.requiresAction = false
m.review.redirectURL = ""
m.review.orderID = 0
m = m.SwitchPage(paymentPage)
m.payment.view = 0
m.payment.form = nil
m.error = &VisibleError{message: "saved card is no longer available. add it again to try."}
return m, nil
default:
m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)}
return m, nil
}
}
m.review.authFailed = false
m.review.retryExhausted = false
m.review.requiresAction = true
m.review.redirectURL = msg.RedirectURL
m.review.orderID = msg.OrderID
m.footer = []footerCommand{{key: "esc", value: "cancel"}}
return m, m.schedulePollOrderStatus(msg.OrderID)
case tea.KeyMsg: case tea.KeyMsg:
if m.CheckingOut { if m.CheckingOut {
return m, nil return m, nil
} }
if m.review.retrying {
return m, nil
}
if m.review.authFailed { if m.review.authFailed {
if msg.String() == "esc" || msg.String() == "enter" { switch msg.String() {
case "r":
if m.review.orderID == 0 {
return m, nil
}
m.review.retrying = true
m.error = nil
return m, m.retryAuthCmd(m.review.orderID)
case "esc":
m.review.authFailed = false m.review.authFailed = false
m = m.SwitchPage(paymentPage) m = m.SwitchPage(paymentPage)
m.payment.view = 0 m.payment.view = 0
@@ -294,6 +353,17 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
} }
return m, nil return m, nil
} }
if m.review.retryExhausted {
if msg.String() == "esc" {
m.review.retryExhausted = false
m.review.orderID = 0
m = m.SwitchPage(paymentPage)
m.payment.view = 0
m.payment.form = nil
return m, nil
}
return m, nil
}
if m.review.requiresAction { if m.review.requiresAction {
if msg.String() == "esc" { if msg.String() == "esc" {
m.review.requiresAction = false m.review.requiresAction = false
@@ -330,6 +400,18 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m Model) switchForCheckoutError(err error) (Model, tea.Cmd) {
switch {
case strings.Contains(err.Error(), "CART_OVER_LIMIT"):
return m.CartSwitch()
default:
m = m.SwitchPage(paymentPage)
m.payment.view = 0
m.payment.form = nil
return m, nil
}
}
func friendlyCheckoutError(raw error) string { func friendlyCheckoutError(raw error) string {
s := raw.Error() s := raw.Error()
switch { switch {
@@ -339,6 +421,8 @@ func friendlyCheckoutError(raw error) string {
return "your bank wants to verify this charge, authentication step coming soon." return "your bank wants to verify this charge, authentication step coming soon."
case strings.Contains(s, "AUTHENTICATION_NOT_SUPPORTED"): case strings.Contains(s, "AUTHENTICATION_NOT_SUPPORTED"):
return "this card doesn't support online authentication. Try another card" return "this card doesn't support online authentication. Try another card"
case strings.Contains(s, "CARD_STORAGE_EXPIRED"):
return "saved card expired. Add it again to verify the card is still yours"
case strings.Contains(s, "CARD_EXPIRED"): case strings.Contains(s, "CARD_EXPIRED"):
return "card expired. Add a new card and try again" return "card expired. Add a new card and try again"
case strings.Contains(s, "CARD_CVC_FAILED"): case strings.Contains(s, "CARD_CVC_FAILED"):

View File

@@ -0,0 +1,394 @@
package tui
import (
"errors"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
)
// newReviewModel returns a Model sitting on the review page with the default
// layout already applied. Use as the starting point for every retry-auth
// state machine test so transitions assert from a realistic baseline.
func newReviewModel(t *testing.T) Model {
t.Helper()
m := NewModel("test")
m.currentPage = reviewPage
return m
}
// keyR / keyEsc / keyJ build the bubbletea v2 key events Update expects.
// KeyMsg is an interface; KeyPressMsg satisfies it. Key.String() returns
// Text when non-empty, otherwise the keystroke (which is why esc relies on
// Code: tea.KeyEsc rather than Text).
func keyR() tea.KeyMsg { return tea.KeyPressMsg{Text: "r", Code: 'r'} }
func keyEsc() tea.KeyMsg { return tea.KeyPressMsg{Code: tea.KeyEsc} }
func keyJ() tea.KeyMsg { return tea.KeyPressMsg{Text: "j", Code: 'j'} }
func TestRetryAuthCmd_NilClient(t *testing.T) {
m := NewModel("test")
m.APIClient = nil
cmd := m.retryAuthCmd(42)
if cmd == nil {
t.Fatal("retryAuthCmd returned nil cmd")
}
msg := cmd()
rm, ok := msg.(RetryAuthResultMsg)
if !ok {
t.Fatalf("expected RetryAuthResultMsg, got %T", msg)
}
if rm.OrderID != 42 {
t.Errorf("OrderID = %d, want 42", rm.OrderID)
}
if rm.Err == nil || !strings.Contains(rm.Err.Error(), "api client unavailable") {
t.Errorf("expected api-client-unavailable err, got %v", rm.Err)
}
}
func TestReviewView_RetryingShowsCopy(t *testing.T) {
m := newReviewModel(t)
m.review.retrying = true
out := m.ReviewView()
if !strings.Contains(out, "retrying authentication") {
t.Fatalf("missing retrying copy in: %q", out)
}
}
func TestReviewView_RetryExhaustedShowsCopy(t *testing.T) {
m := newReviewModel(t)
m.review.retryExhausted = true
out := m.ReviewView()
if !strings.Contains(out, "too many authentication attempts") {
t.Fatalf("missing retry-exhausted copy in: %q", out)
}
if !strings.Contains(out, "press esc to start a new order") {
t.Fatalf("missing recovery hint in: %q", out)
}
}
func TestReviewView_AuthFailedAdvertisesRetry(t *testing.T) {
m := newReviewModel(t)
m.review.authFailed = true
out := m.ReviewView()
if !strings.Contains(out, "press r to retry") {
t.Fatalf("authFailed copy must advertise r: %q", out)
}
if !strings.Contains(out, "esc to go back") {
t.Fatalf("authFailed copy must advertise esc: %q", out)
}
}
func TestRenderRequiresActionView_FrictionlessShowsVerifying(t *testing.T) {
m := newReviewModel(t)
m.review.requiresAction = true
m.review.redirectURL = ""
out := m.renderRequiresActionView()
if !strings.Contains(out, "verifying with your bank") {
t.Fatalf("frictionless retry-success path must show verifying copy: %q", out)
}
}
func TestReviewUpdate_AuthFailed_PressR_StartsRetry(t *testing.T) {
m := newReviewModel(t)
m.review.authFailed = true
m.review.orderID = 7
m.error = &VisibleError{message: "leftover banner"}
out, cmd := m.ReviewUpdate(keyR())
if !out.review.retrying {
t.Fatal("expected retrying=true after r press")
}
if out.error != nil {
t.Fatal("stale banner should be cleared when retry starts")
}
if cmd == nil {
t.Fatal("expected non-nil cmd to call retryAuth")
}
}
func TestReviewUpdate_AuthFailed_PressR_ZeroOrderID_NoOp(t *testing.T) {
m := newReviewModel(t)
m.review.authFailed = true
m.review.orderID = 0
out, cmd := m.ReviewUpdate(keyR())
if out.review.retrying {
t.Fatal("retry must not start without an order id")
}
if cmd != nil {
t.Fatal("expected nil cmd when orderID=0")
}
}
func TestReviewUpdate_AuthFailed_PressEsc_BouncesToPayment(t *testing.T) {
m := newReviewModel(t)
m.review.authFailed = true
m.review.orderID = 7
out, _ := m.ReviewUpdate(keyEsc())
if out.review.authFailed {
t.Fatal("authFailed must clear on esc")
}
if out.currentPage != paymentPage {
t.Fatalf("currentPage = %v, want paymentPage", out.currentPage)
}
}
func TestReviewUpdate_AuthFailed_OtherKey_NoOp(t *testing.T) {
m := newReviewModel(t)
m.review.authFailed = true
m.review.orderID = 7
out, cmd := m.ReviewUpdate(keyJ())
if !out.review.authFailed {
t.Fatal("unrelated key must not clear authFailed")
}
if out.review.retrying {
t.Fatal("unrelated key must not start retry")
}
if cmd != nil {
t.Fatal("unrelated key must not produce a cmd")
}
}
func TestReviewUpdate_Retrying_BlocksKeys(t *testing.T) {
m := newReviewModel(t)
m.review.retrying = true
m.review.authFailed = true
m.review.orderID = 7
out, cmd := m.ReviewUpdate(keyR())
if !out.review.retrying {
t.Fatal("retrying flag should persist while a retry is in flight")
}
if cmd != nil {
t.Fatal("r press while retrying must not spawn a second cmd")
}
out, cmd = m.ReviewUpdate(keyEsc())
if out.currentPage != reviewPage {
t.Fatalf("currentPage changed while retrying: %v", out.currentPage)
}
if cmd != nil {
t.Fatal("esc while retrying must not navigate away")
}
}
func TestReviewUpdate_RetryAuthResult_RequiresAction(t *testing.T) {
m := newReviewModel(t)
m.review.retrying = true
m.review.authFailed = true
m.review.orderID = 7
out, cmd := m.ReviewUpdate(RetryAuthResultMsg{
OrderID: 7,
Status: "requires_action",
RedirectURL: "https://stripe/auth/abc",
})
if out.review.retrying {
t.Fatal("retrying must clear when the result arrives")
}
if out.review.authFailed {
t.Fatal("authFailed must clear on retry success")
}
if !out.review.requiresAction {
t.Fatal("expected requiresAction=true so the QR view renders")
}
if out.review.redirectURL != "https://stripe/auth/abc" {
t.Fatalf("redirectURL = %q", out.review.redirectURL)
}
if cmd == nil {
t.Fatal("expected schedulePollOrderStatus cmd")
}
if len(out.footer) != 1 || out.footer[0].key != "esc" || out.footer[0].value != "cancel" {
t.Fatalf("footer = %+v, want only esc=cancel", out.footer)
}
}
func TestReviewUpdate_RetryAuthResult_FrictionlessSuccess(t *testing.T) {
m := newReviewModel(t)
m.review.retrying = true
m.review.authFailed = true
m.review.orderID = 9
out, cmd := m.ReviewUpdate(RetryAuthResultMsg{
OrderID: 9,
Status: "succeeded",
})
if !out.review.requiresAction {
t.Fatal("frictionless retry must set requiresAction=true to render the verifying view")
}
if out.review.redirectURL != "" {
t.Fatalf("redirectURL must stay empty for frictionless retry, got %q", out.review.redirectURL)
}
if cmd == nil {
t.Fatal("expected poll cmd to wait for the webhook")
}
}
func TestReviewUpdate_RetryAuthResult_RetryLimit(t *testing.T) {
m := newReviewModel(t)
m.review.retrying = true
m.review.authFailed = true
m.review.orderID = 7
out, cmd := m.ReviewUpdate(RetryAuthResultMsg{
OrderID: 7,
Err: errors.New("RETRY_LIMIT: too many authentication attempts, please use a different card"),
})
if out.review.retrying {
t.Fatal("retrying must clear after a result")
}
if !out.review.retryExhausted {
t.Fatal("expected retryExhausted=true on RETRY_LIMIT")
}
if out.review.authFailed {
t.Fatal("authFailed must clear when retryExhausted takes over")
}
if cmd != nil {
t.Fatal("RETRY_LIMIT is terminal — no follow-up cmd")
}
if len(out.footer) != 1 || out.footer[0].key != "esc" {
t.Fatalf("footer = %+v, want only esc=back", out.footer)
}
}
func TestReviewUpdate_RetryAuthResult_CardGone(t *testing.T) {
m := newReviewModel(t)
m.review.retrying = true
m.review.authFailed = true
m.review.orderID = 7
out, _ := m.ReviewUpdate(RetryAuthResultMsg{
OrderID: 7,
Err: errors.New("CARD_NO_LONGER_AVAILABLE: saved card is no longer available, add it again to retry"),
})
if out.currentPage != paymentPage {
t.Fatalf("currentPage = %v, want paymentPage", out.currentPage)
}
if out.review.authFailed || out.review.requiresAction {
t.Fatal("auth state must reset when bouncing to payment")
}
if out.review.orderID != 0 {
t.Fatalf("orderID should reset to 0, got %d", out.review.orderID)
}
if out.error == nil || !strings.Contains(out.error.message, "saved card is no longer available") {
t.Fatalf("error banner missing or wrong copy: %+v", out.error)
}
}
func TestReviewUpdate_RetryAuthResult_Transient(t *testing.T) {
m := newReviewModel(t)
m.review.retrying = true
m.review.authFailed = true
m.review.orderID = 7
out, cmd := m.ReviewUpdate(RetryAuthResultMsg{
OrderID: 7,
Err: errors.New("CARD_DECLINED: card declined"),
})
if out.review.retrying {
t.Fatal("retrying must clear after a result")
}
if !out.review.authFailed {
t.Fatal("authFailed must persist so the user can retry again")
}
if out.review.retryExhausted {
t.Fatal("transient errors must not flip retryExhausted")
}
if out.error == nil {
t.Fatal("expected friendly banner for transient error")
}
if cmd != nil {
t.Fatal("transient error must not start a poll")
}
}
func TestReviewUpdate_RetryAuthResult_OrderIDMismatch(t *testing.T) {
m := newReviewModel(t)
m.review.retrying = true
m.review.authFailed = true
m.review.orderID = 7
out, cmd := m.ReviewUpdate(RetryAuthResultMsg{
OrderID: 99,
Status: "requires_action",
RedirectURL: "https://wrong",
})
if !out.review.retrying {
t.Fatal("mismatched orderID must leave retrying unchanged")
}
if out.review.requiresAction {
t.Fatal("mismatched orderID must not flip requiresAction")
}
if out.review.redirectURL == "https://wrong" {
t.Fatal("mismatched orderID must not adopt the foreign URL")
}
if cmd != nil {
t.Fatal("mismatched orderID must not start a poll")
}
}
func TestReviewUpdate_RetryExhausted_PressEsc_BouncesToPayment(t *testing.T) {
m := newReviewModel(t)
m.review.retryExhausted = true
m.review.orderID = 7
out, _ := m.ReviewUpdate(keyEsc())
if out.review.retryExhausted {
t.Fatal("retryExhausted must clear on esc")
}
if out.review.orderID != 0 {
t.Fatalf("orderID should reset to 0, got %d", out.review.orderID)
}
if out.currentPage != paymentPage {
t.Fatalf("currentPage = %v, want paymentPage", out.currentPage)
}
}
func TestReviewUpdate_RetryExhausted_OtherKey_NoOp(t *testing.T) {
m := newReviewModel(t)
m.review.retryExhausted = true
out, cmd := m.ReviewUpdate(keyR())
if !out.review.retryExhausted {
t.Fatal("unrelated key must not clear retryExhausted")
}
if cmd != nil {
t.Fatal("unrelated key must not produce a cmd")
}
}
func TestReviewUpdate_OrderStatusFailed_SetsRetryFooter(t *testing.T) {
m := newReviewModel(t)
m.review.requiresAction = true
m.review.orderID = 7
out, _ := m.ReviewUpdate(OrderStatusMsg{OrderID: 7, Status: "failed"})
if !out.review.authFailed {
t.Fatal("expected authFailed=true after failed status")
}
if out.review.requiresAction {
t.Fatal("requiresAction must clear when poll resolves to failed")
}
hasR, hasEsc := false, false
for _, c := range out.footer {
if c.key == "r" && c.value == "retry" {
hasR = true
}
if c.key == "esc" && c.value == "back" {
hasEsc = true
}
}
if !hasR || !hasEsc {
t.Fatalf("footer missing r/esc retry hints: %+v", out.footer)
}
}

View File

@@ -0,0 +1,24 @@
package tui
import (
"errors"
"strings"
"testing"
)
func TestFriendlyCheckoutError_CardStorageExpired(t *testing.T) {
got := friendlyCheckoutError(errors.New("CARD_STORAGE_EXPIRED: saved card epired, add it again"))
if !strings.Contains(got, "saved card expired") {
t.Fatalf("want saved-card-expired copy, got %q", got)
}
if strings.Contains(got, "card expired. Add a new card") {
t.Fatalf("CARD_STORAGE_EXPIRED was matched by the CARD_EXPIRED branch: %q", got)
}
}
func TestFriendlyCheckoutError_CardExpiredStillReachable(t *testing.T) {
got := friendlyCheckoutError(errors.New("CARD_EXPIRED: card expired"))
if !strings.Contains(got, "card expired. Add a new card") {
t.Fatalf("want plain card-expired copy, got %q", got)
}
}

View File

@@ -211,6 +211,8 @@ type reviewState struct {
redirectURL string redirectURL string
orderID uint orderID uint
authFailed bool authFailed bool
retrying bool
retryExhausted bool
} }
type confirmState struct { type confirmState struct {
@@ -442,6 +444,18 @@ type OrderStatusMsg struct {
Err error Err error
} }
// RetryAuthResultMsg carries the outcome of POST /orders/{id}/retry-auth.
// Status "requires_action" -> RedirectURL is a fresh challenge URL; resume
// the QR + poll loop. Status "succeeded" -> frictionless retry approved;
// server's APIError string ("RETRY_LIMIT" / "CARD_NO_LONGER_AVAILABLE" /
// transient) - the Update branch matches on substring.
type RetryAuthResultMsg struct {
OrderID uint
Status string
RedirectURL string
Err error
}
// CartSyncedMsg is recieved when the server responds to a cart item update // CartSyncedMsg is recieved when the server responds to a cart item update
type CartSyncedMsg struct { type CartSyncedMsg struct {
UpdateID int64 UpdateID int64
@@ -682,6 +696,23 @@ func (m Model) schedulePollOrderStatus(orderID uint) tea.Cmd {
}) })
} }
func (m Model) retryAuthCmd(orderID uint) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil {
return RetryAuthResultMsg{OrderID: orderID, Err: fmt.Errorf("api client unavailable")}
}
outcome, err := m.APIClient.RetryAuth(orderID)
if err != nil {
return RetryAuthResultMsg{OrderID: orderID, Err: err}
}
return RetryAuthResultMsg{
OrderID: orderID,
Status: outcome.Status,
RedirectURL: outcome.RedirectURL,
}
}
}
func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, tea.Cmd) { func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, tea.Cmd) {
if m.APIClient == nil || m.User == nil { if m.APIClient == nil || m.User == nil {
return m, nil return m, nil

View File

@@ -129,7 +129,7 @@ func (m Model) RefundUpdate(msg tea.Msg) (Model, tea.Cmd) {
state.form = f state.form = f
} }
if state.form.State == huh.StateCompleted { if state.form.State == huh.StateCompleted {
state.form = m.buildRefundForm(state) state.reason = state.form.GetString("reason")
return m, m.setRefundFocus(state, refundFocusMessage) return m, m.setRefundFocus(state, refundFocusMessage)
} }
return m, cmd return m, cmd
@@ -335,7 +335,7 @@ func (m Model) renderRefundTextarea(state *refundState) string {
} }
wrapper := lipgloss.NewStyle(). wrapper := lipgloss.NewStyle().
Border(lipgloss.ThickBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(border) BorderForeground(border)
contentWidth := m.refundContentWidth() contentWidth := m.refundContentWidth()

View File

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