diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..555a26b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + pull_request: + branches: [master] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./... + + - name: Format check + run: | + unformatted=$(gofmt -l $(go list -f '{{.Dir}}' ./...)) + if [ -n "$unformatted" ]; then + echo "These files need gofmt:" + echo "$unformatted" + exit 1 + 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: | + 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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d0b5e5b..5888ac2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore index 0bb5609..7023db7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ .env.example .env.prod +docs/sca-psd2-compliance.md +accept-a-payment/ + FULFILLMENT.md .tmp-gocache/ @@ -30,6 +33,8 @@ api/api-server *.out coverage.txt coverage.html +test-output.json +test-report.xml # Dependency directories vendor/ @@ -98,6 +103,8 @@ docs/ # Migration state (if using a migration tool that creates state files) migrations/*.state +gh-dash/ + loadtest/run.pid @@ -109,3 +116,6 @@ ROADMAP.md *.bak terminalshop.prod.* + +# Backup verification config (real values; example file is committed) +scripts/backups/config.env diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..3d929d7 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,16 @@ +version: "2" + +run: + timeout: 5m + +linters: + default: none + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + exclusions: + paths: + - terminal-shop-source \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..16084c0 --- /dev/null +++ b/Makefile @@ -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) diff --git a/api/handlers/addresses.go b/api/handlers/addresses.go index b62edc9..c3ec6ab 100644 --- a/api/handlers/addresses.go +++ b/api/handlers/addresses.go @@ -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, }) } diff --git a/api/handlers/addresses_test.go b/api/handlers/addresses_test.go index 3094e01..e4d2841 100644 --- a/api/handlers/addresses_test.go +++ b/api/handlers/addresses_test.go @@ -13,7 +13,7 @@ import ( func setupAddressTestDB(t *testing.T) (string, models.User) { t.Helper() testDB := "test_addresses.db" - os.Remove(testDB) + _ = os.Remove(testDB) database.ResetForTesting() db, err := database.Connect(testDB) @@ -42,7 +42,7 @@ func setupAddressTestDB(t *testing.T) (string, models.User) { func TestGetAddressesEmpty(t *testing.T) { testDB, user := setupAddressTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() handler := NewAddressHandler("", "", "") @@ -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 { @@ -74,7 +74,7 @@ func TestGetAddressesEmpty(t *testing.T) { func TestGetAddressesIsolation(t *testing.T) { testDB, user1 := setupAddressTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -105,7 +105,7 @@ func TestGetAddressesIsolation(t *testing.T) { Addresses []models.Address `json:"addresses"` } `json:"data"` } - json.NewDecoder(w.Body).Decode(&resp) + _ = json.NewDecoder(w.Body).Decode(&resp) if len(resp.Data.Addresses) != 1 { t.Fatalf("expexted 1 address for user1, got %d", len(resp.Data.Addresses)) @@ -117,7 +117,7 @@ func TestGetAddressesIsolation(t *testing.T) { func TestGetAddressesWithData(t *testing.T) { testDB, user := setupAddressTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() diff --git a/api/handlers/auth.go b/api/handlers/auth.go index 47137c9..70127d0 100644 --- a/api/handlers/auth.go +++ b/api/handlers/auth.go @@ -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(), }) } diff --git a/api/handlers/auth_test.go b/api/handlers/auth_test.go index 64bd405..a587459 100644 --- a/api/handlers/auth_test.go +++ b/api/handlers/auth_test.go @@ -22,7 +22,7 @@ const testClientSecret = "test-client-secret" func setupAuthHandlerTestDB(t *testing.T) (string, *auth.JWTManager, *AuthHandler) { t.Helper() testDB := "test_auth.db" - os.Remove(testDB) + _ = os.Remove(testDB) database.ResetForTesting() db, err := database.Connect(testDB) @@ -62,7 +62,7 @@ func createAuthTestUser(t *testing.T, fingerprint string) models.User { func TestGetTokenSuccess(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() user := createAuthTestUser(t, "SHA256:testfingerprint123") @@ -108,7 +108,7 @@ func TestGetTokenSuccess(t *testing.T) { func TestGetTokenBadSecret(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() body, _ := json.Marshal(map[string]string{ @@ -128,7 +128,7 @@ func TestGetTokenBadSecret(t *testing.T) { func TestGetTokenMissingFingerprint(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() body, _ := json.Marshal(map[string]string{ @@ -147,7 +147,7 @@ func TestGetTokenMissingFingerprint(t *testing.T) { func TestGetTokenUnknownFingerprint(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() body, _ := json.Marshal(map[string]string{ @@ -167,7 +167,7 @@ func TestGetTokenUnknownFingerprint(t *testing.T) { func TestGetTokenInvalidJSON(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() req := httptest.NewRequest("POST", "/api/v1/auth/token", bytes.NewBufferString("{bad json")) @@ -185,7 +185,7 @@ func TestGetTokenInvalidJSON(t *testing.T) { func TestRegisterWithSSHKeySuccess(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() body, _ := json.Marshal(map[string]string{ @@ -207,7 +207,7 @@ func TestRegisterWithSSHKeySuccess(t *testing.T) { func TestRegisterWithSSHKeyDuplicate(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() createAuthTestUser(t, "SHA256:existingfp") @@ -229,7 +229,7 @@ func TestRegisterWithSSHKeyDuplicate(t *testing.T) { func TestRegisterWithSSHKeyMissingFields(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() body, _ := json.Marshal(map[string]string{ @@ -250,7 +250,7 @@ func TestRegisterWithSSHKeyMissingFields(t *testing.T) { func TestGetUserBySSHKeySuccess(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() createAuthTestUser(t, "SHA256:lookupfp") @@ -267,7 +267,7 @@ func TestGetUserBySSHKeySuccess(t *testing.T) { func TestGetUserBySSHKeyNotFound(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() req := httptest.NewRequest("GET", "/api/v1/auth/ssh?fingerprint=SHA256:nope", nil) @@ -282,7 +282,7 @@ func TestGetUserBySSHKeyNotFound(t *testing.T) { func TestGetUserBySSHKeyMissingParam(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() req := httptest.NewRequest("GET", "/api/v1/auth/ssh", nil) diff --git a/api/handlers/card_test.go b/api/handlers/card_test.go new file mode 100644 index 0000000..66906f7 --- /dev/null +++ b/api/handlers/card_test.go @@ -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) + } +} diff --git a/api/handlers/cards.go b/api/handlers/cards.go index db18d84..c73c3db 100644 --- a/api/handlers/cards.go +++ b/api/handlers/cards.go @@ -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,33 +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 { +func storeRedirect(stripeURL, purpose string) (string, error) { b := make([]byte, 16) - rand.Read(b) + if _, err := rand.Read(b); err != nil { + return "", err + } token := hex.EncodeToString(b) - payRedirectsMu.Lock() - payRedirects[token] = stripeURL - payRedirectsMu.Unlock() - return token + 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. @@ -65,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) { @@ -97,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, }) } @@ -119,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 @@ -130,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, }) } @@ -166,8 +192,12 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) { return } - token := storeRedirect(sess.URL) - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + 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]any{ "url": fmt.Sprintf("%s/pay/%s", h.appURL, token), }) } @@ -272,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, }) } @@ -317,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 diff --git a/api/handlers/cards_expire_test.go b/api/handlers/cards_expire_test.go new file mode 100644 index 0000000..15b66a2 --- /dev/null +++ b/api/handlers/cards_expire_test.go @@ -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 +} diff --git a/api/handlers/cart.go b/api/handlers/cart.go index e44a4db..122d8ed 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -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,12 +25,20 @@ import ( ) type CartHandler struct { - stripeKey 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 string) *CartHandler { - return &CartHandler{stripeKey: stripeSecretKey} +func NewCartHandler(stripeSecretKey, appURL string, maxOrderCents int) *CartHandler { + return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL, maxOrderCents: maxOrderCents} } // getOrCreateCart finds the users cart or creates one. @@ -45,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, @@ -65,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, @@ -84,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), }) } @@ -147,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), }) } @@ -183,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", }) } @@ -208,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) @@ -219,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", }) } @@ -239,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", }) } @@ -294,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") @@ -353,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) @@ -391,16 +431,15 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { // Charge the card. The idempotency key is tied to the order ID so retries // never produce a double charge. piParams := &stripe.PaymentIntentParams{ - Amount: stripe.Int64(int64(total)), - Currency: stripe.String(string(stripe.CurrencyUSD)), - Customer: stripe.String(user.StripeCustomerID), - PaymentMethod: stripe.String(paymentMethodID), - AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{ - Enabled: stripe.Bool(true), - AllowRedirects: stripe.String("never"), - }, - Confirm: stripe.Bool(true), - Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)), + Amount: stripe.Int64(int64(total)), + Currency: stripe.String(string(stripe.CurrencyUSD)), + Customer: stripe.String(user.StripeCustomerID), + PaymentMethod: stripe.String(paymentMethodID), + OffSession: stripe.Bool(true), + PaymentMethodTypes: stripe.StringSlice([]string{"card"}), + ReturnURL: stripe.String(h.appURL + "/post-3ds"), + Confirm: stripe.Bool(true), + Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)), Shipping: &stripe.ShippingDetailsParams{ Name: stripe.String(address.Name), Address: &stripe.AddressParams{ @@ -423,13 +462,17 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { stripeDur := time.Since(stripeStart).Seconds() if err != nil { middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur) - db.Model(&order).Update("status", models.OrderStatusFailed) if stripeErr, ok := err.(*stripe.Error); ok { + if stripeErr.Code == stripe.ErrorCodeAuthenticationRequired && stripeErr.PaymentIntent != nil { + h.respondRequiresAction(w, &order, paymentMethodID, stripeErr.PaymentIntent) + return + } + db.Model(&order).Update("status", models.OrderStatusFailed) switch stripeErr.Type { case stripe.ErrorTypeCard: middleware.RecordCartConversion("card_declined") audit.OrderFailed(userID, order.ID, total, stripeErr.Msg) - utils.RespondError(w, http.StatusPaymentRequired, "CARD_DECLINED", stripeErr.Msg, nil) + utils.RespondError(w, http.StatusPaymentRequired, mapStripeCardErrCode(stripeErr.Code), stripeErr.Msg, nil) case stripe.ErrorTypeInvalidRequest: middleware.RecordCartConversion("stripe_invalid_request") audit.OrderFailed(userID, order.ID, total, stripeErr.Msg) @@ -441,6 +484,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { } return } + db.Model(&order).Update("status", models.OrderStatusFailed) middleware.RecordCartConversion("stripe_error") audit.OrderFailed(userID, order.ID, total, err.Error()) utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil) @@ -448,6 +492,11 @@ 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, paymentMethodID, pi) + return + } + // Commit payment ID and clear cart in a single transaction. txErr := db.Transaction(func(tx *gorm.DB) error { order.StripePaymentID = pi.ID @@ -458,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 @@ -485,7 +534,243 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { } go notify.SlackOrderPaid(&order) - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "order": order, }) } + +// respondRequiresAction handles a PaymentIntent that needs 3DS auth +// The order stays pending. the webhook flips it to paid once the customer +// completes the bank-hosted challenge, the tui polls /api/v1/orders/{id}/status +func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, paymentMethodID string, pi *stripe.PaymentIntent) { + db := database.GetDB() + + // 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 == "" { + 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 + } + } + + if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" { + middleware.RecordCartConversion("sca_missing_next_action") + db.Model(order).Update("status", models.OrderStatusFailed) + audit.OrderFailed(order.UserID, order.ID, order.Total, "stripe returned requires_action without redirect url") + utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "stripe did not return a 3ds url", nil) + return + } + + // Persist the paymentIntent id AND reset status to pending. The + // payment_intent.payment_failed webhook from the initial off-session decline + // often arrives before this response and flips the order to failed — + // override it back so the 3DS-in-flight order shows the correct state. + 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, models.RedirectPurposeCheckout3DS) + if err != nil { + utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to allocate redirect token", nil) + return + } + + middleware.RecordCartConversion("requires_action") + audit.OrderRequiresAction(order.UserID, order.ID, pi.ID) + + utils.RespondSuccess(w, http.StatusAccepted, map[string]any{ + "order_id": order.ID, + "payment_intent_id": pi.ID, + "status": "requires_action", + "redirect_token": token, + "redirect_url": fmt.Sprintf("%s/pay/%s", h.appURL, token), + }) +} + +func mapStripeCardErrCode(code stripe.ErrorCode) string { + switch code { + case stripe.ErrorCodeAuthenticationRequired: + return "AUTHENTICATION_REQUIRED" + case "authentication_not_supported": + return "AUTHENTICATION_NOT_SUPPORTED" + case "card_declined", "": + return "CARD_DECLINED" + case "expired_card": + return "CARD_EXPIRED" + case "incorrect_cvc": + return "CARD_CVC_FAILED" + case "insufficient_funds": + return "CARD_INSUFFICIENT_FUNDS" + case "processing_error": + return "CARD_PROCESSING_ERROR" + } + return "CARD_DECLINED" +} + +//nolint:unused // wired into Convert cart later +func appURLForHandler(r *http.Request) string { + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + host := r.Host + if fh := r.Header.Get("X-Forwarded-Host"); fh != "" { + host = fh + } + 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) +} diff --git a/api/handlers/cart_test.go b/api/handlers/cart_test.go index 7187803..ec6b61e 100644 --- a/api/handlers/cart_test.go +++ b/api/handlers/cart_test.go @@ -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" ) @@ -20,7 +23,7 @@ import ( func setupCartTestDB(t *testing.T) (string, models.User) { t.Helper() testDB := "test_cart.db" - os.Remove(testDB) + _ = os.Remove(testDB) database.ResetForTesting() db, err := database.Connect(testDB) @@ -62,10 +65,10 @@ func authRequest(method, url string, body []byte, userID uint) *http.Request { func TestGetCartEmpty(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + 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"` } @@ -101,13 +104,13 @@ func TestGetCartEmpty(t *testing.T) { func TestSetItemAndGetCart(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + 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, }) @@ -157,19 +160,19 @@ func TestSetItemAndGetCart(t *testing.T) { func TestSetItemUpdateQuantity(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + 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) @@ -190,7 +193,7 @@ func TestSetItemUpdateQuantity(t *testing.T) { } `json:"cart"` } `json:"data"` } - json.NewDecoder(w.Body).Decode(&resp) + _ = json.NewDecoder(w.Body).Decode(&resp) if len(resp.Data.Cart.Items) != 1 { t.Fatalf("Expected 1 item after update, got %d", len(resp.Data.Cart.Items)) @@ -202,19 +205,19 @@ func TestSetItemUpdateQuantity(t *testing.T) { func TestSetItemRemove(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + 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,11 +231,11 @@ 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"` } - json.NewDecoder(w.Body).Decode(&resp) + _ = json.NewDecoder(w.Body).Decode(&resp) if len(resp.Data.Cart.Items) != 0 { t.Errorf("Expected 0 items after removal, got %d", len(resp.Data.Cart.Items)) @@ -241,12 +244,12 @@ func TestSetItemRemove(t *testing.T) { func TestSetItemInvalidProduct(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + 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) @@ -258,12 +261,12 @@ func TestSetItemInvalidProduct(t *testing.T) { func TestSetItemMissingCoffeeID(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + 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) @@ -275,14 +278,14 @@ func TestSetItemMissingCoffeeID(t *testing.T) { func TestClearCart(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + 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,11 +309,11 @@ 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"` } - json.NewDecoder(w.Body).Decode(&resp) + _ = json.NewDecoder(w.Body).Decode(&resp) if len(resp.Data.Cart.Items) != 0 { t.Errorf("Expected 0 items after clear, got %d", len(resp.Data.Cart.Items)) @@ -319,7 +322,7 @@ func TestClearCart(t *testing.T) { func TestSetAddress(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -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) @@ -358,7 +361,7 @@ func TestSetAddress(t *testing.T) { } `json:"cart"` } `json:"data"` } - json.NewDecoder(w.Body).Decode(&resp) + _ = json.NewDecoder(w.Body).Decode(&resp) if resp.Data.Cart.AddressID == nil || *resp.Data.Cart.AddressID != addr.ID { t.Errorf("Expected address_id %d, got %v", addr.ID, resp.Data.Cart.AddressID) @@ -367,7 +370,7 @@ func TestSetAddress(t *testing.T) { func TestSetAddressWrongUser(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -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) @@ -402,7 +405,7 @@ func TestSetAddressWrongUser(t *testing.T) { func TestSetCard(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -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) @@ -440,7 +443,7 @@ func TestSetCard(t *testing.T) { } `json:"cart"` } `json:"data"` } - json.NewDecoder(w.Body).Decode(&resp) + _ = json.NewDecoder(w.Body).Decode(&resp) if resp.Data.Cart.CardID == nil || *resp.Data.Cart.CardID != card.ID { t.Errorf("Expected card_id %d, got %v", card.ID, resp.Data.Cart.CardID) @@ -449,7 +452,7 @@ func TestSetCard(t *testing.T) { func TestSetCardWrongUser(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -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) @@ -482,13 +485,13 @@ func TestSetCardWrongUser(t *testing.T) { func TestConvertCartMissingAddress(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + 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) @@ -505,21 +508,21 @@ func TestConvertCartMissingAddress(t *testing.T) { func TestConvertCartMissingCard(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + 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) @@ -536,10 +539,10 @@ func TestConvertCartMissingCard(t *testing.T) { func TestConvertCartEmpty(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + 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) @@ -553,14 +556,14 @@ func TestConvertCartEmpty(t *testing.T) { func TestMultipleItems(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + 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,12 +581,12 @@ 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"` } - json.NewDecoder(w.Body).Decode(&resp) + _ = json.NewDecoder(w.Body).Decode(&resp) if len(resp.Data.Cart.Items) != 3 { t.Errorf("Expected 3 items, got %d", len(resp.Data.Cart.Items)) @@ -595,7 +598,7 @@ func TestMultipleItems(t *testing.T) { func TestCartIsolation(t *testing.T) { testDB, user1 := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -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) @@ -638,7 +641,7 @@ func TestCartIsolation(t *testing.T) { } `json:"cart"` } `json:"data"` } - json.NewDecoder(w.Body).Decode(&resp) + _ = json.NewDecoder(w.Body).Decode(&resp) if len(resp.Data.Cart.Items) != 1 { t.Fatalf("User 1 expected 1 item, got %d", len(resp.Data.Cart.Items)) @@ -655,7 +658,7 @@ func TestCartIsolation(t *testing.T) { w = httptest.NewRecorder() r.ServeHTTP(w, req) - json.NewDecoder(w.Body).Decode(&resp) + _ = json.NewDecoder(w.Body).Decode(&resp) if len(resp.Data.Cart.Items) != 1 { t.Fatalf("User 2 expected 1 item, got %d", len(resp.Data.Cart.Items)) @@ -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()) + } +} diff --git a/api/handlers/orders.go b/api/handlers/orders.go index 2ceb366..1a3027b 100644 --- a/api/handlers/orders.go +++ b/api/handlers/orders.go @@ -2,14 +2,18 @@ package handlers import ( "encoding/json" + "fmt" + "log/slog" "net/http" "os" "strconv" + "strings" + "time" + "terminalShop/pkg/bring" "terminalShop/pkg/notify" "terminalShop/pkg/shipping" "terminalShop/pkg/shippo" - "time" "terminalShop/api/middleware" "terminalShop/pkg/audit" @@ -20,6 +24,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78/refund" + "gorm.io/gorm" ) type OrderHandler struct { @@ -29,6 +34,16 @@ type OrderHandler struct { bringCustomerNumber string } +type refundRequestPayload struct { + Reason string `json:"reason"` + Message string `json:"message"` +} + +// refundRequestCooldown is the minimum interval between customer-initiated +// refund requests for the same order. Prevents accidental double-clicks and +// repeated submissions from spamming the operator's Slack channel. +const refundRequestCooldown = 15 * time.Minute + func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler { return &OrderHandler{ stripeKey: stripeSecretKey, @@ -38,18 +53,121 @@ func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNum } } +// CreateRefundRequest posts a customer refund request into the order's Slack +// thread. It does not issue a Stripe refund. +func (h *OrderHandler) CreateRefundRequest(w http.ResponseWriter, r *http.Request) { + db := database.GetDB().WithContext(r.Context()) + userID := middleware.UserIDFromContext(r.Context()) + + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil) + return + } + + var req refundRequestPayload + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil) + return + } + + req.Reason = strings.TrimSpace(req.Reason) + req.Message = strings.TrimSpace(req.Message) + + if !models.IsValidRefundReason(req.Reason) { + utils.RespondError(w, http.StatusBadRequest, "INVALID_REASON", "invalid refund reason", nil) + return + } + if req.Reason == models.RefundRequestReasonOther && req.Message == "" { + utils.RespondError(w, http.StatusBadRequest, "MESSAGE_REQUIRED", "message is required when reason is other", nil) + return + } + + var order models.Order + if err := db.Preload("User").Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil { + utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil) + return + } + if !order.CanRequestRefund() { + utils.RespondError(w, http.StatusBadRequest, "INVALID_STATE", + "only paid, shipped, or delivered orders can request refunds", nil) + return + } + + if order.LastRefundRequestAt != nil { + elapsed := time.Since(*order.LastRefundRequestAt) + if elapsed < refundRequestCooldown { + retryAfter := int((refundRequestCooldown - elapsed).Seconds()) + 1 + w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) + utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", + fmt.Sprintf("another refund request was submitted recently; try again in %d min", retryAfter/60+1), + nil) + return + } + } + + text := fmt.Sprintf(":money_with_wings: *Refund request* — %s\n*From:* %s <%s>\n*Total:* $%.2f", + req.Reason, order.User.Name, order.User.Email, order.TotalInDollars()) + if req.Message != "" { + quoted := "> " + strings.ReplaceAll(req.Message, "\n", "\n> ") + text += "\n\n" + quoted + } + + if err := notify.SlackPostToOrderThreadBroadcast(order.ID, text); err != nil { + utils.RespondError(w, http.StatusBadGateway, "SLACK_ERROR", "failed to send refund request", nil) + return + } + + // Record the timestamp after a successful Slack post so a Slack outage + // doesn't consume the user's cooldown — they can retry immediately. + if err := db.Model(&order).Update("last_refund_request_at", time.Now()).Error; err != nil { + slog.Warn("refund request: failed to record timestamp", "order_id", order.ID, "error", err) + } + + utils.RespondSuccess(w, http.StatusOK, map[string]any{ + "status": "sent", + }) +} + func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) { db := database.GetDB().WithContext(r.Context()) userID := middleware.UserIDFromContext(r.Context()) var orders []models.Order - db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders) + db.Where("user_id = ?", userID). + Preload("Items"). + Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }). + Order("created_at desc").Find(&orders) - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "orders": orders, }) } +func (h *OrderHandler) GetOrderStatus(w http.ResponseWriter, r *http.Request) { + db := database.GetDB().WithContext(r.Context()) + userID := middleware.UserIDFromContext(r.Context()) + + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil) + return + } + + var order models.Order + if err := db.Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil { + utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil) + return + } + + utils.RespondSuccess(w, http.StatusOK, map[string]any{ + "id": order.ID, + "status": string(order.Status), + }) +} + // RefundOrder issues a full refund for an order via Stripe and marks it as // refunded. Only orders in paid or shipped status can be refunded. func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) { @@ -103,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", }) @@ -169,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, diff --git a/api/handlers/orders_test.go b/api/handlers/orders_test.go new file mode 100644 index 0000000..d4aafef --- /dev/null +++ b/api/handlers/orders_test.go @@ -0,0 +1,170 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "terminalShop/pkg/database" + "terminalShop/pkg/models" +) + +func setupOrderTestDB(t *testing.T) models.User { + t.Helper() + testDB := filepath.Join(t.TempDir(), "orders.db") + database.ResetForTesting() + t.Cleanup(database.ResetForTesting) + + db, err := database.Connect(testDB) + if err != nil { + t.Fatalf("failed to connect to test database: %v", err) + } + if err := database.Migrate(db); err != nil { + t.Fatalf("failed to migrate: %v", err) + } + if err := database.Seed(db); err != nil { + t.Fatalf("failed to seed: %v", err) + } + + user := models.User{ + SSHKeyFingerprint: "SHA256:ordertestfingerprint", + SSHPublicKey: "ssh-ed25519 AAAA ordertestkey", + Name: "Order Test User", + Email: "orders@example.com", + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + return user +} + +func orderRequest(method, url string, body []byte, userID uint, orderID string) *http.Request { + req := authRequest(method, url, body, userID) + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("id", orderID) + return req.WithContext(contextWithRoute(req.Context(), routeCtx)) +} + +func contextWithRoute(ctx context.Context, routeCtx *chi.Context) context.Context { + return context.WithValue(ctx, chi.RouteCtxKey, routeCtx) +} + +func TestCreateRefundRequestRequiresMessageForOther(t *testing.T) { + user := setupOrderTestDB(t) + + handler := NewOrderHandler("", "", "", "") + body := []byte(`{"reason":"Other","message":" "}`) + req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, "1") + w := httptest.NewRecorder() + + handler.CreateRefundRequest(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } + + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Error.Code != "MESSAGE_REQUIRED" { + t.Errorf("expected MESSAGE_REQUIRED, got %q", resp.Error.Code) + } +} + +func TestCreateRefundRequestRejectsInvalidOrderState(t *testing.T) { + user := setupOrderTestDB(t) + + db := database.GetDB() + order := models.Order{ + UserID: user.ID, + CardID: 1, + Status: models.OrderStatusFailed, + Subtotal: 100, + ShippingCost: 0, + Total: 100, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("failed to create order: %v", err) + } + + handler := NewOrderHandler("", "", "", "") + body := []byte(`{"reason":"Quality issue","message":""}`) + req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID)) + w := httptest.NewRecorder() + + handler.CreateRefundRequest(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } + + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Error.Code != "INVALID_STATE" { + t.Errorf("expected INVALID_STATE, got %q", resp.Error.Code) + } +} + +func TestCreateRefundRequestEnforcesCooldown(t *testing.T) { + user := setupOrderTestDB(t) + + db := database.GetDB() + recent := time.Now().Add(-1 * time.Minute) + order := models.Order{ + UserID: user.ID, + CardID: 1, + Status: models.OrderStatusPaid, + Subtotal: 100, + ShippingCost: 0, + Total: 100, + LastRefundRequestAt: &recent, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("failed to create order: %v", err) + } + + handler := NewOrderHandler("", "", "", "") + body := []byte(`{"reason":"Quality issue","message":""}`) + req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID)) + w := httptest.NewRecorder() + + handler.CreateRefundRequest(w, req) + + if w.Code != http.StatusTooManyRequests { + t.Fatalf("expected status 429, got %d", w.Code) + } + if got := w.Header().Get("Retry-After"); got == "" { + t.Errorf("expected Retry-After header, got empty") + } + + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Error.Code != "RATE_LIMITED" { + t.Errorf("expected RATE_LIMITED, got %q", resp.Error.Code) + } +} diff --git a/api/handlers/products.go b/api/handlers/products.go index 13e642e..b808b48 100644 --- a/api/handlers/products.go +++ b/api/handlers/products.go @@ -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, }) } diff --git a/api/handlers/products_test.go b/api/handlers/products_test.go index 759ee84..8f589a2 100644 --- a/api/handlers/products_test.go +++ b/api/handlers/products_test.go @@ -16,7 +16,7 @@ func setupTestDB(t *testing.T) string { testDB := "test_products.db" // Remove if exists - os.Remove(testDB) + _ = os.Remove(testDB) // Reset singleton database.ResetForTesting() @@ -39,7 +39,7 @@ func setupTestDB(t *testing.T) string { func TestGetProduct(t *testing.T) { testDB := setupTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() handler := NewProductHandler() @@ -78,7 +78,7 @@ func TestGetProduct(t *testing.T) { func TestGetProductNotFound(t *testing.T) { testDB := setupTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() handler := NewProductHandler() @@ -98,7 +98,7 @@ func TestGetProductNotFound(t *testing.T) { func TestGetProductsEmptyDatabase(t *testing.T) { testDB := "test_empty.db" - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() // Create database but don't seed diff --git a/api/handlers/reconcile.go b/api/handlers/reconcile.go index 7708842..d905b25 100644 --- a/api/handlers/reconcile.go +++ b/api/handlers/reconcile.go @@ -7,6 +7,7 @@ import ( "terminalShop/pkg/database" "terminalShop/pkg/models" + "terminalShop/pkg/notify" "github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78/paymentintent" @@ -48,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 { @@ -61,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 @@ -75,3 +76,53 @@ 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() { + db := database.GetDB() + + var orders []models.Order + cutoff := time.Now().Add(-24 * time.Hour) + err := db.Where( + "status = ? AND (tracking_number = '' OR tracking_number IS NULL) AND created_at < ?", + models.OrderStatusPaid, cutoff, + ).Order("created_at ASC").Find(&orders).Error + if err != nil { + reconcileLog().Error("unshipped query failed", "error", err) + return + } + if len(orders) == 0 { + return + } + + reconcileLog().Info("unshipped orders found", "count", len(orders)) + notify.SlackUnshippedReminder(orders) +} diff --git a/api/handlers/slack.go b/api/handlers/slack.go index 9ba937b..5659176 100644 --- a/api/handlers/slack.go +++ b/api/handlers/slack.go @@ -130,10 +130,10 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo resp, err := h.httpClient.Do(req) if err != nil { - notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err)) + _ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err)) return } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var result struct { Data struct { @@ -151,7 +151,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo _ = json.Unmarshal(bodyBytes, &result) if resp.StatusCode >= 300 { - notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message)) + _ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message)) return } @@ -173,7 +173,7 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay return } if order.TrackingNumber == "" { - notify.SlackPostToOrderThread(orderID, ":x: Cannot mark status, the order has no tracking number yet.") + _ = notify.SlackPostToOrderThread(orderID, ":x: Cannot mark status, the order has no tracking number yet.") return } @@ -198,12 +198,12 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay resp, err := h.httpClient.Do(req) if err != nil { - notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err)) + _ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err)) return } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 300 { - notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode)) + _ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode)) return } @@ -245,7 +245,7 @@ func (h *SlackHandler) editClearActions(responseURL, status, actor string) { slog.Warn("slack mark_status: response_url edit failed", "error", err) return } - resp.Body.Close() + _ = resp.Body.Close() } func (h *SlackHandler) verifySignature(r *http.Request, body []byte) bool { @@ -264,7 +264,7 @@ func (h *SlackHandler) verifySignature(r *http.Request, body []byte) bool { } mac := hmac.New(sha256.New, []byte(h.signingSecret)) - fmt.Fprintf(mac, "v0:%d:%s", ts, body) + _, _ = fmt.Fprintf(mac, "v0:%d:%s", ts, body) expected := "v0=" + hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(sigHeader)) diff --git a/api/handlers/view.go b/api/handlers/view.go index 110d45f..999e526 100644 --- a/api/handlers/view.go +++ b/api/handlers/view.go @@ -2,13 +2,17 @@ package handlers import ( "net/http" + "time" "terminalShop/api/middleware" "terminalShop/pkg/database" "terminalShop/pkg/models" "terminalShop/pkg/utils" + + "gorm.io/gorm" ) +// ViewHandler handles bulk TUI bootstrap data type ViewHandler struct{} func NewViewHandler() *ViewHandler { @@ -35,16 +39,22 @@ 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).Preload("Items").Order("created_at DESC").Find(&orders) + db.Where("user_id = ?", userID). + Preload("Items"). + Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }). + Order("created_at DESC").Find(&orders) var cartItems []models.CartItem if cart.ID != 0 { 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, diff --git a/api/handlers/view_test.go b/api/handlers/view_test.go new file mode 100644 index 0000000..dd634be --- /dev/null +++ b/api/handlers/view_test.go @@ -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) + } +} diff --git a/api/handlers/webhook.go b/api/handlers/webhook.go index ebe9b02..2644f84 100644 --- a/api/handlers/webhook.go +++ b/api/handlers/webhook.go @@ -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) @@ -373,7 +396,9 @@ func (h *WebhookHandler) HandleShippo(w http.ResponseWriter, r *http.Request) { if previousTrackingStatus != newStatus { audit.TrackingUpdated(order.ID, order.Carrier, payload.Data.TrackingNumber, string(newStatus)) - go notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails)) + go func() { + _ = notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails)) + }() } if deliveredTransition { audit.OrderDelivered(order.ID, payload.Data.TrackingNumber) diff --git a/api/main.go b/api/main.go index 6197c9a..46f34b9 100644 --- a/api/main.go +++ b/api/main.go @@ -40,7 +40,8 @@ func main() { if err != nil { log.Fatalf("Failed to connect to database: %v", err) } - defer database.Close() + + defer func() { _ = database.Close() }() // Run migrations if err := database.Migrate(db); err != nil { @@ -61,7 +62,7 @@ func main() { if err != nil { log.Printf("tracing init failed (non-fatal): %v", err) } else { - defer shutdown(context.Background()) + defer func() { _ = shutdown(context.Background()) }() } stripeclient.InitOTel() @@ -70,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{ @@ -112,6 +113,37 @@ func main() { } }() + go func() { + time.Sleep(30 * time.Second) + handlers.ReconcileUnshipped() + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ticker.C: + handlers.ReconcileUnshipped() + case <-ctx.Done(): + return + } + } + }() + + // 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 diff --git a/api/routes/routes.go b/api/routes/routes.go index 973b521..9080d60 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -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) + cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL, maxOrderCents) cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL) orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber) addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey) @@ -59,7 +60,11 @@ func SetupRoutes( r.Get("/t/{id}", handlers.TrackingRedirect) r.Get("/card-added", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") - w.Write([]byte(`

