Compare commits
18 Commits
fix/sca-re
...
chore/any-
| Author | SHA1 | Date | |
|---|---|---|---|
| f9c9b7dff5 | |||
| 135f1361e4 | |||
| 64deba7e03 | |||
| 732353d9a4 | |||
| 3420c8dce0 | |||
| f3b94c3271 | |||
| 67e19eca98 | |||
| 728ac24c96 | |||
| 1096c85b10 | |||
| 2f65cf0d90 | |||
| c3e6eb2b84 | |||
| 03009ea6df | |||
| c8b7545ca6 | |||
| 3a3c7ee80b | |||
| 7c8a2b562f | |||
| 1c5765d25b | |||
| e6301e662f | |||
| 8b2889a524 |
33
.github/workflows/ci.yml
vendored
33
.github/workflows/ci.yml
vendored
@@ -8,9 +8,9 @@ jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
@@ -30,9 +30,36 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Lint
|
||||
if: ${{ !env.ACT }}
|
||||
uses: golangci/golangci-lint-action@v7
|
||||
with:
|
||||
version: v2.4.0
|
||||
|
||||
- name: Install tparse
|
||||
run: go install github.com/mfridman/tparse@v0.18.0
|
||||
|
||||
- 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
|
||||
|
||||
6
.github/workflows/deploy.yml
vendored
6
.github/workflows/deploy.yml
vendored
@@ -8,10 +8,10 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.23'
|
||||
go-version-file: go.mod
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
- name: Deploy to server
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -33,6 +33,8 @@ api/api-server
|
||||
*.out
|
||||
coverage.txt
|
||||
coverage.html
|
||||
test-output.json
|
||||
test-report.xml
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
@@ -114,3 +116,6 @@ ROADMAP.md
|
||||
|
||||
*.bak
|
||||
terminalshop.prod.*
|
||||
|
||||
# Backup verification config (real values; example file is committed)
|
||||
scripts/backups/config.env
|
||||
|
||||
55
Makefile
Normal file
55
Makefile
Normal 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)
|
||||
@@ -42,7 +42,7 @@ func (h *AddressHandler) GetAddresses(w http.ResponseWriter, r *http.Request) {
|
||||
var addresses []models.Address
|
||||
db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&addresses)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"addresses": addresses,
|
||||
})
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]any{
|
||||
"address": address,
|
||||
})
|
||||
}
|
||||
@@ -183,7 +183,7 @@ func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"message": "address successfully deleted",
|
||||
})
|
||||
}
|
||||
@@ -218,7 +218,7 @@ func (h *AddressHandler) SetDefaultAddress(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"address": address,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestGetAddressesEmpty(t *testing.T) {
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Addresses []interface{} `json:"addresses"`
|
||||
Addresses []any `json:"addresses"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
|
||||
@@ -108,7 +108,7 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
middleware.RecordAuthAttempt("success")
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"access_token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 1800, // 30 minutes in seconds
|
||||
@@ -146,7 +146,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]interface{}{
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]any{
|
||||
"errors": errors,
|
||||
})
|
||||
return
|
||||
@@ -173,7 +173,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]any{
|
||||
"user": user.ToPublic(),
|
||||
})
|
||||
}
|
||||
@@ -195,7 +195,7 @@ func (h *AuthHandler) GetUserBySSHKey(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"user": user.ToPublic(),
|
||||
})
|
||||
}
|
||||
|
||||
156
api/handlers/card_test.go
Normal file
156
api/handlers/card_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
@@ -24,35 +26,43 @@ import (
|
||||
stripeSession "github.com/stripe/stripe-go/v78/checkout/session"
|
||||
)
|
||||
|
||||
// payRedirects maps short tokens to full Stripe checkout URLs.
|
||||
var (
|
||||
payRedirects = map[string]string{}
|
||||
payRedirectsMu sync.Mutex
|
||||
)
|
||||
const RedirectTTL = 10 * time.Minute
|
||||
|
||||
func storeRedirect(stripeURL string) (string, error) {
|
||||
func storeRedirect(stripeURL, purpose string) (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := hex.EncodeToString(b)
|
||||
payRedirectsMu.Lock()
|
||||
payRedirects[token] = stripeURL
|
||||
payRedirectsMu.Unlock()
|
||||
now := time.Now()
|
||||
rec := models.PayRedirect{
|
||||
Token: token,
|
||||
URL: stripeURL,
|
||||
Purpose: purpose,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(RedirectTTL),
|
||||
}
|
||||
if err := database.GetDB().Create(&rec).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// PayRedirect resolves a short token and redirects to the Stripe checkout URL.
|
||||
func PayRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
token := chi.URLParam(r, "token")
|
||||
payRedirectsMu.Lock()
|
||||
url, ok := payRedirects[token]
|
||||
payRedirectsMu.Unlock()
|
||||
if !ok {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
var rec models.PayRedirect
|
||||
if err := db.Where("token = ?", token).First(&rec).Error; err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, url, http.StatusFound)
|
||||
if time.Now().After(rec.ExpiresAt) {
|
||||
db.Where("token = ?", rec.Token).Delete(&models.PayRedirect{})
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, rec.URL, http.StatusFound)
|
||||
}
|
||||
|
||||
// CardHandler handles saved payment method CRUD.
|
||||
@@ -67,19 +77,24 @@ func NewCardHandler(stripeSecretKey string, appURL string) *CardHandler {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]interface{})
|
||||
// func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]any)
|
||||
|
||||
// GetCard returns a single saved card by ID.
|
||||
func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -99,7 +114,7 @@ func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"card": card,
|
||||
})
|
||||
}
|
||||
@@ -121,6 +136,15 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update card", nil)
|
||||
return
|
||||
@@ -132,7 +156,7 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"card": card,
|
||||
})
|
||||
}
|
||||
@@ -168,12 +192,12 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
token, err := storeRedirect(sess.URL)
|
||||
token, err := storeRedirect(sess.URL, models.RedirectPurposeAddCard)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
|
||||
return
|
||||
}
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
|
||||
})
|
||||
}
|
||||
@@ -278,7 +302,7 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
audit.CardAdded(userID, card.ID, card.Brand, card.Last4)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]any{
|
||||
"card": card,
|
||||
})
|
||||
}
|
||||
@@ -323,11 +347,56 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
audit.CardDeleted(userID, card.ID)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"message": "card deleted",
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
// already have one and persists the customer ID. Safe to call concurrently —
|
||||
// a duplicate Stripe customer is harmless; the local record reflects the first
|
||||
|
||||
155
api/handlers/cards_expire_test.go
Normal file
155
api/handlers/cards_expire_test.go
Normal 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
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/paymentintent"
|
||||
"github.com/stripe/stripe-go/v78/paymentmethod"
|
||||
@@ -23,13 +25,20 @@ import (
|
||||
)
|
||||
|
||||
type CartHandler struct {
|
||||
stripeKey string
|
||||
appURL string
|
||||
stripeKey string
|
||||
appURL string
|
||||
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
|
||||
func NewCartHandler(stripeSecretKey, appURL string) *CartHandler {
|
||||
return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL}
|
||||
func NewCartHandler(stripeSecretKey, appURL string, maxOrderCents int) *CartHandler {
|
||||
return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL, maxOrderCents: maxOrderCents}
|
||||
}
|
||||
|
||||
// getOrCreateCart finds the users cart or creates one.
|
||||
@@ -46,18 +55,18 @@ func getOrCreateCart(ctx context.Context, userID uint) (*models.Cart, error) {
|
||||
return &cart, nil
|
||||
}
|
||||
|
||||
func cartResponse(ctx context.Context, cart *models.Cart) map[string]interface{} {
|
||||
func cartResponse(ctx context.Context, cart *models.Cart) map[string]any {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var items []models.CartItem
|
||||
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
|
||||
|
||||
subtotal := 0
|
||||
itemsData := make([]map[string]interface{}, 0, len(items))
|
||||
itemsData := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
lineTotal := item.Coffee.Price * item.Quantity
|
||||
subtotal += lineTotal
|
||||
itemsData = append(itemsData, map[string]interface{}{
|
||||
itemsData = append(itemsData, map[string]any{
|
||||
"id": item.ID,
|
||||
"coffee_id": item.CoffeeID,
|
||||
"quantity": item.Quantity,
|
||||
@@ -66,7 +75,7 @@ func cartResponse(ctx context.Context, cart *models.Cart) map[string]interface{}
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
return map[string]any{
|
||||
"items": itemsData,
|
||||
"subtotal": subtotal,
|
||||
"address_id": cart.AddressID,
|
||||
@@ -85,7 +94,7 @@ func (h *CartHandler) GetCart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"cart": cartResponse(r.Context(), cart),
|
||||
})
|
||||
}
|
||||
@@ -148,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),
|
||||
})
|
||||
}
|
||||
@@ -184,7 +193,7 @@ func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"message": "address set",
|
||||
})
|
||||
}
|
||||
@@ -209,6 +218,15 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
@@ -220,7 +238,7 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"message": "card set",
|
||||
})
|
||||
}
|
||||
@@ -240,7 +258,7 @@ func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"message": "cart cleared",
|
||||
})
|
||||
}
|
||||
@@ -295,6 +313,16 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
middleware.RecordCartConversion("user_not_found")
|
||||
@@ -354,6 +382,17 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
total := subtotal + cart.ShippingCost
|
||||
|
||||
if h.maxOrderCents > 0 && total > h.maxOrderCents {
|
||||
middleware.RecordCartConversion("validation_over_limit")
|
||||
utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT",
|
||||
fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100),
|
||||
map[string]any{
|
||||
"total_cents": total,
|
||||
"limit_cents": h.maxOrderCents,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if total < 50 {
|
||||
middleware.RecordCartConversion("validation_min_amount")
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil)
|
||||
@@ -425,7 +464,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur)
|
||||
if stripeErr, ok := err.(*stripe.Error); ok {
|
||||
if stripeErr.Code == stripe.ErrorCodeAuthenticationRequired && stripeErr.PaymentIntent != nil {
|
||||
h.respondRequiresAction(w, &order, stripeErr.PaymentIntent)
|
||||
h.respondRequiresAction(w, &order, paymentMethodID, stripeErr.PaymentIntent)
|
||||
return
|
||||
}
|
||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||
@@ -454,7 +493,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
middleware.ObserveStripeRequest("payment_intent_create", "success", stripeDur)
|
||||
|
||||
if pi.Status == stripe.PaymentIntentStatusRequiresAction {
|
||||
h.respondRequiresAction(w, &order, pi)
|
||||
h.respondRequiresAction(w, &order, paymentMethodID, pi)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -468,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 {
|
||||
return err
|
||||
}
|
||||
return tx.Model(cart).Updates(map[string]interface{}{
|
||||
return tx.Model(cart).Updates(map[string]any{
|
||||
"address_id": nil,
|
||||
"card_id": nil,
|
||||
}).Error
|
||||
@@ -495,7 +534,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
go notify.SlackOrderPaid(&order)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"order": order,
|
||||
})
|
||||
}
|
||||
@@ -503,16 +542,21 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
// respondRequiresAction handles a PaymentIntent that needs 3DS auth
|
||||
// The order stays pending. the webhook flips it to paid once the customer
|
||||
// completes the bank-hosted challenge, the tui polls /api/v1/orders/{id}/status
|
||||
func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, pi *stripe.PaymentIntent) {
|
||||
func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, paymentMethodID string, pi *stripe.PaymentIntent) {
|
||||
db := database.GetDB()
|
||||
|
||||
// PaymentIntents returned inside a *stripe.Error are partial — next_action
|
||||
// is often nil. Re-fetch from Stripe to get the canonical PI with the 3DS
|
||||
// challenge URL populated.
|
||||
// After an off-session authentication_required decline, the PI sits in
|
||||
// status=requires_payment_method with payment_method detached. Re-confirm
|
||||
// on-session with the same PM so Stripe pushes the PI into requires_action
|
||||
// and populates next_action.redirect_to_url with the 3DS challenge URL.
|
||||
if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" {
|
||||
fresh, ferr := paymentintent.Get(pi.ID, nil)
|
||||
if ferr == nil && fresh != nil {
|
||||
pi = fresh
|
||||
confirmed, cerr := paymentintent.Confirm(pi.ID, &stripe.PaymentIntentConfirmParams{
|
||||
PaymentMethod: stripe.String(paymentMethodID),
|
||||
OffSession: stripe.Bool(false),
|
||||
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
|
||||
})
|
||||
if cerr == nil && confirmed != nil {
|
||||
pi = confirmed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,15 +568,19 @@ func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models
|
||||
return
|
||||
}
|
||||
|
||||
// persist the paymentIntent id before responding so the reconciliation jon
|
||||
// and webhook can match the eventual succeeded/failed event to this order
|
||||
// even if the customer drops off mid-auth
|
||||
if err := db.Model(order).Update("stripe_payment_id", pi.ID).Error; err != nil {
|
||||
// Persist the paymentIntent id AND reset status to pending. The
|
||||
// payment_intent.payment_failed webhook from the initial off-session decline
|
||||
// often arrives before this response and flips the order to failed —
|
||||
// override it back so the 3DS-in-flight order shows the correct state.
|
||||
if err := db.Model(order).Updates(map[string]any{
|
||||
"stripe_payment_id": pi.ID,
|
||||
"status": models.OrderStatusPending,
|
||||
}).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := storeRedirect(pi.NextAction.RedirectToURL.URL)
|
||||
token, err := storeRedirect(pi.NextAction.RedirectToURL.URL, models.RedirectPurposeCheckout3DS)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to allocate redirect token", nil)
|
||||
return
|
||||
@@ -582,3 +630,147 @@ func appURLForHandler(r *http.Request) string {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -3,14 +3,17 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
@@ -65,7 +68,7 @@ func TestGetCartEmpty(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
req := authRequest("GET", "/api/v1/cart", nil, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -80,8 +83,8 @@ func TestGetCartEmpty(t *testing.T) {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
Items []interface{} `json:"items"`
|
||||
Subtotal int `json:"subtotal"`
|
||||
Items []any `json:"items"`
|
||||
Subtotal int `json:"subtotal"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
@@ -104,10 +107,10 @@ func TestSetItemAndGetCart(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
// Add item (CoffeeID=1, Quantity=2)
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"coffee_id": 1,
|
||||
"quantity": 2,
|
||||
})
|
||||
@@ -160,16 +163,16 @@ func TestSetItemUpdateQuantity(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
// Add item
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2})
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 2})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
|
||||
// Update quantity
|
||||
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5})
|
||||
body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5})
|
||||
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -205,16 +208,16 @@ func TestSetItemRemove(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
// Add item
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3})
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 3})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
|
||||
// Remove item (quantity 0)
|
||||
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 0})
|
||||
body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 0})
|
||||
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -228,7 +231,7 @@ func TestSetItemRemove(t *testing.T) {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
Items []interface{} `json:"items"`
|
||||
Items []any `json:"items"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
@@ -244,9 +247,9 @@ func TestSetItemInvalidProduct(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1})
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 999, "quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -261,9 +264,9 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"quantity": 1})
|
||||
body, _ := json.Marshal(map[string]any{"quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -278,11 +281,11 @@ func TestClearCart(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
// Add two items
|
||||
for _, coffeeID := range []uint{1, 2} {
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": coffeeID, "quantity": 1})
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": coffeeID, "quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -306,7 +309,7 @@ func TestClearCart(t *testing.T) {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
Items []interface{} `json:"items"`
|
||||
Items []any `json:"items"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
@@ -334,9 +337,9 @@ func TestSetAddress(t *testing.T) {
|
||||
}
|
||||
db.Create(&addr)
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||
body, _ := json.Marshal(map[string]any{"address_id": addr.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetAddress(w, req)
|
||||
@@ -388,9 +391,9 @@ func TestSetAddressWrongUser(t *testing.T) {
|
||||
}
|
||||
db.Create(&addr)
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||
body, _ := json.Marshal(map[string]any{"address_id": addr.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetAddress(w, req)
|
||||
@@ -416,9 +419,9 @@ func TestSetCard(t *testing.T) {
|
||||
}
|
||||
db.Create(&card)
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
||||
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetCard(w, req)
|
||||
@@ -468,9 +471,9 @@ func TestSetCardWrongUser(t *testing.T) {
|
||||
}
|
||||
db.Create(&card)
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
||||
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetCard(w, req)
|
||||
@@ -485,10 +488,10 @@ func TestConvertCartMissingAddress(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
// Add item but no address or card
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -508,18 +511,18 @@ func TestConvertCartMissingCard(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("", "", 0)
|
||||
db := database.GetDB()
|
||||
|
||||
// Add item and address
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
|
||||
addr := models.Address{UserID: user.ID, Name: "Test", Street: "123 St", City: "PDX", State: "OR", Zip: "97201", Country: "US"}
|
||||
db.Create(&addr)
|
||||
body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
|
||||
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.SetAddress(w, req)
|
||||
@@ -539,7 +542,7 @@ func TestConvertCartEmpty(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
// Empty cart convert
|
||||
req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
|
||||
@@ -556,11 +559,11 @@ func TestMultipleItems(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
// Add three different items
|
||||
for i := uint(1); i <= 3; i++ {
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": i, "quantity": int(i)})
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": i, "quantity": int(i)})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -578,8 +581,8 @@ func TestMultipleItems(t *testing.T) {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
Items []interface{} `json:"items"`
|
||||
Subtotal int `json:"subtotal"`
|
||||
Items []any `json:"items"`
|
||||
Subtotal int `json:"subtotal"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
@@ -605,19 +608,19 @@ func TestCartIsolation(t *testing.T) {
|
||||
}
|
||||
db.Create(&user2)
|
||||
|
||||
handler := NewCartHandler("", "")
|
||||
handler := NewCartHandler("", "", 0)
|
||||
r := chi.NewRouter()
|
||||
r.Put("/cart/item", handler.SetItem)
|
||||
r.Get("/cart", handler.GetCart)
|
||||
|
||||
// User 1 adds item
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5})
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5})
|
||||
req := authRequest("PUT", "/cart/item", body, user1.ID)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// User 2 adds different item
|
||||
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 2, "quantity": 3})
|
||||
body, _ = json.Marshal(map[string]any{"coffee_id": 2, "quantity": 3})
|
||||
req = authRequest("PUT", "/cart/item", body, user2.ID)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
@@ -664,3 +667,358 @@ func TestCartIsolation(t *testing.T) {
|
||||
t.Errorf("User 2 expected coffee_id 2, got %d", resp.Data.Cart.Items[0].CoffeeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCartLimit(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
capCents int
|
||||
quantity int
|
||||
expectOverCap bool
|
||||
}{
|
||||
{"over cap rejects", 20000, 41, true}, // $205
|
||||
{"at cap accepts", 20000, 40, false}, // $200 boundary
|
||||
{"under cap accepts", 20000, 39, false}, // $195
|
||||
{"zero cap disables", 0, 1000, false}, // explicit off-switch
|
||||
}
|
||||
|
||||
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_overlimit"
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
t.Fatalf("saved user: %v", err)
|
||||
}
|
||||
|
||||
addr := models.Address{
|
||||
UserID: user.ID, Name: "Test", Street: "1 Main St",
|
||||
City: "PDX", State: "OR", Zip: "97201", Country: "US",
|
||||
}
|
||||
if err := db.Create(&addr).Error; err != nil {
|
||||
t.Fatalf("seed address: %v", err)
|
||||
}
|
||||
|
||||
card := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_test_overlimit",
|
||||
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
|
||||
}
|
||||
if err := db.Create(&card).Error; err != nil {
|
||||
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)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 4, "quantity": tc.quantity})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("SetItem: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// set address
|
||||
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
|
||||
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.SetAddress(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("SetAddress: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// set card
|
||||
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.StatusOK {
|
||||
t.Fatalf("SetCard: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Convert
|
||||
req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.ConvertCart(w, req)
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
isOverCap := w.Code == http.StatusBadRequest && resp.Error.Code == "CART_OVER_LIMIT"
|
||||
if isOverCap != tc.expectOverCap {
|
||||
t.Fatalf("expectOverCap=%v got code=%d errCode=%q msg=%q", tc.expectOverCap, w.Code, resp.Error.Code, resp.Error.Message)
|
||||
}
|
||||
if tc.expectOverCap {
|
||||
if got, _ := resp.Error.Details["limit_cents"].(float64); int(got) != tc.capCents {
|
||||
t.Errorf("limit_cents in details: want %d, got %v", tc.capCents, resp.Error.Details["limit_cents"])
|
||||
}
|
||||
if got, _ := resp.Error.Details["total_cents"].(float64); int(got) != tc.quantity*pinnedPrice {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
audit.OrderRefunded(userID, order.ID, order.Total, rf.ID)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"refund_id": rf.ID,
|
||||
"status": "refunded",
|
||||
})
|
||||
@@ -287,7 +287,7 @@ func (h *OrderHandler) UpdateTracking(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
audit.OrderShipped(order.UserID, order.ID, req.Carrier, req.TrackingNumber)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"order_id": order.ID,
|
||||
"carrier": req.Carrier,
|
||||
"tracking_number": req.TrackingNumber,
|
||||
|
||||
@@ -30,7 +30,7 @@ func (h *ProductHandler) GetProducts(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"products": products,
|
||||
})
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"product": product,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func ReconcileOrders(stripeKey string) {
|
||||
"order_id", order.ID, "pi", pi.ID)
|
||||
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&order).Updates(map[string]interface{}{
|
||||
if err := tx.Model(&order).Updates(map[string]any{
|
||||
"status": models.OrderStatusPaid,
|
||||
"stripe_payment_id": pi.ID,
|
||||
}).Error; err != nil {
|
||||
@@ -62,7 +62,7 @@ func ReconcileOrders(stripeKey string) {
|
||||
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&cart).Updates(map[string]interface{}{
|
||||
return tx.Model(&cart).Updates(map[string]any{
|
||||
"address_id": nil,
|
||||
"card_id": nil,
|
||||
}).Error
|
||||
@@ -77,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
|
||||
// number and posts single Slack reminder. Pure read + notif
|
||||
func ReconcileUnshipped() {
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/database"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ViewHandler handles bulk TUI bootstrap data
|
||||
type ViewHandler struct{}
|
||||
|
||||
func NewViewHandler() *ViewHandler {
|
||||
@@ -37,7 +39,10 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
|
||||
db.Where("user_id = ?", userID).Find(&addresses)
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"user": user.ToPublic(),
|
||||
"products": products,
|
||||
"cart": cartItems,
|
||||
|
||||
70
api/handlers/view_test.go
Normal file
70
api/handlers/view_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
|
||||
}
|
||||
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&order).Updates(map[string]interface{}{
|
||||
if err := tx.Model(&order).Updates(map[string]any{
|
||||
"status": models.OrderStatusPaid,
|
||||
"stripe_payment_id": pi.ID,
|
||||
}).Error; err != nil {
|
||||
@@ -139,7 +139,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
|
||||
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete cart item: %w", err)
|
||||
}
|
||||
return tx.Model(&cart).Updates(map[string]interface{}{
|
||||
return tx.Model(&cart).Updates(map[string]any{
|
||||
"address_id": nil,
|
||||
"card_id": nil,
|
||||
}).Error
|
||||
@@ -150,6 +150,12 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
|
||||
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)
|
||||
go notify.SlackOrderPaid(&order)
|
||||
webhookLog().Info("order marked paid", "order_id", orderIDStr, "pi", pi.ID)
|
||||
@@ -167,6 +173,16 @@ func (h *WebhookHandler) handlePaymentIntentFailed(ctx context.Context, event st
|
||||
return
|
||||
}
|
||||
|
||||
// authentication_required is recoverable — the cart handler re-confirms the
|
||||
// PI on-session and the customer completes 3DS via redirect. Marking the
|
||||
// order failed here would race with the recovery path and surface a
|
||||
// momentarily-broken state in the TUI.
|
||||
if pi.LastPaymentError != nil && string(pi.LastPaymentError.Code) == "authentication_required" {
|
||||
webhookLog().Info("ignoring authentication_required payment_failed; handled via 3ds redirect",
|
||||
"order_id", orderIDStr, "pi", pi.ID)
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
|
||||
Update("status", models.OrderStatusFailed).Error; err != nil {
|
||||
@@ -245,8 +261,14 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, eve
|
||||
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
|
||||
// fired even though no new row was inserted.
|
||||
if uerr := db.Model(&dup).Update("updated_at", time.Now()).Error; uerr != nil {
|
||||
// fired even though no new row was inserted. storage_expires_at is
|
||||
// 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().Info("skipped duplicate card via local-db match", "user_id", user.ID, "existing_card_id", dup.ID)
|
||||
@@ -262,6 +284,7 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, eve
|
||||
ExpMonth: int(pm.Card.ExpMonth),
|
||||
ExpYear: int(pm.Card.ExpYear),
|
||||
}
|
||||
card.InitializeStorageTTL(time.Now())
|
||||
|
||||
if err := db.Create(&card).Error; err != nil {
|
||||
webhookLog().Error("failed to save card", "user_id", user.ID, "error", err)
|
||||
|
||||
18
api/main.go
18
api/main.go
@@ -71,7 +71,7 @@ func main() {
|
||||
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
|
||||
|
||||
// Setup routes
|
||||
router := routes.SetupRoutes(version, cfg.StripeSecretKey, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL, cfg.SlackSigningSecret, cfg.AdminAPIKey, cfg.APIPort)
|
||||
router := routes.SetupRoutes(version, cfg.StripeSecretKey, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL, cfg.SlackSigningSecret, cfg.AdminAPIKey, cfg.APIPort, cfg.MaxOrderCents)
|
||||
|
||||
// Create HTTP server
|
||||
server := &http.Server{
|
||||
@@ -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.")
|
||||
|
||||
// Block until signal received
|
||||
|
||||
@@ -29,6 +29,7 @@ func SetupRoutes(
|
||||
slackSigningSecret string,
|
||||
adminAPIKey string,
|
||||
apiPort string,
|
||||
maxOrderCents int,
|
||||
) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -46,7 +47,7 @@ func SetupRoutes(
|
||||
healthHandler := handlers.NewHealthHandler(version)
|
||||
productHandler := handlers.NewProductHandler()
|
||||
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
|
||||
cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL)
|
||||
cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL, maxOrderCents)
|
||||
cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
|
||||
orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber)
|
||||
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
|
||||
@@ -131,6 +132,7 @@ func SetupRoutes(
|
||||
// Orders
|
||||
r.Get("/orders", orderHandler.GetOrders)
|
||||
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", orderHandler.RefundOrder)
|
||||
|
||||
|
||||
17
go.mod
17
go.mod
@@ -39,6 +39,7 @@ require (
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // 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/cenkalti/backoff/v5 v5.0.3 // 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/uax29/v2 v2.7.0 // 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/fatih/color v1.18.0 // 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/errors v0.7.1 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // 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/gotesttools/gotestfmt/v2 v2.5.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/hashicorp/go-version v1.6.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/cpuid/v2 v2.2.10 // 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-sqlite3 v1.14.34 // 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.yaml.in/yaml/v2 v2.4.2 // 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/sync v0.20.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/tools v0.43.0 // 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/grpc v1.80.0 // indirect
|
||||
@@ -109,4 +120,10 @@ require (
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/clickhouse v0.7.0 // 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
27
go.sum
@@ -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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
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/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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/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/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/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/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
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/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/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
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/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/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/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
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/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.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-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=
|
||||
@@ -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-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-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/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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-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.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-20191011141410-1b5146add898/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/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc=
|
||||
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/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
|
||||
1
main.go
1
main.go
@@ -8,7 +8,6 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
@@ -104,16 +104,16 @@ func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error)
|
||||
|
||||
// APIResponse represents the standard API response format
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Data map[string]any `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// APIError represents an error from the API
|
||||
type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// ProductsResponse represents the products API response
|
||||
@@ -280,14 +280,31 @@ type CardResponse struct {
|
||||
}
|
||||
|
||||
// ConvertCartResponse represents the cart conversion (checkout) API response.
|
||||
// 200 carries Order populated; 202 (requires_action / 3DS) carries the
|
||||
// redirect fields and OrderID instead.
|
||||
type ConvertCartResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Order models.Order `json:"order"`
|
||||
Order *models.Order `json:"order,omitempty"`
|
||||
OrderID uint `json:"order_id,omitempty"`
|
||||
PaymentIntentID string `json:"payment_intent_id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
RedirectToken string `json:"redirect_token,omitempty"`
|
||||
RedirectURL string `json:"redirect_url,omitempty"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CheckoutOutcome is the unified result of ConvertCart. RequiresAction=true
|
||||
// means the customer needs to complete 3DS at RedirectURL; poll
|
||||
// GetOrderStatus(OrderID) until the webhook flips the order to paid or failed.
|
||||
type CheckoutOutcome struct {
|
||||
Order *models.Order
|
||||
OrderID uint
|
||||
RequiresAction bool
|
||||
RedirectURL string
|
||||
}
|
||||
|
||||
// GetCart fetches the user's current cart.
|
||||
func (c *Client) GetCart() (*CartData, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL)
|
||||
@@ -322,7 +339,7 @@ func (c *Client) GetCart() (*CartData, error) {
|
||||
func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/item", c.BaseURL)
|
||||
|
||||
body := map[string]interface{}{
|
||||
body := map[string]any{
|
||||
"coffee_id": coffeeID,
|
||||
"quantity": quantity,
|
||||
}
|
||||
@@ -362,7 +379,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
|
||||
func (c *Client) SetCartAddress(addressID uint) error {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/address", c.BaseURL)
|
||||
|
||||
body := map[string]interface{}{
|
||||
body := map[string]any{
|
||||
"address_id": addressID,
|
||||
}
|
||||
jsonData, err := json.Marshal(body)
|
||||
@@ -401,7 +418,7 @@ func (c *Client) SetCartAddress(addressID uint) error {
|
||||
func (c *Client) SetCartCard(cardID uint) error {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/card", c.BaseURL)
|
||||
|
||||
body := map[string]interface{}{
|
||||
body := map[string]any{
|
||||
"card_id": cardID,
|
||||
}
|
||||
jsonData, err := json.Marshal(body)
|
||||
@@ -467,7 +484,9 @@ func (c *Client) ClearCart() error {
|
||||
}
|
||||
|
||||
// ConvertCart converts the cart into an order, charging the saved card.
|
||||
func (c *Client) ConvertCart() (*models.Order, error) {
|
||||
// Returns a CheckoutOutcome that either carries the completed Order or
|
||||
// indicates the customer must complete 3DS authentication via RedirectURL.
|
||||
func (c *Client) ConvertCart() (*CheckoutOutcome, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL)
|
||||
|
||||
req, err := http.NewRequest("POST", url, nil)
|
||||
@@ -493,7 +512,21 @@ func (c *Client) ConvertCart() (*models.Order, error) {
|
||||
return nil, fmt.Errorf("failed to convert cart")
|
||||
}
|
||||
|
||||
return &convertResp.Data.Order, nil
|
||||
if convertResp.Data.Status == "requires_action" {
|
||||
return &CheckoutOutcome{
|
||||
OrderID: convertResp.Data.OrderID,
|
||||
RequiresAction: true,
|
||||
RedirectURL: convertResp.Data.RedirectURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if convertResp.Data.Order == nil {
|
||||
return nil, fmt.Errorf("malformed convert response: missing order")
|
||||
}
|
||||
return &CheckoutOutcome{
|
||||
Order: convertResp.Data.Order,
|
||||
OrderID: convertResp.Data.Order.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type OrderStatusResponse struct {
|
||||
@@ -530,6 +563,58 @@ func (c *Client) GetOrderStatus(id uint) (string, error) {
|
||||
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.
|
||||
func (c *Client) GetCards() ([]models.Card, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cards", c.BaseURL)
|
||||
|
||||
@@ -13,16 +13,22 @@ import (
|
||||
"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
|
||||
|
||||
const (
|
||||
eventCardAdded eventType = "card_added"
|
||||
eventCardDeleted eventType = "card_deleted"
|
||||
eventCardExpired eventType = "card_expired"
|
||||
eventOrderCreated eventType = "order_created"
|
||||
eventOrderPaid eventType = "order_paid"
|
||||
eventOrderShipped eventType = "order_shipped"
|
||||
eventOrderFailed eventType = "order_failed"
|
||||
eventOrderRequiresAction eventType = "order_requires_action"
|
||||
eventOrderRequiresAction eventType = EventOrderRequiresAction
|
||||
eventOrderRefunded eventType = "order_refunded"
|
||||
eventPaymentCritical eventType = "payment_critical"
|
||||
eventLabelPurchased eventType = "label_purchased"
|
||||
@@ -113,6 +119,17 @@ func CardDeleted(userID, cardID uint) {
|
||||
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).
|
||||
func OrderCreated(userID, orderID uint, amountCents int) {
|
||||
e := event{
|
||||
@@ -301,7 +318,6 @@ func attrsToMap(a []any) map[string]any {
|
||||
return m
|
||||
}
|
||||
|
||||
//nolint:unused // called from ConvertCart in the next update
|
||||
func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
|
||||
e := event{
|
||||
Event: eventOrderRequiresAction,
|
||||
|
||||
@@ -53,7 +53,7 @@ func (m *JWTManager) GenerateToken(userID uint, email, username string) (string,
|
||||
|
||||
// ValidateToken validates a JWT token and returns the claims
|
||||
func (m *JWTManager) ValidateToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||
// Verify signing method
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
@@ -30,6 +31,7 @@ type Config struct {
|
||||
ShippoWebhookSecret string
|
||||
SlackSigningSecret string
|
||||
AdminAPIKey string
|
||||
MaxOrderCents int
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables and validates required secrets.
|
||||
@@ -66,6 +68,7 @@ func Load() (*Config, error) {
|
||||
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
|
||||
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
|
||||
AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
|
||||
MaxOrderCents: loadMaxOrderCents(),
|
||||
}
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
@@ -130,3 +133,20 @@ func getEnvOrDefault(key, defaultValue string) string {
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $200. Explicit 0 disables
|
||||
// the cap. Garbage or negative values fall back to the default with a warning
|
||||
// so an operator typo cannot silently disable a fraud control.
|
||||
func loadMaxOrderCents() int {
|
||||
const defaultCap = 20000
|
||||
raw := os.Getenv("MAX_ORDER_CENTS")
|
||||
if raw == "" {
|
||||
return defaultCap
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 0 {
|
||||
fmt.Printf("WARNING: MAX_ORDER_CENTS=%q invalid, falling back to default %d\n", raw, defaultCap)
|
||||
return defaultCap
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
34
pkg/config/config_test.go
Normal file
34
pkg/config/config_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadMaxOrderCents(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
env string // "" means unset
|
||||
want int
|
||||
}{
|
||||
{"unset uses default", "", 20000},
|
||||
{"explicit zero disables", "0", 0},
|
||||
{"valid override", "50000", 50000},
|
||||
{"negative falls back to default", "-1", 20000},
|
||||
{"garbage falls back to default", "two hundred", 20000},
|
||||
{"empty after trim falls back to default", "", 20000},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.env == "" {
|
||||
t.Setenv("MAX_ORDER_CENTS", "")
|
||||
} else {
|
||||
t.Setenv("MAX_ORDER_CENTS", tc.env)
|
||||
}
|
||||
got := loadMaxOrderCents()
|
||||
if got != tc.want {
|
||||
t.Errorf("loadMaxOrderCents() = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package database
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -24,10 +26,23 @@ func Migrate(db *gorm.DB) error {
|
||||
&models.OrderItem{}, // Line items within orders
|
||||
&models.Address{}, // Saved addresses
|
||||
&models.OrderEvent{},
|
||||
&models.PayRedirect{}, // short-token -> stripe url store (10 min TTL)
|
||||
); err != nil {
|
||||
return fmt.Errorf("migration failed: %w", err)
|
||||
}
|
||||
|
||||
if err := backfillCardStorageExpiration(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println("Database migrations completed successfully")
|
||||
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
|
||||
}
|
||||
|
||||
67
pkg/database/migrate_test.go
Normal file
67
pkg/database/migrate_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Seed populates the database with initial data
|
||||
@@ -27,7 +28,7 @@ func Seed(db *gorm.DB) error {
|
||||
RoastType: "Dark Roast",
|
||||
Ounces: 2,
|
||||
BeanType: "Arabica",
|
||||
Price: 350, // $3.50
|
||||
Price: 1800, // $18.00
|
||||
Color: "#8B4513",
|
||||
Description: "Bold and intense coffee with a rich crema",
|
||||
},
|
||||
@@ -36,7 +37,7 @@ func Seed(db *gorm.DB) error {
|
||||
RoastType: "Medium Roast",
|
||||
Ounces: 8,
|
||||
BeanType: "Arabica",
|
||||
Price: 400, // $4.00
|
||||
Price: 2200, // $22.00
|
||||
Color: "#A0522D",
|
||||
Description: "Smooth and balanced espresso diluted with hot water",
|
||||
},
|
||||
@@ -45,7 +46,7 @@ func Seed(db *gorm.DB) error {
|
||||
RoastType: "Medium Roast",
|
||||
Ounces: 6,
|
||||
BeanType: "Arabica",
|
||||
Price: 450, // $4.50
|
||||
Price: 2200, // $22.00
|
||||
Color: "#C4A574",
|
||||
Description: "Espresso with steamed milk and a thick layer of foam",
|
||||
},
|
||||
@@ -54,7 +55,7 @@ func Seed(db *gorm.DB) error {
|
||||
RoastType: "Medium Roast",
|
||||
Ounces: 12,
|
||||
BeanType: "Arabica",
|
||||
Price: 500, // $5.00
|
||||
Price: 2500, // $25.00
|
||||
Color: "#D2B48C",
|
||||
Description: "Creamy espresso with plenty of steamed milk and a touch of foam",
|
||||
},
|
||||
@@ -63,7 +64,7 @@ func Seed(db *gorm.DB) error {
|
||||
RoastType: "Dark Roast",
|
||||
Ounces: 12,
|
||||
BeanType: "Arabica",
|
||||
Price: 550, // $5.50
|
||||
Price: 3000, // $30.00
|
||||
Color: "#8B4513",
|
||||
Description: "Chocolate and espresso combined with steamed milk. Sweet and indulgent.",
|
||||
},
|
||||
@@ -72,7 +73,7 @@ func Seed(db *gorm.DB) error {
|
||||
RoastType: "Dark Roast",
|
||||
Ounces: 3,
|
||||
BeanType: "Robusta Blend",
|
||||
Price: 425, // $4.25
|
||||
Price: 1800, // $18.00
|
||||
Color: "#DAA520",
|
||||
Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.",
|
||||
},
|
||||
|
||||
@@ -6,21 +6,25 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const cardStorageTTL = 180 * 24 * time.Hour
|
||||
|
||||
// Card represents a saved payment method linked to a Stripe token.
|
||||
// The actual card details live on Stripe's servers — we only store
|
||||
// a reference and display info (last 4 digits, brand).
|
||||
type Card struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_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
|
||||
Brand string `gorm:"size:50" json:"brand"` // Visa, Mastercard, etc.
|
||||
ExpMonth int `gorm:"not null" json:"exp_month"`
|
||||
ExpYear int `gorm:"not null" json:"exp_year"`
|
||||
IsDefault bool `gorm:"default:false" json:"is_default"` // User's preferred card
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_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
|
||||
Brand string `gorm:"size:50" json:"brand"` // Visa, Mastercard, etc.
|
||||
ExpMonth int `gorm:"not null" json:"exp_month"`
|
||||
ExpYear int `gorm:"not null" json:"exp_year"`
|
||||
IsDefault bool `gorm:"default:false" json:"is_default"` // User's preferred card
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
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
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
@@ -30,3 +34,20 @@ type Card struct {
|
||||
func (Card) TableName() string {
|
||||
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
26
pkg/models/card_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
23
pkg/models/pay_redirect.go
Normal file
23
pkg/models/pay_redirect.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// PayRedirect maps a short token to a stripe hosted url with a TTL.
|
||||
// survives api restarts so a customer mid-3Ds authentication still
|
||||
// lands on the bank-hosted challenge page after a deploy or crash
|
||||
type PayRedirect struct {
|
||||
Token string `gorm:"primaryKey;size:64" json:"token"`
|
||||
URL string `gorm:"type:text;not null" json:"url"`
|
||||
Purpose string `gorm:"size:32;not null" json:"purpose"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
}
|
||||
|
||||
const (
|
||||
RedirectPurposeCheckout3DS = "checkout_3ds"
|
||||
RedirectPurposeAddCard = "add_card"
|
||||
)
|
||||
|
||||
func (PayRedirect) TableName() string {
|
||||
return "pay_redirects"
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/tui/qrfefe"
|
||||
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
@@ -169,6 +170,30 @@ func (m Model) ReviewView() string {
|
||||
if m.CheckingOut {
|
||||
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 {
|
||||
content := m.theme.TextError().Bold(true).Padding(1).Render("authentication failed — press r to retry, esc to go back")
|
||||
return lipgloss.Place(
|
||||
m.widthContainer,
|
||||
lipgloss.Height(content),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
content,
|
||||
)
|
||||
}
|
||||
if m.review.requiresAction {
|
||||
return m.renderRequiresActionView()
|
||||
}
|
||||
if m.review.success {
|
||||
content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!")
|
||||
return lipgloss.Place(
|
||||
@@ -187,17 +212,50 @@ func (m Model) ReviewView() string {
|
||||
)
|
||||
}
|
||||
|
||||
// renderRequiresActionView shows a QR for the 3DS challenge URL while the TUI
|
||||
// polls the order status. Mirrors the add-card QR layout in payment_form.go.
|
||||
func (m Model) renderRequiresActionView() string {
|
||||
base := m.theme.TextLabel()
|
||||
accent := m.theme.TextHighlight()
|
||||
header := m.theme.TextAccent().Bold(true).Render("your bank wants to verify this charge")
|
||||
|
||||
if m.review.redirectURL == "" {
|
||||
return base.Render(" verifying with your bank...")
|
||||
}
|
||||
|
||||
qr, qrSize, err := qrfefe.Generate(m.review.redirectURL)
|
||||
if err != nil || qrSize > m.widthContent {
|
||||
body := header + "\n\n" +
|
||||
base.Render("open this url on your phone to authenticate:") + "\n\n" +
|
||||
accent.Render(m.review.redirectURL) + "\n\n" +
|
||||
base.Render("waiting for completion...")
|
||||
return lipgloss.Place(m.widthContainer, lipgloss.Height(body), lipgloss.Center, lipgloss.Center, body)
|
||||
}
|
||||
|
||||
// Same gotcha as payment_form.go: lipgloss layout helpers corrupt qrfefe ANSI.
|
||||
return qr +
|
||||
header + "\n" +
|
||||
base.Render("scan or visit to complete authentication") + "\n" +
|
||||
accent.Render(m.review.redirectURL) + "\n\n" +
|
||||
base.Render("waiting for completion...")
|
||||
}
|
||||
|
||||
func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case CheckoutResultMsg:
|
||||
m.CheckingOut = false
|
||||
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.review.cardJustAdded = false
|
||||
m.payment.view = 0
|
||||
m.payment.form = nil
|
||||
return m, nil
|
||||
return m, cmd
|
||||
}
|
||||
if msg.RequiresAction {
|
||||
m.review.requiresAction = true
|
||||
m.review.redirectURL = msg.RedirectURL
|
||||
m.review.orderID = msg.OrderID
|
||||
return m, m.schedulePollOrderStatus(msg.OrderID)
|
||||
}
|
||||
m.Cart = make(map[uint]*models.CartItem)
|
||||
m.cart.cursor = 0
|
||||
@@ -206,10 +264,116 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
m.review.success = true
|
||||
return m, nil
|
||||
|
||||
case OrderStatusMsg:
|
||||
if !m.review.requiresAction || msg.OrderID != m.review.orderID {
|
||||
return m, nil
|
||||
}
|
||||
if msg.Err != nil {
|
||||
// Transient — keep polling.
|
||||
return m, m.schedulePollOrderStatus(msg.OrderID)
|
||||
}
|
||||
switch msg.Status {
|
||||
case "paid", "shipped", "delivered":
|
||||
m.Cart = make(map[uint]*models.CartItem)
|
||||
m.cart.cursor = 0
|
||||
m.ShippingInfo = nil
|
||||
m.OrdersLoaded = false
|
||||
m.review.requiresAction = false
|
||||
m.review.redirectURL = ""
|
||||
m.review.success = true
|
||||
return m, nil
|
||||
case "failed", "cancelled":
|
||||
m.review.requiresAction = false
|
||||
m.review.redirectURL = ""
|
||||
m.review.authFailed = true
|
||||
m.footer = []footerCommand{{key: "r", value: "retry"}, {key: "esc", value: "back"}}
|
||||
return m, nil
|
||||
default:
|
||||
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:
|
||||
if m.CheckingOut {
|
||||
return m, nil
|
||||
}
|
||||
if m.review.retrying {
|
||||
return m, nil
|
||||
}
|
||||
if m.review.authFailed {
|
||||
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 = m.SwitchPage(paymentPage)
|
||||
m.payment.view = 0
|
||||
m.payment.form = nil
|
||||
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 msg.String() == "esc" {
|
||||
m.review.requiresAction = false
|
||||
m.review.redirectURL = ""
|
||||
m = m.SwitchPage(paymentPage)
|
||||
m.payment.view = 0
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.review.success {
|
||||
m.review.success = false
|
||||
m.review.cardJustAdded = false
|
||||
@@ -236,13 +400,29 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
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 {
|
||||
s := raw.Error()
|
||||
switch {
|
||||
case strings.Contains(s, "CART_OVER_LIMIT"):
|
||||
return "order total is over the per-order limit. remove items and try again."
|
||||
case strings.Contains(s, "AUTHENTICATION_REQUIRED"):
|
||||
return "your bank wants to verify this charge, authentication step coming soon."
|
||||
case strings.Contains(s, "AUTHENTICATION_NOT_SUPPORTED"):
|
||||
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"):
|
||||
return "card expired. Add a new card and try again"
|
||||
case strings.Contains(s, "CARD_CVC_FAILED"):
|
||||
|
||||
394
pkg/tui/confirmation_retry_test.go
Normal file
394
pkg/tui/confirmation_retry_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
24
pkg/tui/confirmation_test.go
Normal file
24
pkg/tui/confirmation_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,17 @@ type reviewState struct {
|
||||
cardJustAdded bool
|
||||
cardWasDuplicate bool
|
||||
success bool
|
||||
|
||||
// 3DS / SCA flow: when the backend returns 202 requires_action,
|
||||
// requiresAction is true and redirectURL is the Stripe-hosted challenge
|
||||
// page. The TUI shows a QR and polls GetOrderStatus(orderID) until the
|
||||
// order flips to paid or failed.
|
||||
requiresAction bool
|
||||
redirectURL string
|
||||
orderID uint
|
||||
authFailed bool
|
||||
retrying bool
|
||||
retryExhausted bool
|
||||
}
|
||||
|
||||
type confirmState struct {
|
||||
@@ -418,11 +429,33 @@ func (m Model) Init() tea.Cmd {
|
||||
}
|
||||
|
||||
type CheckoutResultMsg struct {
|
||||
OrderID uint
|
||||
Total int
|
||||
RequiresAction bool
|
||||
RedirectURL string
|
||||
Err error
|
||||
}
|
||||
|
||||
// OrderStatusMsg carries the result of a poll against /orders/{id}/status.
|
||||
// Emitted on the 2s tick while waiting for 3DS authentication to complete.
|
||||
type OrderStatusMsg struct {
|
||||
OrderID uint
|
||||
Total int
|
||||
Status string
|
||||
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
|
||||
type CartSyncedMsg struct {
|
||||
UpdateID int64
|
||||
@@ -631,12 +664,52 @@ func (m Model) checkoutWithSavedCard() tea.Cmd {
|
||||
}
|
||||
|
||||
// 3. Convert the cart to an order
|
||||
order, err := m.APIClient.ConvertCart()
|
||||
outcome, err := m.APIClient.ConvertCart()
|
||||
if err != nil {
|
||||
return CheckoutResultMsg{Err: err}
|
||||
}
|
||||
|
||||
return CheckoutResultMsg{OrderID: order.ID, Total: order.Total}
|
||||
if outcome.RequiresAction {
|
||||
return CheckoutResultMsg{
|
||||
OrderID: outcome.OrderID,
|
||||
RequiresAction: true,
|
||||
RedirectURL: outcome.RedirectURL,
|
||||
}
|
||||
}
|
||||
|
||||
return CheckoutResultMsg{OrderID: outcome.Order.ID, Total: outcome.Order.Total}
|
||||
}
|
||||
}
|
||||
|
||||
// schedulePollOrderStatus sleeps 2s then polls /orders/{id}/status. Used while
|
||||
// waiting for the customer to complete 3DS authentication.
|
||||
func (m Model) schedulePollOrderStatus(orderID uint) tea.Cmd {
|
||||
return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
|
||||
if m.APIClient == nil {
|
||||
return OrderStatusMsg{OrderID: orderID, Err: fmt.Errorf("api client unavailable")}
|
||||
}
|
||||
status, err := m.APIClient.GetOrderStatus(orderID)
|
||||
if err != nil {
|
||||
return OrderStatusMsg{OrderID: orderID, Err: err}
|
||||
}
|
||||
return OrderStatusMsg{OrderID: orderID, Status: status}
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ func (m Model) RefundUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
state.form = f
|
||||
}
|
||||
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, cmd
|
||||
@@ -335,7 +335,7 @@ func (m Model) renderRefundTextarea(state *refundState) string {
|
||||
}
|
||||
|
||||
wrapper := lipgloss.NewStyle().
|
||||
Border(lipgloss.ThickBorder()).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(border)
|
||||
|
||||
contentWidth := m.refundContentWidth()
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
|
||||
// SuccessResponse represents a successful API response
|
||||
type SuccessResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data"`
|
||||
Success bool `json:"success"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
// ErrorResponse represents an error API response
|
||||
@@ -20,13 +20,13 @@ type ErrorResponse struct {
|
||||
|
||||
// Error contains error details
|
||||
type Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// RespondSuccess sends a successful JSON response
|
||||
func RespondSuccess(w http.ResponseWriter, statusCode int, data interface{}) {
|
||||
func RespondSuccess(w http.ResponseWriter, statusCode int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
@@ -39,7 +39,7 @@ func RespondSuccess(w http.ResponseWriter, statusCode int, data interface{}) {
|
||||
}
|
||||
|
||||
// RespondError sends an error JSON response
|
||||
func RespondError(w http.ResponseWriter, statusCode int, code, message string, details map[string]interface{}) {
|
||||
func RespondError(w http.ResponseWriter, statusCode int, code, message string, details map[string]any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
|
||||
13
scripts/backups/01-log-tail.sh
Executable file
13
scripts/backups/01-log-tail.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Show last 20 lines of the cron log + its mtime. Cheapest "did it run" check.
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
source "$SCRIPT_DIR/lib.sh"
|
||||
|
||||
print_header "Backup log: mtime + last 20 lines"
|
||||
spinner_start "fetching log from $SSH_HOST..."
|
||||
ssh "$SSH_HOST" "
|
||||
sudo stat -c 'Last modified: %y' '$BACKUP_LOG'
|
||||
echo; sudo tail -20 '$BACKUP_LOG'
|
||||
"
|
||||
spinner_stop
|
||||
14
scripts/backups/02-snapshots.sh
Executable file
14
scripts/backups/02-snapshots.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# List every restic snapshot with timestamps and tags.
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
source "$SCRIPT_DIR/lib.sh"
|
||||
|
||||
print_header "All restic snapshots"
|
||||
spinner_start "querying restic via SFTP..."
|
||||
ssh "$SSH_HOST" "
|
||||
export RESTIC_PASSWORD_FILE='$RESTIC_PASSWORD_FILE'
|
||||
export RESTIC_REPOSITORY='$RESTIC_REPO'
|
||||
sudo -E restic -o sftp.args='-i $RESTIC_SSH_KEY' snapshots
|
||||
"
|
||||
spinner_stop
|
||||
21
scripts/backups/03-check.sh
Executable file
21
scripts/backups/03-check.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# restic repo integrity check.
|
||||
# ./03-check.sh metadata only (~5s)
|
||||
# ./03-check.sh --deep + 10% data-pack re-read (~30s)
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
source "$SCRIPT_DIR/lib.sh"
|
||||
|
||||
EXTRA=""
|
||||
if [ "${1:-}" = "--deep" ]; then
|
||||
EXTRA="--read-data-subset=10%"
|
||||
fi
|
||||
|
||||
print_header "restic check ${EXTRA:-(metadata only)}"
|
||||
spinner_start "running restic check on $SSH_HOST..."
|
||||
ssh "$SSH_HOST" "
|
||||
export RESTIC_PASSWORD_FILE='$RESTIC_PASSWORD_FILE'
|
||||
export RESTIC_REPOSITORY='$RESTIC_REPO'
|
||||
sudo -E restic -o sftp.args='-i $RESTIC_SSH_KEY' check $EXTRA
|
||||
"
|
||||
spinner_stop
|
||||
57
scripts/backups/04-restore-verify.sh
Executable file
57
scripts/backups/04-restore-verify.sh
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Restore the latest snapshot to /tmp on the remote box, then:
|
||||
# - PRAGMA integrity_check on the restored DB
|
||||
# - row-count diff vs the live DB
|
||||
# Cleans up /tmp dir at the end.
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
source "$SCRIPT_DIR/lib.sh"
|
||||
|
||||
print_header "Restore latest snapshot and verify SQLite"
|
||||
spinner_start "restoring latest snapshot to /tmp on $SSH_HOST..."
|
||||
|
||||
ssh "$SSH_HOST" \
|
||||
"PWFILE='$RESTIC_PASSWORD_FILE' \
|
||||
REPO='$RESTIC_REPO' \
|
||||
KEY='$RESTIC_SSH_KEY' \
|
||||
LIVE='$LIVE_DB' \
|
||||
bash -s" <<'REMOTE'
|
||||
set -e
|
||||
DIR=/tmp/tshop-restore-test
|
||||
sudo rm -rf "$DIR" && sudo mkdir -p "$DIR"
|
||||
|
||||
sudo -E RESTIC_PASSWORD_FILE="$PWFILE" RESTIC_REPOSITORY="$REPO" \
|
||||
restic -o sftp.args="-i $KEY" restore latest --target "$DIR"
|
||||
|
||||
DB=$(sudo find "$DIR" -name 'terminalshop.db' | head -1)
|
||||
if [ -z "$DB" ]; then
|
||||
echo 'restored DB not found' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo '--- restored file ---'
|
||||
sudo stat -c '%y %s bytes %n' "$DB"
|
||||
|
||||
echo
|
||||
echo '--- PRAGMA integrity_check ---'
|
||||
sudo sqlite3 "$DB" 'PRAGMA integrity_check;'
|
||||
|
||||
echo
|
||||
echo '--- PRAGMA quick_check ---'
|
||||
sudo sqlite3 "$DB" 'PRAGMA quick_check;'
|
||||
|
||||
echo
|
||||
echo '--- restored vs live row counts ---'
|
||||
printf '%-22s %10s %10s\n' table restored live
|
||||
for t in $(sudo sqlite3 "$DB" 'SELECT name FROM sqlite_master WHERE type="table";'); do
|
||||
r=$(sudo sqlite3 "$DB" "SELECT COUNT(*) FROM \"$t\";")
|
||||
l=$(sudo sqlite3 "$LIVE" "SELECT COUNT(*) FROM \"$t\";" 2>/dev/null || echo 'n/a')
|
||||
printf '%-22s %10s %10s\n' "$t" "$r" "$l"
|
||||
done
|
||||
|
||||
sudo rm -rf "$DIR"
|
||||
echo
|
||||
echo 'OK'
|
||||
REMOTE
|
||||
spinner_stop
|
||||
41
scripts/backups/05-freshness.sh
Executable file
41
scripts/backups/05-freshness.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# Print latest snapshot age. Exit 0 if fresh, 1 if older than STALE_HOURS.
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
source "$SCRIPT_DIR/lib.sh"
|
||||
|
||||
print_header "Latest snapshot freshness"
|
||||
|
||||
spinner_start "querying latest snapshot..."
|
||||
LATEST=$(ssh "$SSH_HOST" "
|
||||
export RESTIC_PASSWORD_FILE='$RESTIC_PASSWORD_FILE'
|
||||
export RESTIC_REPOSITORY='$RESTIC_REPO'
|
||||
sudo -E restic -o sftp.args='-i $RESTIC_SSH_KEY' snapshots --json
|
||||
" | jq -r 'max_by(.time) | .time')
|
||||
spinner_stop
|
||||
|
||||
if [ -z "$LATEST" ] || [ "$LATEST" = "null" ]; then
|
||||
echo "ERROR: could not parse latest snapshot time" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
NOW=$(date -u +%s)
|
||||
# GNU date (Linux) first, then BSD date (macOS) as fallback.
|
||||
if THEN=$(date -u -d "$LATEST" +%s 2>/dev/null); then
|
||||
:
|
||||
else
|
||||
CLEAN=$(echo "$LATEST" | sed 's/\.[0-9]*Z$/Z/')
|
||||
THEN=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$CLEAN" +%s)
|
||||
fi
|
||||
|
||||
AGE_HRS=$(( (NOW - THEN) / 3600 ))
|
||||
echo "Latest snapshot: $LATEST"
|
||||
echo "Age: ${AGE_HRS}h"
|
||||
echo "Threshold: ${STALE_HOURS}h"
|
||||
|
||||
if [ "$AGE_HRS" -lt "$STALE_HOURS" ]; then
|
||||
print_ok "snapshot within freshness window"
|
||||
else
|
||||
print_fail "STALE — investigate"
|
||||
exit 1
|
||||
fi
|
||||
59
scripts/backups/06-lfs-direct.sh
Executable file
59
scripts/backups/06-lfs-direct.sh
Executable file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Independent filesystem check on lfs-remote. No restic, no password needed.
|
||||
# Proves the repo dir exists, files are present, recent uploads landed,
|
||||
# and the backup partition has room.
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
source "$SCRIPT_DIR/lib.sh"
|
||||
|
||||
print_header "lfs-remote filesystem-level repo check"
|
||||
spinner_start "inspecting $LFS_HOST filesystem..."
|
||||
|
||||
ssh "$LFS_HOST" "
|
||||
set -e
|
||||
REPO='$LFS_REPO_PATH'
|
||||
WARN_PCT=$LFS_DISK_WARN_PCT
|
||||
|
||||
if [ ! -d \"\$REPO\" ]; then
|
||||
echo 'ERROR: repo dir missing on lfs' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '--- snapshot pointer count (one file per restic snapshot) ---'
|
||||
COUNT=\$(find \"\$REPO/snapshots\" -type f 2>/dev/null | wc -l)
|
||||
echo \"snapshots/: \$COUNT files\"
|
||||
|
||||
echo
|
||||
echo '--- repo size on disk ---'
|
||||
du -sh \"\$REPO\"
|
||||
|
||||
echo
|
||||
echo '--- recent uploads (files newer than 48h) ---'
|
||||
RECENT=\$(find \"\$REPO\" -type f -mtime -2 2>/dev/null | wc -l)
|
||||
echo \"files added in last 48h: \$RECENT\"
|
||||
if [ \"\$RECENT\" -eq 0 ]; then
|
||||
echo 'WARN: no new files in last 48h' >&2
|
||||
fi
|
||||
|
||||
echo
|
||||
echo '--- most recent snapshot pointer ---'
|
||||
LATEST=\$(ls -t \"\$REPO/snapshots\"/* 2>/dev/null | head -1)
|
||||
if [ -n \"\$LATEST\" ]; then
|
||||
stat -c '%y %n' \"\$LATEST\"
|
||||
else
|
||||
echo 'WARN: no snapshot files found' >&2
|
||||
fi
|
||||
|
||||
echo
|
||||
echo '--- disk usage ---'
|
||||
df -h \"\$REPO\"
|
||||
USED=\$(df --output=pcent \"\$REPO\" 2>/dev/null | tail -1 | tr -dc 0-9)
|
||||
if [ -n \"\$USED\" ] && [ \"\$USED\" -gt \"\$WARN_PCT\" ]; then
|
||||
echo \"FAIL: backup partition \${USED}% full (threshold \${WARN_PCT}%)\" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo 'OK'
|
||||
"
|
||||
spinner_stop
|
||||
38
scripts/backups/07-lfs-restic-check.sh
Executable file
38
scripts/backups/07-lfs-restic-check.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Independent restic check from the lfs-remote side. Reads repo via a
|
||||
# LOCAL filesystem path (not SFTP), so it proves the data is parseable
|
||||
# without any involvement from the prod box at restic level.
|
||||
#
|
||||
# Needs:
|
||||
# - restic installed on lfs-remote (see scratchpkg port at /usr/ports/main/restic)
|
||||
# - SSH access to both prod (to fetch the repo password) and lfs
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
source "$SCRIPT_DIR/lib.sh"
|
||||
|
||||
print_header "lfs-remote restic check (independent vantage point)"
|
||||
|
||||
# Precondition: restic must be on lfs.
|
||||
if ! ssh "$LFS_HOST" 'command -v restic >/dev/null 2>&1'; then
|
||||
echo 'ERROR: restic not in PATH on lfs-remote' >&2
|
||||
echo "Install via scratchpkg, then re-run." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Pull restic password from prod (needs sudo on prod).
|
||||
spinner_start "fetching restic password from $SSH_HOST..."
|
||||
PW=$(ssh "$SSH_HOST" "sudo cat '$RESTIC_PASSWORD_FILE'")
|
||||
spinner_stop
|
||||
if [ -z "$PW" ]; then
|
||||
echo 'ERROR: could not read restic password from prod' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Run restic against the LOCAL repo path on lfs. Password fed via stdin
|
||||
# so it never lands in argv or a file on lfs.
|
||||
spinner_start "running restic check from $LFS_HOST..."
|
||||
ssh "$LFS_HOST" "restic -r '$LFS_REPO_PATH' --password-file=/dev/stdin check" <<<"$PW"
|
||||
spinner_stop
|
||||
|
||||
echo
|
||||
print_ok "lfs-side restic check passed"
|
||||
138
scripts/backups/check-all.sh
Executable file
138
scripts/backups/check-all.sh
Executable file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env bash
|
||||
# Master runner. Executes every backup check in sequence.
|
||||
# ./check-all.sh stop on first failure; interactive menu when TTY
|
||||
# ./check-all.sh --keep-going keep running after failures (no menu)
|
||||
# ./check-all.sh --deep use --deep on 03-check.sh
|
||||
#
|
||||
# When stdin and stdout are TTYs, a failed step opens a drill-down menu:
|
||||
# [r]etry [l]og [u]nlock [s]kip [q]uit
|
||||
# Non-TTY (cron / piped) skips the menu and behaves like the old runner.
|
||||
set -uo pipefail
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
|
||||
KEEP_GOING=0
|
||||
DEEP=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--keep-going) KEEP_GOING=1 ;;
|
||||
--deep) DEEP=1 ;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 [--keep-going] [--deep]"
|
||||
echo " --keep-going don't stop on first failure, suppress interactive menu"
|
||||
echo " --deep pass --deep to 03-check.sh (10% data re-read)"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unknown flag: $arg" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Pull in config + color helpers (also gives us SSH_HOST + restic env for unlock).
|
||||
source "$SCRIPT_DIR/lib.sh"
|
||||
|
||||
declare -a STEP_NAMES=()
|
||||
declare -a STEP_STATUS=() # OK | FAIL
|
||||
FAIL=0
|
||||
|
||||
prompt_after_fail() {
|
||||
# returns 0=skip, 1=quit, 2=retry
|
||||
local script="$1"
|
||||
while :; do
|
||||
printf '\n%sNext?%s [r]etry [l]og [u]nlock [s]kip [q]uit > ' "$C_BOLD" "$C_RESET"
|
||||
local choice
|
||||
read -r choice
|
||||
case "$choice" in
|
||||
r|R) return 2 ;;
|
||||
l|L) "$SCRIPT_DIR/01-log-tail.sh"; echo ;;
|
||||
u|U)
|
||||
echo
|
||||
ssh "$SSH_HOST" "
|
||||
export RESTIC_PASSWORD_FILE='$RESTIC_PASSWORD_FILE'
|
||||
export RESTIC_REPOSITORY='$RESTIC_REPO'
|
||||
sudo -E restic -o sftp.args='-i $RESTIC_SSH_KEY' unlock
|
||||
"
|
||||
echo
|
||||
;;
|
||||
s|S) return 0 ;;
|
||||
q|Q) return 1 ;;
|
||||
"") ;;
|
||||
*) printf 'unknown: %s\n' "$choice" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
run_step() {
|
||||
local script="$1"; shift
|
||||
echo
|
||||
printf '%s########## %s %s ##########%s\n' "$C_CYAN$C_BOLD" "$script" "$*" "$C_RESET"
|
||||
|
||||
local attempts=0
|
||||
while :; do
|
||||
attempts=$((attempts + 1))
|
||||
if "$SCRIPT_DIR/$script" "$@"; then
|
||||
STEP_NAMES+=("$script")
|
||||
STEP_STATUS+=("OK")
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '%sFAILED:%s %s\n' "$C_RED$C_BOLD" "$C_RESET" "$script" >&2
|
||||
|
||||
if [ -t 0 ] && [ -t 1 ] && [ "$KEEP_GOING" -eq 0 ]; then
|
||||
prompt_after_fail "$script"
|
||||
case "$?" in
|
||||
0) STEP_NAMES+=("$script"); STEP_STATUS+=("FAIL"); FAIL=$((FAIL + 1)); return 0 ;;
|
||||
1) STEP_NAMES+=("$script"); STEP_STATUS+=("FAIL"); FAIL=$((FAIL + 1)); print_summary; exit 1 ;;
|
||||
2) echo
|
||||
printf '%s>>>>>> retry %s (attempt %d) <<<<<<%s\n' "$C_CYAN$C_BOLD" "$script" $((attempts + 1)) "$C_RESET"
|
||||
continue ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# non-interactive path
|
||||
STEP_NAMES+=("$script")
|
||||
STEP_STATUS+=("FAIL")
|
||||
FAIL=$((FAIL + 1))
|
||||
if [ "$KEEP_GOING" -eq 0 ]; then
|
||||
print_summary
|
||||
exit 1
|
||||
fi
|
||||
return 0
|
||||
done
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
echo
|
||||
printf '%s===============================================%s\n' "$C_CYAN$C_BOLD" "$C_RESET"
|
||||
printf ' %-28s %s\n' "Script" "Result"
|
||||
printf ' %-28s %s\n' "----------------------------" "--------"
|
||||
local i
|
||||
for i in "${!STEP_NAMES[@]}"; do
|
||||
local name="${STEP_NAMES[$i]}"
|
||||
local status="${STEP_STATUS[$i]}"
|
||||
local color sym
|
||||
case "$status" in
|
||||
OK) color="$C_GREEN"; sym="✓ OK" ;;
|
||||
FAIL) color="$C_RED" ; sym="✗ FAIL" ;;
|
||||
esac
|
||||
printf ' %-28s %s%s%s\n' "$name" "$color" "$sym" "$C_RESET"
|
||||
done
|
||||
printf '%s===============================================%s\n' "$C_CYAN$C_BOLD" "$C_RESET"
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
printf '%sAll checks passed.%s\n' "$C_GREEN$C_BOLD" "$C_RESET"
|
||||
else
|
||||
printf '%s%d check(s) failed.%s\n' "$C_RED$C_BOLD" "$FAIL" "$C_RESET"
|
||||
fi
|
||||
}
|
||||
|
||||
run_step 01-log-tail.sh
|
||||
run_step 02-snapshots.sh
|
||||
if [ "$DEEP" -eq 1 ]; then run_step 03-check.sh --deep; else run_step 03-check.sh; fi
|
||||
run_step 04-restore-verify.sh
|
||||
run_step 05-freshness.sh
|
||||
run_step 06-lfs-direct.sh
|
||||
run_step 07-lfs-restic-check.sh
|
||||
|
||||
print_summary
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
28
scripts/backups/config.env.example
Normal file
28
scripts/backups/config.env.example
Normal file
@@ -0,0 +1,28 @@
|
||||
# Backup verification config.
|
||||
# Copy to config.env (gitignored) and fill in your real values.
|
||||
#
|
||||
# cp config.env.example config.env
|
||||
# $EDITOR config.env
|
||||
|
||||
# SSH alias for the prod box (resolved via ~/.ssh/config).
|
||||
SSH_HOST="ubuntu-helsinki"
|
||||
|
||||
# Restic repository URL. Format: sftp:HOST:PATH
|
||||
# HOST is the Tailscale name of the backup target box.
|
||||
RESTIC_REPO="sftp:YOUR_TAILNET_HOST.ts.net:/root/backups/tshop"
|
||||
|
||||
# Paths on the REMOTE prod box (not your laptop).
|
||||
RESTIC_PASSWORD_FILE="/root/.restic-password"
|
||||
RESTIC_SSH_KEY="/root/.ssh/tshop-backup"
|
||||
BACKUP_LOG="/var/log/backup-tshop.log"
|
||||
LIVE_DB="/root/terminalShop/data/terminalshop.db"
|
||||
|
||||
# Freshness alert threshold (hours). 24h cron + 4h slop = 28.
|
||||
STALE_HOURS="28"
|
||||
|
||||
# lfs-remote = the box that RECEIVES the backups.
|
||||
# Used by 06-lfs-direct.sh and 07-lfs-restic-check.sh for out-of-band checks.
|
||||
LFS_HOST="lfs-remote"
|
||||
LFS_REPO_PATH="/root/backups/tshop"
|
||||
# 06 fails if the backup partition is more full than this percent.
|
||||
LFS_DISK_WARN_PCT="80"
|
||||
65
scripts/backups/lib.sh
Executable file
65
scripts/backups/lib.sh
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared loader for the backup verification scripts.
|
||||
# Each script should: source "$SCRIPT_DIR/lib.sh"
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
|
||||
if [ ! -f "$SCRIPT_DIR/config.env" ]; then
|
||||
echo "ERROR: $SCRIPT_DIR/config.env missing." >&2
|
||||
echo "Run: cp $SCRIPT_DIR/config.env.example $SCRIPT_DIR/config.env" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$SCRIPT_DIR/config.env"
|
||||
|
||||
# ANSI colors. Emit only when stdout is a TTY and NO_COLOR is unset.
|
||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
|
||||
C_RED=$'\033[1;31m'
|
||||
C_GREEN=$'\033[1;32m'
|
||||
C_YELLOW=$'\033[1;33m'
|
||||
C_BLUE=$'\033[1;34m'
|
||||
C_CYAN=$'\033[1;36m'
|
||||
C_BOLD=$'\033[1m'
|
||||
C_RESET=$'\033[0m'
|
||||
else
|
||||
C_RED=""; C_GREEN=""; C_YELLOW=""; C_BLUE=""; C_CYAN=""; C_BOLD=""; C_RESET=""
|
||||
fi
|
||||
|
||||
print_header() { printf '\n%s=== %s ===%s\n' "$C_CYAN" "$1" "$C_RESET"; }
|
||||
print_ok() { printf '%sOK%s%s\n' "$C_GREEN" "$C_RESET" "${1:+ — $1}"; }
|
||||
print_fail() { printf '%sFAIL%s%s\n' "$C_RED" "$C_RESET" "${1:+ — $1}" >&2; }
|
||||
print_warn() { printf '%sWARN%s%s\n' "$C_YELLOW" "$C_RESET" "${1:+ — $1}" >&2; }
|
||||
print_info() { printf '%s%s%s\n' "$C_BLUE" "$1" "$C_RESET"; }
|
||||
|
||||
# Spinner: shows a rotating braille char while a foreground command runs.
|
||||
# Use as a pair around the silent/blocking call:
|
||||
# spinner_start "fetching log..."
|
||||
# ssh ...
|
||||
# spinner_stop
|
||||
# Auto-skips when stdout isn't a TTY (cron / piped). Real output from the
|
||||
# wrapped command will visually overwrite the spinner — by design.
|
||||
SPINNER_PID=""
|
||||
spinner_start() {
|
||||
[ -t 1 ] && [ -z "${NO_COLOR:-}" ] || return 0
|
||||
local msg="${1:-working}"
|
||||
(
|
||||
trap 'exit 0' TERM
|
||||
local chars=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
|
||||
local i=0
|
||||
while :; do
|
||||
i=$(( (i + 1) % ${#chars[@]} ))
|
||||
printf '\r%s%s%s %s' "$C_CYAN" "${chars[$i]}" "$C_RESET" "$msg"
|
||||
sleep 0.08
|
||||
done
|
||||
) &
|
||||
SPINNER_PID=$!
|
||||
disown "$SPINNER_PID" 2>/dev/null || true
|
||||
}
|
||||
spinner_stop() {
|
||||
[ -n "$SPINNER_PID" ] || return 0
|
||||
kill "$SPINNER_PID" 2>/dev/null || true
|
||||
wait "$SPINNER_PID" 2>/dev/null || true
|
||||
printf '\r\033[K'
|
||||
SPINNER_PID=""
|
||||
}
|
||||
Reference in New Issue
Block a user