Compare commits

...

1 Commits

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

65
.github/workflows/ci.yml vendored Normal file
View File

@@ -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

View File

@@ -8,10 +8,10 @@ jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: actions/setup-go@v5 - uses: actions/setup-go@v6
with: with:
go-version: '1.23' go-version-file: go.mod
- name: Run tests - name: Run tests
run: go test ./... run: go test ./...
- name: Deploy to server - name: Deploy to server

10
.gitignore vendored
View File

@@ -5,6 +5,9 @@
.env.example .env.example
.env.prod .env.prod
docs/sca-psd2-compliance.md
accept-a-payment/
FULFILLMENT.md FULFILLMENT.md
.tmp-gocache/ .tmp-gocache/
@@ -30,6 +33,8 @@ api/api-server
*.out *.out
coverage.txt coverage.txt
coverage.html coverage.html
test-output.json
test-report.xml
# Dependency directories # Dependency directories
vendor/ vendor/
@@ -98,6 +103,8 @@ docs/
# Migration state (if using a migration tool that creates state files) # Migration state (if using a migration tool that creates state files)
migrations/*.state migrations/*.state
gh-dash/
loadtest/run.pid loadtest/run.pid
@@ -109,3 +116,6 @@ ROADMAP.md
*.bak *.bak
terminalshop.prod.* terminalshop.prod.*
# Backup verification config (real values; example file is committed)
scripts/backups/config.env

16
.golangci.yml Normal file
View File

@@ -0,0 +1,16 @@
version: "2"
run:
timeout: 5m
linters:
default: none
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
exclusions:
paths:
- terminal-shop-source

55
Makefile Normal file
View File

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

View File

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

View File

@@ -13,7 +13,7 @@ import (
func setupAddressTestDB(t *testing.T) (string, models.User) { func setupAddressTestDB(t *testing.T) (string, models.User) {
t.Helper() t.Helper()
testDB := "test_addresses.db" testDB := "test_addresses.db"
os.Remove(testDB) _ = os.Remove(testDB)
database.ResetForTesting() database.ResetForTesting()
db, err := database.Connect(testDB) db, err := database.Connect(testDB)
@@ -42,7 +42,7 @@ func setupAddressTestDB(t *testing.T) (string, models.User) {
func TestGetAddressesEmpty(t *testing.T) { func TestGetAddressesEmpty(t *testing.T) {
testDB, user := setupAddressTestDB(t) testDB, user := setupAddressTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewAddressHandler("", "", "") handler := NewAddressHandler("", "", "")
@@ -58,7 +58,7 @@ func TestGetAddressesEmpty(t *testing.T) {
var resp struct { var resp struct {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
Addresses []interface{} `json:"addresses"` Addresses []any `json:"addresses"`
} `json:"data"` } `json:"data"`
} }
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
@@ -74,7 +74,7 @@ func TestGetAddressesEmpty(t *testing.T) {
func TestGetAddressesIsolation(t *testing.T) { func TestGetAddressesIsolation(t *testing.T) {
testDB, user1 := setupAddressTestDB(t) testDB, user1 := setupAddressTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
db := database.GetDB() db := database.GetDB()
@@ -105,7 +105,7 @@ func TestGetAddressesIsolation(t *testing.T) {
Addresses []models.Address `json:"addresses"` Addresses []models.Address `json:"addresses"`
} `json:"data"` } `json:"data"`
} }
json.NewDecoder(w.Body).Decode(&resp) _ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Addresses) != 1 { if len(resp.Data.Addresses) != 1 {
t.Fatalf("expexted 1 address for user1, got %d", len(resp.Data.Addresses)) 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) { func TestGetAddressesWithData(t *testing.T) {
testDB, user := setupAddressTestDB(t) testDB, user := setupAddressTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
db := database.GetDB() db := database.GetDB()

View File

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

View File

@@ -22,7 +22,7 @@ const testClientSecret = "test-client-secret"
func setupAuthHandlerTestDB(t *testing.T) (string, *auth.JWTManager, *AuthHandler) { func setupAuthHandlerTestDB(t *testing.T) (string, *auth.JWTManager, *AuthHandler) {
t.Helper() t.Helper()
testDB := "test_auth.db" testDB := "test_auth.db"
os.Remove(testDB) _ = os.Remove(testDB)
database.ResetForTesting() database.ResetForTesting()
db, err := database.Connect(testDB) db, err := database.Connect(testDB)
@@ -62,7 +62,7 @@ func createAuthTestUser(t *testing.T, fingerprint string) models.User {
func TestGetTokenSuccess(t *testing.T) { func TestGetTokenSuccess(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t) testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
user := createAuthTestUser(t, "SHA256:testfingerprint123") user := createAuthTestUser(t, "SHA256:testfingerprint123")
@@ -108,7 +108,7 @@ func TestGetTokenSuccess(t *testing.T) {
func TestGetTokenBadSecret(t *testing.T) { func TestGetTokenBadSecret(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t) testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
@@ -128,7 +128,7 @@ func TestGetTokenBadSecret(t *testing.T) {
func TestGetTokenMissingFingerprint(t *testing.T) { func TestGetTokenMissingFingerprint(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t) testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
@@ -147,7 +147,7 @@ func TestGetTokenMissingFingerprint(t *testing.T) {
func TestGetTokenUnknownFingerprint(t *testing.T) { func TestGetTokenUnknownFingerprint(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t) testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
@@ -167,7 +167,7 @@ func TestGetTokenUnknownFingerprint(t *testing.T) {
func TestGetTokenInvalidJSON(t *testing.T) { func TestGetTokenInvalidJSON(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t) testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
req := httptest.NewRequest("POST", "/api/v1/auth/token", bytes.NewBufferString("{bad json")) 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) { func TestRegisterWithSSHKeySuccess(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t) testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
@@ -207,7 +207,7 @@ func TestRegisterWithSSHKeySuccess(t *testing.T) {
func TestRegisterWithSSHKeyDuplicate(t *testing.T) { func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t) testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
createAuthTestUser(t, "SHA256:existingfp") createAuthTestUser(t, "SHA256:existingfp")
@@ -229,7 +229,7 @@ func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
func TestRegisterWithSSHKeyMissingFields(t *testing.T) { func TestRegisterWithSSHKeyMissingFields(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t) testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
@@ -250,7 +250,7 @@ func TestRegisterWithSSHKeyMissingFields(t *testing.T) {
func TestGetUserBySSHKeySuccess(t *testing.T) { func TestGetUserBySSHKeySuccess(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t) testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
createAuthTestUser(t, "SHA256:lookupfp") createAuthTestUser(t, "SHA256:lookupfp")
@@ -267,7 +267,7 @@ func TestGetUserBySSHKeySuccess(t *testing.T) {
func TestGetUserBySSHKeyNotFound(t *testing.T) { func TestGetUserBySSHKeyNotFound(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t) testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
req := httptest.NewRequest("GET", "/api/v1/auth/ssh?fingerprint=SHA256:nope", nil) 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) { func TestGetUserBySSHKeyMissingParam(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t) testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
req := httptest.NewRequest("GET", "/api/v1/auth/ssh", nil) req := httptest.NewRequest("GET", "/api/v1/auth/ssh", nil)

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

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

View File

@@ -5,9 +5,11 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"strconv" "strconv"
"sync" "strings"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78"
@@ -24,33 +26,43 @@ import (
stripeSession "github.com/stripe/stripe-go/v78/checkout/session" stripeSession "github.com/stripe/stripe-go/v78/checkout/session"
) )
// payRedirects maps short tokens to full Stripe checkout URLs. const RedirectTTL = 10 * time.Minute
var (
payRedirects = map[string]string{}
payRedirectsMu sync.Mutex
)
func storeRedirect(stripeURL string) string { func storeRedirect(stripeURL, purpose string) (string, error) {
b := make([]byte, 16) b := make([]byte, 16)
rand.Read(b) if _, err := rand.Read(b); err != nil {
return "", err
}
token := hex.EncodeToString(b) token := hex.EncodeToString(b)
payRedirectsMu.Lock() now := time.Now()
payRedirects[token] = stripeURL rec := models.PayRedirect{
payRedirectsMu.Unlock() Token: token,
return 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. // PayRedirect resolves a short token and redirects to the Stripe checkout URL.
func PayRedirect(w http.ResponseWriter, r *http.Request) { func PayRedirect(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token") token := chi.URLParam(r, "token")
payRedirectsMu.Lock() db := database.GetDB().WithContext(r.Context())
url, ok := payRedirects[token] var rec models.PayRedirect
payRedirectsMu.Unlock() if err := db.Where("token = ?", token).First(&rec).Error; err != nil {
if !ok {
http.NotFound(w, r) http.NotFound(w, r)
return 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. // 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. // GetCards returns all saved cards for the authenticated user.
// Cards past their retention deadline are filtered out at read time;
// physical deletion runs in handlers.ReconcileExpiredCards.
func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) { func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context()) db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context()) userID := middleware.UserIDFromContext(r.Context())
var cards []models.Card var cards []models.Card
db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&cards) db.Where(
"user_id = ? AND (storage_expires_at IS NULL OR storage_expires_at > ?)",
userID, time.Now(),
).Order("is_default DESC, created_at DESC").Find(&cards)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"cards": cards, "cards": cards,
}) })
} }
// func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]interface{}) // func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]any)
// GetCard returns a single saved card by ID. // GetCard returns a single saved card by ID.
func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) { func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
@@ -97,7 +114,7 @@ func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"card": card, "card": card,
}) })
} }
@@ -119,6 +136,15 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
return return
} }
if card.IsStorageExpired(time.Now()) {
if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
if err := db.Model(&models.Card{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil { if err := db.Model(&models.Card{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update card", nil) utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update card", nil)
return return
@@ -130,7 +156,7 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"card": card, "card": card,
}) })
} }
@@ -166,8 +192,12 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
return return
} }
token := storeRedirect(sess.URL) token, err := storeRedirect(sess.URL, models.RedirectPurposeAddCard)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ 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), "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) audit.CardAdded(userID, card.ID, card.Brand, card.Last4)
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{ utils.RespondSuccess(w, http.StatusCreated, map[string]any{
"card": card, "card": card,
}) })
} }
@@ -317,11 +347,56 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
audit.CardDeleted(userID, card.ID) audit.CardDeleted(userID, card.ID)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"message": "card deleted", "message": "card deleted",
}) })
} }
// expireStoredCard removes a card whose retention deadline elapsed.
// Local state (cart references + card row) is committed atomically;
// Stripe detach and audit run only after the local commit so retries
// cannot double-fire the audit event.
func expireStoredCard(db *gorm.DB, card *models.Card, stripeKey string) error {
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", card.ID, card.UserID).Update("card_id", nil).Error; err != nil {
return fmt.Errorf("failed to clear expired card from cart: %w", err)
}
if err := tx.Delete(card).Error; err != nil {
return fmt.Errorf("failed to delete expired card: %w", err)
}
return nil
}); err != nil {
return err
}
if stripeKey != "" && strings.HasPrefix(card.StripePaymentID, "pm_") {
stripe.Key = stripeKey
if _, err := paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{}); err != nil {
slog.Warn("failed to detach expired card", "card_id", card.ID, "error", err)
}
}
audit.CardExpired(card.UserID, card.ID)
return nil
}
func refreshCardStorageTTL(db *gorm.DB, cardID uint, now time.Time) error {
return db.Model(&models.Card{}).Where("id = ?", cardID).Updates(map[string]any{
"last_used_at": now,
"storage_expires_at": models.CardStorageExpiresAt(now),
}).Error
}
func respondCardStorageExpired(w http.ResponseWriter) {
utils.RespondError(
w,
http.StatusGone,
"CARD_STORAGE_EXPIRED",
"saved card expired, add it again",
nil,
)
}
// ensureStripeCustomer creates a Stripe Customer for the user if they don't // ensureStripeCustomer creates a Stripe Customer for the user if they don't
// already have one and persists the customer ID. Safe to call concurrently — // already have one and persists the customer ID. Safe to call concurrently —
// a duplicate Stripe customer is harmless; the local record reflects the first // a duplicate Stripe customer is harmless; the local record reflects the first

View File

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

View File

@@ -5,8 +5,10 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"time" "time"
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentintent" "github.com/stripe/stripe-go/v78/paymentintent"
"github.com/stripe/stripe-go/v78/paymentmethod" "github.com/stripe/stripe-go/v78/paymentmethod"
@@ -24,11 +26,19 @@ import (
type CartHandler struct { 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 // NewCartHandler creates a new cart handler with the Stripe secret key
func NewCartHandler(stripeSecretKey string) *CartHandler { func NewCartHandler(stripeSecretKey, appURL string, maxOrderCents int) *CartHandler {
return &CartHandler{stripeKey: stripeSecretKey} return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL, maxOrderCents: maxOrderCents}
} }
// getOrCreateCart finds the users cart or creates one. // 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 return &cart, nil
} }
func cartResponse(ctx context.Context, cart *models.Cart) map[string]interface{} { func cartResponse(ctx context.Context, cart *models.Cart) map[string]any {
db := database.GetDB().WithContext(ctx) db := database.GetDB().WithContext(ctx)
var items []models.CartItem var items []models.CartItem
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items) db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
subtotal := 0 subtotal := 0
itemsData := make([]map[string]interface{}, 0, len(items)) itemsData := make([]map[string]any, 0, len(items))
for _, item := range items { for _, item := range items {
lineTotal := item.Coffee.Price * item.Quantity lineTotal := item.Coffee.Price * item.Quantity
subtotal += lineTotal subtotal += lineTotal
itemsData = append(itemsData, map[string]interface{}{ itemsData = append(itemsData, map[string]any{
"id": item.ID, "id": item.ID,
"coffee_id": item.CoffeeID, "coffee_id": item.CoffeeID,
"quantity": item.Quantity, "quantity": item.Quantity,
@@ -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, "items": itemsData,
"subtotal": subtotal, "subtotal": subtotal,
"address_id": cart.AddressID, "address_id": cart.AddressID,
@@ -84,7 +94,7 @@ func (h *CartHandler) GetCart(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"cart": cartResponse(r.Context(), cart), "cart": cartResponse(r.Context(), cart),
}) })
} }
@@ -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), "cart": cartResponse(r.Context(), cart),
}) })
} }
@@ -183,7 +193,7 @@ func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"message": "address set", "message": "address set",
}) })
} }
@@ -208,6 +218,15 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
return return
} }
if card.IsStorageExpired(time.Now()) {
if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
cart, err := getOrCreateCart(r.Context(), userID) cart, err := getOrCreateCart(r.Context(), userID)
if err != nil { if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil) utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
@@ -219,7 +238,7 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"message": "card set", "message": "card set",
}) })
} }
@@ -239,7 +258,7 @@ func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"message": "cart cleared", "message": "cart cleared",
}) })
} }
@@ -294,6 +313,16 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
return return
} }
if card.IsStorageExpired(time.Now()) {
middleware.RecordCartConversion("validation_card_expired")
if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
var user models.User var user models.User
if err := db.First(&user, userID).Error; err != nil { if err := db.First(&user, userID).Error; err != nil {
middleware.RecordCartConversion("user_not_found") middleware.RecordCartConversion("user_not_found")
@@ -353,6 +382,17 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
total := subtotal + cart.ShippingCost 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 { if total < 50 {
middleware.RecordCartConversion("validation_min_amount") middleware.RecordCartConversion("validation_min_amount")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil) utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil)
@@ -395,10 +435,9 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
Currency: stripe.String(string(stripe.CurrencyUSD)), Currency: stripe.String(string(stripe.CurrencyUSD)),
Customer: stripe.String(user.StripeCustomerID), Customer: stripe.String(user.StripeCustomerID),
PaymentMethod: stripe.String(paymentMethodID), PaymentMethod: stripe.String(paymentMethodID),
AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{ OffSession: stripe.Bool(true),
Enabled: stripe.Bool(true), PaymentMethodTypes: stripe.StringSlice([]string{"card"}),
AllowRedirects: stripe.String("never"), ReturnURL: stripe.String(h.appURL + "/post-3ds"),
},
Confirm: stripe.Bool(true), Confirm: stripe.Bool(true),
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)), Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
Shipping: &stripe.ShippingDetailsParams{ Shipping: &stripe.ShippingDetailsParams{
@@ -423,13 +462,17 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
stripeDur := time.Since(stripeStart).Seconds() stripeDur := time.Since(stripeStart).Seconds()
if err != nil { if err != nil {
middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur) middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur)
db.Model(&order).Update("status", models.OrderStatusFailed)
if stripeErr, ok := err.(*stripe.Error); ok { 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 { switch stripeErr.Type {
case stripe.ErrorTypeCard: case stripe.ErrorTypeCard:
middleware.RecordCartConversion("card_declined") middleware.RecordCartConversion("card_declined")
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg) 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: case stripe.ErrorTypeInvalidRequest:
middleware.RecordCartConversion("stripe_invalid_request") middleware.RecordCartConversion("stripe_invalid_request")
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg) audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
@@ -441,6 +484,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
} }
return return
} }
db.Model(&order).Update("status", models.OrderStatusFailed)
middleware.RecordCartConversion("stripe_error") middleware.RecordCartConversion("stripe_error")
audit.OrderFailed(userID, order.ID, total, err.Error()) audit.OrderFailed(userID, order.ID, total, err.Error())
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil) 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) 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. // Commit payment ID and clear cart in a single transaction.
txErr := db.Transaction(func(tx *gorm.DB) error { txErr := db.Transaction(func(tx *gorm.DB) error {
order.StripePaymentID = pi.ID 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 { if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return err return err
} }
return tx.Model(cart).Updates(map[string]interface{}{ return tx.Model(cart).Updates(map[string]any{
"address_id": nil, "address_id": nil,
"card_id": nil, "card_id": nil,
}).Error }).Error
@@ -485,7 +534,243 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
} }
go notify.SlackOrderPaid(&order) go notify.SlackOrderPaid(&order)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"order": order, "order": order,
}) })
} }
// 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)
}

View File

@@ -3,14 +3,17 @@ package handlers
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"testing" "testing"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"terminalShop/api/middleware" "terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database" "terminalShop/pkg/database"
"terminalShop/pkg/models" "terminalShop/pkg/models"
) )
@@ -20,7 +23,7 @@ import (
func setupCartTestDB(t *testing.T) (string, models.User) { func setupCartTestDB(t *testing.T) (string, models.User) {
t.Helper() t.Helper()
testDB := "test_cart.db" testDB := "test_cart.db"
os.Remove(testDB) _ = os.Remove(testDB)
database.ResetForTesting() database.ResetForTesting()
db, err := database.Connect(testDB) 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) { func TestGetCartEmpty(t *testing.T) {
testDB, user := setupCartTestDB(t) testDB, user := setupCartTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("") handler := NewCartHandler("", "", 0)
req := authRequest("GET", "/api/v1/cart", nil, user.ID) req := authRequest("GET", "/api/v1/cart", nil, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -80,7 +83,7 @@ func TestGetCartEmpty(t *testing.T) {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
Cart struct { Cart struct {
Items []interface{} `json:"items"` Items []any `json:"items"`
Subtotal int `json:"subtotal"` Subtotal int `json:"subtotal"`
} `json:"cart"` } `json:"cart"`
} `json:"data"` } `json:"data"`
@@ -101,13 +104,13 @@ func TestGetCartEmpty(t *testing.T) {
func TestSetItemAndGetCart(t *testing.T) { func TestSetItemAndGetCart(t *testing.T) {
testDB, user := setupCartTestDB(t) testDB, user := setupCartTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("") handler := NewCartHandler("", "", 0)
// Add item (CoffeeID=1, Quantity=2) // Add item (CoffeeID=1, Quantity=2)
body, _ := json.Marshal(map[string]interface{}{ body, _ := json.Marshal(map[string]any{
"coffee_id": 1, "coffee_id": 1,
"quantity": 2, "quantity": 2,
}) })
@@ -157,19 +160,19 @@ func TestSetItemAndGetCart(t *testing.T) {
func TestSetItemUpdateQuantity(t *testing.T) { func TestSetItemUpdateQuantity(t *testing.T) {
testDB, user := setupCartTestDB(t) testDB, user := setupCartTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("") handler := NewCartHandler("", "", 0)
// Add item // Add item
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2}) body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 2})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
// Update quantity // Update quantity
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5}) body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5})
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID) req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -190,7 +193,7 @@ func TestSetItemUpdateQuantity(t *testing.T) {
} `json:"cart"` } `json:"cart"`
} `json:"data"` } `json:"data"`
} }
json.NewDecoder(w.Body).Decode(&resp) _ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 1 { if len(resp.Data.Cart.Items) != 1 {
t.Fatalf("Expected 1 item after update, got %d", len(resp.Data.Cart.Items)) 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) { func TestSetItemRemove(t *testing.T) {
testDB, user := setupCartTestDB(t) testDB, user := setupCartTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("") handler := NewCartHandler("", "", 0)
// Add item // Add item
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3}) body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 3})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
// Remove item (quantity 0) // Remove item (quantity 0)
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 0}) body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 0})
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID) req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -228,11 +231,11 @@ func TestSetItemRemove(t *testing.T) {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
Cart struct { Cart struct {
Items []interface{} `json:"items"` Items []any `json:"items"`
} `json:"cart"` } `json:"cart"`
} `json:"data"` } `json:"data"`
} }
json.NewDecoder(w.Body).Decode(&resp) _ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 0 { if len(resp.Data.Cart.Items) != 0 {
t.Errorf("Expected 0 items after removal, got %d", len(resp.Data.Cart.Items)) 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) { func TestSetItemInvalidProduct(t *testing.T) {
testDB, user := setupCartTestDB(t) testDB, user := setupCartTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() 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) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -258,12 +261,12 @@ func TestSetItemInvalidProduct(t *testing.T) {
func TestSetItemMissingCoffeeID(t *testing.T) { func TestSetItemMissingCoffeeID(t *testing.T) {
testDB, user := setupCartTestDB(t) testDB, user := setupCartTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() 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) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -275,14 +278,14 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
func TestClearCart(t *testing.T) { func TestClearCart(t *testing.T) {
testDB, user := setupCartTestDB(t) testDB, user := setupCartTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("") handler := NewCartHandler("", "", 0)
// Add two items // Add two items
for _, coffeeID := range []uint{1, 2} { for _, coffeeID := range []uint{1, 2} {
body, _ := json.Marshal(map[string]interface{}{"coffee_id": coffeeID, "quantity": 1}) body, _ := json.Marshal(map[string]any{"coffee_id": coffeeID, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -306,11 +309,11 @@ func TestClearCart(t *testing.T) {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
Cart struct { Cart struct {
Items []interface{} `json:"items"` Items []any `json:"items"`
} `json:"cart"` } `json:"cart"`
} `json:"data"` } `json:"data"`
} }
json.NewDecoder(w.Body).Decode(&resp) _ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 0 { if len(resp.Data.Cart.Items) != 0 {
t.Errorf("Expected 0 items after clear, got %d", len(resp.Data.Cart.Items)) 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) { func TestSetAddress(t *testing.T) {
testDB, user := setupCartTestDB(t) testDB, user := setupCartTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
db := database.GetDB() db := database.GetDB()
@@ -334,9 +337,9 @@ func TestSetAddress(t *testing.T) {
} }
db.Create(&addr) 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) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetAddress(w, req) handler.SetAddress(w, req)
@@ -358,7 +361,7 @@ func TestSetAddress(t *testing.T) {
} `json:"cart"` } `json:"cart"`
} `json:"data"` } `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 { 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) 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) { func TestSetAddressWrongUser(t *testing.T) {
testDB, user := setupCartTestDB(t) testDB, user := setupCartTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
db := database.GetDB() db := database.GetDB()
@@ -388,9 +391,9 @@ func TestSetAddressWrongUser(t *testing.T) {
} }
db.Create(&addr) 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) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetAddress(w, req) handler.SetAddress(w, req)
@@ -402,7 +405,7 @@ func TestSetAddressWrongUser(t *testing.T) {
func TestSetCard(t *testing.T) { func TestSetCard(t *testing.T) {
testDB, user := setupCartTestDB(t) testDB, user := setupCartTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
db := database.GetDB() db := database.GetDB()
@@ -416,9 +419,9 @@ func TestSetCard(t *testing.T) {
} }
db.Create(&card) 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) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetCard(w, req) handler.SetCard(w, req)
@@ -440,7 +443,7 @@ func TestSetCard(t *testing.T) {
} `json:"cart"` } `json:"cart"`
} `json:"data"` } `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 { 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) 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) { func TestSetCardWrongUser(t *testing.T) {
testDB, user := setupCartTestDB(t) testDB, user := setupCartTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
db := database.GetDB() db := database.GetDB()
@@ -468,9 +471,9 @@ func TestSetCardWrongUser(t *testing.T) {
} }
db.Create(&card) 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) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetCard(w, req) handler.SetCard(w, req)
@@ -482,13 +485,13 @@ func TestSetCardWrongUser(t *testing.T) {
func TestConvertCartMissingAddress(t *testing.T) { func TestConvertCartMissingAddress(t *testing.T) {
testDB, user := setupCartTestDB(t) testDB, user := setupCartTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("") handler := NewCartHandler("", "", 0)
// Add item but no address or card // Add item but no address or card
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1}) body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -505,21 +508,21 @@ func TestConvertCartMissingAddress(t *testing.T) {
func TestConvertCartMissingCard(t *testing.T) { func TestConvertCartMissingCard(t *testing.T) {
testDB, user := setupCartTestDB(t) testDB, user := setupCartTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("") handler := NewCartHandler("", "", 0)
db := database.GetDB() db := database.GetDB()
// Add item and address // Add item and address
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1}) body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
addr := models.Address{UserID: user.ID, Name: "Test", Street: "123 St", City: "PDX", State: "OR", Zip: "97201", Country: "US"} addr := models.Address{UserID: user.ID, Name: "Test", Street: "123 St", City: "PDX", State: "OR", Zip: "97201", Country: "US"}
db.Create(&addr) db.Create(&addr)
body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID}) body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID) req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
handler.SetAddress(w, req) handler.SetAddress(w, req)
@@ -536,10 +539,10 @@ func TestConvertCartMissingCard(t *testing.T) {
func TestConvertCartEmpty(t *testing.T) { func TestConvertCartEmpty(t *testing.T) {
testDB, user := setupCartTestDB(t) testDB, user := setupCartTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("") handler := NewCartHandler("", "", 0)
// Empty cart convert // Empty cart convert
req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID) req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
@@ -553,14 +556,14 @@ func TestConvertCartEmpty(t *testing.T) {
func TestMultipleItems(t *testing.T) { func TestMultipleItems(t *testing.T) {
testDB, user := setupCartTestDB(t) testDB, user := setupCartTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("") handler := NewCartHandler("", "", 0)
// Add three different items // Add three different items
for i := uint(1); i <= 3; i++ { for i := uint(1); i <= 3; i++ {
body, _ := json.Marshal(map[string]interface{}{"coffee_id": i, "quantity": int(i)}) body, _ := json.Marshal(map[string]any{"coffee_id": i, "quantity": int(i)})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -578,12 +581,12 @@ func TestMultipleItems(t *testing.T) {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
Cart struct { Cart struct {
Items []interface{} `json:"items"` Items []any `json:"items"`
Subtotal int `json:"subtotal"` Subtotal int `json:"subtotal"`
} `json:"cart"` } `json:"cart"`
} `json:"data"` } `json:"data"`
} }
json.NewDecoder(w.Body).Decode(&resp) _ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 3 { if len(resp.Data.Cart.Items) != 3 {
t.Errorf("Expected 3 items, got %d", len(resp.Data.Cart.Items)) 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) { func TestCartIsolation(t *testing.T) {
testDB, user1 := setupCartTestDB(t) testDB, user1 := setupCartTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
db := database.GetDB() db := database.GetDB()
@@ -605,19 +608,19 @@ func TestCartIsolation(t *testing.T) {
} }
db.Create(&user2) db.Create(&user2)
handler := NewCartHandler("") handler := NewCartHandler("", "", 0)
r := chi.NewRouter() r := chi.NewRouter()
r.Put("/cart/item", handler.SetItem) r.Put("/cart/item", handler.SetItem)
r.Get("/cart", handler.GetCart) r.Get("/cart", handler.GetCart)
// User 1 adds item // User 1 adds item
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5}) body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5})
req := authRequest("PUT", "/cart/item", body, user1.ID) req := authRequest("PUT", "/cart/item", body, user1.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
// User 2 adds different item // User 2 adds different item
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 2, "quantity": 3}) body, _ = json.Marshal(map[string]any{"coffee_id": 2, "quantity": 3})
req = authRequest("PUT", "/cart/item", body, user2.ID) req = authRequest("PUT", "/cart/item", body, user2.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
@@ -638,7 +641,7 @@ func TestCartIsolation(t *testing.T) {
} `json:"cart"` } `json:"cart"`
} `json:"data"` } `json:"data"`
} }
json.NewDecoder(w.Body).Decode(&resp) _ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 1 { if len(resp.Data.Cart.Items) != 1 {
t.Fatalf("User 1 expected 1 item, got %d", len(resp.Data.Cart.Items)) 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() w = httptest.NewRecorder()
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
json.NewDecoder(w.Body).Decode(&resp) _ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 1 { if len(resp.Data.Cart.Items) != 1 {
t.Fatalf("User 2 expected 1 item, got %d", len(resp.Data.Cart.Items)) 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) 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())
}
}

View File

@@ -2,14 +2,18 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"fmt"
"log/slog"
"net/http" "net/http"
"os" "os"
"strconv" "strconv"
"strings"
"time"
"terminalShop/pkg/bring" "terminalShop/pkg/bring"
"terminalShop/pkg/notify" "terminalShop/pkg/notify"
"terminalShop/pkg/shipping" "terminalShop/pkg/shipping"
"terminalShop/pkg/shippo" "terminalShop/pkg/shippo"
"time"
"terminalShop/api/middleware" "terminalShop/api/middleware"
"terminalShop/pkg/audit" "terminalShop/pkg/audit"
@@ -20,6 +24,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/refund" "github.com/stripe/stripe-go/v78/refund"
"gorm.io/gorm"
) )
type OrderHandler struct { type OrderHandler struct {
@@ -29,6 +34,16 @@ type OrderHandler struct {
bringCustomerNumber string 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 { func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
return &OrderHandler{ return &OrderHandler{
stripeKey: stripeSecretKey, 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) { func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context()) db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context()) userID := middleware.UserIDFromContext(r.Context())
var orders []models.Order 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, "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 // 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. // refunded. Only orders in paid or shipped status can be refunded.
func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) { 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) audit.OrderRefunded(userID, order.ID, order.Total, rf.ID)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"refund_id": rf.ID, "refund_id": rf.ID,
"status": "refunded", "status": "refunded",
}) })
@@ -169,7 +287,7 @@ func (h *OrderHandler) UpdateTracking(w http.ResponseWriter, r *http.Request) {
audit.OrderShipped(order.UserID, order.ID, req.Carrier, req.TrackingNumber) audit.OrderShipped(order.UserID, order.ID, req.Carrier, req.TrackingNumber)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"order_id": order.ID, "order_id": order.ID,
"carrier": req.Carrier, "carrier": req.Carrier,
"tracking_number": req.TrackingNumber, "tracking_number": req.TrackingNumber,

170
api/handlers/orders_test.go Normal file
View File

@@ -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)
}
}

View File

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

View File

@@ -16,7 +16,7 @@ func setupTestDB(t *testing.T) string {
testDB := "test_products.db" testDB := "test_products.db"
// Remove if exists // Remove if exists
os.Remove(testDB) _ = os.Remove(testDB)
// Reset singleton // Reset singleton
database.ResetForTesting() database.ResetForTesting()
@@ -39,7 +39,7 @@ func setupTestDB(t *testing.T) string {
func TestGetProduct(t *testing.T) { func TestGetProduct(t *testing.T) {
testDB := setupTestDB(t) testDB := setupTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewProductHandler() handler := NewProductHandler()
@@ -78,7 +78,7 @@ func TestGetProduct(t *testing.T) {
func TestGetProductNotFound(t *testing.T) { func TestGetProductNotFound(t *testing.T) {
testDB := setupTestDB(t) testDB := setupTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewProductHandler() handler := NewProductHandler()
@@ -98,7 +98,7 @@ func TestGetProductNotFound(t *testing.T) {
func TestGetProductsEmptyDatabase(t *testing.T) { func TestGetProductsEmptyDatabase(t *testing.T) {
testDB := "test_empty.db" testDB := "test_empty.db"
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
// Create database but don't seed // Create database but don't seed

View File

@@ -7,6 +7,7 @@ import (
"terminalShop/pkg/database" "terminalShop/pkg/database"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"terminalShop/pkg/notify"
"github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentintent" "github.com/stripe/stripe-go/v78/paymentintent"
@@ -48,7 +49,7 @@ func ReconcileOrders(stripeKey string) {
"order_id", order.ID, "pi", pi.ID) "order_id", order.ID, "pi", pi.ID)
txErr := db.Transaction(func(tx *gorm.DB) error { txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]interface{}{ if err := tx.Model(&order).Updates(map[string]any{
"status": models.OrderStatusPaid, "status": models.OrderStatusPaid,
"stripe_payment_id": pi.ID, "stripe_payment_id": pi.ID,
}).Error; err != nil { }).Error; err != nil {
@@ -61,7 +62,7 @@ func ReconcileOrders(stripeKey string) {
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil { if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return err return err
} }
return tx.Model(&cart).Updates(map[string]interface{}{ return tx.Model(&cart).Updates(map[string]any{
"address_id": nil, "address_id": nil,
"card_id": nil, "card_id": nil,
}).Error }).Error
@@ -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)
}

View File

@@ -130,10 +130,10 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
resp, err := h.httpClient.Do(req) resp, err := h.httpClient.Do(req)
if err != nil { 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 return
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var result struct { var result struct {
Data struct { Data struct {
@@ -151,7 +151,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
_ = json.Unmarshal(bodyBytes, &result) _ = json.Unmarshal(bodyBytes, &result)
if resp.StatusCode >= 300 { 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 return
} }
@@ -173,7 +173,7 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay
return return
} }
if order.TrackingNumber == "" { 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 return
} }
@@ -198,12 +198,12 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay
resp, err := h.httpClient.Do(req) resp, err := h.httpClient.Do(req)
if err != nil { 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 return
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 { 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 return
} }
@@ -245,7 +245,7 @@ func (h *SlackHandler) editClearActions(responseURL, status, actor string) {
slog.Warn("slack mark_status: response_url edit failed", "error", err) slog.Warn("slack mark_status: response_url edit failed", "error", err)
return return
} }
resp.Body.Close() _ = resp.Body.Close()
} }
func (h *SlackHandler) verifySignature(r *http.Request, body []byte) bool { 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)) 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)) expected := "v0=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sigHeader)) return hmac.Equal([]byte(expected), []byte(sigHeader))

View File

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

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

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

View File

@@ -125,7 +125,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
} }
txErr := db.Transaction(func(tx *gorm.DB) error { txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]interface{}{ if err := tx.Model(&order).Updates(map[string]any{
"status": models.OrderStatusPaid, "status": models.OrderStatusPaid,
"stripe_payment_id": pi.ID, "stripe_payment_id": pi.ID,
}).Error; err != nil { }).Error; err != nil {
@@ -139,7 +139,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil { if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return fmt.Errorf("failed to delete cart item: %w", err) return fmt.Errorf("failed to delete cart item: %w", err)
} }
return tx.Model(&cart).Updates(map[string]interface{}{ return tx.Model(&cart).Updates(map[string]any{
"address_id": nil, "address_id": nil,
"card_id": nil, "card_id": nil,
}).Error }).Error
@@ -150,6 +150,12 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
return return
} }
if order.CardID != 0 {
if err := refreshCardStorageTTL(db, order.CardID, time.Now()); err != nil {
webhookLog().Warn("failed to refresh card storage ttl", "card_id", order.CardID, "error", err)
}
}
audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID) audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID)
go notify.SlackOrderPaid(&order) go notify.SlackOrderPaid(&order)
webhookLog().Info("order marked paid", "order_id", orderIDStr, "pi", pi.ID) webhookLog().Info("order marked paid", "order_id", orderIDStr, "pi", pi.ID)
@@ -167,6 +173,16 @@ func (h *WebhookHandler) handlePaymentIntentFailed(ctx context.Context, event st
return 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) db := database.GetDB().WithContext(ctx)
if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending). if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
Update("status", models.OrderStatusFailed).Error; err != nil { 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) webhookLog().Warn("failed to detach duplicate pm", "user_id", user.ID, "error", derr)
} }
// Bump UpdatedAt so the TUI poll signal can detect that the webhook // Bump UpdatedAt so the TUI poll signal can detect that the webhook
// fired even though no new row was inserted. // fired even though no new row was inserted. storage_expires_at is
if uerr := db.Model(&dup).Update("updated_at", time.Now()).Error; uerr != nil { // reset because the user just re-added the card; this is a card-add
// path, not a payment-success refresh, so last_used_at stays as-is.
now := time.Now()
if uerr := db.Model(&dup).Updates(map[string]any{
"updated_at": now,
"storage_expires_at": models.CardStorageExpiresAt(now),
}).Error; uerr != nil {
webhookLog().Warn("failed to bump dup card updated_at", "card_id", dup.ID, "error", uerr) webhookLog().Warn("failed to bump dup card updated_at", "card_id", dup.ID, "error", uerr)
} }
webhookLog().Info("skipped duplicate card via local-db match", "user_id", user.ID, "existing_card_id", dup.ID) webhookLog().Info("skipped duplicate card via local-db match", "user_id", user.ID, "existing_card_id", dup.ID)
@@ -262,6 +284,7 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, eve
ExpMonth: int(pm.Card.ExpMonth), ExpMonth: int(pm.Card.ExpMonth),
ExpYear: int(pm.Card.ExpYear), ExpYear: int(pm.Card.ExpYear),
} }
card.InitializeStorageTTL(time.Now())
if err := db.Create(&card).Error; err != nil { if err := db.Create(&card).Error; err != nil {
webhookLog().Error("failed to save card", "user_id", user.ID, "error", err) webhookLog().Error("failed to save card", "user_id", user.ID, "error", err)
@@ -373,7 +396,9 @@ func (h *WebhookHandler) HandleShippo(w http.ResponseWriter, r *http.Request) {
if previousTrackingStatus != newStatus { if previousTrackingStatus != newStatus {
audit.TrackingUpdated(order.ID, order.Carrier, payload.Data.TrackingNumber, string(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 { if deliveredTransition {
audit.OrderDelivered(order.ID, payload.Data.TrackingNumber) audit.OrderDelivered(order.ID, payload.Data.TrackingNumber)

View File

@@ -40,7 +40,8 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("Failed to connect to database: %v", err) log.Fatalf("Failed to connect to database: %v", err)
} }
defer database.Close()
defer func() { _ = database.Close() }()
// Run migrations // Run migrations
if err := database.Migrate(db); err != nil { if err := database.Migrate(db); err != nil {
@@ -61,7 +62,7 @@ func main() {
if err != nil { if err != nil {
log.Printf("tracing init failed (non-fatal): %v", err) log.Printf("tracing init failed (non-fatal): %v", err)
} else { } else {
defer shutdown(context.Background()) defer func() { _ = shutdown(context.Background()) }()
} }
stripeclient.InitOTel() stripeclient.InitOTel()
@@ -70,7 +71,7 @@ func main() {
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute) jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
// Setup routes // Setup routes
router := routes.SetupRoutes(version, cfg.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 // Create HTTP server
server := &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.") log.Println("API server started. Press Ctrl+C to shutdown.")
// Block until signal received // Block until signal received

View File

@@ -29,6 +29,7 @@ func SetupRoutes(
slackSigningSecret string, slackSigningSecret string,
adminAPIKey string, adminAPIKey string,
apiPort string, apiPort string,
maxOrderCents int,
) *chi.Mux { ) *chi.Mux {
r := chi.NewRouter() r := chi.NewRouter()
@@ -46,7 +47,7 @@ func SetupRoutes(
healthHandler := handlers.NewHealthHandler(version) healthHandler := handlers.NewHealthHandler(version)
productHandler := handlers.NewProductHandler() productHandler := handlers.NewProductHandler()
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey) authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
cartHandler := handlers.NewCartHandler(stripeSecretKey) cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL, maxOrderCents)
cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL) cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber) orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber)
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey) addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
@@ -59,7 +60,11 @@ func SetupRoutes(
r.Get("/t/{id}", handlers.TrackingRedirect) r.Get("/t/{id}", handlers.TrackingRedirect)
r.Get("/card-added", func(w http.ResponseWriter, r *http.Request) { r.Get("/card-added", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`)) _, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`))
})
r.Get("/post-3ds", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Authentication complete.</h2><p>Return to your terminal.</p></body></html>`))
}) })
r.Handle("/metrics", middleware.MetricsHandler()) r.Handle("/metrics", middleware.MetricsHandler())
@@ -126,6 +131,9 @@ func SetupRoutes(
// Orders // Orders
r.Get("/orders", orderHandler.GetOrders) 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) r.Post("/orders/{id}/refund", orderHandler.RefundOrder)
// Addresses // Addresses

70
go.mod
View File

@@ -1,14 +1,14 @@
module terminalShop module terminalShop
go 1.25.7 go 1.25.9
require ( require (
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 charm.land/bubbles/v2 v2.1.0
github.com/charmbracelet/bubbletea v1.3.10 charm.land/bubbletea/v2 v2.0.6
github.com/charmbracelet/huh v0.8.0 charm.land/huh/v2 v2.0.3
github.com/charmbracelet/lipgloss v1.1.0 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/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/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/cors v1.2.2 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/exporters/otlp/otlptrace/otlptracehttp v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/trace 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/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
@@ -32,42 +32,45 @@ require (
) )
require ( require (
charm.land/log/v2 v2.0.0 // indirect
github.com/ClickHouse/ch-go v0.61.5 // indirect github.com/ClickHouse/ch-go v0.61.5 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect github.com/andybalholm/brotli v1.1.1 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bitfield/gotestdox v0.2.2 // indirect
github.com/catppuccin/go v0.3.0 // indirect github.com/catppuccin/go v0.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/keygen v0.5.3 // indirect github.com/charmbracelet/keygen v0.5.4 // indirect
github.com/charmbracelet/log v0.4.1 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/conpty v0.1.1 // indirect
github.com/charmbracelet/x/conpty v0.1.0 // indirect github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 // indirect
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // 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/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/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.0 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/creack/pty v1.1.24 // indirect github.com/creack/pty v1.1.24 // indirect
github.com/dnephin/pflag v1.0.7 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/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/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect github.com/go-faster/errors v0.7.1 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gotesttools/gotestfmt/v2 v2.5.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect github.com/hashicorp/go-version v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -78,15 +81,13 @@ require (
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/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-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/paulmach/orb v0.11.1 // indirect github.com/paulmach/orb v0.11.1 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // 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/otel/metric v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/exp v0.0.0-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/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.35.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/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect google.golang.org/grpc v1.80.0 // indirect
@@ -116,4 +120,10 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/clickhouse v0.7.0 // indirect gorm.io/driver/clickhouse v0.7.0 // indirect
gorm.io/driver/mysql v1.5.7 // indirect gorm.io/driver/mysql v1.5.7 // indirect
gotest.tools/gotestsum v1.13.0 // indirect
)
tool (
github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt
gotest.tools/gotestsum
) )

145
go.sum
View File

@@ -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 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4=
github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg= 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= 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/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 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE=
github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= 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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM=
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/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc= 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/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/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14= github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 h1:4EG8pCHK5fa8dIxv97VHC8hdkJAz6QNm1WB9BuD/WhY=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7/go.mod h1:O2BTD/aMVQDmrvqroIO3fB6zXUuU07ZpVt21QTmZjRg=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
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-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 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 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= 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 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= 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 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= 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.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
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/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk=
github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ= github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ=
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c/go.mod h1:0ghtL4RhKBjyWMkHCWosp2aHSEXShQeQMLQ0qBCytfU= github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c/go.mod h1:0ghtL4RhKBjyWMkHCWosp2aHSEXShQeQMLQ0qBCytfU=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
@@ -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/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 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= 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.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 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.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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 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.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gotesttools/gotestfmt/v2 v2.5.0 h1:fSU3MnR+E+fvuXdw1l8xbufKhDxY3Tfjsjx/I1WerB4=
github.com/gotesttools/gotestfmt/v2 v2.5.0/go.mod h1:oQJg2KZ2aGoqEbMC2PDaAeBYm0tOkocgixK9FzsCdp4=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
@@ -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/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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
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-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= 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/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 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 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/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 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU= 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-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-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.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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= 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.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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/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.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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= 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-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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -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/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc= gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc=
gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y= gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y=
gotest.tools/gotestsum v1.13.0 h1:+Lh454O9mu9AMG1APV4o0y7oDYKyik/3kBOiCqiEpRo=
gotest.tools/gotestsum v1.13.0/go.mod h1:7f0NS5hFb0dWr4NtcsAsF0y1kzjEFfAil0HiBQJE03Q=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=

17
main.go
View File

@@ -8,14 +8,13 @@ import (
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time" "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/ssh"
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wish/bubbletea"
"github.com/charmbracelet/wish/logging"
"terminalShop/pkg/auth" "terminalShop/pkg/auth"
"terminalShop/pkg/config" "terminalShop/pkg/config"
@@ -36,9 +35,6 @@ var (
) )
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
renderer := bubbletea.MakeRenderer(s)
// Extract SSH public key from session
pubKey := s.PublicKey() pubKey := s.PublicKey()
var fingerprint, pubKeyStr string var fingerprint, pubKeyStr string
@@ -47,9 +43,8 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
pubKeyStr = auth.FormatSSHPublicKey(pubKey) pubKeyStr = auth.FormatSSHPublicKey(pubKey)
} }
// Create TUI model with user context (no registration screen needed!) m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
m := tui.NewModelWithAuth(renderer, fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey) return m, nil
return m, []tea.ProgramOption{tea.WithAltScreen()}
} }
func main() { func main() {

View File

@@ -56,7 +56,6 @@ type TokenResponse struct {
} }
func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) { func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) {
url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL) url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL)
reqBody := TokenRequest{ reqBody := TokenRequest{
@@ -80,7 +79,7 @@ func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (
if err != nil { if err != nil {
return "", models.PublicUser{}, fmt.Errorf("token refresh request failed: %w", err) 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 { if resp.StatusCode != http.StatusOK {
return "", models.PublicUser{}, fmt.Errorf("token refresh returned status %d", resp.StatusCode) return "", models.PublicUser{}, fmt.Errorf("token refresh returned status %d", resp.StatusCode)
@@ -106,7 +105,7 @@ func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error)
// APIResponse represents the standard API response format // APIResponse represents the standard API response format
type APIResponse struct { type APIResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Data map[string]interface{} `json:"data"` Data map[string]any `json:"data"`
Error *APIError `json:"error,omitempty"` Error *APIError `json:"error,omitempty"`
} }
@@ -114,7 +113,7 @@ type APIResponse struct {
type APIError struct { type APIError struct {
Code string `json:"code"` Code string `json:"code"`
Message string `json:"message"` Message string `json:"message"`
Details map[string]interface{} `json:"details,omitempty"` Details map[string]any `json:"details,omitempty"`
} }
// ProductsResponse represents the products API response // ProductsResponse represents the products API response
@@ -137,7 +136,7 @@ func (c *Client) GetProducts() ([]models.Coffee, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch products: %w", err) return nil, fmt.Errorf("failed to fetch products: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode) return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
@@ -167,7 +166,7 @@ func (c *Client) Health() error {
if err != nil { if err != nil {
return fmt.Errorf("health check failed: %w", err) return fmt.Errorf("health check failed: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("API returned status %d", resp.StatusCode) return fmt.Errorf("API returned status %d", resp.StatusCode)
@@ -217,7 +216,7 @@ func (c *Client) RegisterUser(username, sshPublicKey, sshKeyFingerprint string)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to register user: %w", err) return nil, fmt.Errorf("failed to register user: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var registerResp RegisterResponse var registerResp RegisterResponse
if err := json.NewDecoder(resp.Body).Decode(&registerResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&registerResp); err != nil {
@@ -281,14 +280,31 @@ type CardResponse struct {
} }
// ConvertCartResponse represents the cart conversion (checkout) API response. // 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 { type ConvertCartResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { 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"` } `json:"data"`
Error *APIError `json:"error,omitempty"` 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. // GetCart fetches the user's current cart.
func (c *Client) GetCart() (*CartData, error) { func (c *Client) GetCart() (*CartData, error) {
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL) url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL)
@@ -302,7 +318,7 @@ func (c *Client) GetCart() (*CartData, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch cart: %w", err) return nil, fmt.Errorf("failed to fetch cart: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var cartResp CartResponse var cartResp CartResponse
if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil { 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) { func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
url := fmt.Sprintf("%s/api/v1/cart/item", c.BaseURL) url := fmt.Sprintf("%s/api/v1/cart/item", c.BaseURL)
body := map[string]interface{}{ body := map[string]any{
"coffee_id": coffeeID, "coffee_id": coffeeID,
"quantity": quantity, "quantity": quantity,
} }
@@ -342,7 +358,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to set cart item: %w", err) return nil, fmt.Errorf("failed to set cart item: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var cartResp CartResponse var cartResp CartResponse
if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil { 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 { func (c *Client) SetCartAddress(addressID uint) error {
url := fmt.Sprintf("%s/api/v1/cart/address", c.BaseURL) url := fmt.Sprintf("%s/api/v1/cart/address", c.BaseURL)
body := map[string]interface{}{ body := map[string]any{
"address_id": addressID, "address_id": addressID,
} }
jsonData, err := json.Marshal(body) jsonData, err := json.Marshal(body)
@@ -381,7 +397,7 @@ func (c *Client) SetCartAddress(addressID uint) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to set cart address: %w", err) return fmt.Errorf("failed to set cart address: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var result APIResponse var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 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 { func (c *Client) SetCartCard(cardID uint) error {
url := fmt.Sprintf("%s/api/v1/cart/card", c.BaseURL) url := fmt.Sprintf("%s/api/v1/cart/card", c.BaseURL)
body := map[string]interface{}{ body := map[string]any{
"card_id": cardID, "card_id": cardID,
} }
jsonData, err := json.Marshal(body) jsonData, err := json.Marshal(body)
@@ -420,7 +436,7 @@ func (c *Client) SetCartCard(cardID uint) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to set cart card: %w", err) return fmt.Errorf("failed to set cart card: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var result APIResponse var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -450,7 +466,7 @@ func (c *Client) ClearCart() error {
if err != nil { if err != nil {
return fmt.Errorf("failed to clear cart: %w", err) return fmt.Errorf("failed to clear cart: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var result APIResponse var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 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. // 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) url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL)
req, err := http.NewRequest("POST", url, nil) req, err := http.NewRequest("POST", url, nil)
@@ -480,7 +498,7 @@ func (c *Client) ConvertCart() (*models.Order, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to convert cart: %w", err) return nil, fmt.Errorf("failed to convert cart: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var convertResp ConvertCartResponse var convertResp ConvertCartResponse
if err := json.NewDecoder(resp.Body).Decode(&convertResp); err != nil { 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 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. // GetCards fetches all saved cards for the user.
@@ -510,7 +628,7 @@ func (c *Client) GetCards() ([]models.Card, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch cards: %w", err) return nil, fmt.Errorf("failed to fetch cards: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var cardsResp CardsResponse var cardsResp CardsResponse
if err := json.NewDecoder(resp.Body).Decode(&cardsResp); err != nil { 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 { if err != nil {
return nil, fmt.Errorf("failed to save card: %w", err) return nil, fmt.Errorf("failed to save card: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var cardResp CardResponse var cardResp CardResponse
if err := json.NewDecoder(resp.Body).Decode(&cardResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&cardResp); err != nil {
@@ -590,7 +708,7 @@ func (c *Client) CollectCard() (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("failed to collect card: %w", err) return "", fmt.Errorf("failed to collect card: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var collectResp CollectCardResponse var collectResp CollectCardResponse
if err := json.NewDecoder(resp.Body).Decode(&collectResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&collectResp); err != nil {
@@ -619,7 +737,7 @@ func (c *Client) DeleteCard(id uint) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to delete card: %w", err) return fmt.Errorf("failed to delete card: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("delete card failed with status %d", resp.StatusCode) return fmt.Errorf("delete card failed with status %d", resp.StatusCode)
@@ -636,6 +754,20 @@ type OrderResponse struct {
Error *APIError `json:"error,omitempty"` 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) { func (c *Client) GetOrders() ([]models.Order, error) {
url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL) url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL)
@@ -648,7 +780,7 @@ func (c *Client) GetOrders() ([]models.Order, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch orders: %w", err) return nil, fmt.Errorf("failed to fetch orders: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var ordersResp OrderResponse var ordersResp OrderResponse
if err := json.NewDecoder(resp.Body).Decode(&ordersResp); err != nil { 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 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 // The Addresses API response
type AddressResponse struct { type AddressResponse struct {
Success bool `json:"success"` 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) return nil, fmt.Errorf("failed to fetch addresses: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var addrResp AddressResponse var addrResp AddressResponse
if err := json.NewDecoder(resp.Body).Decode(&addrResp); err != nil { 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 { if err != nil {
return nil, fmt.Errorf("failed to create address: %w", err) return nil, fmt.Errorf("failed to create address: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var result struct { var result struct {
Success bool `json:"success"` Success bool `json:"success"`
@@ -769,7 +937,7 @@ func (c *Client) DeleteAddress(id uint) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to delete address: %w", err) return fmt.Errorf("failed to delete address: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("delete address failed with status %d", resp.StatusCode) return fmt.Errorf("delete address failed with status %d", resp.StatusCode)
@@ -804,7 +972,7 @@ func (c *Client) GetViewInit() (ViewInitData, error) {
if err != nil { if err != nil {
return ViewInitData{}, fmt.Errorf("view init request failed: %w", err) return ViewInitData{}, fmt.Errorf("view init request failed: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return ViewInitData{}, fmt.Errorf("view init returned status: %d", resp.StatusCode) return ViewInitData{}, fmt.Errorf("view init returned status: %d", resp.StatusCode)
} }

View File

@@ -7,20 +7,28 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"gorm.io/gorm" "gorm.io/gorm"
) )
// EventOrderRequiresAction is the audit event-type string emitted whenever a
// PaymentIntent is pushed into requires_action. explored so cart-flow handler
// can count retry attempts without hardcoding the string literal.
const EventOrderRequiresAction = "order_requires_action"
type eventType string type eventType string
const ( const (
eventCardAdded eventType = "card_added" eventCardAdded eventType = "card_added"
eventCardDeleted eventType = "card_deleted" eventCardDeleted eventType = "card_deleted"
eventCardExpired eventType = "card_expired"
eventOrderCreated eventType = "order_created" eventOrderCreated eventType = "order_created"
eventOrderPaid eventType = "order_paid" eventOrderPaid eventType = "order_paid"
eventOrderShipped eventType = "order_shipped" eventOrderShipped eventType = "order_shipped"
eventOrderFailed eventType = "order_failed" eventOrderFailed eventType = "order_failed"
eventOrderRequiresAction eventType = EventOrderRequiresAction
eventOrderRefunded eventType = "order_refunded" eventOrderRefunded eventType = "order_refunded"
eventPaymentCritical eventType = "payment_critical" eventPaymentCritical eventType = "payment_critical"
eventLabelPurchased eventType = "label_purchased" eventLabelPurchased eventType = "label_purchased"
@@ -111,6 +119,17 @@ func CardDeleted(userID, cardID uint) {
persist(0, eventCardDeleted, e) persist(0, eventCardDeleted, e)
} }
// CardExpired records that a saved payment method expired from inactivity
func CardExpired(userID, cardID uint) {
e := event{
Event: eventCardExpired,
UserID: userID,
CardID: cardID,
}
slog.Info("audit", e.attrs()...)
persist(0, eventCardExpired, e)
}
// OrderCreated records that an order record was created (before payment). // OrderCreated records that an order record was created (before payment).
func OrderCreated(userID, orderID uint, amountCents int) { func OrderCreated(userID, orderID uint, amountCents int) {
e := event{ e := event{
@@ -298,3 +317,14 @@ func attrsToMap(a []any) map[string]any {
} }
return m 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)
}

View File

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

View File

@@ -17,7 +17,7 @@ import (
func setupAuthTestDB(t *testing.T) string { func setupAuthTestDB(t *testing.T) string {
t.Helper() t.Helper()
testDB := "test_ssh_auth.db" testDB := "test_ssh_auth.db"
os.Remove(testDB) _ = os.Remove(testDB)
database.ResetForTesting() database.ResetForTesting()
db, err := database.Connect(testDB) db, err := database.Connect(testDB)
@@ -47,7 +47,7 @@ func newTestSSHKey(t *testing.T) gossh.PublicKey {
func TestAuthenticateSSHKeyNewUser(t *testing.T) { func TestAuthenticateSSHKeyNewUser(t *testing.T) {
testDB := setupAuthTestDB(t) testDB := setupAuthTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
db := database.GetDB() db := database.GetDB()
@@ -72,7 +72,7 @@ func TestAuthenticateSSHKeyNewUser(t *testing.T) {
func TestAuthenticateSSHKeyExistingUser(t *testing.T) { func TestAuthenticateSSHKeyExistingUser(t *testing.T) {
testDB := setupAuthTestDB(t) testDB := setupAuthTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
db := database.GetDB() db := database.GetDB()
@@ -96,7 +96,7 @@ func TestAuthenticateSSHKeyExistingUser(t *testing.T) {
func TestRegisterWithSSHKey(t *testing.T) { func TestRegisterWithSSHKey(t *testing.T) {
testDB := setupAuthTestDB(t) testDB := setupAuthTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
db := database.GetDB() db := database.GetDB()
@@ -121,7 +121,7 @@ func TestRegisterWithSSHKey(t *testing.T) {
func TestRegisterWithSSHKeyDuplicate(t *testing.T) { func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
testDB := setupAuthTestDB(t) testDB := setupAuthTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
db := database.GetDB() db := database.GetDB()
@@ -145,7 +145,7 @@ func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
func TestIsSSHKeyRegistered(t *testing.T) { func TestIsSSHKeyRegistered(t *testing.T) {
testDB := setupAuthTestDB(t) testDB := setupAuthTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
db := database.GetDB() db := database.GetDB()
@@ -164,7 +164,7 @@ func TestIsSSHKeyRegistered(t *testing.T) {
} }
// After authentication (which auto-creates) // After authentication (which auto-creates)
svc.AuthenticateSSHKey(key) _, _, _ = svc.AuthenticateSSHKey(key)
registered, err = svc.IsSSHKeyRegistered(key) registered, err = svc.IsSSHKeyRegistered(key)
if err != nil { if err != nil {

View File

@@ -197,7 +197,7 @@ func (c *BookingClient) doJSON(ctx context.Context, url string, body any, out an
if err != nil { if err != nil {
return fmt.Errorf("request: %w", err) return fmt.Errorf("request: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body) respBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return fmt.Errorf("read response: %w", err) return fmt.Errorf("read response: %w", err)

View File

@@ -110,7 +110,7 @@ func (c *Client) ValidateAddress(ctx context.Context, addr Address) (*Address, e
if err != nil { if err != nil {
return nil, fmt.Errorf("bring request failed: %w", err) return nil, fmt.Errorf("bring request failed: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body) respBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"strconv"
"strings" "strings"
"github.com/joho/godotenv" "github.com/joho/godotenv"
@@ -30,6 +31,7 @@ type Config struct {
ShippoWebhookSecret string ShippoWebhookSecret string
SlackSigningSecret string SlackSigningSecret string
AdminAPIKey string AdminAPIKey string
MaxOrderCents int
} }
// Load reads configuration from environment variables and validates required secrets. // Load reads configuration from environment variables and validates required secrets.
@@ -66,6 +68,7 @@ func Load() (*Config, error) {
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"), ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"), SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
AdminAPIKey: os.Getenv("ADMIN_API_KEY"), AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
MaxOrderCents: loadMaxOrderCents(),
} }
if err := cfg.validate(); err != nil { if err := cfg.validate(); err != nil {
@@ -130,3 +133,20 @@ func getEnvOrDefault(key, defaultValue string) string {
} }
return defaultValue return defaultValue
} }
// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $200. Explicit 0 disables
// the cap. Garbage or negative values fall back to the default with a warning
// so an operator typo cannot silently disable a fraud control.
func loadMaxOrderCents() int {
const defaultCap = 20000
raw := os.Getenv("MAX_ORDER_CENTS")
if raw == "" {
return defaultCap
}
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
fmt.Printf("WARNING: MAX_ORDER_CENTS=%q invalid, falling back to default %d\n", raw, defaultCap)
return defaultCap
}
return n
}

34
pkg/config/config_test.go Normal file
View File

@@ -0,0 +1,34 @@
package config
import (
"testing"
)
func TestLoadMaxOrderCents(t *testing.T) {
cases := []struct {
name string
env string // "" means unset
want int
}{
{"unset uses default", "", 20000},
{"explicit zero disables", "0", 0},
{"valid override", "50000", 50000},
{"negative falls back to default", "-1", 20000},
{"garbage falls back to default", "two hundred", 20000},
{"empty after trim falls back to default", "", 20000},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.env == "" {
t.Setenv("MAX_ORDER_CENTS", "")
} else {
t.Setenv("MAX_ORDER_CENTS", tc.env)
}
got := loadMaxOrderCents()
if got != tc.want {
t.Errorf("loadMaxOrderCents() = %d, want %d", got, tc.want)
}
})
}
}

View File

@@ -101,7 +101,7 @@ func Close() error {
// WARNING: Only use this in tests! // WARNING: Only use this in tests!
func ResetForTesting() { func ResetForTesting() {
if db != nil { if db != nil {
Close() _ = Close()
db = nil db = nil
once = sync.Once{} once = sync.Once{}
} }

View File

@@ -10,7 +10,7 @@ import (
func TestConnect(t *testing.T) { func TestConnect(t *testing.T) {
// Use a temporary test database // Use a temporary test database
testDB := "test_terminalshop.db" testDB := "test_terminalshop.db"
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer ResetForTesting() defer ResetForTesting()
db, err := Connect(testDB) db, err := Connect(testDB)
@@ -35,7 +35,7 @@ func TestConnect(t *testing.T) {
func TestMigrate(t *testing.T) { func TestMigrate(t *testing.T) {
testDB := "test_migrate.db" testDB := "test_migrate.db"
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer ResetForTesting() defer ResetForTesting()
db, err := Connect(testDB) db, err := Connect(testDB)
@@ -57,7 +57,7 @@ func TestMigrate(t *testing.T) {
func TestSeed(t *testing.T) { func TestSeed(t *testing.T) {
testDB := "test_seed.db" testDB := "test_seed.db"
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer ResetForTesting() defer ResetForTesting()
db, err := Connect(testDB) db, err := Connect(testDB)
@@ -115,7 +115,7 @@ func TestSeed(t *testing.T) {
func TestGetDB(t *testing.T) { func TestGetDB(t *testing.T) {
testDB := "test_getdb.db" testDB := "test_getdb.db"
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer ResetForTesting() defer ResetForTesting()
// Initialize connection // Initialize connection

View File

@@ -3,6 +3,8 @@ package database
import ( import (
"fmt" "fmt"
"log" "log"
"time"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"gorm.io/gorm" "gorm.io/gorm"
@@ -24,10 +26,23 @@ func Migrate(db *gorm.DB) error {
&models.OrderItem{}, // Line items within orders &models.OrderItem{}, // Line items within orders
&models.Address{}, // Saved addresses &models.Address{}, // Saved addresses
&models.OrderEvent{}, &models.OrderEvent{},
&models.PayRedirect{}, // short-token -> stripe url store (10 min TTL)
); err != nil { ); err != nil {
return fmt.Errorf("migration failed: %w", err) return fmt.Errorf("migration failed: %w", err)
} }
if err := backfillCardStorageExpiration(db); err != nil {
return err
}
log.Println("Database migrations completed successfully") log.Println("Database migrations completed successfully")
return nil return nil
} }
func backfillCardStorageExpiration(db *gorm.DB) error {
expiresAt := models.CardStorageExpiresAt(time.Now())
if err := db.Model(&models.Card{}).Where("storage_expires_at IS NULL").Update("storage_expires_at", expiresAt).Error; err != nil {
return fmt.Errorf("card storage ttl backfill failed: %w", err)
}
return nil
}

View File

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

View File

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

View File

@@ -6,6 +6,8 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
const cardStorageTTL = 180 * 24 * time.Hour
// Card represents a saved payment method linked to a Stripe token. // Card represents a saved payment method linked to a Stripe token.
// The actual card details live on Stripe's servers — we only store // The actual card details live on Stripe's servers — we only store
// a reference and display info (last 4 digits, brand). // a reference and display info (last 4 digits, brand).
@@ -21,6 +23,8 @@ type Card struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
StorageExpiresAt *time.Time `gorm:"index" json:"storage_expires_at,omitempty"`
// Associations // Associations
User User `gorm:"foreignKey:UserID" json:"-"` User User `gorm:"foreignKey:UserID" json:"-"`
@@ -30,3 +34,20 @@ type Card struct {
func (Card) TableName() string { func (Card) TableName() string {
return "cards" return "cards"
} }
// CardStorageExpiresAt returns the inactivity expiration for a saved card
func CardStorageExpiresAt(now time.Time) *time.Time {
expiresAt := now.Add(cardStorageTTL)
return &expiresAt
}
// InitializeStorageTTL sets the initial retention deadline without marking
// the card as used for payment
func (c *Card) InitializeStorageTTL(now time.Time) {
c.StorageExpiresAt = CardStorageExpiresAt(now)
}
// IsStorageExpired reports whether the saved card retention deadline elapsed
func (c *Card) IsStorageExpired(now time.Time) bool {
return c.StorageExpiresAt != nil && !c.StorageExpiresAt.After(now)
}

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

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

View File

@@ -66,6 +66,10 @@ type Order struct {
// Slack parent message ts for this order's thread (NULL if Slack notify disabled). // Slack parent message ts for this order's thread (NULL if Slack notify disabled).
SlackThreadTS *string `gorm:"size:64" json:"-"` 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"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel
@@ -74,6 +78,7 @@ type Order struct {
User User `gorm:"foreignKey:UserID" json:"-"` User User `gorm:"foreignKey:UserID" json:"-"`
Card Card `gorm:"foreignKey:CardID" json:"-"` Card Card `gorm:"foreignKey:CardID" json:"-"`
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"` Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
Events []OrderEvent `gorm:"foreignKey:OrderID" json:"events,omitempty"`
} }
// TableName specifies the table name for the Order model. // TableName specifies the table name for the Order model.
@@ -112,3 +117,35 @@ func (o *Order) TotalInDollars() float64 {
func (o *Order) IsActive() bool { func (o *Order) IsActive() bool {
return o.Status == OrderStatusPaid || o.Status == OrderStatusShipped 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
}

View File

@@ -11,10 +11,27 @@ const (
DisplayKindBrand DisplayKind = "brand" // shipped / in transit DisplayKindBrand DisplayKind = "brand" // shipped / in transit
DisplayKindSuccess DisplayKind = "success" // delivered DisplayKindSuccess DisplayKind = "success" // delivered
DisplayKindError DisplayKind = "error" // cancelled, failed, refunded 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 { func (order *Order) DisplayState() string {
if order.refundPending() {
return "Refund Requested"
}
switch order.Status { switch order.Status {
case OrderStatusPending: case OrderStatusPending:
return "Awaiting payment" return "Awaiting payment"
@@ -54,6 +71,9 @@ func (order *Order) DisplayState() string {
} }
func (order *Order) DisplayKind() DisplayKind { func (order *Order) DisplayKind() DisplayKind {
if order.refundPending() {
return DisplayKindRefund
}
switch order.Status { switch order.Status {
case OrderStatusPending: case OrderStatusPending:
return DisplayKindNeutral return DisplayKindNeutral

View File

@@ -1,8 +1,12 @@
package models package models
import "testing" import (
"testing"
"time"
)
func TestOrderDisplayState(t *testing.T) { func TestOrderDisplayState(t *testing.T) {
now := time.Now()
tests := []struct { tests := []struct {
name string name string
order Order order Order
@@ -23,6 +27,10 @@ func TestOrderDisplayState(t *testing.T) {
{"cancelled", Order{Status: OrderStatusCancelled}, "Cancelled", DisplayKindError}, {"cancelled", Order{Status: OrderStatusCancelled}, "Cancelled", DisplayKindError},
{"refunded", Order{Status: OrderStatusRefunded}, "Refunded", DisplayKindError}, {"refunded", Order{Status: OrderStatusRefunded}, "Refunded", DisplayKindError},
{"failed", Order{Status: OrderStatusFailed}, "Payment failed", 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 { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {

View File

@@ -1,6 +1,9 @@
package models package models
import "time" import (
"encoding/json"
"time"
)
type OrderEvent struct { type OrderEvent struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
@@ -14,3 +17,47 @@ type OrderEvent struct {
func (OrderEvent) TableName() string { func (OrderEvent) TableName() string {
return "order_events" 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
}

View File

@@ -0,0 +1,23 @@
package models
import "time"
// PayRedirect maps a short token to a stripe hosted url with a TTL.
// survives api restarts so a customer mid-3Ds authentication still
// lands on the bank-hosted challenge page after a deploy or crash
type PayRedirect struct {
Token string `gorm:"primaryKey;size:64" json:"token"`
URL string `gorm:"type:text;not null" json:"url"`
Purpose string `gorm:"size:32;not null" json:"purpose"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
}
const (
RedirectPurposeCheckout3DS = "checkout_3ds"
RedirectPurposeAddCard = "add_card"
)
func (PayRedirect) TableName() string {
return "pay_redirects"
}

View File

@@ -1,3 +1,4 @@
// Package notify provides a simple interface to send notifications to Slack.
package notify package notify
import ( import (
@@ -14,8 +15,10 @@ import (
"terminalShop/pkg/models" "terminalShop/pkg/models"
) )
// slack client
var slackClient = &http.Client{Timeout: 5 * time.Second} var slackClient = &http.Client{Timeout: 5 * time.Second}
// dollars converts cents to dollars
func dollars(cents int) string { func dollars(cents int) string {
return fmt.Sprintf("$%.2f", float64(cents)/100) 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) slog.Warn("slack post failed", "error", err)
return "", false return "", false
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var parsed struct { var parsed struct {
Ok bool `json:"ok"` 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) slog.Warn("slack pin call failed", "endpoint", endpoint, "error", err)
return false return false
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var parsed struct { var parsed struct {
Ok bool `json:"ok"` Ok bool `json:"ok"`
@@ -185,29 +188,49 @@ func SlackUnpinOrder(orderID uint) {
} }
// SlackPostToOrderThread posts a plain-text reply inside the order's thread. // 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 // It looks up the thread ts from the database and returns an error when the
// saved thread (Slack was disabled when it was created, or post failed earlier). // reply cannot be posted.
func SlackPostToOrderThread(orderID uint, text string) { 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") token := os.Getenv("SLACK_BOT_TOKEN")
channel := os.Getenv("SLACK_CHANNEL") channel := os.Getenv("SLACK_CHANNEL")
if token == "" || channel == "" { if token == "" || channel == "" {
return return fmt.Errorf("slack is not configured")
} }
var order models.Order var order models.Order
if err := database.GetDB().Select("id", "slack_thread_ts").Where("id = ?", orderID).First(&order).Error; err != nil { 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) 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 == "" { 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, "channel": channel,
"thread_ts": *order.SlackThreadTS, "thread_ts": *order.SlackThreadTS,
"text": text, "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) { 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)
}

View File

@@ -270,7 +270,7 @@ func (c *Client) doJSON(ctx context.Context, method, path string, body any, out
if err != nil { if err != nil {
return fmt.Errorf("request: %w", err) return fmt.Errorf("request: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body) respBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return fmt.Errorf("read response: %w", err) 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 { if err != nil {
return nil, fmt.Errorf("shippo request failed: %w", err) return nil, fmt.Errorf("shippo request failed: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body) respBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {

View File

@@ -1,9 +1,9 @@
package tui package tui
import ( import (
"github.com/charmbracelet/bubbles/viewport" "charm.land/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"terminalShop/pkg/models" "terminalShop/pkg/models"
) )
@@ -33,12 +33,12 @@ func (m Model) updateAccountViewport() Model {
} }
if !m.account.viewportReady { 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.detailViewport.KeyMap = modifiedKeyMap
m.account.viewportReady = true m.account.viewportReady = true
} else { } else {
m.account.detailViewport.Width = detailWidth m.account.detailViewport.SetWidth(detailWidth)
m.account.detailViewport.Height = availableHeight m.account.detailViewport.SetHeight(availableHeight)
} }
return m return m
@@ -128,8 +128,8 @@ func (m Model) scrollToAccountDetailItem() Model {
} }
targetY := headerOffset + (selectedIndex * itemHeight) targetY := headerOffset + (selectedIndex * itemHeight)
vpH := m.account.detailViewport.Height vpH := m.account.detailViewport.Height()
offset := m.account.detailViewport.YOffset offset := m.account.detailViewport.YOffset()
// 1-item buffer: scroll when the NEXT item would be off-screen, // 1-item buffer: scroll when the NEXT item would be off-screen,
// not when the current item hits the very edge // 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) m.account.detailViewport, cmd = m.account.detailViewport.Update(msg)
return m, cmd return m, cmd
case "r":
if m.account.orderViewState == 2 {
if order, ok := m.selectedRefundableOrder(); ok {
return m.OpenRefundComposer(order.ID)
}
}
case "x", "d": case "x", "d":
if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) { if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) {
m.account.addressDeleting = &m.account.addressCursor m.account.addressDeleting = &m.account.addressCursor
@@ -324,7 +331,8 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
switch selectedItem { switch selectedItem {
case "order history", "active orders": case "order history", "active orders":
if len(m.currentOrderList()) > 0 { if len(m.currentOrderList()) > 0 {
if m.account.orderViewState == 0 { switch m.account.orderViewState {
case 0:
m.account.orderViewState = 1 m.account.orderViewState = 1
m.account.orderCursor = 0 m.account.orderCursor = 0
m.account.detailViewport.GotoTop() m.account.detailViewport.GotoTop()
@@ -334,16 +342,19 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
{key: "esc", value: "back"}, {key: "esc", value: "back"},
{key: "q", value: "quit"}, {key: "q", value: "quit"},
} }
} else if m.account.orderViewState == 1 { case 1:
// Save scroll position before entering detail view m.account.orderYOffset = m.account.detailViewport.YOffset()
m.account.orderYOffset = m.account.detailViewport.YOffset
m.account.orderViewState = 2 m.account.orderViewState = 2
m.account.detailViewport.GotoTop() m.account.detailViewport.GotoTop()
m.footer = []footerCommand{ footer := []footerCommand{
{key: "esc", value: "back"}, {key: "esc", value: "back"},
{key: "s", value: "shop"}, {key: "s", value: "shop"},
{key: "q", value: "quit"}, {key: "q", value: "quit"},
} }
if _, ok := m.selectedRefundableOrder(); ok {
footer = append([]footerCommand{{key: "r", value: "refund"}}, footer...)
}
m.footer = footer
} }
} }
case "addresses": case "addresses":

View File

@@ -6,7 +6,7 @@ import (
"terminalShop/pkg/models" "terminalShop/pkg/models"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
) )
func (m Model) OrdersView(width int) string { func (m Model) OrdersView(width int) string {
@@ -37,6 +37,21 @@ func (m Model) HistoryOrders() []models.Order {
return out 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 { func (m Model) currentOrderList() []models.Order {
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) { if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
return nil 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 // Shipping address
b.WriteString(labelStyle.Render("Ship To")) b.WriteString(labelStyle.Render("Ship To"))
b.WriteString("\n") b.WriteString("\n")
@@ -223,7 +256,11 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string {
b.WriteString("\n\n") b.WriteString("\n\n")
// Footer hint // Footer hint
if order.CanRequestRefund() {
b.WriteString(dimStyle.Render("r: refund esc: back to orders"))
} else {
b.WriteString(dimStyle.Render("esc: back to orders")) b.WriteString(dimStyle.Render("esc: back to orders"))
}
return b.String() return b.String()
} }
@@ -241,11 +278,9 @@ func (m Model) displayStyle(k models.DisplayKind) lipgloss.Style {
case models.DisplayKindError: case models.DisplayKindError:
return m.theme.TextError() return m.theme.TextError()
case models.DisplayKindWarning: 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() return m.theme.TextLoading()
case models.DisplayKindRefund:
return m.theme.TextBrand()
} }
return m.theme.TextHighlight() return m.theme.TextHighlight()
} }

View File

@@ -1,7 +1,7 @@
package tui package tui
import ( import (
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
) )
func (m Model) BuildBreadcrumbs() string { func (m Model) BuildBreadcrumbs() string {

View File

@@ -3,9 +3,9 @@ package tui
import ( import (
"fmt" "fmt"
"github.com/charmbracelet/bubbles/viewport" "charm.land/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
) )
func (m Model) updateCartViewport() Model { func (m Model) updateCartViewport() Model {
@@ -17,12 +17,12 @@ func (m Model) updateCartViewport() Model {
availH = 1 availH = 1
} }
if !m.cart.viewportReady { 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.viewport.KeyMap = viewport.KeyMap{}
m.cart.viewportReady = true m.cart.viewportReady = true
} else { } else {
m.cart.viewport.Width = m.widthContent m.cart.viewport.SetWidth(m.widthContent)
m.cart.viewport.Height = availH m.cart.viewport.SetHeight(availH)
} }
return m return m
} }
@@ -116,11 +116,11 @@ func (m Model) CartView() string {
} }
itemHeight := 4 + 2*boxPadding + 1 itemHeight := 4 + 2*boxPadding + 1
targetY := m.cart.cursor * itemHeight targetY := m.cart.cursor * itemHeight
if targetY < m.cart.viewport.YOffset { if targetY < m.cart.viewport.YOffset() {
m.cart.viewport.SetYOffset(targetY) m.cart.viewport.SetYOffset(targetY)
} }
if targetY+itemHeight > m.cart.viewport.YOffset+m.cart.viewport.Height { if targetY+itemHeight > m.cart.viewport.YOffset()+m.cart.viewport.Height() {
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height + itemHeight) m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height() + itemHeight)
} }
} }
return lipgloss.Place( return lipgloss.Place(

View File

@@ -3,11 +3,13 @@ package tui
import ( import (
"fmt" "fmt"
"strings" "strings"
"terminalShop/pkg/models"
"github.com/charmbracelet/bubbles/viewport" "terminalShop/pkg/models"
tea "github.com/charmbracelet/bubbletea" "terminalShop/pkg/tui/qrfefe"
"github.com/charmbracelet/lipgloss"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
) )
// RenderConfirmation renders the order confirmation view // RenderConfirmation renders the order confirmation view
@@ -73,12 +75,12 @@ func (m Model) updateConfirmViewport() Model {
availH = 1 availH = 1
} }
if !m.confirm.viewportReady { 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.viewport.KeyMap = viewport.DefaultKeyMap()
m.confirm.viewportReady = true m.confirm.viewportReady = true
} else { } else {
m.confirm.viewport.Width = m.widthContent m.confirm.viewport.SetWidth(m.widthContent)
m.confirm.viewport.Height = availH m.confirm.viewport.SetHeight(availH)
} }
return m return m
} }
@@ -145,18 +147,18 @@ func (m Model) generateReviewContent() string {
if m.ShippingInfo.Street2 != "" { if m.ShippingInfo.Street2 != "" {
view.WriteString(m.ShippingInfo.Street2 + "\n") 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") view.WriteString("\n")
if m.SelectedCard != nil { 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") view.WriteString("\n")
subtotal := m.CalculateSubtotal() 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(m.theme.TextAccent().Render(fmt.Sprintf("total: %s", formatUSD(subtotal))) + "\n")
view.WriteString("\n") view.WriteString("\n")
view.WriteString(m.theme.TextBrand().Render("press enter to confirm") + "\n") view.WriteString(m.theme.TextBrand().Render("press enter to confirm") + "\n")
@@ -168,6 +170,30 @@ func (m Model) ReviewView() string {
if m.CheckingOut { if m.CheckingOut {
return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...") return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...")
} }
if m.review.retrying {
return m.theme.TextAccent().Bold(true).Padding(1).Render(" retrying authentication...")
}
if m.review.retryExhausted {
content := m.theme.TextError().Bold(true).Padding(1).Render("too many authentication attempts") + "\n" + m.theme.TextLabel().Padding(0, 1).Render("press esc to start a new order")
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(content),
lipgloss.Center, lipgloss.Center,
content,
)
}
if m.review.authFailed {
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 { if m.review.success {
content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!") content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!")
return lipgloss.Place( 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) { func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case CheckoutResultMsg: case CheckoutResultMsg:
m.CheckingOut = false m.CheckingOut = false
if msg.Err != nil { if msg.Err != nil {
m = m.SwitchPage(paymentPage) var cmd tea.Cmd
m.error = &VisibleError{message: fmt.Sprintf("checkout failed: %v", msg.Err)} m, cmd = m.switchForCheckoutError(msg.Err)
m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)}
m.review.cardJustAdded = false m.review.cardJustAdded = false
m.payment.view = 0 return m, cmd
m.payment.form = nil }
return m, nil if msg.RequiresAction {
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 = make(map[uint]*models.CartItem)
m.cart.cursor = 0 m.cart.cursor = 0
@@ -205,10 +264,116 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.review.success = true m.review.success = true
return m, nil 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: case tea.KeyMsg:
if m.CheckingOut { if m.CheckingOut {
return m, nil return m, nil
} }
if m.review.retrying {
return m, nil
}
if m.review.authFailed {
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 { if m.review.success {
m.review.success = false m.review.success = false
m.review.cardJustAdded = false m.review.cardJustAdded = false
@@ -234,3 +399,40 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
} }
return m, nil return m, nil
} }
func (m Model) switchForCheckoutError(err error) (Model, tea.Cmd) {
switch {
case strings.Contains(err.Error(), "CART_OVER_LIMIT"):
return m.CartSwitch()
default:
m = m.SwitchPage(paymentPage)
m.payment.view = 0
m.payment.form = nil
return m, nil
}
}
func friendlyCheckoutError(raw error) string {
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."
}

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ package tui
import ( import (
"strings" "strings"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
) )
func (m Model) BuildFooter() string { func (m Model) BuildFooter() string {

View File

@@ -3,8 +3,8 @@ package tui
import ( import (
"fmt" "fmt"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"github.com/charmbracelet/lipgloss/table" "charm.land/lipgloss/v2/table"
) )
func (m Model) BuildHeader() string { func (m Model) BuildHeader() string {
@@ -44,7 +44,7 @@ func (m Model) BuildHeader() string {
tabs = []string{logo, shop, account, cart} 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) return m.theme.Base().Padding(0, 1).AlignHorizontal(lipgloss.Center)
}).Render() }).Render()

View File

@@ -1,8 +1,8 @@
package tui package tui
import ( import (
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
) )
func (m Model) BuildHelpView() string { func (m Model) BuildHelpView() string {

View File

@@ -1,9 +1,9 @@
package tui package tui
import ( import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"fmt" "fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
) )
func (m Model) BuildMenuView() string { func (m Model) BuildMenuView() string {

View File

@@ -7,10 +7,9 @@ import (
"sort" "sort"
"time" "time"
"github.com/charmbracelet/bubbles/key" "charm.land/bubbles/v2/key"
"github.com/charmbracelet/bubbles/viewport" "charm.land/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/lipgloss"
"github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78"
stripetoken "github.com/stripe/stripe-go/v78/token" stripetoken "github.com/stripe/stripe-go/v78/token"
@@ -65,6 +64,11 @@ type VisibleError struct {
message string message string
} }
// VisibleNotice is a typed success message displayed to the user as a banner.
type VisibleNotice struct {
message string
}
type Model struct { type Model struct {
// User authentication // User authentication
User *models.User User *models.User
@@ -111,10 +115,10 @@ type Model struct {
Loading bool // true when fetching data from API Loading bool // true when fetching data from API
error *VisibleError error *VisibleError
notice *VisibleNotice
APIClient *api.Client APIClient *api.Client
// Rendering // Rendering
renderer *lipgloss.Renderer
theme theme.Theme theme theme.Theme
// Checkout flow state (spans multiple pages) // Checkout flow state (spans multiple pages)
@@ -130,6 +134,7 @@ type Model struct {
shipping shippingState shipping shippingState
payment paymentState payment paymentState
account accountState account accountState
refund refundState
review reviewState review reviewState
confirm confirmState confirm confirmState
@@ -197,6 +202,17 @@ type reviewState struct {
cardJustAdded bool cardJustAdded bool
cardWasDuplicate bool cardWasDuplicate bool
success 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 { type confirmState struct {
@@ -219,6 +235,7 @@ func (m Model) SwitchPage(p page) Model {
m.currentPage = p m.currentPage = p
m.switched = true m.switched = true
m.error = nil m.error = nil
m.notice = nil
return m return m
} }
@@ -240,7 +257,6 @@ func (m Model) CartSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(cartPage) m = m.SwitchPage(cartPage)
m = m.updateCartViewport() m = m.updateCartViewport()
if m.IsCartEmpty() { if m.IsCartEmpty() {
m.footer = []footerCommand{ m.footer = []footerCommand{
{key: "j/k", value: "items"}, {key: "j/k", value: "items"},
{key: "+/-", value: "qty"}, {key: "+/-", value: "qty"},
@@ -400,23 +416,6 @@ func (m Model) fetchCartCmd() tea.Msg {
return CartFetchedMsg{Cart: cart, Err: err} 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 { func (m Model) Init() tea.Cmd {
return tea.Batch( return tea.Batch(
m.authCmd, m.authCmd,
@@ -432,6 +431,28 @@ func (m Model) Init() tea.Cmd {
type CheckoutResultMsg struct { type CheckoutResultMsg struct {
OrderID uint OrderID uint
Total int 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
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 Err error
} }
@@ -643,12 +664,52 @@ func (m Model) checkoutWithSavedCard() tea.Cmd {
} }
// 3. Convert the cart to an order // 3. Convert the cart to an order
order, err := m.APIClient.ConvertCart() outcome, err := m.APIClient.ConvertCart()
if err != nil { if err != nil {
return CheckoutResultMsg{Err: err} 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 { func (m Model) saveCardOnlyCmd(form PaymentFormCompleteMsg) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if m.APIClient == nil { if m.APIClient == nil {
@@ -824,18 +833,12 @@ func (m Model) SavedCardsIsEmpty() bool {
return len(m.SavedCards) == 0 return len(m.SavedCards) == 0
} }
// NewModel creates a new model using the default renderer. // NewModel creates a new model.
func NewModel(username string) 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{ m := Model{
Username: username, Username: username,
currentPage: splashPage, currentPage: splashPage,
renderer: renderer, theme: theme.BasicTheme(),
theme: theme.BasicTheme(renderer),
Coffees: []models.Coffee{ Coffees: []models.Coffee{
{ {
Name: "Espresso", Name: "Espresso",
@@ -902,8 +905,8 @@ func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
} }
// NewModelWithAuth creates a new model with user authentication context. // 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 { func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
m := newModelWithRenderer("", renderer) m := NewModel("")
m.Fingerprint = fingerprint m.Fingerprint = fingerprint
m.AuthFingerprintKey = clientSecret m.AuthFingerprintKey = clientSecret
m.StripePublicKey = stripePublicKey m.StripePublicKey = stripePublicKey
@@ -929,7 +932,7 @@ func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr
} }
func newViewport(w, h int) viewport.Model { 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 { func (m Model) collectCardCmd() tea.Cmd {
@@ -945,8 +948,10 @@ func (m Model) collectCardCmd() tea.Cmd {
} }
} }
const ordersPollInterval = 10 * time.Second const (
const cardPollTimeout = 10 * time.Minute ordersPollInterval = 10 * time.Second
cardPollTimeout = 10 * time.Minute
)
type OrdersPollTickMsg struct{} type OrdersPollTickMsg struct{}

View File

@@ -5,10 +5,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/charmbracelet/bubbles/viewport" "charm.land/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/huh" "charm.land/huh/v2"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"terminalShop/pkg/tui/qrfefe" "terminalShop/pkg/tui/qrfefe"
@@ -280,12 +280,12 @@ func (m Model) updatePaymentViewport() Model {
availH = 1 availH = 1
} }
if !m.payment.viewportReady { 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.viewport.KeyMap = viewport.KeyMap{}
m.payment.viewportReady = true m.payment.viewportReady = true
} else { } else {
m.payment.viewport.Width = m.widthContent m.payment.viewport.SetWidth(m.widthContent)
m.payment.viewport.Height = availH m.payment.viewport.SetHeight(availH)
} }
return m return m
} }
@@ -301,7 +301,7 @@ func (m Model) PaymentPageView() string {
// Bypass viewport — it strips the raw ANSI codes qrfefe emits. // Bypass viewport — it strips the raw ANSI codes qrfefe emits.
return lipgloss.Place( return lipgloss.Place(
m.widthContainer, m.widthContainer,
m.payment.viewport.Height, m.payment.viewport.Height(),
lipgloss.Center, lipgloss.Center, lipgloss.Center, lipgloss.Center,
m.renderPaymentHttpsView(), m.renderPaymentHttpsView(),
) )
@@ -310,11 +310,11 @@ func (m Model) PaymentPageView() string {
m.payment.viewport.SetContent(content) m.payment.viewport.SetContent(content)
itemHeight := 3 itemHeight := 3
targetY := m.payment.cardCursor * itemHeight targetY := m.payment.cardCursor * itemHeight
if targetY < m.payment.viewport.YOffset { if targetY < m.payment.viewport.YOffset() {
m.payment.viewport.SetYOffset(targetY) m.payment.viewport.SetYOffset(targetY)
} }
if targetY+itemHeight > m.payment.viewport.YOffset+m.payment.viewport.Height { if targetY+itemHeight > m.payment.viewport.YOffset()+m.payment.viewport.Height() {
m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height + itemHeight + 1) m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height() + itemHeight + 1)
} }
if m.payment.cardCursor == len(m.SavedCards) { if m.payment.cardCursor == len(m.SavedCards) {
m.payment.viewport.GotoBottom() m.payment.viewport.GotoBottom()

395
pkg/tui/refund.go Normal file
View File

@@ -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))
}

View File

@@ -6,10 +6,10 @@ import (
"terminalShop/pkg/models" "terminalShop/pkg/models"
"github.com/charmbracelet/bubbles/viewport" "charm.land/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/huh" "charm.land/huh/v2"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
) )
// countries defines the available countries with their dial codes. // countries defines the available countries with their dial codes.
@@ -262,12 +262,12 @@ func (m Model) updateShippingViewport() Model {
availH = 1 availH = 1
} }
if !m.shipping.viewportReady { 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.viewport.KeyMap = viewport.KeyMap{}
m.shipping.viewportReady = true m.shipping.viewportReady = true
} else { } else {
m.shipping.viewport.Width = m.widthContent m.shipping.viewport.SetWidth(m.widthContent)
m.shipping.viewport.Height = availH m.shipping.viewport.SetHeight(availH)
} }
return m return m
} }
@@ -281,11 +281,11 @@ func (m Model) ShippingPageView() string {
m.shipping.viewport.SetContent(content) m.shipping.viewport.SetContent(content)
itemHeight := 4 itemHeight := 4
targetY := m.shipping.addressCursor * itemHeight targetY := m.shipping.addressCursor * itemHeight
if targetY < m.shipping.viewport.YOffset { if targetY < m.shipping.viewport.YOffset() {
m.shipping.viewport.SetYOffset(targetY) m.shipping.viewport.SetYOffset(targetY)
} }
if targetY+itemHeight > m.shipping.viewport.YOffset+m.shipping.viewport.Height { if targetY+itemHeight > m.shipping.viewport.YOffset()+m.shipping.viewport.Height() {
m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height + itemHeight + 1) m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height() + itemHeight + 1)
} }
if m.shipping.addressCursor == len(m.SavedAddresses) { if m.shipping.addressCursor == len(m.SavedAddresses) {
m.shipping.viewport.GotoBottom() m.shipping.viewport.GotoBottom()

View File

@@ -4,9 +4,9 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/charmbracelet/bubbles/viewport" "charm.land/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
"terminalShop/pkg/models" "terminalShop/pkg/models"
) )
@@ -55,10 +55,10 @@ func (m Model) updateShopViewports() Model {
m.shop.detailViewport.KeyMap = modifiedKeyMap m.shop.detailViewport.KeyMap = modifiedKeyMap
m.shop.viewportsReady = true m.shop.viewportsReady = true
} else { } else {
m.shop.menuViewport.Width = menuWidth m.shop.menuViewport.SetWidth(menuWidth)
m.shop.menuViewport.Height = availableHeight m.shop.menuViewport.SetHeight(availableHeight)
m.shop.detailViewport.Width = detailWidth m.shop.detailViewport.SetWidth(detailWidth)
m.shop.detailViewport.Height = availableHeight m.shop.detailViewport.SetHeight(availableHeight)
} }
return m return m
} }
@@ -226,7 +226,7 @@ func (m Model) shopMoveSelected(previous bool) Model {
if m.shop.viewportsReady { if m.shop.viewportsReady {
targetY := (m.shop.selected + 2) * 1 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() m.shop.detailViewport.GotoTop()
} }
return m return m

View File

@@ -5,9 +5,9 @@ import (
"terminalShop/pkg/api" "terminalShop/pkg/api"
"terminalShop/pkg/models" "terminalShop/pkg/models"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
) )
type DelayCompleteMsg struct{} type DelayCompleteMsg struct{}

View File

@@ -3,7 +3,7 @@ package tui
import ( import (
"strings" "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 { func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string {

View File

@@ -1,51 +1,50 @@
package theme package theme
import ( import (
"github.com/charmbracelet/bubbles/help" "charm.land/bubbles/v2/help"
"github.com/charmbracelet/huh" "charm.land/huh/v2"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
) )
// HuhTheme creates a huh form theme using this project's color palette. // HuhTheme creates a huh form theme using this project's color palette.
func HuhTheme(t Theme) *huh.Theme { func HuhTheme(t Theme) huh.Theme {
var th 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 := &s.Focused
f.Base = t.renderer.NewStyle(). f.Base = lipgloss.NewStyle().
PaddingLeft(1). PaddingLeft(1).
BorderStyle(lipgloss.ThickBorder()). BorderStyle(lipgloss.ThickBorder()).
BorderLeft(true). BorderLeft(true).
BorderForeground(t.accent) BorderForeground(t.accent)
f.Title = t.renderer.NewStyle().Foreground(t.body) f.Title = lipgloss.NewStyle().Foreground(t.body)
f.Description = t.renderer.NewStyle().Foreground(t.body) f.Description = lipgloss.NewStyle().Foreground(t.body)
f.TextInput.Cursor = t.renderer.NewStyle().Foreground(t.highlight) f.TextInput.Cursor = lipgloss.NewStyle().Foreground(t.highlight)
f.TextInput.Placeholder = t.renderer.NewStyle().Foreground(t.dim) f.TextInput.Placeholder = lipgloss.NewStyle().Foreground(t.dim)
f.TextInput.Prompt = t.renderer.NewStyle().Foreground(t.accent) f.TextInput.Prompt = lipgloss.NewStyle().Foreground(t.accent)
f.TextInput.Text = t.renderer.NewStyle().Foreground(t.accent) f.TextInput.Text = lipgloss.NewStyle().Foreground(t.accent)
f.ErrorIndicator = t.renderer.NewStyle().Foreground(t.error) f.ErrorIndicator = lipgloss.NewStyle().Foreground(t.error)
f.ErrorMessage = t.renderer.NewStyle().Foreground(t.error) f.ErrorMessage = lipgloss.NewStyle().Foreground(t.error)
f.SelectSelector = t.renderer.NewStyle().Foreground(t.accent).SetString("> ") f.SelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ")
f.Option = t.renderer.NewStyle().Foreground(t.body) f.Option = lipgloss.NewStyle().Foreground(t.body)
f.SelectedOption = t.renderer.NewStyle().Foreground(t.highlight).Bold(true) f.SelectedOption = lipgloss.NewStyle().Foreground(t.highlight).Bold(true)
f.MultiSelectSelector = t.renderer.NewStyle().Foreground(t.accent).SetString("> ") f.MultiSelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ")
f.SelectedPrefix = t.renderer.NewStyle().Foreground(t.highlight).SetString("[x] ") f.SelectedPrefix = lipgloss.NewStyle().Foreground(t.highlight).SetString("[x] ")
f.UnselectedPrefix = t.renderer.NewStyle().Foreground(t.dim).SetString("[ ] ") f.UnselectedPrefix = lipgloss.NewStyle().Foreground(t.dim).SetString("[ ] ")
f.UnselectedOption = t.renderer.NewStyle().Foreground(t.body) f.UnselectedOption = lipgloss.NewStyle().Foreground(t.body)
th.Help = help.New().Styles s.Help = help.New().Styles
th.Blurred = copyFieldStyles(*f) s.Blurred = copyFieldStyles(*f)
th.Blurred.Base = th.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) s.Blurred.Base = s.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
th.Blurred.Title = th.Blurred.Title.Foreground(t.body) s.Blurred.Title = s.Blurred.Title.Foreground(t.body)
th.Blurred.SelectSelector = t.renderer.NewStyle().SetString(" ") s.Blurred.SelectSelector = lipgloss.NewStyle().SetString(" ")
th.Blurred.SelectedOption = t.renderer.NewStyle().Foreground(t.success).Bold(true) s.Blurred.SelectedOption = lipgloss.NewStyle().Foreground(t.success).Bold(true)
th.Blurred.Option = t.renderer.NewStyle().Foreground(t.dim) 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 } func copyFieldStyles(f huh.FieldStyles) huh.FieldStyles { return f }

View File

@@ -1,56 +1,55 @@
package theme package theme
import ( import (
"github.com/charmbracelet/huh" "image/color"
"github.com/charmbracelet/lipgloss"
"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 { type Theme struct {
renderer *lipgloss.Renderer background color.Color
border color.Color
background lipgloss.TerminalColor body color.Color
border lipgloss.TerminalColor dim color.Color
body lipgloss.TerminalColor accent color.Color
dim lipgloss.TerminalColor highlight color.Color
accent lipgloss.TerminalColor brand color.Color
highlight lipgloss.TerminalColor label color.Color
brand lipgloss.TerminalColor success color.Color
label lipgloss.TerminalColor error color.Color
success lipgloss.TerminalColor
error lipgloss.TerminalColor
base lipgloss.Style base lipgloss.Style
form *huh.Theme form huh.Theme
} }
// BasicTheme constructs the default theme for terminal coffee shop. func BasicTheme() Theme {
func BasicTheme(renderer *lipgloss.Renderer) Theme { t := Theme{}
t := Theme{renderer: renderer}
t.background = lipgloss.AdaptiveColor{Dark: "#000000", Light: "#FBFCFD"} t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#000000"), Light: lipgloss.Color("#FBFCFD")}
t.border = lipgloss.AdaptiveColor{Dark: "#666666", Light: "#D7DBDF"} t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#666666"), Light: lipgloss.Color("#D7DBDF")}
t.body = lipgloss.Color("#AAAAAA") t.body = lipgloss.Color("#AAAAAA")
t.dim = lipgloss.Color("#666666") 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.highlight = lipgloss.Color("#4682B4")
t.brand = lipgloss.Color("#4682B4") t.brand = lipgloss.Color("#4682B4")
t.label = lipgloss.Color("#999999") t.label = lipgloss.Color("#999999")
t.success = lipgloss.Color("#00FF00") t.success = lipgloss.Color("#00FF00")
t.error = lipgloss.Color("196") t.error = lipgloss.Color("196")
t.base = renderer.NewStyle().Foreground(t.body) t.base = lipgloss.NewStyle().Foreground(t.body)
t.form = HuhTheme(t) t.form = HuhTheme(t)
return t return t
} }
// this should resemble something like catppuccin func Catppuccin() Theme {
func Catppuccin(renderer *lipgloss.Renderer) Theme { t := Theme{}
t := Theme{renderer: renderer}
t.background = lipgloss.AdaptiveColor{Dark: "#1e1e2e", Light: "#eff1f5"} t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#1e1e2e"), Light: lipgloss.Color("#eff1f5")}
t.border = lipgloss.AdaptiveColor{Dark: "#45475A", Light: "#BCC0CC"} t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#45475A"), Light: lipgloss.Color("#BCC0CC")}
t.body = lipgloss.Color("#AAAAAA") t.body = lipgloss.Color("#AAAAAA")
t.dim = lipgloss.Color("#6C7086") t.dim = lipgloss.Color("#6C7086")
t.accent = lipgloss.Color("#CBA6F7") t.accent = lipgloss.Color("#CBA6F7")
@@ -60,25 +59,25 @@ func Catppuccin(renderer *lipgloss.Renderer) Theme {
t.success = lipgloss.Color("#A6E3A1") t.success = lipgloss.Color("#A6E3A1")
t.error = lipgloss.Color("#F38BA8") t.error = lipgloss.Color("#F38BA8")
t.base = renderer.NewStyle().Foreground(t.body) t.base = lipgloss.NewStyle().Foreground(t.body)
t.form = HuhTheme(t) t.form = HuhTheme(t)
return t return t
} }
// Color accessors — use in .Foreground() / .Background() calls. // Color accessors.
func (t Theme) Background() lipgloss.TerminalColor { return t.background } func (t Theme) Background() color.Color { return t.background }
func (t Theme) Border() lipgloss.TerminalColor { return t.border } func (t Theme) Border() color.Color { return t.border }
func (t Theme) Body() lipgloss.TerminalColor { return t.body } func (t Theme) Body() color.Color { return t.body }
func (t Theme) Dim() lipgloss.TerminalColor { return t.dim } func (t Theme) Dim() color.Color { return t.dim }
func (t Theme) Accent() lipgloss.TerminalColor { return t.accent } func (t Theme) Accent() color.Color { return t.accent }
func (t Theme) Highlight() lipgloss.TerminalColor { return t.highlight } func (t Theme) Highlight() color.Color { return t.highlight }
func (t Theme) Brand() lipgloss.TerminalColor { return t.brand } func (t Theme) Brand() color.Color { return t.brand }
func (t Theme) Label() lipgloss.TerminalColor { return t.label } func (t Theme) Label() color.Color { return t.label }
func (t Theme) Success() lipgloss.TerminalColor { return t.success } func (t Theme) Success() color.Color { return t.success }
func (t Theme) Error() lipgloss.TerminalColor { return t.error } func (t Theme) Error() color.Color { return t.error }
// Style helpers — return a copy of base with the correct foreground. // Style helpers.
// Chain .Bold(), .Width(), .Padding() etc. on the returned style.
func (t Theme) Base() lipgloss.Style { return t.base } func (t Theme) Base() lipgloss.Style { return t.base }
func (t Theme) TextBody() lipgloss.Style { return t.Base().Foreground(t.body) } func (t Theme) TextBody() lipgloss.Style { return t.Base().Foreground(t.body) }
func (t Theme) TextAccent() lipgloss.Style { return t.Base().Foreground(t.accent) } 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) TextError() lipgloss.Style { return t.Base().Foreground(t.error) }
func (t Theme) TextLoading() lipgloss.Style { return t.Base().Foreground(lipgloss.Color("205")) } 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 { func (t Theme) PanelError() lipgloss.Style {
return t.Base().Background(lipgloss.Color("52")).Foreground(lipgloss.Color("196")) 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 }

View File

@@ -2,11 +2,12 @@ package tui
import ( import (
"fmt" "fmt"
"terminalShop/pkg/api"
"terminalShop/pkg/models"
"time" "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) { 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 { if m.payment.form != nil {
m.payment.form.form = m.buildPaymentForm(m.payment.form) 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 return m, nil
} }
@@ -51,9 +56,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case VisibleError: case VisibleError:
m.error = &msg m.error = &msg
m.notice = nil
return m, nil
case VisibleNotice:
m.notice = &msg
m.error = nil
return m, nil return m, nil
case error: case error:
m.error = &VisibleError{message: msg.Error()} m.error = &VisibleError{message: msg.Error()}
m.notice = nil
if m.currentPage == shopPage || m.currentPage == cartPage { if m.currentPage == shopPage || m.currentPage == cartPage {
return m, m.fetchCartCmd 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 { if m.shipping.form != nil || m.payment.form != nil {
break break
} }
// Dismiss error banner with ESC // Refund overlay owns input when open; dispatched below before page handlers.
if msg.String() == "esc" && m.error != nil { if m.refund.open {
break
}
// Dismiss banners with ESC.
if msg.String() == "esc" && (m.error != nil || m.notice != nil) {
m.error = nil m.error = nil
m.notice = nil
return m, nil return m, nil
} }
// Global navigation // 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 // 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 // Page-specifics
switch m.currentPage { switch m.currentPage {
case shopPage: case shopPage:

View File

@@ -1,7 +1,8 @@
package tui package tui
import ( import (
"github.com/charmbracelet/lipgloss" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
) )
// ResizeView renders a message when the terminal is too small. // 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 { if m.viewportWidth == 0 {
m.updateLayout(120, 30) m.updateLayout(120, 30)
} }
@@ -75,8 +82,16 @@ func (m Model) View() string {
content = loadingStyle.Render("Loading products from API...") content = loadingStyle.Render("Loading products from API...")
} else if m.error != nil { } else if m.error != nil {
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1) 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) errorBanner := errorStyle.Render(m.error.message)
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight) 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 { } else {
content = m.buildPageContent(availableContentHeight) content = m.buildPageContent(availableContentHeight)
} }
@@ -108,6 +123,10 @@ func (m Model) View() string {
MaxHeight(m.heightContainer). MaxHeight(m.heightContainer).
Render(child) Render(child)
if m.refund.open {
constrained = m.RenderRefundOverlay()
}
// Center the entire container in the terminal viewport // Center the entire container in the terminal viewport
return lipgloss.Place( return lipgloss.Place(
m.viewportWidth, m.viewportWidth,

View File

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

View File

@@ -66,7 +66,7 @@ func CcnValidator(cardNumber string) error {
func IsDigits(name string) ErrorHandler { func IsDigits(name string) ErrorHandler {
return func(str string) error { return func(str string) error {
for _, c := range str { 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) return fmt.Errorf("[%s] expected only digits but got %s", name, str)
} }
} }

13
scripts/backups/01-log-tail.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Show last 20 lines of the cron log + its mtime. Cheapest "did it run" check.
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
source "$SCRIPT_DIR/lib.sh"
print_header "Backup log: mtime + last 20 lines"
spinner_start "fetching log from $SSH_HOST..."
ssh "$SSH_HOST" "
sudo stat -c 'Last modified: %y' '$BACKUP_LOG'
echo; sudo tail -20 '$BACKUP_LOG'
"
spinner_stop

14
scripts/backups/02-snapshots.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# List every restic snapshot with timestamps and tags.
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
source "$SCRIPT_DIR/lib.sh"
print_header "All restic snapshots"
spinner_start "querying restic via SFTP..."
ssh "$SSH_HOST" "
export RESTIC_PASSWORD_FILE='$RESTIC_PASSWORD_FILE'
export RESTIC_REPOSITORY='$RESTIC_REPO'
sudo -E restic -o sftp.args='-i $RESTIC_SSH_KEY' snapshots
"
spinner_stop

21
scripts/backups/03-check.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# restic repo integrity check.
# ./03-check.sh metadata only (~5s)
# ./03-check.sh --deep + 10% data-pack re-read (~30s)
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
source "$SCRIPT_DIR/lib.sh"
EXTRA=""
if [ "${1:-}" = "--deep" ]; then
EXTRA="--read-data-subset=10%"
fi
print_header "restic check ${EXTRA:-(metadata only)}"
spinner_start "running restic check on $SSH_HOST..."
ssh "$SSH_HOST" "
export RESTIC_PASSWORD_FILE='$RESTIC_PASSWORD_FILE'
export RESTIC_REPOSITORY='$RESTIC_REPO'
sudo -E restic -o sftp.args='-i $RESTIC_SSH_KEY' check $EXTRA
"
spinner_stop

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Restore the latest snapshot to /tmp on the remote box, then:
# - PRAGMA integrity_check on the restored DB
# - row-count diff vs the live DB
# Cleans up /tmp dir at the end.
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
source "$SCRIPT_DIR/lib.sh"
print_header "Restore latest snapshot and verify SQLite"
spinner_start "restoring latest snapshot to /tmp on $SSH_HOST..."
ssh "$SSH_HOST" \
"PWFILE='$RESTIC_PASSWORD_FILE' \
REPO='$RESTIC_REPO' \
KEY='$RESTIC_SSH_KEY' \
LIVE='$LIVE_DB' \
bash -s" <<'REMOTE'
set -e
DIR=/tmp/tshop-restore-test
sudo rm -rf "$DIR" && sudo mkdir -p "$DIR"
sudo -E RESTIC_PASSWORD_FILE="$PWFILE" RESTIC_REPOSITORY="$REPO" \
restic -o sftp.args="-i $KEY" restore latest --target "$DIR"
DB=$(sudo find "$DIR" -name 'terminalshop.db' | head -1)
if [ -z "$DB" ]; then
echo 'restored DB not found' >&2
exit 1
fi
echo
echo '--- restored file ---'
sudo stat -c '%y %s bytes %n' "$DB"
echo
echo '--- PRAGMA integrity_check ---'
sudo sqlite3 "$DB" 'PRAGMA integrity_check;'
echo
echo '--- PRAGMA quick_check ---'
sudo sqlite3 "$DB" 'PRAGMA quick_check;'
echo
echo '--- restored vs live row counts ---'
printf '%-22s %10s %10s\n' table restored live
for t in $(sudo sqlite3 "$DB" 'SELECT name FROM sqlite_master WHERE type="table";'); do
r=$(sudo sqlite3 "$DB" "SELECT COUNT(*) FROM \"$t\";")
l=$(sudo sqlite3 "$LIVE" "SELECT COUNT(*) FROM \"$t\";" 2>/dev/null || echo 'n/a')
printf '%-22s %10s %10s\n' "$t" "$r" "$l"
done
sudo rm -rf "$DIR"
echo
echo 'OK'
REMOTE
spinner_stop

41
scripts/backups/05-freshness.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Print latest snapshot age. Exit 0 if fresh, 1 if older than STALE_HOURS.
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
source "$SCRIPT_DIR/lib.sh"
print_header "Latest snapshot freshness"
spinner_start "querying latest snapshot..."
LATEST=$(ssh "$SSH_HOST" "
export RESTIC_PASSWORD_FILE='$RESTIC_PASSWORD_FILE'
export RESTIC_REPOSITORY='$RESTIC_REPO'
sudo -E restic -o sftp.args='-i $RESTIC_SSH_KEY' snapshots --json
" | jq -r 'max_by(.time) | .time')
spinner_stop
if [ -z "$LATEST" ] || [ "$LATEST" = "null" ]; then
echo "ERROR: could not parse latest snapshot time" >&2
exit 2
fi
NOW=$(date -u +%s)
# GNU date (Linux) first, then BSD date (macOS) as fallback.
if THEN=$(date -u -d "$LATEST" +%s 2>/dev/null); then
:
else
CLEAN=$(echo "$LATEST" | sed 's/\.[0-9]*Z$/Z/')
THEN=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$CLEAN" +%s)
fi
AGE_HRS=$(( (NOW - THEN) / 3600 ))
echo "Latest snapshot: $LATEST"
echo "Age: ${AGE_HRS}h"
echo "Threshold: ${STALE_HOURS}h"
if [ "$AGE_HRS" -lt "$STALE_HOURS" ]; then
print_ok "snapshot within freshness window"
else
print_fail "STALE — investigate"
exit 1
fi

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Independent filesystem check on lfs-remote. No restic, no password needed.
# Proves the repo dir exists, files are present, recent uploads landed,
# and the backup partition has room.
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
source "$SCRIPT_DIR/lib.sh"
print_header "lfs-remote filesystem-level repo check"
spinner_start "inspecting $LFS_HOST filesystem..."
ssh "$LFS_HOST" "
set -e
REPO='$LFS_REPO_PATH'
WARN_PCT=$LFS_DISK_WARN_PCT
if [ ! -d \"\$REPO\" ]; then
echo 'ERROR: repo dir missing on lfs' >&2
exit 1
fi
echo '--- snapshot pointer count (one file per restic snapshot) ---'
COUNT=\$(find \"\$REPO/snapshots\" -type f 2>/dev/null | wc -l)
echo \"snapshots/: \$COUNT files\"
echo
echo '--- repo size on disk ---'
du -sh \"\$REPO\"
echo
echo '--- recent uploads (files newer than 48h) ---'
RECENT=\$(find \"\$REPO\" -type f -mtime -2 2>/dev/null | wc -l)
echo \"files added in last 48h: \$RECENT\"
if [ \"\$RECENT\" -eq 0 ]; then
echo 'WARN: no new files in last 48h' >&2
fi
echo
echo '--- most recent snapshot pointer ---'
LATEST=\$(ls -t \"\$REPO/snapshots\"/* 2>/dev/null | head -1)
if [ -n \"\$LATEST\" ]; then
stat -c '%y %n' \"\$LATEST\"
else
echo 'WARN: no snapshot files found' >&2
fi
echo
echo '--- disk usage ---'
df -h \"\$REPO\"
USED=\$(df --output=pcent \"\$REPO\" 2>/dev/null | tail -1 | tr -dc 0-9)
if [ -n \"\$USED\" ] && [ \"\$USED\" -gt \"\$WARN_PCT\" ]; then
echo \"FAIL: backup partition \${USED}% full (threshold \${WARN_PCT}%)\" >&2
exit 1
fi
echo
echo 'OK'
"
spinner_stop

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Independent restic check from the lfs-remote side. Reads repo via a
# LOCAL filesystem path (not SFTP), so it proves the data is parseable
# without any involvement from the prod box at restic level.
#
# Needs:
# - restic installed on lfs-remote (see scratchpkg port at /usr/ports/main/restic)
# - SSH access to both prod (to fetch the repo password) and lfs
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
source "$SCRIPT_DIR/lib.sh"
print_header "lfs-remote restic check (independent vantage point)"
# Precondition: restic must be on lfs.
if ! ssh "$LFS_HOST" 'command -v restic >/dev/null 2>&1'; then
echo 'ERROR: restic not in PATH on lfs-remote' >&2
echo "Install via scratchpkg, then re-run." >&2
exit 2
fi
# Pull restic password from prod (needs sudo on prod).
spinner_start "fetching restic password from $SSH_HOST..."
PW=$(ssh "$SSH_HOST" "sudo cat '$RESTIC_PASSWORD_FILE'")
spinner_stop
if [ -z "$PW" ]; then
echo 'ERROR: could not read restic password from prod' >&2
exit 2
fi
# Run restic against the LOCAL repo path on lfs. Password fed via stdin
# so it never lands in argv or a file on lfs.
spinner_start "running restic check from $LFS_HOST..."
ssh "$LFS_HOST" "restic -r '$LFS_REPO_PATH' --password-file=/dev/stdin check" <<<"$PW"
spinner_stop
echo
print_ok "lfs-side restic check passed"

138
scripts/backups/check-all.sh Executable file
View File

@@ -0,0 +1,138 @@
#!/usr/bin/env bash
# Master runner. Executes every backup check in sequence.
# ./check-all.sh stop on first failure; interactive menu when TTY
# ./check-all.sh --keep-going keep running after failures (no menu)
# ./check-all.sh --deep use --deep on 03-check.sh
#
# When stdin and stdout are TTYs, a failed step opens a drill-down menu:
# [r]etry [l]og [u]nlock [s]kip [q]uit
# Non-TTY (cron / piped) skips the menu and behaves like the old runner.
set -uo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
KEEP_GOING=0
DEEP=0
for arg in "$@"; do
case "$arg" in
--keep-going) KEEP_GOING=1 ;;
--deep) DEEP=1 ;;
-h|--help)
echo "Usage: $0 [--keep-going] [--deep]"
echo " --keep-going don't stop on first failure, suppress interactive menu"
echo " --deep pass --deep to 03-check.sh (10% data re-read)"
exit 0
;;
*)
echo "unknown flag: $arg" >&2
exit 2
;;
esac
done
# Pull in config + color helpers (also gives us SSH_HOST + restic env for unlock).
source "$SCRIPT_DIR/lib.sh"
declare -a STEP_NAMES=()
declare -a STEP_STATUS=() # OK | FAIL
FAIL=0
prompt_after_fail() {
# returns 0=skip, 1=quit, 2=retry
local script="$1"
while :; do
printf '\n%sNext?%s [r]etry [l]og [u]nlock [s]kip [q]uit > ' "$C_BOLD" "$C_RESET"
local choice
read -r choice
case "$choice" in
r|R) return 2 ;;
l|L) "$SCRIPT_DIR/01-log-tail.sh"; echo ;;
u|U)
echo
ssh "$SSH_HOST" "
export RESTIC_PASSWORD_FILE='$RESTIC_PASSWORD_FILE'
export RESTIC_REPOSITORY='$RESTIC_REPO'
sudo -E restic -o sftp.args='-i $RESTIC_SSH_KEY' unlock
"
echo
;;
s|S) return 0 ;;
q|Q) return 1 ;;
"") ;;
*) printf 'unknown: %s\n' "$choice" ;;
esac
done
}
run_step() {
local script="$1"; shift
echo
printf '%s########## %s %s ##########%s\n' "$C_CYAN$C_BOLD" "$script" "$*" "$C_RESET"
local attempts=0
while :; do
attempts=$((attempts + 1))
if "$SCRIPT_DIR/$script" "$@"; then
STEP_NAMES+=("$script")
STEP_STATUS+=("OK")
return 0
fi
printf '%sFAILED:%s %s\n' "$C_RED$C_BOLD" "$C_RESET" "$script" >&2
if [ -t 0 ] && [ -t 1 ] && [ "$KEEP_GOING" -eq 0 ]; then
prompt_after_fail "$script"
case "$?" in
0) STEP_NAMES+=("$script"); STEP_STATUS+=("FAIL"); FAIL=$((FAIL + 1)); return 0 ;;
1) STEP_NAMES+=("$script"); STEP_STATUS+=("FAIL"); FAIL=$((FAIL + 1)); print_summary; exit 1 ;;
2) echo
printf '%s>>>>>> retry %s (attempt %d) <<<<<<%s\n' "$C_CYAN$C_BOLD" "$script" $((attempts + 1)) "$C_RESET"
continue ;;
esac
fi
# non-interactive path
STEP_NAMES+=("$script")
STEP_STATUS+=("FAIL")
FAIL=$((FAIL + 1))
if [ "$KEEP_GOING" -eq 0 ]; then
print_summary
exit 1
fi
return 0
done
}
print_summary() {
echo
printf '%s===============================================%s\n' "$C_CYAN$C_BOLD" "$C_RESET"
printf ' %-28s %s\n' "Script" "Result"
printf ' %-28s %s\n' "----------------------------" "--------"
local i
for i in "${!STEP_NAMES[@]}"; do
local name="${STEP_NAMES[$i]}"
local status="${STEP_STATUS[$i]}"
local color sym
case "$status" in
OK) color="$C_GREEN"; sym="✓ OK" ;;
FAIL) color="$C_RED" ; sym="✗ FAIL" ;;
esac
printf ' %-28s %s%s%s\n' "$name" "$color" "$sym" "$C_RESET"
done
printf '%s===============================================%s\n' "$C_CYAN$C_BOLD" "$C_RESET"
if [ "$FAIL" -eq 0 ]; then
printf '%sAll checks passed.%s\n' "$C_GREEN$C_BOLD" "$C_RESET"
else
printf '%s%d check(s) failed.%s\n' "$C_RED$C_BOLD" "$FAIL" "$C_RESET"
fi
}
run_step 01-log-tail.sh
run_step 02-snapshots.sh
if [ "$DEEP" -eq 1 ]; then run_step 03-check.sh --deep; else run_step 03-check.sh; fi
run_step 04-restore-verify.sh
run_step 05-freshness.sh
run_step 06-lfs-direct.sh
run_step 07-lfs-restic-check.sh
print_summary
[ "$FAIL" -eq 0 ] || exit 1

View File

@@ -0,0 +1,28 @@
# Backup verification config.
# Copy to config.env (gitignored) and fill in your real values.
#
# cp config.env.example config.env
# $EDITOR config.env
# SSH alias for the prod box (resolved via ~/.ssh/config).
SSH_HOST="ubuntu-helsinki"
# Restic repository URL. Format: sftp:HOST:PATH
# HOST is the Tailscale name of the backup target box.
RESTIC_REPO="sftp:YOUR_TAILNET_HOST.ts.net:/root/backups/tshop"
# Paths on the REMOTE prod box (not your laptop).
RESTIC_PASSWORD_FILE="/root/.restic-password"
RESTIC_SSH_KEY="/root/.ssh/tshop-backup"
BACKUP_LOG="/var/log/backup-tshop.log"
LIVE_DB="/root/terminalShop/data/terminalshop.db"
# Freshness alert threshold (hours). 24h cron + 4h slop = 28.
STALE_HOURS="28"
# lfs-remote = the box that RECEIVES the backups.
# Used by 06-lfs-direct.sh and 07-lfs-restic-check.sh for out-of-band checks.
LFS_HOST="lfs-remote"
LFS_REPO_PATH="/root/backups/tshop"
# 06 fails if the backup partition is more full than this percent.
LFS_DISK_WARN_PCT="80"

65
scripts/backups/lib.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Shared loader for the backup verification scripts.
# Each script should: source "$SCRIPT_DIR/lib.sh"
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
if [ ! -f "$SCRIPT_DIR/config.env" ]; then
echo "ERROR: $SCRIPT_DIR/config.env missing." >&2
echo "Run: cp $SCRIPT_DIR/config.env.example $SCRIPT_DIR/config.env" >&2
exit 1
fi
# shellcheck source=/dev/null
source "$SCRIPT_DIR/config.env"
# ANSI colors. Emit only when stdout is a TTY and NO_COLOR is unset.
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
C_RED=$'\033[1;31m'
C_GREEN=$'\033[1;32m'
C_YELLOW=$'\033[1;33m'
C_BLUE=$'\033[1;34m'
C_CYAN=$'\033[1;36m'
C_BOLD=$'\033[1m'
C_RESET=$'\033[0m'
else
C_RED=""; C_GREEN=""; C_YELLOW=""; C_BLUE=""; C_CYAN=""; C_BOLD=""; C_RESET=""
fi
print_header() { printf '\n%s=== %s ===%s\n' "$C_CYAN" "$1" "$C_RESET"; }
print_ok() { printf '%sOK%s%s\n' "$C_GREEN" "$C_RESET" "${1:+ — $1}"; }
print_fail() { printf '%sFAIL%s%s\n' "$C_RED" "$C_RESET" "${1:+ — $1}" >&2; }
print_warn() { printf '%sWARN%s%s\n' "$C_YELLOW" "$C_RESET" "${1:+ — $1}" >&2; }
print_info() { printf '%s%s%s\n' "$C_BLUE" "$1" "$C_RESET"; }
# Spinner: shows a rotating braille char while a foreground command runs.
# Use as a pair around the silent/blocking call:
# spinner_start "fetching log..."
# ssh ...
# spinner_stop
# Auto-skips when stdout isn't a TTY (cron / piped). Real output from the
# wrapped command will visually overwrite the spinner — by design.
SPINNER_PID=""
spinner_start() {
[ -t 1 ] && [ -z "${NO_COLOR:-}" ] || return 0
local msg="${1:-working}"
(
trap 'exit 0' TERM
local chars=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
local i=0
while :; do
i=$(( (i + 1) % ${#chars[@]} ))
printf '\r%s%s%s %s' "$C_CYAN" "${chars[$i]}" "$C_RESET" "$msg"
sleep 0.08
done
) &
SPINNER_PID=$!
disown "$SPINNER_PID" 2>/dev/null || true
}
spinner_stop() {
[ -n "$SPINNER_PID" ] || return 0
kill "$SPINNER_PID" 2>/dev/null || true
wait "$SPINNER_PID" 2>/dev/null || true
printf '\r\033[K'
SPINNER_PID=""
}