Card added.

You can close this tab.

`)) + _, _ = w.Write([]byte(`

Card added.

You can close this tab.

`)) + }) + r.Get("/post-3ds", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(`

Authentication complete.

Return to your terminal.

`)) }) r.Handle("/metrics", middleware.MetricsHandler()) @@ -126,6 +131,9 @@ 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) // Addresses diff --git a/go.mod b/go.mod index 6ea3dde..f5646ab 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,14 @@ module terminalShop -go 1.25.7 +go 1.25.9 require ( - github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/huh v0.8.0 - github.com/charmbracelet/lipgloss v1.1.0 + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.6 + charm.land/huh/v2 v2.0.3 + charm.land/lipgloss/v2 v2.0.3 + charm.land/wish/v2 v2.0.1 github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 - github.com/charmbracelet/wish v1.4.7 github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 @@ -23,7 +23,7 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 - golang.org/x/crypto v0.49.0 + golang.org/x/crypto v0.50.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 @@ -32,42 +32,45 @@ require ( ) require ( + charm.land/log/v2 v2.0.0 // indirect github.com/ClickHouse/ch-go v0.61.5 // indirect github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bitfield/gotestdox v0.2.2 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/keygen v0.5.3 // indirect - github.com/charmbracelet/log v0.4.1 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/conpty v0.1.0 // indirect - github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/keygen v0.5.4 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/conpty v0.1.1 // indirect + github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect - github.com/charmbracelet/x/input v0.3.4 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect - github.com/charmbracelet/x/windows v0.2.0 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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.0 // 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 @@ -78,15 +81,13 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // 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 - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/paulmach/orb v0.11.1 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect @@ -104,11 +105,14 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect 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-20240719175910-8a7402abbf56 // 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.42.0 // indirect - golang.org/x/text v0.35.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 @@ -116,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 ) diff --git a/go.sum b/go.sum index 2a61dc4..7faf317 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,15 @@ +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= +charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= +charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= +charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= +charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s= +charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0= +charm.land/wish/v2 v2.0.1 h1:xYOsvQG/bYNRoKUBt0AEyMiCv9rksmCOG/fD5WifAzg= +charm.land/wish/v2 v2.0.1/go.mod h1:uBWdsKTFk9BE0JsdEv8KKF8/abBfRe/hKEGOBm3OKZA= github.com/ClickHouse/ch-go v0.61.5 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4= github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg= github.com/ClickHouse/clickhouse-go/v2 v2.30.0 h1:AG4D/hW39qa58+JHQIFOSnxyL46H6h2lrmGGk17dhFo= @@ -10,77 +22,67 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +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= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= -github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= -github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4= -github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk= -github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA= +github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM= github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc= github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE= -github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc= -github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= -github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= -github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= -github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac= +github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= +github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 h1:4EG8pCHK5fa8dIxv97VHC8hdkJAz6QNm1WB9BuD/WhY= +github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7/go.mod h1:O2BTD/aMVQDmrvqroIO3fB6zXUuU07ZpVt21QTmZjRg= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= -github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0= -github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= -github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= -github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= -github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= -github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= -github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +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= @@ -91,8 +93,8 @@ github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= -github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= -github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= +github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -111,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= @@ -147,25 +153,22 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +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-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +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= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU= @@ -247,12 +250,14 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +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= @@ -273,23 +278,25 @@ 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-20210809222454-d867a43fc93e/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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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= @@ -326,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= diff --git a/main.go b/main.go index d88edc0..a2cbfdd 100644 --- a/main.go +++ b/main.go @@ -8,14 +8,13 @@ import ( "os" "os/signal" "syscall" - "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" + "charm.land/wish/v2" + "charm.land/wish/v2/bubbletea" + "charm.land/wish/v2/logging" "github.com/charmbracelet/ssh" - "github.com/charmbracelet/wish" - "github.com/charmbracelet/wish/bubbletea" - "github.com/charmbracelet/wish/logging" "terminalShop/pkg/auth" "terminalShop/pkg/config" @@ -36,9 +35,6 @@ var ( ) func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { - renderer := bubbletea.MakeRenderer(s) - - // Extract SSH public key from session pubKey := s.PublicKey() var fingerprint, pubKeyStr string @@ -47,9 +43,8 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { pubKeyStr = auth.FormatSSHPublicKey(pubKey) } - // Create TUI model with user context (no registration screen needed!) - m := tui.NewModelWithAuth(renderer, fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey) - return m, []tea.ProgramOption{tea.WithAltScreen()} + m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey) + return m, nil } func main() { diff --git a/pkg/api/client.go b/pkg/api/client.go index 7898ae0..1c49728 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -56,7 +56,6 @@ type TokenResponse struct { } func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) { - url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL) reqBody := TokenRequest{ @@ -80,7 +79,7 @@ func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) ( if err != nil { return "", models.PublicUser{}, fmt.Errorf("token refresh request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return "", models.PublicUser{}, fmt.Errorf("token refresh returned status %d", resp.StatusCode) @@ -105,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 @@ -137,7 +136,7 @@ func (c *Client) GetProducts() ([]models.Coffee, error) { if err != nil { return nil, fmt.Errorf("failed to fetch products: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("API returned status %d", resp.StatusCode) @@ -167,7 +166,7 @@ func (c *Client) Health() error { if err != nil { return fmt.Errorf("health check failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("API returned status %d", resp.StatusCode) @@ -217,7 +216,7 @@ func (c *Client) RegisterUser(username, sshPublicKey, sshKeyFingerprint string) if err != nil { return nil, fmt.Errorf("failed to register user: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var registerResp RegisterResponse if err := json.NewDecoder(resp.Body).Decode(®isterResp); err != nil { @@ -281,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) @@ -302,7 +318,7 @@ func (c *Client) GetCart() (*CartData, error) { if err != nil { return nil, fmt.Errorf("failed to fetch cart: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var cartResp CartResponse if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil { @@ -323,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, } @@ -342,7 +358,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) { if err != nil { return nil, fmt.Errorf("failed to set cart item: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var cartResp CartResponse if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil { @@ -363,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) @@ -381,7 +397,7 @@ func (c *Client) SetCartAddress(addressID uint) error { if err != nil { return fmt.Errorf("failed to set cart address: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var result APIResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { @@ -402,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) @@ -420,7 +436,7 @@ func (c *Client) SetCartCard(cardID uint) error { if err != nil { return fmt.Errorf("failed to set cart card: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var result APIResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { @@ -450,7 +466,7 @@ func (c *Client) ClearCart() error { if err != nil { return fmt.Errorf("failed to clear cart: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var result APIResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { @@ -468,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) @@ -480,7 +498,7 @@ func (c *Client) ConvertCart() (*models.Order, error) { if err != nil { return nil, fmt.Errorf("failed to convert cart: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var convertResp ConvertCartResponse if err := json.NewDecoder(resp.Body).Decode(&convertResp); err != nil { @@ -494,7 +512,107 @@ 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 { + Success bool `json:"success"` + Data struct { + ID uint `json:"id"` + Status string `json:"status"` + } `json:"data"` + Error *APIError `json:"error,omitempty"` +} + +func (c *Client) GetOrderStatus(id uint) (string, error) { + url := fmt.Sprintf("%s/api/v1/orders/%d/status", c.BaseURL, id) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + resp, err := c.doRequest(req) + if err != nil { + return "", fmt.Errorf("failed to fetch order status: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var sResp OrderStatusResponse + if err := json.NewDecoder(resp.Body).Decode(&sResp); err != nil { + return "", fmt.Errorf("failed to decode order status response: %w", err) + } + if !sResp.Success { + if sResp.Error != nil { + return "", fmt.Errorf("%s: %s", sResp.Error.Code, sResp.Error.Message) + } + return "", fmt.Errorf("failed to fetch order status") + } + 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. @@ -510,7 +628,7 @@ func (c *Client) GetCards() ([]models.Card, error) { if err != nil { return nil, fmt.Errorf("failed to fetch cards: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var cardsResp CardsResponse if err := json.NewDecoder(resp.Body).Decode(&cardsResp); err != nil { @@ -553,7 +671,7 @@ func (c *Client) SaveCard(params SaveCardRequest) (*models.Card, error) { if err != nil { return nil, fmt.Errorf("failed to save card: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var cardResp CardResponse if err := json.NewDecoder(resp.Body).Decode(&cardResp); err != nil { @@ -590,7 +708,7 @@ func (c *Client) CollectCard() (string, error) { if err != nil { return "", fmt.Errorf("failed to collect card: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var collectResp CollectCardResponse if err := json.NewDecoder(resp.Body).Decode(&collectResp); err != nil { @@ -619,7 +737,7 @@ func (c *Client) DeleteCard(id uint) error { if err != nil { return fmt.Errorf("failed to delete card: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("delete card failed with status %d", resp.StatusCode) @@ -636,6 +754,20 @@ type OrderResponse struct { Error *APIError `json:"error,omitempty"` } +// RefundRequest is the customer-facing refund request payload. +type RefundRequest struct { + Reason string `json:"reason"` + Message string `json:"message"` +} + +type refundRequestResponse struct { + Success bool `json:"success"` + Data struct { + Status string `json:"status"` + } `json:"data"` + Error *APIError `json:"error,omitempty"` +} + func (c *Client) GetOrders() ([]models.Order, error) { url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL) @@ -648,7 +780,7 @@ func (c *Client) GetOrders() ([]models.Order, error) { if err != nil { return nil, fmt.Errorf("failed to fetch orders: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var ordersResp OrderResponse if err := json.NewDecoder(resp.Body).Decode(&ordersResp); err != nil { @@ -664,6 +796,42 @@ func (c *Client) GetOrders() ([]models.Order, error) { return ordersResp.Data.Orders, nil } +// CreateRefundRequest posts a refund request for an order. +func (c *Client) CreateRefundRequest(orderID uint, params RefundRequest) error { + url := fmt.Sprintf("%s/api/v1/orders/%d/refund-request", c.BaseURL, orderID) + + jsonData, err := json.Marshal(params) + if err != nil { + return fmt.Errorf("failed to marshal refund request: %w", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create refund request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.doRequest(req) + if err != nil { + return fmt.Errorf("failed to send refund request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var refundResp refundRequestResponse + if err := json.NewDecoder(resp.Body).Decode(&refundResp); err != nil { + return fmt.Errorf("failed to decode refund response: %w", err) + } + + if !refundResp.Success { + if refundResp.Error != nil { + return fmt.Errorf("%s: %s", refundResp.Error.Code, refundResp.Error.Message) + } + return fmt.Errorf("failed to send refund request") + } + + return nil +} + // The Addresses API response type AddressResponse struct { Success bool `json:"success"` @@ -686,7 +854,7 @@ func (c *Client) GetAddresses() ([]models.Address, error) { return nil, fmt.Errorf("failed to fetch addresses: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var addrResp AddressResponse if err := json.NewDecoder(resp.Body).Decode(&addrResp); err != nil { @@ -732,7 +900,7 @@ func (c *Client) CreateAddress(addrReq CreateAddressRequest) (*models.Address, e if err != nil { return nil, fmt.Errorf("failed to create address: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var result struct { Success bool `json:"success"` @@ -769,7 +937,7 @@ func (c *Client) DeleteAddress(id uint) error { if err != nil { return fmt.Errorf("failed to delete address: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("delete address failed with status %d", resp.StatusCode) @@ -804,7 +972,7 @@ func (c *Client) GetViewInit() (ViewInitData, error) { if err != nil { return ViewInitData{}, fmt.Errorf("view init request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return ViewInitData{}, fmt.Errorf("view init returned status: %d", resp.StatusCode) } diff --git a/pkg/audit/audit.go b/pkg/audit/audit.go index e2e5644..cb60c12 100644 --- a/pkg/audit/audit.go +++ b/pkg/audit/audit.go @@ -7,20 +7,28 @@ import ( "encoding/json" "fmt" "log/slog" + "terminalShop/pkg/models" "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 = EventOrderRequiresAction eventOrderRefunded eventType = "order_refunded" eventPaymentCritical eventType = "payment_critical" eventLabelPurchased eventType = "label_purchased" @@ -111,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{ @@ -298,3 +317,14 @@ func attrsToMap(a []any) map[string]any { } return m } + +func OrderRequiresAction(userID, orderID uint, paymentIntentID string) { + e := event{ + Event: eventOrderRequiresAction, + UserID: userID, + OrderID: orderID, + StripeID: paymentIntentID, + } + slog.Info("audit", e.attrs()...) + persist(orderID, eventOrderRequiresAction, e) +} diff --git a/pkg/auth/jwt.go b/pkg/auth/jwt.go index e70a411..7b21b60 100644 --- a/pkg/auth/jwt.go +++ b/pkg/auth/jwt.go @@ -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"]) diff --git a/pkg/auth/ssh_auth_test.go b/pkg/auth/ssh_auth_test.go index c889856..cf887dd 100644 --- a/pkg/auth/ssh_auth_test.go +++ b/pkg/auth/ssh_auth_test.go @@ -17,7 +17,7 @@ import ( func setupAuthTestDB(t *testing.T) string { t.Helper() testDB := "test_ssh_auth.db" - os.Remove(testDB) + _ = os.Remove(testDB) database.ResetForTesting() db, err := database.Connect(testDB) @@ -47,7 +47,7 @@ func newTestSSHKey(t *testing.T) gossh.PublicKey { func TestAuthenticateSSHKeyNewUser(t *testing.T) { testDB := setupAuthTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -72,7 +72,7 @@ func TestAuthenticateSSHKeyNewUser(t *testing.T) { func TestAuthenticateSSHKeyExistingUser(t *testing.T) { testDB := setupAuthTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -96,7 +96,7 @@ func TestAuthenticateSSHKeyExistingUser(t *testing.T) { func TestRegisterWithSSHKey(t *testing.T) { testDB := setupAuthTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -121,7 +121,7 @@ func TestRegisterWithSSHKey(t *testing.T) { func TestRegisterWithSSHKeyDuplicate(t *testing.T) { testDB := setupAuthTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -145,7 +145,7 @@ func TestRegisterWithSSHKeyDuplicate(t *testing.T) { func TestIsSSHKeyRegistered(t *testing.T) { testDB := setupAuthTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -164,7 +164,7 @@ func TestIsSSHKeyRegistered(t *testing.T) { } // After authentication (which auto-creates) - svc.AuthenticateSSHKey(key) + _, _, _ = svc.AuthenticateSSHKey(key) registered, err = svc.IsSSHKeyRegistered(key) if err != nil { diff --git a/pkg/bring/booking.go b/pkg/bring/booking.go index f7cdbba..9b89eb4 100644 --- a/pkg/bring/booking.go +++ b/pkg/bring/booking.go @@ -197,7 +197,7 @@ func (c *BookingClient) doJSON(ctx context.Context, url string, body any, out an if err != nil { return fmt.Errorf("request: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("read response: %w", err) diff --git a/pkg/bring/bring.go b/pkg/bring/bring.go index 5d8db7c..659c567 100644 --- a/pkg/bring/bring.go +++ b/pkg/bring/bring.go @@ -110,7 +110,7 @@ func (c *Client) ValidateAddress(ctx context.Context, addr Address) (*Address, e if err != nil { return nil, fmt.Errorf("bring request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { diff --git a/pkg/config/config.go b/pkg/config/config.go index 6ca5bff..8a84fe7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..7592f6b --- /dev/null +++ b/pkg/config/config_test.go @@ -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) + } + }) + } +} diff --git a/pkg/database/db.go b/pkg/database/db.go index c134e59..78fe60d 100644 --- a/pkg/database/db.go +++ b/pkg/database/db.go @@ -101,7 +101,7 @@ func Close() error { // WARNING: Only use this in tests! func ResetForTesting() { if db != nil { - Close() + _ = Close() db = nil once = sync.Once{} } diff --git a/pkg/database/db_test.go b/pkg/database/db_test.go index 94f511f..1fcf301 100644 --- a/pkg/database/db_test.go +++ b/pkg/database/db_test.go @@ -10,7 +10,7 @@ import ( func TestConnect(t *testing.T) { // Use a temporary test database testDB := "test_terminalshop.db" - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer ResetForTesting() db, err := Connect(testDB) @@ -35,7 +35,7 @@ func TestConnect(t *testing.T) { func TestMigrate(t *testing.T) { testDB := "test_migrate.db" - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer ResetForTesting() db, err := Connect(testDB) @@ -57,7 +57,7 @@ func TestMigrate(t *testing.T) { func TestSeed(t *testing.T) { testDB := "test_seed.db" - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer ResetForTesting() db, err := Connect(testDB) @@ -115,7 +115,7 @@ func TestSeed(t *testing.T) { func TestGetDB(t *testing.T) { testDB := "test_getdb.db" - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer ResetForTesting() // Initialize connection diff --git a/pkg/database/migrate.go b/pkg/database/migrate.go index 1ba5131..d22a946 100644 --- a/pkg/database/migrate.go +++ b/pkg/database/migrate.go @@ -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 +} diff --git a/pkg/database/migrate_test.go b/pkg/database/migrate_test.go new file mode 100644 index 0000000..4832dbe --- /dev/null +++ b/pkg/database/migrate_test.go @@ -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) + } +} diff --git a/pkg/database/seed.go b/pkg/database/seed.go index 812e3d0..4d21fef 100644 --- a/pkg/database/seed.go +++ b/pkg/database/seed.go @@ -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.", }, diff --git a/pkg/models/card.go b/pkg/models/card.go index 1b68e09..7ca7bb2 100644 --- a/pkg/models/card.go +++ b/pkg/models/card.go @@ -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) +} diff --git a/pkg/models/card_test.go b/pkg/models/card_test.go new file mode 100644 index 0000000..d741baf --- /dev/null +++ b/pkg/models/card_test.go @@ -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") + } +} diff --git a/pkg/models/order.go b/pkg/models/order.go index 1c78825..3273c64 100644 --- a/pkg/models/order.go +++ b/pkg/models/order.go @@ -66,14 +66,19 @@ type Order struct { // Slack parent message ts for this order's thread (NULL if Slack notify disabled). SlackThreadTS *string `gorm:"size:64" json:"-"` + // LastRefundRequestAt timestamps the most recent customer-initiated refund + // request for this order. Used to rate-limit repeat submissions. + LastRefundRequestAt *time.Time `json:"-"` + CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel // Associations - User User `gorm:"foreignKey:UserID" json:"-"` - Card Card `gorm:"foreignKey:CardID" json:"-"` - Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"` + User User `gorm:"foreignKey:UserID" json:"-"` + Card Card `gorm:"foreignKey:CardID" json:"-"` + Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"` + Events []OrderEvent `gorm:"foreignKey:OrderID" json:"events,omitempty"` } // TableName specifies the table name for the Order model. @@ -112,3 +117,35 @@ func (o *Order) TotalInDollars() float64 { func (o *Order) IsActive() bool { return o.Status == OrderStatusPaid || o.Status == OrderStatusShipped } + +// RefundRequestReasons is the canonical list of customer-facing refund reasons +// accepted by both the API and the TUI. +var RefundRequestReasons = []string{ + "Wrong item received", + "Item damaged in transit", + "Item never arrived", + "Quality issue", + "Other", +} + +// RefundRequestReasonOther is the sentinel reason that requires a free-form message. +const RefundRequestReasonOther = "Other" + +// IsValidRefundReason reports whether r is one of the accepted refund reasons. +func IsValidRefundReason(r string) bool { + for _, reason := range RefundRequestReasons { + if reason == r { + return true + } + } + return false +} + +// CanRequestRefund reports whether a customer is allowed to request a refund +// for this order. Paid, shipped, and delivered orders qualify; everything else +// is either still unpaid or already in a terminal state. +func (o *Order) CanRequestRefund() bool { + return o.Status == OrderStatusPaid || + o.Status == OrderStatusShipped || + o.Status == OrderStatusDelivered +} diff --git a/pkg/models/order_display.go b/pkg/models/order_display.go index 5a21f9e..b7ddb5e 100644 --- a/pkg/models/order_display.go +++ b/pkg/models/order_display.go @@ -11,10 +11,27 @@ const ( DisplayKindBrand DisplayKind = "brand" // shipped / in transit DisplayKindSuccess DisplayKind = "success" // delivered DisplayKindError DisplayKind = "error" // cancelled, failed, refunded - DisplayKindWarning DisplayKind = "warning" // refund pending, delivery issue + DisplayKindWarning DisplayKind = "warning" // delivery issue, returned + DisplayKindRefund DisplayKind = "refund" // refund requested, awaiting review ) +// refundPending reports whether the order has an open customer refund request +// that has not yet been resolved by a terminal status change. +func (order *Order) refundPending() bool { + if order.LastRefundRequestAt == nil { + return false + } + switch order.Status { + case OrderStatusRefunded, OrderStatusCancelled, OrderStatusFailed: + return false + } + return true +} + func (order *Order) DisplayState() string { + if order.refundPending() { + return "Refund Requested" + } switch order.Status { case OrderStatusPending: return "Awaiting payment" @@ -54,6 +71,9 @@ func (order *Order) DisplayState() string { } func (order *Order) DisplayKind() DisplayKind { + if order.refundPending() { + return DisplayKindRefund + } switch order.Status { case OrderStatusPending: return DisplayKindNeutral diff --git a/pkg/models/order_display_test.go b/pkg/models/order_display_test.go index d81d63e..6321292 100644 --- a/pkg/models/order_display_test.go +++ b/pkg/models/order_display_test.go @@ -1,8 +1,12 @@ package models -import "testing" +import ( + "testing" + "time" +) func TestOrderDisplayState(t *testing.T) { + now := time.Now() tests := []struct { name string order Order @@ -23,6 +27,10 @@ func TestOrderDisplayState(t *testing.T) { {"cancelled", Order{Status: OrderStatusCancelled}, "Cancelled", DisplayKindError}, {"refunded", Order{Status: OrderStatusRefunded}, "Refunded", DisplayKindError}, {"failed", Order{Status: OrderStatusFailed}, "Payment failed", DisplayKindError}, + {"paid with open refund request", Order{Status: OrderStatusPaid, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund}, + {"delivered with open refund request", Order{Status: OrderStatusDelivered, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund}, + {"refunded order ignores prior request", Order{Status: OrderStatusRefunded, LastRefundRequestAt: &now}, "Refunded", DisplayKindError}, + {"cancelled order ignores prior request", Order{Status: OrderStatusCancelled, LastRefundRequestAt: &now}, "Cancelled", DisplayKindError}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/pkg/models/order_event.go b/pkg/models/order_event.go index 95c4fc1..39dc058 100644 --- a/pkg/models/order_event.go +++ b/pkg/models/order_event.go @@ -1,6 +1,9 @@ package models -import "time" +import ( + "encoding/json" + "time" +) type OrderEvent struct { ID uint `gorm:"primaryKey" json:"id"` @@ -14,3 +17,47 @@ type OrderEvent struct { func (OrderEvent) TableName() string { return "order_events" } + +func (e *OrderEvent) PayloadMap() map[string]any { + if e.Payload == "" { + return nil + } + var m map[string]any + if err := json.Unmarshal([]byte(e.Payload), &m); err != nil { + return nil + } + return m +} + +func (e *OrderEvent) DisplayLabel() (string, bool) { + switch e.Type { + case "order_paid": + return "Paid", true + case "label_purchased": + return "Label created", true + case "order_delivered": + return "Delivered", true + case "order_refunded": + return "Refunded", true + case "order_failed": + return "Payment failed", true + case "tracking_updated": + status, _ := e.PayloadMap()["status"].(string) + return trackingLabel(status) + } + return "", false +} + +func trackingLabel(status string) (string, bool) { + switch status { + case "transit": + return "In transit", true + case "failure": + return "Delivery issue", true + case "returned": + return "Returned to sender", true + case "pre_transit": + return "Label scanned by carrier", true + } + return "", false +} diff --git a/pkg/models/pay_redirect.go b/pkg/models/pay_redirect.go new file mode 100644 index 0000000..b0eb64d --- /dev/null +++ b/pkg/models/pay_redirect.go @@ -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" +} diff --git a/pkg/notify/slack.go b/pkg/notify/slack.go index 2e9c364..86677fc 100644 --- a/pkg/notify/slack.go +++ b/pkg/notify/slack.go @@ -1,3 +1,4 @@ +// Package notify provides a simple interface to send notifications to Slack. package notify import ( @@ -14,8 +15,10 @@ import ( "terminalShop/pkg/models" ) +// slack client var slackClient = &http.Client{Timeout: 5 * time.Second} +// dollars converts cents to dollars func dollars(cents int) string { return fmt.Sprintf("$%.2f", float64(cents)/100) } @@ -32,7 +35,7 @@ func postSlackMessage(token string, payload map[string]any) (string, bool) { slog.Warn("slack post failed", "error", err) return "", false } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var parsed struct { Ok bool `json:"ok"` @@ -66,7 +69,7 @@ func slackPinCall(token, endpoint, channel, ts, benignErr string) bool { slog.Warn("slack pin call failed", "endpoint", endpoint, "error", err) return false } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var parsed struct { Ok bool `json:"ok"` @@ -185,29 +188,49 @@ func SlackUnpinOrder(orderID uint) { } // SlackPostToOrderThread posts a plain-text reply inside the order's thread. -// Looks up the thread ts from the database. Silent no-op if the order has no -// saved thread (Slack was disabled when it was created, or post failed earlier). -func SlackPostToOrderThread(orderID uint, text string) { +// It looks up the thread ts from the database and returns an error when the +// reply cannot be posted. +func SlackPostToOrderThread(orderID uint, text string) error { + return slackPostToOrderThread(orderID, text, false) +} + +// SlackPostToOrderThreadBroadcast posts a thread reply that is also surfaced +// to the channel (reply_broadcast=true) so channel members get a normal +// notification. Use for messages that need immediate operator attention. +func SlackPostToOrderThreadBroadcast(orderID uint, text string) error { + return slackPostToOrderThread(orderID, text, true) +} + +func slackPostToOrderThread(orderID uint, text string, broadcast bool) error { token := os.Getenv("SLACK_BOT_TOKEN") channel := os.Getenv("SLACK_CHANNEL") if token == "" || channel == "" { - return + return fmt.Errorf("slack is not configured") } var order models.Order if err := database.GetDB().Select("id", "slack_thread_ts").Where("id = ?", orderID).First(&order).Error; err != nil { slog.Warn("failed to look up order for thread post", "order_id", orderID, "error", err) - return + return fmt.Errorf("failed to look up order thread: %w", err) } if order.SlackThreadTS == nil || *order.SlackThreadTS == "" { - return + return fmt.Errorf("order has no slack thread") } - _, _ = postSlackMessage(token, map[string]any{ + payload := map[string]any{ "channel": channel, "thread_ts": *order.SlackThreadTS, "text": text, - }) + } + if broadcast { + payload["reply_broadcast"] = true + } + + if _, ok := postSlackMessage(token, payload); !ok { + slog.Warn("failed to post slack thread reply", "order_id", orderID) + return fmt.Errorf("failed to post slack thread reply") + } + return nil } func SlackPostLabelPurchased(orderID uint, labelURL string, costCents int) { @@ -261,4 +284,44 @@ func markStatusActionBlock(orderID uint) map[string]any { } } -var _ = time.Second +func SlackUnshippedReminder(orders []models.Order) { + token := os.Getenv("SLACK_BOT_TOKEN") + channel := os.Getenv("SLACK_CHANNEL") + if token == "" || channel == "" || len(orders) == 0 { + return + } + + workspace := os.Getenv("SLACK_WORKSPACE_SUBDOMAIN") + + lines := []string{fmt.Sprintf(":hourglass_flowing_sand: %d order(s) awaiting label", len(orders))} + for _, order := range orders { + lines = append(lines, " "+unshippedOrderLine(order, channel, workspace)) + } + + _, _ = postSlackMessage(token, map[string]any{ + "channel": channel, + "text": strings.Join(lines, "\n"), + }) +} + +func unshippedOrderLine(order models.Order, channel, workspace string) string { + age := time.Since(order.CreatedAt).Round(time.Hour) + location := order.ShippingCountry + if order.ShippingCity != "" { + location = order.ShippingCity + ", " + order.ShippingCountry + } + + label := fmt.Sprintf("Order #%d", order.ID) + if order.SlackThreadTS != nil && *order.SlackThreadTS != "" && workspace != "" { + ts := strings.ReplaceAll(*order.SlackThreadTS, ".", "") + permalink := fmt.Sprintf("https://%s.slack.com/archives/%s/p%s", workspace, channel, ts) + label = fmt.Sprintf("<%s|Order #%d>", permalink, order.ID) + } + + tag := "" + if order.ShippingCountry == "NO" { + tag = " _(NO * Bring blocked)_" + } + + return fmt.Sprintf("%s * %s old * %s * %s%s", label, age, dollars(order.Total), location, tag) +} diff --git a/pkg/shippo/shippo.go b/pkg/shippo/shippo.go index b224cd2..2ae9139 100644 --- a/pkg/shippo/shippo.go +++ b/pkg/shippo/shippo.go @@ -270,7 +270,7 @@ func (c *Client) doJSON(ctx context.Context, method, path string, body any, out if err != nil { return fmt.Errorf("request: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("read response: %w", err) @@ -432,7 +432,7 @@ func (c *Client) ValidateAddress(ctx context.Context, addr Address) (*Address, e if err != nil { return nil, fmt.Errorf("shippo request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { diff --git a/pkg/tui/account.go b/pkg/tui/account.go index f34a691..589afbf 100644 --- a/pkg/tui/account.go +++ b/pkg/tui/account.go @@ -1,9 +1,9 @@ package tui import ( - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "terminalShop/pkg/models" ) @@ -33,12 +33,12 @@ func (m Model) updateAccountViewport() Model { } if !m.account.viewportReady { - m.account.detailViewport = viewport.New(detailWidth, availableHeight) + m.account.detailViewport = viewport.New(viewport.WithWidth(detailWidth), viewport.WithHeight(availableHeight)) m.account.detailViewport.KeyMap = modifiedKeyMap m.account.viewportReady = true } else { - m.account.detailViewport.Width = detailWidth - m.account.detailViewport.Height = availableHeight + m.account.detailViewport.SetWidth(detailWidth) + m.account.detailViewport.SetHeight(availableHeight) } return m @@ -128,8 +128,8 @@ func (m Model) scrollToAccountDetailItem() Model { } targetY := headerOffset + (selectedIndex * itemHeight) - vpH := m.account.detailViewport.Height - offset := m.account.detailViewport.YOffset + vpH := m.account.detailViewport.Height() + offset := m.account.detailViewport.YOffset() // 1-item buffer: scroll when the NEXT item would be off-screen, // not when the current item hits the very edge @@ -288,6 +288,13 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { m.account.detailViewport, cmd = m.account.detailViewport.Update(msg) return m, cmd + case "r": + if m.account.orderViewState == 2 { + if order, ok := m.selectedRefundableOrder(); ok { + return m.OpenRefundComposer(order.ID) + } + } + case "x", "d": if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) { m.account.addressDeleting = &m.account.addressCursor @@ -324,7 +331,8 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { switch selectedItem { case "order history", "active orders": if len(m.currentOrderList()) > 0 { - if m.account.orderViewState == 0 { + switch m.account.orderViewState { + case 0: m.account.orderViewState = 1 m.account.orderCursor = 0 m.account.detailViewport.GotoTop() @@ -334,16 +342,19 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { {key: "esc", value: "back"}, {key: "q", value: "quit"}, } - } else if m.account.orderViewState == 1 { - // Save scroll position before entering detail view - m.account.orderYOffset = m.account.detailViewport.YOffset + case 1: + m.account.orderYOffset = m.account.detailViewport.YOffset() m.account.orderViewState = 2 m.account.detailViewport.GotoTop() - m.footer = []footerCommand{ + footer := []footerCommand{ {key: "esc", value: "back"}, {key: "s", value: "shop"}, {key: "q", value: "quit"}, } + if _, ok := m.selectedRefundableOrder(); ok { + footer = append([]footerCommand{{key: "r", value: "refund"}}, footer...) + } + m.footer = footer } } case "addresses": diff --git a/pkg/tui/account_orders.go b/pkg/tui/account_orders.go index d14085b..280ad5b 100644 --- a/pkg/tui/account_orders.go +++ b/pkg/tui/account_orders.go @@ -6,7 +6,7 @@ import ( "terminalShop/pkg/models" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) func (m Model) OrdersView(width int) string { @@ -37,6 +37,21 @@ func (m Model) HistoryOrders() []models.Order { return out } +// selectedRefundableOrder returns the order under the cursor in the active +// order-detail view, paired with ok=true, when the user is allowed to file a +// refund request for it. Returns (zero, false) otherwise. +func (m Model) selectedRefundableOrder() (models.Order, bool) { + orders := m.currentOrderList() + if m.account.orderCursor < 0 || m.account.orderCursor >= len(orders) { + return models.Order{}, false + } + order := orders[m.account.orderCursor] + if !order.CanRequestRefund() { + return models.Order{}, false + } + return order, true +} + func (m Model) currentOrderList() []models.Order { if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) { return nil @@ -201,6 +216,24 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string { } } + visible := make([]models.OrderEvent, 0, len(order.Events)) + for _, event := range order.Events { + if _, ok := event.DisplayLabel(); ok { + visible = append(visible, event) + } + } + if len(visible) > 0 { + b.WriteString(labelStyle.Render("Timeline")) + b.WriteString("\n") + for _, event := range visible { + label, _ := event.DisplayLabel() + when := event.CreatedAt.Format("Jan 02 3:04 PM") + b.WriteString(valueStyle.Render(fmt.Sprintf(" %-20s %s", label, dimStyle.Render(when)))) + b.WriteString("\n") + } + b.WriteString("\n") + } + // Shipping address b.WriteString(labelStyle.Render("Ship To")) b.WriteString("\n") @@ -223,7 +256,11 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string { b.WriteString("\n\n") // Footer hint - b.WriteString(dimStyle.Render("esc: back to orders")) + if order.CanRequestRefund() { + b.WriteString(dimStyle.Render("r: refund esc: back to orders")) + } else { + b.WriteString(dimStyle.Render("esc: back to orders")) + } return b.String() } @@ -241,11 +278,9 @@ func (m Model) displayStyle(k models.DisplayKind) lipgloss.Style { case models.DisplayKindError: return m.theme.TextError() case models.DisplayKindWarning: - // !TODO add refunds and change this - // No warning color in theme yet, reuse loading pink as visual flag - // for now return m.theme.TextLoading() + case models.DisplayKindRefund: + return m.theme.TextBrand() } return m.theme.TextHighlight() } - diff --git a/pkg/tui/breadcrumbs.go b/pkg/tui/breadcrumbs.go index 189d7f7..c7548e3 100644 --- a/pkg/tui/breadcrumbs.go +++ b/pkg/tui/breadcrumbs.go @@ -1,7 +1,7 @@ package tui import ( - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) func (m Model) BuildBreadcrumbs() string { diff --git a/pkg/tui/cart.go b/pkg/tui/cart.go index 082b8f5..018fab8 100644 --- a/pkg/tui/cart.go +++ b/pkg/tui/cart.go @@ -3,9 +3,9 @@ package tui import ( "fmt" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) func (m Model) updateCartViewport() Model { @@ -17,12 +17,12 @@ func (m Model) updateCartViewport() Model { availH = 1 } if !m.cart.viewportReady { - m.cart.viewport = viewport.New(m.widthContent, availH) + m.cart.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH)) m.cart.viewport.KeyMap = viewport.KeyMap{} m.cart.viewportReady = true } else { - m.cart.viewport.Width = m.widthContent - m.cart.viewport.Height = availH + m.cart.viewport.SetWidth(m.widthContent) + m.cart.viewport.SetHeight(availH) } return m } @@ -116,11 +116,11 @@ func (m Model) CartView() string { } itemHeight := 4 + 2*boxPadding + 1 targetY := m.cart.cursor * itemHeight - if targetY < m.cart.viewport.YOffset { + if targetY < m.cart.viewport.YOffset() { m.cart.viewport.SetYOffset(targetY) } - if targetY+itemHeight > m.cart.viewport.YOffset+m.cart.viewport.Height { - m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height + itemHeight) + if targetY+itemHeight > m.cart.viewport.YOffset()+m.cart.viewport.Height() { + m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height() + itemHeight) } } return lipgloss.Place( diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index 42fcfe6..e68b2c0 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -3,11 +3,13 @@ package tui import ( "fmt" "strings" - "terminalShop/pkg/models" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "terminalShop/pkg/models" + "terminalShop/pkg/tui/qrfefe" + + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) // RenderConfirmation renders the order confirmation view @@ -73,12 +75,12 @@ func (m Model) updateConfirmViewport() Model { availH = 1 } if !m.confirm.viewportReady { - m.confirm.viewport = viewport.New(m.widthContent, availH) + m.confirm.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH)) m.confirm.viewport.KeyMap = viewport.DefaultKeyMap() m.confirm.viewportReady = true } else { - m.confirm.viewport.Width = m.widthContent - m.confirm.viewport.Height = availH + m.confirm.viewport.SetWidth(m.widthContent) + m.confirm.viewport.SetHeight(availH) } return m } @@ -145,18 +147,18 @@ func (m Model) generateReviewContent() string { if m.ShippingInfo.Street2 != "" { view.WriteString(m.ShippingInfo.Street2 + "\n") } - view.WriteString(fmt.Sprintf("%s, %s %s, %s\n", m.ShippingInfo.City, m.ShippingInfo.State, m.ShippingInfo.Zip, m.ShippingInfo.Country)) + fmt.Fprintf(&view, "%s, %s %s, %s\n", m.ShippingInfo.City, m.ShippingInfo.State, m.ShippingInfo.Zip, m.ShippingInfo.Country) } view.WriteString("\n") if m.SelectedCard != nil { - view.WriteString(fmt.Sprintf("cc: **** **** **** %s\n", m.SelectedCard.Last4)) + fmt.Fprintf(&view, "cc: **** **** **** %s\n", m.SelectedCard.Last4) } view.WriteString("\n") subtotal := m.CalculateSubtotal() - view.WriteString(fmt.Sprintf("subtotal: %s\n", formatUSD(subtotal))) + fmt.Fprintf(&view, "subtotal: %s\n", formatUSD(subtotal)) view.WriteString(m.theme.TextAccent().Render(fmt.Sprintf("total: %s", formatUSD(subtotal))) + "\n") view.WriteString("\n") view.WriteString(m.theme.TextBrand().Render("press enter to confirm") + "\n") @@ -168,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( @@ -186,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) - m.error = &VisibleError{message: fmt.Sprintf("checkout failed: %v", msg.Err)} + 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 @@ -205,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 @@ -234,3 +399,40 @@ 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"): + return "card cvc incorrect. Check the back of the card and try again" + case strings.Contains(s, "CARD_INSUFFICIENT_FUNDS"): + return "insufficient funds on this card" + case strings.Contains(s, "CARD_PROCESSING_ERROR"): + return "card processing error. Try again in a moment" + case strings.Contains(s, "CARD_DECLINED"): + return "card declined" + } + return "checkout failed, try again." +} diff --git a/pkg/tui/confirmation_retry_test.go b/pkg/tui/confirmation_retry_test.go new file mode 100644 index 0000000..2dcf44c --- /dev/null +++ b/pkg/tui/confirmation_retry_test.go @@ -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) + } +} diff --git a/pkg/tui/confirmation_test.go b/pkg/tui/confirmation_test.go new file mode 100644 index 0000000..bc57583 --- /dev/null +++ b/pkg/tui/confirmation_test.go @@ -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) + } +} diff --git a/pkg/tui/footer.go b/pkg/tui/footer.go index 9710cd9..5709549 100644 --- a/pkg/tui/footer.go +++ b/pkg/tui/footer.go @@ -3,7 +3,7 @@ package tui import ( "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) func (m Model) BuildFooter() string { diff --git a/pkg/tui/header.go b/pkg/tui/header.go index fdf1e15..44a620a 100644 --- a/pkg/tui/header.go +++ b/pkg/tui/header.go @@ -3,8 +3,8 @@ package tui import ( "fmt" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/table" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" ) func (m Model) BuildHeader() string { @@ -44,7 +44,7 @@ func (m Model) BuildHeader() string { tabs = []string{logo, shop, account, cart} } - tbl := table.New().Border(lipgloss.NormalBorder()).BorderStyle(m.renderer.NewStyle().Foreground(m.theme.Border())).Row(tabs...).Width(m.widthContent).StyleFunc(func(row, col int) lipgloss.Style { + tbl := table.New().Border(lipgloss.NormalBorder()).BorderStyle(lipgloss.NewStyle().Foreground(m.theme.Border())).Row(tabs...).Width(m.widthContent).StyleFunc(func(row, col int) lipgloss.Style { return m.theme.Base().Padding(0, 1).AlignHorizontal(lipgloss.Center) }).Render() diff --git a/pkg/tui/help.go b/pkg/tui/help.go index d93c53b..e4327f4 100644 --- a/pkg/tui/help.go +++ b/pkg/tui/help.go @@ -1,8 +1,8 @@ package tui import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) func (m Model) BuildHelpView() string { diff --git a/pkg/tui/menu.go b/pkg/tui/menu.go index ba748f2..f2596f3 100644 --- a/pkg/tui/menu.go +++ b/pkg/tui/menu.go @@ -1,9 +1,9 @@ package tui import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "fmt" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) func (m Model) BuildMenuView() string { diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 9ffda67..aaf6b7e 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -7,10 +7,9 @@ import ( "sort" "time" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" "github.com/stripe/stripe-go/v78" stripetoken "github.com/stripe/stripe-go/v78/token" @@ -65,6 +64,11 @@ type VisibleError struct { message string } +// VisibleNotice is a typed success message displayed to the user as a banner. +type VisibleNotice struct { + message string +} + type Model struct { // User authentication User *models.User @@ -111,11 +115,11 @@ type Model struct { Loading bool // true when fetching data from API error *VisibleError + notice *VisibleNotice APIClient *api.Client // Rendering - renderer *lipgloss.Renderer - theme theme.Theme + theme theme.Theme // Checkout flow state (spans multiple pages) CheckingOut bool @@ -130,6 +134,7 @@ type Model struct { shipping shippingState payment paymentState account accountState + refund refundState review reviewState confirm confirmState @@ -197,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 { @@ -219,6 +235,7 @@ func (m Model) SwitchPage(p page) Model { m.currentPage = p m.switched = true m.error = nil + m.notice = nil return m } @@ -240,7 +257,6 @@ func (m Model) CartSwitch() (Model, tea.Cmd) { m = m.SwitchPage(cartPage) m = m.updateCartViewport() if m.IsCartEmpty() { - m.footer = []footerCommand{ {key: "j/k", value: "items"}, {key: "+/-", value: "qty"}, @@ -400,23 +416,6 @@ func (m Model) fetchCartCmd() tea.Msg { return CartFetchedMsg{Cart: cart, Err: err} } -// fetchProductsCmd fetches products from the API -func (m Model) fetchProductsCmd() tea.Msg { - if m.APIClient == nil { - log.Println("[TUI] APIClient is nil, using fallback products") - return ProductsMsg{Err: nil} // No API client, use fallback - } - - log.Printf("[TUI] Fetching products from API at %s", m.APIClient.BaseURL) - products, err := m.APIClient.GetProducts() - if err != nil { - log.Printf("[TUI] Failed to fetch products: %v", err) - } else { - log.Printf("[TUI] Loaded %d products from API", len(products)) - } - return ProductsMsg{Products: products, Err: err} -} - func (m Model) Init() tea.Cmd { return tea.Batch( m.authCmd, @@ -430,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 @@ -643,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, + } } } @@ -670,58 +731,6 @@ func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, tea.Cmd) { } } -// saveCardAndConvert tokenizes the card locally using the Stripe publishable -// key (raw card data never reaches the backend), saves the token, then converts the cart -func (m Model) saveCardAndConvert(form PaymentFormCompleteMsg) tea.Cmd { - return func() tea.Msg { - if m.APIClient == nil { - return CheckoutResultMsg{Err: fmt.Errorf("API client not available")} - } - - stripe.Key = m.StripePublicKey - tok, err := stripetoken.New(&stripe.TokenParams{ - Card: &stripe.CardParams{ - Name: stripe.String(form.CardName), - Number: stripe.String(form.CardNumber), - ExpMonth: stripe.String(form.ExpiryMonth), - ExpYear: stripe.String(form.ExpiryYear), - CVC: stripe.String(form.CVC), - AddressZip: stripe.String(form.BillingZip), - }, - }) - if err != nil { - if stripeErr, ok := err.(*stripe.Error); ok { - return CheckoutResultMsg{Err: fmt.Errorf("%s", stripeErr.Msg)} - } - return CheckoutResultMsg{Err: fmt.Errorf("failed to tokenize card: %w", err)} - } - card, err := m.APIClient.SaveCard(api.SaveCardRequest{Token: tok.ID}) - if err != nil { - return CheckoutResultMsg{Err: fmt.Errorf("failed to save card: %w", err)} - } - - if err := m.resolveCartAddress(); err != nil { - return CheckoutResultMsg{Err: err} - } - - // 3. Set the card on the cart - if err := m.APIClient.SetCartCard(card.ID); err != nil { - return CheckoutResultMsg{Err: fmt.Errorf("failed to set card: %w", err)} - } - - // 4. Convert the cart to an order - order, err := m.APIClient.ConvertCart() - if err != nil { - return CheckoutResultMsg{Err: err} - } - - return CheckoutResultMsg{ - OrderID: order.ID, - Total: order.Total, - } - } -} - func (m Model) saveCardOnlyCmd(form PaymentFormCompleteMsg) tea.Cmd { return func() tea.Msg { if m.APIClient == nil { @@ -824,18 +833,12 @@ func (m Model) SavedCardsIsEmpty() bool { return len(m.SavedCards) == 0 } -// NewModel creates a new model using the default renderer. +// NewModel creates a new model. func NewModel(username string) Model { - return newModelWithRenderer(username, lipgloss.DefaultRenderer()) -} - -// newModelWithRenderer creates a new model with the given renderer. -func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model { m := Model{ Username: username, currentPage: splashPage, - renderer: renderer, - theme: theme.BasicTheme(renderer), + theme: theme.BasicTheme(), Coffees: []models.Coffee{ { Name: "Espresso", @@ -902,8 +905,8 @@ func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model { } // NewModelWithAuth creates a new model with user authentication context. -func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model { - m := newModelWithRenderer("", renderer) +func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model { + m := NewModel("") m.Fingerprint = fingerprint m.AuthFingerprintKey = clientSecret m.StripePublicKey = stripePublicKey @@ -929,7 +932,7 @@ func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr } func newViewport(w, h int) viewport.Model { - return viewport.New(w, h) + return viewport.New(viewport.WithWidth(w), viewport.WithHeight(h)) } func (m Model) collectCardCmd() tea.Cmd { @@ -945,8 +948,10 @@ func (m Model) collectCardCmd() tea.Cmd { } } -const ordersPollInterval = 10 * time.Second -const cardPollTimeout = 10 * time.Minute +const ( + ordersPollInterval = 10 * time.Second + cardPollTimeout = 10 * time.Minute +) type OrdersPollTickMsg struct{} diff --git a/pkg/tui/payment_form.go b/pkg/tui/payment_form.go index 64f5a26..724876b 100644 --- a/pkg/tui/payment_form.go +++ b/pkg/tui/payment_form.go @@ -5,10 +5,10 @@ import ( "strings" "time" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + "charm.land/lipgloss/v2" "terminalShop/pkg/models" "terminalShop/pkg/tui/qrfefe" @@ -280,12 +280,12 @@ func (m Model) updatePaymentViewport() Model { availH = 1 } if !m.payment.viewportReady { - m.payment.viewport = viewport.New(m.widthContent, availH) + m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH)) m.payment.viewport.KeyMap = viewport.KeyMap{} m.payment.viewportReady = true } else { - m.payment.viewport.Width = m.widthContent - m.payment.viewport.Height = availH + m.payment.viewport.SetWidth(m.widthContent) + m.payment.viewport.SetHeight(availH) } return m } @@ -301,7 +301,7 @@ func (m Model) PaymentPageView() string { // Bypass viewport — it strips the raw ANSI codes qrfefe emits. return lipgloss.Place( m.widthContainer, - m.payment.viewport.Height, + m.payment.viewport.Height(), lipgloss.Center, lipgloss.Center, m.renderPaymentHttpsView(), ) @@ -310,11 +310,11 @@ func (m Model) PaymentPageView() string { m.payment.viewport.SetContent(content) itemHeight := 3 targetY := m.payment.cardCursor * itemHeight - if targetY < m.payment.viewport.YOffset { + if targetY < m.payment.viewport.YOffset() { m.payment.viewport.SetYOffset(targetY) } - if targetY+itemHeight > m.payment.viewport.YOffset+m.payment.viewport.Height { - m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height + itemHeight + 1) + if targetY+itemHeight > m.payment.viewport.YOffset()+m.payment.viewport.Height() { + m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height() + itemHeight + 1) } if m.payment.cardCursor == len(m.SavedCards) { m.payment.viewport.GotoBottom() diff --git a/pkg/tui/refund.go b/pkg/tui/refund.go new file mode 100644 index 0000000..6be0b14 --- /dev/null +++ b/pkg/tui/refund.go @@ -0,0 +1,395 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/textarea" + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + "charm.land/lipgloss/v2" + + "terminalShop/pkg/api" + "terminalShop/pkg/models" +) + +// Refund composer layout constants. +const ( + refundMinTextareaRows = 5 + refundMaxTextareaRows = 10 + // chrome above the textarea inside the modal (title, label, padding, footer). + refundModalChrome = 12 + refundModalMaxWidth = 58 + refundModalMinWidth = 24 + // horizontal chrome the modal subtracts from m.widthContent. + refundModalHGutter = 6 + // inner padding around the textarea inside the modal body. + refundTextareaHPad = 4 +) + +// Nerd-font glyphs used in the refund composer. Requires a Nerd Font in the +// SSH client terminal; renders as tofu otherwise. +const ( + refundIconUndo = "\uf0e2" // nf-fa-undo + refundIconTag = "\uf02b" // nf-oct-tag + refundIconComment = "\uf41f" // nf-oct-comment + refundIconKey = "\uf11c" // nf-fa-keyboard_o +) + +// refundFocus identifies which field in the refund composer has keyboard focus. +type refundFocus int + +const ( + refundFocusReason refundFocus = iota + refundFocusMessage + refundFocusCount +) + +type refundState struct { + open bool + orderID uint + gen uint64 // bumped each open so late async results from a prior session are ignored + reason string + form *huh.Form + textarea textarea.Model + focusIdx refundFocus + submitting bool + err string + reasonErr string + messageErr string +} + +type refundRequestMsg struct { + OrderID uint + Gen uint64 + Err error +} + +// OpenRefundComposer opens the refund request composer for an order. +func (m Model) OpenRefundComposer(orderID uint) (Model, tea.Cmd) { + state := refundState{ + open: true, + orderID: orderID, + gen: m.refund.gen + 1, + } + state.textarea = m.newRefundTextarea() + state.form = m.buildRefundForm(&state) + m.refund = state + return m, m.refund.form.Init() +} + +// RefundUpdate handles all input and async results while the refund composer is open. +func (m Model) RefundUpdate(msg tea.Msg) (Model, tea.Cmd) { + state := &m.refund + if !state.open { + return m, nil + } + + switch msg := msg.(type) { + case refundRequestMsg: + if msg.Gen != state.gen || msg.OrderID != state.orderID { + return m, nil + } + state.submitting = false + if msg.Err != nil { + state.err = fmt.Sprintf("failed to send refund request: %v", msg.Err) + return m, nil + } + gen := state.gen + m.refund = refundState{gen: gen} + m.notice = &VisibleNotice{message: "Refund request sent"} + return m, nil + + case tea.WindowSizeMsg: + state.form = m.buildRefundForm(state) + m.resizeRefundTextarea(state) + return m, nil + + case tea.KeyMsg: + if state.submitting { + return m, nil + } + switch msg.String() { + case "esc": + gen := state.gen + m.refund = refundState{gen: gen} + return m, nil + case "ctrl+s": + return m, m.submitRefundRequest(state) + case "tab", "shift+tab": + return m, m.setRefundFocus(state, (state.focusIdx+1)%refundFocusCount) + } + } + + if state.focusIdx == refundFocusReason { + state.err = "" + state.reasonErr = "" + form, cmd := state.form.Update(msg) + if f, ok := form.(*huh.Form); ok { + state.form = f + } + if state.form.State == huh.StateCompleted { + state.reason = state.form.GetString("reason") + return m, m.setRefundFocus(state, refundFocusMessage) + } + return m, cmd + } + + if _, ok := msg.(tea.KeyMsg); ok { + state.err = "" + state.messageErr = "" + } + ta, cmd := state.textarea.Update(msg) + state.textarea = ta + return m, cmd +} + +func (m Model) buildRefundForm(state *refundState) *huh.Form { + options := make([]huh.Option[string], 0, len(models.RefundRequestReasons)) + for _, reason := range models.RefundRequestReasons { + options = append(options, huh.NewOption(reason, reason)) + } + + return huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Reason"). + Key("reason"). + Options(options...). + Value(&state.reason). + Height(len(models.RefundRequestReasons) + 1), + ), + ). + WithShowErrors(false). + WithShowHelp(false). + WithTheme(m.theme.Form()). + WithLayout(huh.LayoutStack). + WithWidth(m.refundContentWidth()) +} + +func (m Model) newRefundTextarea() textarea.Model { + ta := textarea.New() + ta.Prompt = "" + ta.Placeholder = "Describe the issue" + ta.ShowLineNumbers = true + ta.CharLimit = 65536 + ta.DynamicHeight = false + ta.SetHeight(m.refundTextareaHeight()) + ta.SetWidth(m.refundTextareaWidth()) + + base := lipgloss.NewStyle() + // Cursor-line uses a darker grey than theme.Border() so the active line + // reads as a subtle wash, not a bright bar competing with the highlight. + cursorLineBG := lipgloss.Color("236") + ta.SetStyles(textarea.Styles{ + Focused: textarea.StyleState{ + Base: base, + Text: base.Foreground(m.theme.Accent()), + LineNumber: base.Foreground(m.theme.Dim()), + CursorLine: base.Background(cursorLineBG).Foreground(m.theme.Accent()), + CursorLineNumber: base.Foreground(m.theme.Body()), + Placeholder: base.Foreground(m.theme.Dim()), + EndOfBuffer: base.Foreground(m.theme.Dim()), + }, + Blurred: textarea.StyleState{ + Base: base, + Text: base.Foreground(m.theme.Body()), + LineNumber: base.Foreground(m.theme.Dim()), + CursorLine: base.Foreground(m.theme.Background()), + CursorLineNumber: base.Foreground(m.theme.Dim()), + Placeholder: base.Foreground(m.theme.Dim()), + EndOfBuffer: base.Foreground(m.theme.Dim()), + }, + Cursor: textarea.CursorStyle{ + Color: m.theme.Highlight(), + Shape: tea.CursorBlock, + Blink: true, + }, + }) + return ta +} + +func (m Model) resizeRefundTextarea(state *refundState) { + state.textarea.SetHeight(m.refundTextareaHeight()) + state.textarea.SetWidth(m.refundTextareaWidth()) +} + +func (m Model) setRefundFocus(state *refundState, focusIdx refundFocus) tea.Cmd { + state.focusIdx = focusIdx + if focusIdx == refundFocusReason { + state.textarea.Blur() + state.form = m.buildRefundForm(state) + return state.form.Init() + } + return state.textarea.Focus() +} + +func (m Model) submitRefundRequest(state *refundState) tea.Cmd { + state.err = "" + state.reasonErr = "" + state.messageErr = "" + + reason := strings.TrimSpace(state.reason) + message := strings.TrimSpace(state.textarea.Value()) + + if reason == "" { + state.reasonErr = "select a refund reason" + return nil + } + + if reason == models.RefundRequestReasonOther && message == "" { + state.messageErr = "message is required when reason is other" + return nil + } + + state.submitting = true + return m.createRefundRequestCmd(state.orderID, state.gen, reason, message) +} + +func (m Model) createRefundRequestCmd(orderID uint, gen uint64, reason string, message string) tea.Cmd { + return func() tea.Msg { + if m.APIClient == nil || m.User == nil { + return refundRequestMsg{OrderID: orderID, Gen: gen, Err: fmt.Errorf("not authenticated")} + } + err := m.APIClient.CreateRefundRequest(orderID, api.RefundRequest{ + Reason: reason, + Message: message, + }) + return refundRequestMsg{OrderID: orderID, Gen: gen, Err: err} + } +} + +// RenderRefundOverlay renders the refund composer as a centered modal. +// The modal has no border or background fill — only padding for breathing +// room. lipgloss.Place handles centering against the container. +func (m Model) RenderRefundOverlay() string { + state := &m.refund + contentWidth := m.refundContentWidth() + + titleText := fmt.Sprintf("%s Refund request — Order #%d", refundIconUndo, state.orderID) + title := m.theme.TextAccent(). + Bold(true). + Width(contentWidth). + Border(lipgloss.NormalBorder(), false, false, true, false). + BorderForeground(m.theme.Highlight()). + Render(titleText) + + body := lipgloss.JoinVertical( + lipgloss.Left, + title, + "", + m.renderRefundReason(state), + "", + m.renderRefundTextarea(state), + "", + m.renderRefundFooter(state), + ) + + modal := lipgloss.NewStyle(). + Padding(1, 2). + Render(body) + + return lipgloss.Place(m.widthContainer, m.heightContainer, lipgloss.Center, lipgloss.Center, modal) +} + +func (m Model) renderRefundReason(state *refundState) string { + if state.focusIdx == 0 && state.form != nil { + out := state.form.View() + if state.reasonErr != "" { + out += "\n" + m.theme.TextError().Render(state.reasonErr) + } + return out + } + + value := state.reason + if value == "" { + value = "Select a reason" + } + + label := m.theme.TextLabel().Render(refundIconTag + " Reason") + selected := m.theme.TextAccent(). + Border(lipgloss.HiddenBorder()). + PaddingLeft(1). + Width(m.refundContentWidth()). + Render(value) + + out := lipgloss.JoinVertical(lipgloss.Left, label, selected) + if state.reasonErr != "" { + out += "\n" + m.theme.TextError().Render(state.reasonErr) + } + return out +} + +func (m Model) renderRefundTextarea(state *refundState) string { + label := "Message (optional)" + placeholder := "Describe the issue" + if state.reason == models.RefundRequestReasonOther { + label = "Message (required)" + placeholder = "Describe the issue..." + } + state.textarea.Placeholder = placeholder + + border := m.theme.Border() + if state.focusIdx == refundFocusMessage { + border = m.theme.Highlight() + } + + wrapper := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(border) + + contentWidth := m.refundContentWidth() + state.textarea.SetWidth(contentWidth - wrapper.GetHorizontalFrameSize()) + state.textarea.SetHeight(m.refundTextareaHeight()) + + textareaBox := wrapper.Render(state.textarea.View()) + + counter := m.theme.TextDim(). + Width(contentWidth). + Align(lipgloss.Right). + Render(fmt.Sprintf("%d chars", state.textarea.Length())) + + out := lipgloss.JoinVertical( + lipgloss.Left, + m.theme.TextLabel().Render(refundIconComment+" "+label), + textareaBox, + counter, + ) + if state.messageErr != "" { + out += "\n" + m.theme.TextError().Render(state.messageErr) + } + return out +} + +func (m Model) renderRefundFooter(state *refundState) string { + if state.submitting { + return m.theme.TextAccent().Render("sending refund request...") + } + if state.err != "" { + return m.theme.TextError().Render(state.err) + } + key := m.theme.TextHighlight().Bold(true) + sep := m.theme.TextDim().Render(" · ") + dim := m.theme.TextDim() + return dim.Render(refundIconKey+" ") + + key.Render("tab") + dim.Render(" next") + sep + + key.Render("shift+tab") + dim.Render(" prev") + sep + + key.Render("ctrl+s") + dim.Render(" submit") + sep + + key.Render("esc") + dim.Render(" cancel") +} + +func (m Model) refundContentWidth() int { + width := min(m.widthContent-refundModalHGutter, refundModalMaxWidth) + if width < refundModalMinWidth { + width = max(1, m.widthContent-refundTextareaHPad) + } + return width +} + +func (m Model) refundTextareaWidth() int { + return max(1, m.refundContentWidth()-refundTextareaHPad) +} + +func (m Model) refundTextareaHeight() int { + return max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome)) +} diff --git a/pkg/tui/shipping_form.go b/pkg/tui/shipping_form.go index db8afdf..63d997a 100644 --- a/pkg/tui/shipping_form.go +++ b/pkg/tui/shipping_form.go @@ -6,10 +6,10 @@ import ( "terminalShop/pkg/models" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + "charm.land/lipgloss/v2" ) // countries defines the available countries with their dial codes. @@ -262,12 +262,12 @@ func (m Model) updateShippingViewport() Model { availH = 1 } if !m.shipping.viewportReady { - m.shipping.viewport = viewport.New(m.widthContent, availH) + m.shipping.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH)) m.shipping.viewport.KeyMap = viewport.KeyMap{} m.shipping.viewportReady = true } else { - m.shipping.viewport.Width = m.widthContent - m.shipping.viewport.Height = availH + m.shipping.viewport.SetWidth(m.widthContent) + m.shipping.viewport.SetHeight(availH) } return m } @@ -281,11 +281,11 @@ func (m Model) ShippingPageView() string { m.shipping.viewport.SetContent(content) itemHeight := 4 targetY := m.shipping.addressCursor * itemHeight - if targetY < m.shipping.viewport.YOffset { + if targetY < m.shipping.viewport.YOffset() { m.shipping.viewport.SetYOffset(targetY) } - if targetY+itemHeight > m.shipping.viewport.YOffset+m.shipping.viewport.Height { - m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height + itemHeight + 1) + if targetY+itemHeight > m.shipping.viewport.YOffset()+m.shipping.viewport.Height() { + m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height() + itemHeight + 1) } if m.shipping.addressCursor == len(m.SavedAddresses) { m.shipping.viewport.GotoBottom() diff --git a/pkg/tui/shop.go b/pkg/tui/shop.go index 33c6038..3a7aac8 100644 --- a/pkg/tui/shop.go +++ b/pkg/tui/shop.go @@ -4,9 +4,9 @@ import ( "fmt" "strings" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "terminalShop/pkg/models" ) @@ -55,10 +55,10 @@ func (m Model) updateShopViewports() Model { m.shop.detailViewport.KeyMap = modifiedKeyMap m.shop.viewportsReady = true } else { - m.shop.menuViewport.Width = menuWidth - m.shop.menuViewport.Height = availableHeight - m.shop.detailViewport.Width = detailWidth - m.shop.detailViewport.Height = availableHeight + m.shop.menuViewport.SetWidth(menuWidth) + m.shop.menuViewport.SetHeight(availableHeight) + m.shop.detailViewport.SetWidth(detailWidth) + m.shop.detailViewport.SetHeight(availableHeight) } return m } @@ -226,7 +226,7 @@ func (m Model) shopMoveSelected(previous bool) Model { if m.shop.viewportsReady { targetY := (m.shop.selected + 2) * 1 - m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height/2) + m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height()/2) m.shop.detailViewport.GotoTop() } return m diff --git a/pkg/tui/splash.go b/pkg/tui/splash.go index 00a0441..e222ad2 100644 --- a/pkg/tui/splash.go +++ b/pkg/tui/splash.go @@ -5,9 +5,9 @@ import ( "terminalShop/pkg/api" "terminalShop/pkg/models" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) type DelayCompleteMsg struct{} diff --git a/pkg/tui/styles.go b/pkg/tui/styles.go index f5ca40e..c42ad32 100644 --- a/pkg/tui/styles.go +++ b/pkg/tui/styles.go @@ -3,7 +3,7 @@ package tui import ( "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string { diff --git a/pkg/tui/theme/huh.go b/pkg/tui/theme/huh.go index 6135e74..22b4e2b 100644 --- a/pkg/tui/theme/huh.go +++ b/pkg/tui/theme/huh.go @@ -1,51 +1,50 @@ package theme import ( - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/help" + "charm.land/huh/v2" + "charm.land/lipgloss/v2" ) // HuhTheme creates a huh form theme using this project's color palette. -func HuhTheme(t Theme) *huh.Theme { - var th huh.Theme +func HuhTheme(t Theme) huh.Theme { + var s huh.Styles - th.FieldSeparator = t.renderer.NewStyle().SetString("\n\n") + s.FieldSeparator = lipgloss.NewStyle().SetString("\n\n") - f := &th.Focused - f.Base = t.renderer.NewStyle(). + f := &s.Focused + f.Base = lipgloss.NewStyle(). PaddingLeft(1). BorderStyle(lipgloss.ThickBorder()). BorderLeft(true). BorderForeground(t.accent) - f.Title = t.renderer.NewStyle().Foreground(t.body) - f.Description = t.renderer.NewStyle().Foreground(t.body) - f.TextInput.Cursor = t.renderer.NewStyle().Foreground(t.highlight) - f.TextInput.Placeholder = t.renderer.NewStyle().Foreground(t.dim) - f.TextInput.Prompt = t.renderer.NewStyle().Foreground(t.accent) - f.TextInput.Text = t.renderer.NewStyle().Foreground(t.accent) - f.ErrorIndicator = t.renderer.NewStyle().Foreground(t.error) - f.ErrorMessage = t.renderer.NewStyle().Foreground(t.error) + f.Title = lipgloss.NewStyle().Foreground(t.body) + f.Description = lipgloss.NewStyle().Foreground(t.body) + f.TextInput.Cursor = lipgloss.NewStyle().Foreground(t.highlight) + f.TextInput.Placeholder = lipgloss.NewStyle().Foreground(t.dim) + f.TextInput.Prompt = lipgloss.NewStyle().Foreground(t.accent) + f.TextInput.Text = lipgloss.NewStyle().Foreground(t.accent) + f.ErrorIndicator = lipgloss.NewStyle().Foreground(t.error) + f.ErrorMessage = lipgloss.NewStyle().Foreground(t.error) - f.SelectSelector = t.renderer.NewStyle().Foreground(t.accent).SetString("> ") - f.Option = t.renderer.NewStyle().Foreground(t.body) - f.SelectedOption = t.renderer.NewStyle().Foreground(t.highlight).Bold(true) - f.MultiSelectSelector = t.renderer.NewStyle().Foreground(t.accent).SetString("> ") - f.SelectedPrefix = t.renderer.NewStyle().Foreground(t.highlight).SetString("[x] ") - f.UnselectedPrefix = t.renderer.NewStyle().Foreground(t.dim).SetString("[ ] ") - f.UnselectedOption = t.renderer.NewStyle().Foreground(t.body) + f.SelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ") + f.Option = lipgloss.NewStyle().Foreground(t.body) + f.SelectedOption = lipgloss.NewStyle().Foreground(t.highlight).Bold(true) + f.MultiSelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ") + f.SelectedPrefix = lipgloss.NewStyle().Foreground(t.highlight).SetString("[x] ") + f.UnselectedPrefix = lipgloss.NewStyle().Foreground(t.dim).SetString("[ ] ") + f.UnselectedOption = lipgloss.NewStyle().Foreground(t.body) - th.Help = help.New().Styles + s.Help = help.New().Styles - th.Blurred = copyFieldStyles(*f) - th.Blurred.Base = th.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) - th.Blurred.Title = th.Blurred.Title.Foreground(t.body) - th.Blurred.SelectSelector = t.renderer.NewStyle().SetString(" ") - th.Blurred.SelectedOption = t.renderer.NewStyle().Foreground(t.success).Bold(true) - th.Blurred.Option = t.renderer.NewStyle().Foreground(t.dim) + s.Blurred = copyFieldStyles(*f) + s.Blurred.Base = s.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) + s.Blurred.Title = s.Blurred.Title.Foreground(t.body) + s.Blurred.SelectSelector = lipgloss.NewStyle().SetString(" ") + s.Blurred.SelectedOption = lipgloss.NewStyle().Foreground(t.success).Bold(true) + s.Blurred.Option = lipgloss.NewStyle().Foreground(t.dim) - return &th + return huh.ThemeFunc(func(bool) *huh.Styles { return &s }) } -// copyFieldStyles returns a copy of FieldStyles (value type, assignment is a copy). func copyFieldStyles(f huh.FieldStyles) huh.FieldStyles { return f } diff --git a/pkg/tui/theme/theme.go b/pkg/tui/theme/theme.go index d1a1539..617dc76 100644 --- a/pkg/tui/theme/theme.go +++ b/pkg/tui/theme/theme.go @@ -1,56 +1,55 @@ package theme import ( - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" + "image/color" + + "charm.land/huh/v2" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/compat" ) -// Theme holds all colors and styles for the terminal coffee shop TUI. +// Theme holds all colors and styles for the TUI type Theme struct { - renderer *lipgloss.Renderer - - background lipgloss.TerminalColor - border lipgloss.TerminalColor - body lipgloss.TerminalColor - dim lipgloss.TerminalColor - accent lipgloss.TerminalColor - highlight lipgloss.TerminalColor - brand lipgloss.TerminalColor - label lipgloss.TerminalColor - success lipgloss.TerminalColor - error lipgloss.TerminalColor + background color.Color + border color.Color + body color.Color + dim color.Color + accent color.Color + highlight color.Color + brand color.Color + label color.Color + success color.Color + error color.Color base lipgloss.Style - form *huh.Theme + form huh.Theme } -// BasicTheme constructs the default theme for terminal coffee shop. -func BasicTheme(renderer *lipgloss.Renderer) Theme { - t := Theme{renderer: renderer} +func BasicTheme() Theme { + t := Theme{} - t.background = lipgloss.AdaptiveColor{Dark: "#000000", Light: "#FBFCFD"} - t.border = lipgloss.AdaptiveColor{Dark: "#666666", Light: "#D7DBDF"} + t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#000000"), Light: lipgloss.Color("#FBFCFD")} + t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#666666"), Light: lipgloss.Color("#D7DBDF")} t.body = lipgloss.Color("#AAAAAA") t.dim = lipgloss.Color("#666666") - t.accent = lipgloss.AdaptiveColor{Dark: "#FFFFFF", Light: "#11181C"} + t.accent = compat.AdaptiveColor{Dark: lipgloss.Color("#FFFFFF"), Light: lipgloss.Color("#11181C")} t.highlight = lipgloss.Color("#4682B4") t.brand = lipgloss.Color("#4682B4") t.label = lipgloss.Color("#999999") t.success = lipgloss.Color("#00FF00") t.error = lipgloss.Color("196") - t.base = renderer.NewStyle().Foreground(t.body) + t.base = lipgloss.NewStyle().Foreground(t.body) t.form = HuhTheme(t) return t } -// this should resemble something like catppuccin -func Catppuccin(renderer *lipgloss.Renderer) Theme { - t := Theme{renderer: renderer} +func Catppuccin() Theme { + t := Theme{} - t.background = lipgloss.AdaptiveColor{Dark: "#1e1e2e", Light: "#eff1f5"} - t.border = lipgloss.AdaptiveColor{Dark: "#45475A", Light: "#BCC0CC"} + t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#1e1e2e"), Light: lipgloss.Color("#eff1f5")} + t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#45475A"), Light: lipgloss.Color("#BCC0CC")} t.body = lipgloss.Color("#AAAAAA") t.dim = lipgloss.Color("#6C7086") t.accent = lipgloss.Color("#CBA6F7") @@ -60,25 +59,25 @@ func Catppuccin(renderer *lipgloss.Renderer) Theme { t.success = lipgloss.Color("#A6E3A1") t.error = lipgloss.Color("#F38BA8") - t.base = renderer.NewStyle().Foreground(t.body) + t.base = lipgloss.NewStyle().Foreground(t.body) t.form = HuhTheme(t) + return t } -// Color accessors — use in .Foreground() / .Background() calls. -func (t Theme) Background() lipgloss.TerminalColor { return t.background } -func (t Theme) Border() lipgloss.TerminalColor { return t.border } -func (t Theme) Body() lipgloss.TerminalColor { return t.body } -func (t Theme) Dim() lipgloss.TerminalColor { return t.dim } -func (t Theme) Accent() lipgloss.TerminalColor { return t.accent } -func (t Theme) Highlight() lipgloss.TerminalColor { return t.highlight } -func (t Theme) Brand() lipgloss.TerminalColor { return t.brand } -func (t Theme) Label() lipgloss.TerminalColor { return t.label } -func (t Theme) Success() lipgloss.TerminalColor { return t.success } -func (t Theme) Error() lipgloss.TerminalColor { return t.error } +// Color accessors. +func (t Theme) Background() color.Color { return t.background } +func (t Theme) Border() color.Color { return t.border } +func (t Theme) Body() color.Color { return t.body } +func (t Theme) Dim() color.Color { return t.dim } +func (t Theme) Accent() color.Color { return t.accent } +func (t Theme) Highlight() color.Color { return t.highlight } +func (t Theme) Brand() color.Color { return t.brand } +func (t Theme) Label() color.Color { return t.label } +func (t Theme) Success() color.Color { return t.success } +func (t Theme) Error() color.Color { return t.error } -// Style helpers — return a copy of base with the correct foreground. -// Chain .Bold(), .Width(), .Padding() etc. on the returned style. +// Style helpers. func (t Theme) Base() lipgloss.Style { return t.base } func (t Theme) TextBody() lipgloss.Style { return t.Base().Foreground(t.body) } func (t Theme) TextAccent() lipgloss.Style { return t.Base().Foreground(t.accent) } @@ -90,10 +89,8 @@ func (t Theme) TextSuccess() lipgloss.Style { return t.Base().Foreground(t.suc func (t Theme) TextError() lipgloss.Style { return t.Base().Foreground(t.error) } func (t Theme) TextLoading() lipgloss.Style { return t.Base().Foreground(lipgloss.Color("205")) } -// PanelError returns a style for the error banner: dark red bg, red fg. func (t Theme) PanelError() lipgloss.Style { return t.Base().Background(lipgloss.Color("52")).Foreground(lipgloss.Color("196")) } -// Form returns the huh form theme for shipping and payment forms. -func (t Theme) Form() *huh.Theme { return t.form } +func (t Theme) Form() huh.Theme { return t.form } diff --git a/pkg/tui/update.go b/pkg/tui/update.go index 4f831d9..84888db 100644 --- a/pkg/tui/update.go +++ b/pkg/tui/update.go @@ -2,11 +2,12 @@ package tui import ( "fmt" - "terminalShop/pkg/api" - "terminalShop/pkg/models" "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" + + "terminalShop/pkg/api" + "terminalShop/pkg/models" ) func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -36,6 +37,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.payment.form != nil { m.payment.form.form = m.buildPaymentForm(m.payment.form) } + if m.refund.open { + m.refund.form = m.buildRefundForm(&m.refund) + m.resizeRefundTextarea(&m.refund) + } } return m, nil } @@ -51,9 +56,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case VisibleError: m.error = &msg + m.notice = nil + return m, nil + case VisibleNotice: + m.notice = &msg + m.error = nil return m, nil case error: m.error = &VisibleError{message: msg.Error()} + m.notice = nil if m.currentPage == shopPage || m.currentPage == cartPage { return m, m.fetchCartCmd } @@ -167,9 +178,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.shipping.form != nil || m.payment.form != nil { break } - // Dismiss error banner with ESC - if msg.String() == "esc" && m.error != nil { + // Refund overlay owns input when open; dispatched below before page handlers. + if m.refund.open { + break + } + // Dismiss banners with ESC. + if msg.String() == "esc" && (m.error != nil || m.notice != nil) { m.error = nil + m.notice = nil return m, nil } // Global navigation @@ -192,6 +208,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // non-globals. fall through to page dispatch below } + + // Refund overlay intercepts all input after global handling. + if m.refund.open { + return m.RefundUpdate(msg) + } + // Page-specifics switch m.currentPage { case shopPage: diff --git a/pkg/tui/views.go b/pkg/tui/views.go index 64630d5..a68f512 100644 --- a/pkg/tui/views.go +++ b/pkg/tui/views.go @@ -1,7 +1,8 @@ package tui import ( - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) // ResizeView renders a message when the terminal is too small. @@ -19,7 +20,13 @@ func (m Model) ResizeView() string { ) } -func (m Model) View() string { +func (m Model) View() tea.View { + v := tea.NewView(m.renderString()) + v.AltScreen = true + return v +} + +func (m Model) renderString() string { if m.viewportWidth == 0 { m.updateLayout(120, 30) } @@ -75,8 +82,16 @@ func (m Model) View() string { content = loadingStyle.Render("Loading products from API...") } else if m.error != nil { errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1) + // soft-wrap long messages instead of clipping. + if m.widthContent > 4 { + errorStyle = errorStyle.Width(m.widthContent - 4) + } errorBanner := errorStyle.Render(m.error.message) content = errorBanner + "\n" + m.buildPageContent(availableContentHeight) + } else if m.notice != nil { + noticeStyle := m.theme.TextSuccess().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Success()).Padding(0, 1).MarginBottom(1) + noticeBanner := noticeStyle.Render(m.notice.message) + content = noticeBanner + "\n" + m.buildPageContent(availableContentHeight) } else { content = m.buildPageContent(availableContentHeight) } @@ -108,6 +123,10 @@ func (m Model) View() string { MaxHeight(m.heightContainer). Render(child) + if m.refund.open { + constrained = m.RenderRefundOverlay() + } + // Center the entire container in the terminal viewport return lipgloss.Place( m.viewportWidth, diff --git a/pkg/utils/response.go b/pkg/utils/response.go index c10a775..02c48c9 100644 --- a/pkg/utils/response.go +++ b/pkg/utils/response.go @@ -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) @@ -35,11 +35,11 @@ func RespondSuccess(w http.ResponseWriter, statusCode int, data interface{}) { Data: data, } - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) } // RespondError sends an error JSON response -func RespondError(w http.ResponseWriter, statusCode int, code, message string, details map[string]interface{}) { +func RespondError(w http.ResponseWriter, statusCode int, code, message string, details map[string]any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) @@ -52,7 +52,7 @@ func RespondError(w http.ResponseWriter, statusCode int, code, message string, d }, } - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) } // HealthData represents health check data diff --git a/pkg/validate/validate.go b/pkg/validate/validate.go index f771fad..8bcff01 100644 --- a/pkg/validate/validate.go +++ b/pkg/validate/validate.go @@ -66,7 +66,7 @@ func CcnValidator(cardNumber string) error { func IsDigits(name string) ErrorHandler { return func(str string) error { for _, c := range str { - if !(c >= '0' && c <= '9') { + if c < '0' || c > '9' { return fmt.Errorf("[%s] expected only digits but got %s", name, str) } } diff --git a/scripts/backups/01-log-tail.sh b/scripts/backups/01-log-tail.sh new file mode 100755 index 0000000..980b20d --- /dev/null +++ b/scripts/backups/01-log-tail.sh @@ -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 diff --git a/scripts/backups/02-snapshots.sh b/scripts/backups/02-snapshots.sh new file mode 100755 index 0000000..8dc29a2 --- /dev/null +++ b/scripts/backups/02-snapshots.sh @@ -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 diff --git a/scripts/backups/03-check.sh b/scripts/backups/03-check.sh new file mode 100755 index 0000000..e2069b3 --- /dev/null +++ b/scripts/backups/03-check.sh @@ -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 diff --git a/scripts/backups/04-restore-verify.sh b/scripts/backups/04-restore-verify.sh new file mode 100755 index 0000000..9e19048 --- /dev/null +++ b/scripts/backups/04-restore-verify.sh @@ -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 diff --git a/scripts/backups/05-freshness.sh b/scripts/backups/05-freshness.sh new file mode 100755 index 0000000..66790a4 --- /dev/null +++ b/scripts/backups/05-freshness.sh @@ -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 diff --git a/scripts/backups/06-lfs-direct.sh b/scripts/backups/06-lfs-direct.sh new file mode 100755 index 0000000..f781e65 --- /dev/null +++ b/scripts/backups/06-lfs-direct.sh @@ -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 diff --git a/scripts/backups/07-lfs-restic-check.sh b/scripts/backups/07-lfs-restic-check.sh new file mode 100755 index 0000000..caf5994 --- /dev/null +++ b/scripts/backups/07-lfs-restic-check.sh @@ -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" diff --git a/scripts/backups/check-all.sh b/scripts/backups/check-all.sh new file mode 100755 index 0000000..6a6890d --- /dev/null +++ b/scripts/backups/check-all.sh @@ -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 diff --git a/scripts/backups/config.env.example b/scripts/backups/config.env.example new file mode 100644 index 0000000..5d93718 --- /dev/null +++ b/scripts/backups/config.env.example @@ -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" diff --git a/scripts/backups/lib.sh b/scripts/backups/lib.sh new file mode 100755 index 0000000..9a7e3bc --- /dev/null +++ b/scripts/backups/lib.sh @@ -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="" +}