From 60f52f985116c70c3d58a670e819fefd75a65a7b Mon Sep 17 00:00:00 2001 From: marcus06 Date: Sat, 23 May 2026 21:43:30 +0200 Subject: [PATCH 01/35] add ci (#1) * ci(checks): add pull request workflow and golangci-lint config * refactor(lint): resolve golangci-lint findings * style(tui): drop traling blank line for gofmt * fix(cards): handle crypto/rand.Read error in storeRedirect --- .github/workflows/ci.yml | 38 +++++++++++++++++++ .golangci.yml | 16 ++++++++ api/handlers/addresses_test.go | 10 ++--- api/handlers/auth_test.go | 24 ++++++------ api/handlers/cards.go | 14 +++++-- api/handlers/cart_test.go | 50 ++++++++++++------------ api/handlers/products_test.go | 8 ++-- api/handlers/slack.go | 8 ++-- api/main.go | 5 ++- api/routes/routes.go | 2 +- pkg/api/client.go | 38 +++++++++---------- pkg/auth/ssh_auth_test.go | 14 +++---- pkg/bring/booking.go | 2 +- pkg/bring/bring.go | 2 +- pkg/database/db.go | 2 +- pkg/database/db_test.go | 8 ++-- pkg/notify/slack.go | 4 +- pkg/shippo/shippo.go | 4 +- pkg/tui/account.go | 6 +-- pkg/tui/account_orders.go | 1 - pkg/tui/confirmation.go | 6 +-- pkg/tui/model.go | 69 ---------------------------------- pkg/utils/response.go | 4 +- pkg/validate/validate.go | 2 +- 24 files changed, 164 insertions(+), 173 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .golangci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6e52b1e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + pull_request: + branches: [master] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + 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 + uses: golangci/golangci-lint-action@v7 + with: + version: v2.4.0 + + - name: Test (race detector) + run: go test -race ./... diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..3d929d7 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,16 @@ +version: "2" + +run: + timeout: 5m + +linters: + default: none + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + exclusions: + paths: + - terminal-shop-source \ No newline at end of file diff --git a/api/handlers/addresses_test.go b/api/handlers/addresses_test.go index 3094e01..c022c6d 100644 --- a/api/handlers/addresses_test.go +++ b/api/handlers/addresses_test.go @@ -13,7 +13,7 @@ import ( func setupAddressTestDB(t *testing.T) (string, models.User) { t.Helper() testDB := "test_addresses.db" - os.Remove(testDB) + _ = os.Remove(testDB) database.ResetForTesting() db, err := database.Connect(testDB) @@ -42,7 +42,7 @@ func setupAddressTestDB(t *testing.T) (string, models.User) { func TestGetAddressesEmpty(t *testing.T) { testDB, user := setupAddressTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() handler := NewAddressHandler("", "", "") @@ -74,7 +74,7 @@ func TestGetAddressesEmpty(t *testing.T) { func TestGetAddressesIsolation(t *testing.T) { testDB, user1 := setupAddressTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -105,7 +105,7 @@ func TestGetAddressesIsolation(t *testing.T) { Addresses []models.Address `json:"addresses"` } `json:"data"` } - json.NewDecoder(w.Body).Decode(&resp) + _ = json.NewDecoder(w.Body).Decode(&resp) if len(resp.Data.Addresses) != 1 { t.Fatalf("expexted 1 address for user1, got %d", len(resp.Data.Addresses)) @@ -117,7 +117,7 @@ func TestGetAddressesIsolation(t *testing.T) { func TestGetAddressesWithData(t *testing.T) { testDB, user := setupAddressTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() diff --git a/api/handlers/auth_test.go b/api/handlers/auth_test.go index 64bd405..a587459 100644 --- a/api/handlers/auth_test.go +++ b/api/handlers/auth_test.go @@ -22,7 +22,7 @@ const testClientSecret = "test-client-secret" func setupAuthHandlerTestDB(t *testing.T) (string, *auth.JWTManager, *AuthHandler) { t.Helper() testDB := "test_auth.db" - os.Remove(testDB) + _ = os.Remove(testDB) database.ResetForTesting() db, err := database.Connect(testDB) @@ -62,7 +62,7 @@ func createAuthTestUser(t *testing.T, fingerprint string) models.User { func TestGetTokenSuccess(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() user := createAuthTestUser(t, "SHA256:testfingerprint123") @@ -108,7 +108,7 @@ func TestGetTokenSuccess(t *testing.T) { func TestGetTokenBadSecret(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() body, _ := json.Marshal(map[string]string{ @@ -128,7 +128,7 @@ func TestGetTokenBadSecret(t *testing.T) { func TestGetTokenMissingFingerprint(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() body, _ := json.Marshal(map[string]string{ @@ -147,7 +147,7 @@ func TestGetTokenMissingFingerprint(t *testing.T) { func TestGetTokenUnknownFingerprint(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() body, _ := json.Marshal(map[string]string{ @@ -167,7 +167,7 @@ func TestGetTokenUnknownFingerprint(t *testing.T) { func TestGetTokenInvalidJSON(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() req := httptest.NewRequest("POST", "/api/v1/auth/token", bytes.NewBufferString("{bad json")) @@ -185,7 +185,7 @@ func TestGetTokenInvalidJSON(t *testing.T) { func TestRegisterWithSSHKeySuccess(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() body, _ := json.Marshal(map[string]string{ @@ -207,7 +207,7 @@ func TestRegisterWithSSHKeySuccess(t *testing.T) { func TestRegisterWithSSHKeyDuplicate(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() createAuthTestUser(t, "SHA256:existingfp") @@ -229,7 +229,7 @@ func TestRegisterWithSSHKeyDuplicate(t *testing.T) { func TestRegisterWithSSHKeyMissingFields(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() body, _ := json.Marshal(map[string]string{ @@ -250,7 +250,7 @@ func TestRegisterWithSSHKeyMissingFields(t *testing.T) { func TestGetUserBySSHKeySuccess(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() createAuthTestUser(t, "SHA256:lookupfp") @@ -267,7 +267,7 @@ func TestGetUserBySSHKeySuccess(t *testing.T) { func TestGetUserBySSHKeyNotFound(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() req := httptest.NewRequest("GET", "/api/v1/auth/ssh?fingerprint=SHA256:nope", nil) @@ -282,7 +282,7 @@ func TestGetUserBySSHKeyNotFound(t *testing.T) { func TestGetUserBySSHKeyMissingParam(t *testing.T) { testDB, _, handler := setupAuthHandlerTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() req := httptest.NewRequest("GET", "/api/v1/auth/ssh", nil) diff --git a/api/handlers/cards.go b/api/handlers/cards.go index db18d84..8f05b08 100644 --- a/api/handlers/cards.go +++ b/api/handlers/cards.go @@ -30,14 +30,16 @@ var ( payRedirectsMu sync.Mutex ) -func storeRedirect(stripeURL string) string { +func storeRedirect(stripeURL string) (string, error) { b := make([]byte, 16) - rand.Read(b) + if _, err := rand.Read(b); err != nil { + return "", err + } token := hex.EncodeToString(b) payRedirectsMu.Lock() payRedirects[token] = stripeURL payRedirectsMu.Unlock() - return token + return token, nil } // PayRedirect resolves a short token and redirects to the Stripe checkout URL. @@ -166,7 +168,11 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) { return } - token := storeRedirect(sess.URL) + token, err := storeRedirect(sess.URL) + if err != nil { + utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil) + return + } utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ "url": fmt.Sprintf("%s/pay/%s", h.appURL, token), }) diff --git a/api/handlers/cart_test.go b/api/handlers/cart_test.go index 7187803..819f42c 100644 --- a/api/handlers/cart_test.go +++ b/api/handlers/cart_test.go @@ -20,7 +20,7 @@ import ( func setupCartTestDB(t *testing.T) (string, models.User) { t.Helper() testDB := "test_cart.db" - os.Remove(testDB) + _ = os.Remove(testDB) database.ResetForTesting() db, err := database.Connect(testDB) @@ -62,7 +62,7 @@ func authRequest(method, url string, body []byte, userID uint) *http.Request { func TestGetCartEmpty(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() handler := NewCartHandler("") @@ -101,7 +101,7 @@ func TestGetCartEmpty(t *testing.T) { func TestSetItemAndGetCart(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() handler := NewCartHandler("") @@ -157,7 +157,7 @@ func TestSetItemAndGetCart(t *testing.T) { func TestSetItemUpdateQuantity(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() handler := NewCartHandler("") @@ -190,7 +190,7 @@ func TestSetItemUpdateQuantity(t *testing.T) { } `json:"cart"` } `json:"data"` } - json.NewDecoder(w.Body).Decode(&resp) + _ = json.NewDecoder(w.Body).Decode(&resp) if len(resp.Data.Cart.Items) != 1 { t.Fatalf("Expected 1 item after update, got %d", len(resp.Data.Cart.Items)) @@ -202,7 +202,7 @@ func TestSetItemUpdateQuantity(t *testing.T) { func TestSetItemRemove(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() handler := NewCartHandler("") @@ -232,7 +232,7 @@ func TestSetItemRemove(t *testing.T) { } `json:"cart"` } `json:"data"` } - json.NewDecoder(w.Body).Decode(&resp) + _ = json.NewDecoder(w.Body).Decode(&resp) if len(resp.Data.Cart.Items) != 0 { t.Errorf("Expected 0 items after removal, got %d", len(resp.Data.Cart.Items)) @@ -241,7 +241,7 @@ func TestSetItemRemove(t *testing.T) { func TestSetItemInvalidProduct(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() handler := NewCartHandler("") @@ -258,7 +258,7 @@ func TestSetItemInvalidProduct(t *testing.T) { func TestSetItemMissingCoffeeID(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() handler := NewCartHandler("") @@ -275,7 +275,7 @@ func TestSetItemMissingCoffeeID(t *testing.T) { func TestClearCart(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() handler := NewCartHandler("") @@ -310,7 +310,7 @@ func TestClearCart(t *testing.T) { } `json:"cart"` } `json:"data"` } - json.NewDecoder(w.Body).Decode(&resp) + _ = json.NewDecoder(w.Body).Decode(&resp) if len(resp.Data.Cart.Items) != 0 { t.Errorf("Expected 0 items after clear, got %d", len(resp.Data.Cart.Items)) @@ -319,7 +319,7 @@ func TestClearCart(t *testing.T) { func TestSetAddress(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -358,7 +358,7 @@ func TestSetAddress(t *testing.T) { } `json:"cart"` } `json:"data"` } - json.NewDecoder(w.Body).Decode(&resp) + _ = json.NewDecoder(w.Body).Decode(&resp) if resp.Data.Cart.AddressID == nil || *resp.Data.Cart.AddressID != addr.ID { t.Errorf("Expected address_id %d, got %v", addr.ID, resp.Data.Cart.AddressID) @@ -367,7 +367,7 @@ func TestSetAddress(t *testing.T) { func TestSetAddressWrongUser(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -402,7 +402,7 @@ func TestSetAddressWrongUser(t *testing.T) { func TestSetCard(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -440,7 +440,7 @@ func TestSetCard(t *testing.T) { } `json:"cart"` } `json:"data"` } - json.NewDecoder(w.Body).Decode(&resp) + _ = json.NewDecoder(w.Body).Decode(&resp) if resp.Data.Cart.CardID == nil || *resp.Data.Cart.CardID != card.ID { t.Errorf("Expected card_id %d, got %v", card.ID, resp.Data.Cart.CardID) @@ -449,7 +449,7 @@ func TestSetCard(t *testing.T) { func TestSetCardWrongUser(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -482,7 +482,7 @@ func TestSetCardWrongUser(t *testing.T) { func TestConvertCartMissingAddress(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() handler := NewCartHandler("") @@ -505,7 +505,7 @@ func TestConvertCartMissingAddress(t *testing.T) { func TestConvertCartMissingCard(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() handler := NewCartHandler("") @@ -536,7 +536,7 @@ func TestConvertCartMissingCard(t *testing.T) { func TestConvertCartEmpty(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() handler := NewCartHandler("") @@ -553,7 +553,7 @@ func TestConvertCartEmpty(t *testing.T) { func TestMultipleItems(t *testing.T) { testDB, user := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() handler := NewCartHandler("") @@ -583,7 +583,7 @@ func TestMultipleItems(t *testing.T) { } `json:"cart"` } `json:"data"` } - json.NewDecoder(w.Body).Decode(&resp) + _ = json.NewDecoder(w.Body).Decode(&resp) if len(resp.Data.Cart.Items) != 3 { t.Errorf("Expected 3 items, got %d", len(resp.Data.Cart.Items)) @@ -595,7 +595,7 @@ func TestMultipleItems(t *testing.T) { func TestCartIsolation(t *testing.T) { testDB, user1 := setupCartTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -638,7 +638,7 @@ func TestCartIsolation(t *testing.T) { } `json:"cart"` } `json:"data"` } - json.NewDecoder(w.Body).Decode(&resp) + _ = json.NewDecoder(w.Body).Decode(&resp) if len(resp.Data.Cart.Items) != 1 { t.Fatalf("User 1 expected 1 item, got %d", len(resp.Data.Cart.Items)) @@ -655,7 +655,7 @@ func TestCartIsolation(t *testing.T) { w = httptest.NewRecorder() r.ServeHTTP(w, req) - json.NewDecoder(w.Body).Decode(&resp) + _ = json.NewDecoder(w.Body).Decode(&resp) if len(resp.Data.Cart.Items) != 1 { t.Fatalf("User 2 expected 1 item, got %d", len(resp.Data.Cart.Items)) diff --git a/api/handlers/products_test.go b/api/handlers/products_test.go index 759ee84..8f589a2 100644 --- a/api/handlers/products_test.go +++ b/api/handlers/products_test.go @@ -16,7 +16,7 @@ func setupTestDB(t *testing.T) string { testDB := "test_products.db" // Remove if exists - os.Remove(testDB) + _ = os.Remove(testDB) // Reset singleton database.ResetForTesting() @@ -39,7 +39,7 @@ func setupTestDB(t *testing.T) string { func TestGetProduct(t *testing.T) { testDB := setupTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() handler := NewProductHandler() @@ -78,7 +78,7 @@ func TestGetProduct(t *testing.T) { func TestGetProductNotFound(t *testing.T) { testDB := setupTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() handler := NewProductHandler() @@ -98,7 +98,7 @@ func TestGetProductNotFound(t *testing.T) { func TestGetProductsEmptyDatabase(t *testing.T) { testDB := "test_empty.db" - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() // Create database but don't seed diff --git a/api/handlers/slack.go b/api/handlers/slack.go index 9ba937b..83e8aa7 100644 --- a/api/handlers/slack.go +++ b/api/handlers/slack.go @@ -133,7 +133,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err)) return } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var result struct { Data struct { @@ -201,7 +201,7 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err)) return } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 300 { notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode)) return @@ -245,7 +245,7 @@ func (h *SlackHandler) editClearActions(responseURL, status, actor string) { slog.Warn("slack mark_status: response_url edit failed", "error", err) return } - resp.Body.Close() + _ = resp.Body.Close() } func (h *SlackHandler) verifySignature(r *http.Request, body []byte) bool { @@ -264,7 +264,7 @@ func (h *SlackHandler) verifySignature(r *http.Request, body []byte) bool { } mac := hmac.New(sha256.New, []byte(h.signingSecret)) - fmt.Fprintf(mac, "v0:%d:%s", ts, body) + _, _ = fmt.Fprintf(mac, "v0:%d:%s", ts, body) expected := "v0=" + hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(sigHeader)) diff --git a/api/main.go b/api/main.go index 6197c9a..731f5e5 100644 --- a/api/main.go +++ b/api/main.go @@ -40,7 +40,8 @@ func main() { if err != nil { log.Fatalf("Failed to connect to database: %v", err) } - defer database.Close() + + defer func() { _ = database.Close() }() // Run migrations if err := database.Migrate(db); err != nil { @@ -61,7 +62,7 @@ func main() { if err != nil { log.Printf("tracing init failed (non-fatal): %v", err) } else { - defer shutdown(context.Background()) + defer func() { _ = shutdown(context.Background()) }() } stripeclient.InitOTel() diff --git a/api/routes/routes.go b/api/routes/routes.go index 973b521..cad2fa4 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -59,7 +59,7 @@ func SetupRoutes( r.Get("/t/{id}", handlers.TrackingRedirect) r.Get("/card-added", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") - w.Write([]byte(`

Card added.

You can close this tab.

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

Card added.

You can close this tab.

`)) }) r.Handle("/metrics", middleware.MetricsHandler()) diff --git a/pkg/api/client.go b/pkg/api/client.go index 7898ae0..9ccd4d9 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -80,7 +80,7 @@ func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) ( if err != nil { return "", models.PublicUser{}, fmt.Errorf("token refresh request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return "", models.PublicUser{}, fmt.Errorf("token refresh returned status %d", resp.StatusCode) @@ -137,7 +137,7 @@ func (c *Client) GetProducts() ([]models.Coffee, error) { if err != nil { return nil, fmt.Errorf("failed to fetch products: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("API returned status %d", resp.StatusCode) @@ -167,7 +167,7 @@ func (c *Client) Health() error { if err != nil { return fmt.Errorf("health check failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("API returned status %d", resp.StatusCode) @@ -217,7 +217,7 @@ func (c *Client) RegisterUser(username, sshPublicKey, sshKeyFingerprint string) if err != nil { return nil, fmt.Errorf("failed to register user: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var registerResp RegisterResponse if err := json.NewDecoder(resp.Body).Decode(®isterResp); err != nil { @@ -302,7 +302,7 @@ func (c *Client) GetCart() (*CartData, error) { if err != nil { return nil, fmt.Errorf("failed to fetch cart: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var cartResp CartResponse if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil { @@ -342,7 +342,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) { if err != nil { return nil, fmt.Errorf("failed to set cart item: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var cartResp CartResponse if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil { @@ -381,7 +381,7 @@ func (c *Client) SetCartAddress(addressID uint) error { if err != nil { return fmt.Errorf("failed to set cart address: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var result APIResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { @@ -420,7 +420,7 @@ func (c *Client) SetCartCard(cardID uint) error { if err != nil { return fmt.Errorf("failed to set cart card: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var result APIResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { @@ -450,7 +450,7 @@ func (c *Client) ClearCart() error { if err != nil { return fmt.Errorf("failed to clear cart: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var result APIResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { @@ -480,7 +480,7 @@ func (c *Client) ConvertCart() (*models.Order, error) { if err != nil { return nil, fmt.Errorf("failed to convert cart: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var convertResp ConvertCartResponse if err := json.NewDecoder(resp.Body).Decode(&convertResp); err != nil { @@ -510,7 +510,7 @@ func (c *Client) GetCards() ([]models.Card, error) { if err != nil { return nil, fmt.Errorf("failed to fetch cards: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var cardsResp CardsResponse if err := json.NewDecoder(resp.Body).Decode(&cardsResp); err != nil { @@ -553,7 +553,7 @@ func (c *Client) SaveCard(params SaveCardRequest) (*models.Card, error) { if err != nil { return nil, fmt.Errorf("failed to save card: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var cardResp CardResponse if err := json.NewDecoder(resp.Body).Decode(&cardResp); err != nil { @@ -590,7 +590,7 @@ func (c *Client) CollectCard() (string, error) { if err != nil { return "", fmt.Errorf("failed to collect card: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var collectResp CollectCardResponse if err := json.NewDecoder(resp.Body).Decode(&collectResp); err != nil { @@ -619,7 +619,7 @@ func (c *Client) DeleteCard(id uint) error { if err != nil { return fmt.Errorf("failed to delete card: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("delete card failed with status %d", resp.StatusCode) @@ -648,7 +648,7 @@ func (c *Client) GetOrders() ([]models.Order, error) { if err != nil { return nil, fmt.Errorf("failed to fetch orders: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var ordersResp OrderResponse if err := json.NewDecoder(resp.Body).Decode(&ordersResp); err != nil { @@ -686,7 +686,7 @@ func (c *Client) GetAddresses() ([]models.Address, error) { return nil, fmt.Errorf("failed to fetch addresses: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var addrResp AddressResponse if err := json.NewDecoder(resp.Body).Decode(&addrResp); err != nil { @@ -732,7 +732,7 @@ func (c *Client) CreateAddress(addrReq CreateAddressRequest) (*models.Address, e if err != nil { return nil, fmt.Errorf("failed to create address: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var result struct { Success bool `json:"success"` @@ -769,7 +769,7 @@ func (c *Client) DeleteAddress(id uint) error { if err != nil { return fmt.Errorf("failed to delete address: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("delete address failed with status %d", resp.StatusCode) @@ -804,7 +804,7 @@ func (c *Client) GetViewInit() (ViewInitData, error) { if err != nil { return ViewInitData{}, fmt.Errorf("view init request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return ViewInitData{}, fmt.Errorf("view init returned status: %d", resp.StatusCode) } diff --git a/pkg/auth/ssh_auth_test.go b/pkg/auth/ssh_auth_test.go index c889856..cf887dd 100644 --- a/pkg/auth/ssh_auth_test.go +++ b/pkg/auth/ssh_auth_test.go @@ -17,7 +17,7 @@ import ( func setupAuthTestDB(t *testing.T) string { t.Helper() testDB := "test_ssh_auth.db" - os.Remove(testDB) + _ = os.Remove(testDB) database.ResetForTesting() db, err := database.Connect(testDB) @@ -47,7 +47,7 @@ func newTestSSHKey(t *testing.T) gossh.PublicKey { func TestAuthenticateSSHKeyNewUser(t *testing.T) { testDB := setupAuthTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -72,7 +72,7 @@ func TestAuthenticateSSHKeyNewUser(t *testing.T) { func TestAuthenticateSSHKeyExistingUser(t *testing.T) { testDB := setupAuthTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -96,7 +96,7 @@ func TestAuthenticateSSHKeyExistingUser(t *testing.T) { func TestRegisterWithSSHKey(t *testing.T) { testDB := setupAuthTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -121,7 +121,7 @@ func TestRegisterWithSSHKey(t *testing.T) { func TestRegisterWithSSHKeyDuplicate(t *testing.T) { testDB := setupAuthTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -145,7 +145,7 @@ func TestRegisterWithSSHKeyDuplicate(t *testing.T) { func TestIsSSHKeyRegistered(t *testing.T) { testDB := setupAuthTestDB(t) - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() @@ -164,7 +164,7 @@ func TestIsSSHKeyRegistered(t *testing.T) { } // After authentication (which auto-creates) - svc.AuthenticateSSHKey(key) + _, _, _ = svc.AuthenticateSSHKey(key) registered, err = svc.IsSSHKeyRegistered(key) if err != nil { diff --git a/pkg/bring/booking.go b/pkg/bring/booking.go index f7cdbba..9b89eb4 100644 --- a/pkg/bring/booking.go +++ b/pkg/bring/booking.go @@ -197,7 +197,7 @@ func (c *BookingClient) doJSON(ctx context.Context, url string, body any, out an if err != nil { return fmt.Errorf("request: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("read response: %w", err) diff --git a/pkg/bring/bring.go b/pkg/bring/bring.go index 5d8db7c..659c567 100644 --- a/pkg/bring/bring.go +++ b/pkg/bring/bring.go @@ -110,7 +110,7 @@ func (c *Client) ValidateAddress(ctx context.Context, addr Address) (*Address, e if err != nil { return nil, fmt.Errorf("bring request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { diff --git a/pkg/database/db.go b/pkg/database/db.go index c134e59..78fe60d 100644 --- a/pkg/database/db.go +++ b/pkg/database/db.go @@ -101,7 +101,7 @@ func Close() error { // WARNING: Only use this in tests! func ResetForTesting() { if db != nil { - Close() + _ = Close() db = nil once = sync.Once{} } diff --git a/pkg/database/db_test.go b/pkg/database/db_test.go index 94f511f..1fcf301 100644 --- a/pkg/database/db_test.go +++ b/pkg/database/db_test.go @@ -10,7 +10,7 @@ import ( func TestConnect(t *testing.T) { // Use a temporary test database testDB := "test_terminalshop.db" - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer ResetForTesting() db, err := Connect(testDB) @@ -35,7 +35,7 @@ func TestConnect(t *testing.T) { func TestMigrate(t *testing.T) { testDB := "test_migrate.db" - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer ResetForTesting() db, err := Connect(testDB) @@ -57,7 +57,7 @@ func TestMigrate(t *testing.T) { func TestSeed(t *testing.T) { testDB := "test_seed.db" - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer ResetForTesting() db, err := Connect(testDB) @@ -115,7 +115,7 @@ func TestSeed(t *testing.T) { func TestGetDB(t *testing.T) { testDB := "test_getdb.db" - defer os.Remove(testDB) + defer func() { _ = os.Remove(testDB) }() defer ResetForTesting() // Initialize connection diff --git a/pkg/notify/slack.go b/pkg/notify/slack.go index 2e9c364..65034d4 100644 --- a/pkg/notify/slack.go +++ b/pkg/notify/slack.go @@ -32,7 +32,7 @@ func postSlackMessage(token string, payload map[string]any) (string, bool) { slog.Warn("slack post failed", "error", err) return "", false } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var parsed struct { Ok bool `json:"ok"` @@ -66,7 +66,7 @@ func slackPinCall(token, endpoint, channel, ts, benignErr string) bool { slog.Warn("slack pin call failed", "endpoint", endpoint, "error", err) return false } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var parsed struct { Ok bool `json:"ok"` diff --git a/pkg/shippo/shippo.go b/pkg/shippo/shippo.go index b224cd2..2ae9139 100644 --- a/pkg/shippo/shippo.go +++ b/pkg/shippo/shippo.go @@ -270,7 +270,7 @@ func (c *Client) doJSON(ctx context.Context, method, path string, body any, out if err != nil { return fmt.Errorf("request: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("read response: %w", err) @@ -432,7 +432,7 @@ func (c *Client) ValidateAddress(ctx context.Context, addr Address) (*Address, e if err != nil { return nil, fmt.Errorf("shippo request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { diff --git a/pkg/tui/account.go b/pkg/tui/account.go index f34a691..76a13a1 100644 --- a/pkg/tui/account.go +++ b/pkg/tui/account.go @@ -324,7 +324,8 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { switch selectedItem { case "order history", "active orders": if len(m.currentOrderList()) > 0 { - if m.account.orderViewState == 0 { + switch m.account.orderViewState { + case 0: m.account.orderViewState = 1 m.account.orderCursor = 0 m.account.detailViewport.GotoTop() @@ -334,8 +335,7 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { {key: "esc", value: "back"}, {key: "q", value: "quit"}, } - } else if m.account.orderViewState == 1 { - // Save scroll position before entering detail view + case 1: m.account.orderYOffset = m.account.detailViewport.YOffset m.account.orderViewState = 2 m.account.detailViewport.GotoTop() diff --git a/pkg/tui/account_orders.go b/pkg/tui/account_orders.go index d14085b..44aaf7d 100644 --- a/pkg/tui/account_orders.go +++ b/pkg/tui/account_orders.go @@ -248,4 +248,3 @@ func (m Model) displayStyle(k models.DisplayKind) lipgloss.Style { } return m.theme.TextHighlight() } - diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index 42fcfe6..f6c0a8c 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -145,18 +145,18 @@ func (m Model) generateReviewContent() string { if m.ShippingInfo.Street2 != "" { view.WriteString(m.ShippingInfo.Street2 + "\n") } - view.WriteString(fmt.Sprintf("%s, %s %s, %s\n", m.ShippingInfo.City, m.ShippingInfo.State, m.ShippingInfo.Zip, m.ShippingInfo.Country)) + fmt.Fprintf(&view, "%s, %s %s, %s\n", m.ShippingInfo.City, m.ShippingInfo.State, m.ShippingInfo.Zip, m.ShippingInfo.Country) } view.WriteString("\n") if m.SelectedCard != nil { - view.WriteString(fmt.Sprintf("cc: **** **** **** %s\n", m.SelectedCard.Last4)) + fmt.Fprintf(&view, "cc: **** **** **** %s\n", m.SelectedCard.Last4) } view.WriteString("\n") subtotal := m.CalculateSubtotal() - view.WriteString(fmt.Sprintf("subtotal: %s\n", formatUSD(subtotal))) + fmt.Fprintf(&view, "subtotal: %s\n", formatUSD(subtotal)) view.WriteString(m.theme.TextAccent().Render(fmt.Sprintf("total: %s", formatUSD(subtotal))) + "\n") view.WriteString("\n") view.WriteString(m.theme.TextBrand().Render("press enter to confirm") + "\n") diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 9ffda67..9427eb1 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -400,23 +400,6 @@ func (m Model) fetchCartCmd() tea.Msg { return CartFetchedMsg{Cart: cart, Err: err} } -// fetchProductsCmd fetches products from the API -func (m Model) fetchProductsCmd() tea.Msg { - if m.APIClient == nil { - log.Println("[TUI] APIClient is nil, using fallback products") - return ProductsMsg{Err: nil} // No API client, use fallback - } - - log.Printf("[TUI] Fetching products from API at %s", m.APIClient.BaseURL) - products, err := m.APIClient.GetProducts() - if err != nil { - log.Printf("[TUI] Failed to fetch products: %v", err) - } else { - log.Printf("[TUI] Loaded %d products from API", len(products)) - } - return ProductsMsg{Products: products, Err: err} -} - func (m Model) Init() tea.Cmd { return tea.Batch( m.authCmd, @@ -670,58 +653,6 @@ func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, tea.Cmd) { } } -// saveCardAndConvert tokenizes the card locally using the Stripe publishable -// key (raw card data never reaches the backend), saves the token, then converts the cart -func (m Model) saveCardAndConvert(form PaymentFormCompleteMsg) tea.Cmd { - return func() tea.Msg { - if m.APIClient == nil { - return CheckoutResultMsg{Err: fmt.Errorf("API client not available")} - } - - stripe.Key = m.StripePublicKey - tok, err := stripetoken.New(&stripe.TokenParams{ - Card: &stripe.CardParams{ - Name: stripe.String(form.CardName), - Number: stripe.String(form.CardNumber), - ExpMonth: stripe.String(form.ExpiryMonth), - ExpYear: stripe.String(form.ExpiryYear), - CVC: stripe.String(form.CVC), - AddressZip: stripe.String(form.BillingZip), - }, - }) - if err != nil { - if stripeErr, ok := err.(*stripe.Error); ok { - return CheckoutResultMsg{Err: fmt.Errorf("%s", stripeErr.Msg)} - } - return CheckoutResultMsg{Err: fmt.Errorf("failed to tokenize card: %w", err)} - } - card, err := m.APIClient.SaveCard(api.SaveCardRequest{Token: tok.ID}) - if err != nil { - return CheckoutResultMsg{Err: fmt.Errorf("failed to save card: %w", err)} - } - - if err := m.resolveCartAddress(); err != nil { - return CheckoutResultMsg{Err: err} - } - - // 3. Set the card on the cart - if err := m.APIClient.SetCartCard(card.ID); err != nil { - return CheckoutResultMsg{Err: fmt.Errorf("failed to set card: %w", err)} - } - - // 4. Convert the cart to an order - order, err := m.APIClient.ConvertCart() - if err != nil { - return CheckoutResultMsg{Err: err} - } - - return CheckoutResultMsg{ - OrderID: order.ID, - Total: order.Total, - } - } -} - func (m Model) saveCardOnlyCmd(form PaymentFormCompleteMsg) tea.Cmd { return func() tea.Msg { if m.APIClient == nil { diff --git a/pkg/utils/response.go b/pkg/utils/response.go index c10a775..04615e4 100644 --- a/pkg/utils/response.go +++ b/pkg/utils/response.go @@ -35,7 +35,7 @@ func RespondSuccess(w http.ResponseWriter, statusCode int, data interface{}) { Data: data, } - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) } // RespondError sends an error JSON response @@ -52,7 +52,7 @@ func RespondError(w http.ResponseWriter, statusCode int, code, message string, d }, } - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) } // HealthData represents health check data diff --git a/pkg/validate/validate.go b/pkg/validate/validate.go index f771fad..8bcff01 100644 --- a/pkg/validate/validate.go +++ b/pkg/validate/validate.go @@ -66,7 +66,7 @@ func CcnValidator(cardNumber string) error { func IsDigits(name string) ErrorHandler { return func(str string) error { for _, c := range str { - if !(c >= '0' && c <= '9') { + if c < '0' || c > '9' { return fmt.Errorf("[%s] expected only digits but got %s", name, str) } } -- 2.49.0 From 366d80a9eeef5e0542edd6c60121b12d9a6c8b1a Mon Sep 17 00:00:00 2001 From: marcus06 Date: Sat, 23 May 2026 22:12:46 +0200 Subject: [PATCH 02/35] test(dillydallying): testing the ci workflow (#2) --- api/handlers/cards.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/handlers/cards.go b/api/handlers/cards.go index 8f05b08..332b1c4 100644 --- a/api/handlers/cards.go +++ b/api/handlers/cards.go @@ -180,7 +180,7 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) { // SaveCardRequest holds the raw card fields submitted by the TUI. // Tokenization happens server-side so the Stripe secret key never -// touches the SSH client or TUI layer. +// touches the SSH client or TUI layer.. type SaveCardRequest struct { Token string `json:"token"` } -- 2.49.0 From 85b0c53c0abde173fab4583ff00d65e17379639b Mon Sep 17 00:00:00 2001 From: marcus06 Date: Sun, 24 May 2026 12:02:50 +0200 Subject: [PATCH 03/35] docs(comments): cleanup comments (#3) --- api/handlers/cards.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/handlers/cards.go b/api/handlers/cards.go index 332b1c4..8f05b08 100644 --- a/api/handlers/cards.go +++ b/api/handlers/cards.go @@ -180,7 +180,7 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) { // SaveCardRequest holds the raw card fields submitted by the TUI. // Tokenization happens server-side so the Stripe secret key never -// touches the SSH client or TUI layer.. +// touches the SSH client or TUI layer. type SaveCardRequest struct { Token string `json:"token"` } -- 2.49.0 From 50e1ba32f31388f5aaa6b40be9b14da1e53dc2f6 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Sun, 24 May 2026 12:26:34 +0200 Subject: [PATCH 04/35] docs(comments): add some comments (#4) --- pkg/notify/slack.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/notify/slack.go b/pkg/notify/slack.go index 65034d4..6f61bd7 100644 --- a/pkg/notify/slack.go +++ b/pkg/notify/slack.go @@ -16,6 +16,7 @@ import ( var slackClient = &http.Client{Timeout: 5 * time.Second} +// dollars converts cents to dollars func dollars(cents int) string { return fmt.Sprintf("$%.2f", float64(cents)/100) } -- 2.49.0 From c429c1966e89fe56aa29be3bad522e479fb8e0cc Mon Sep 17 00:00:00 2001 From: marcus06 Date: Sun, 24 May 2026 18:44:25 +0200 Subject: [PATCH 05/35] docs(comments): add some comments (#5) this is the subject of the commit --- pkg/notify/slack.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/notify/slack.go b/pkg/notify/slack.go index 6f61bd7..8bfbc51 100644 --- a/pkg/notify/slack.go +++ b/pkg/notify/slack.go @@ -1,3 +1,4 @@ +// Package notify provides a simple interface to send notifications to Slack. package notify import ( -- 2.49.0 From c78021f5201568998f4f34b65537e486206e8b9c Mon Sep 17 00:00:00 2001 From: marcus06 Date: Sun, 24 May 2026 18:57:58 +0200 Subject: [PATCH 06/35] docs(comments): add some comments (#6) --- pkg/notify/slack.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/notify/slack.go b/pkg/notify/slack.go index 8bfbc51..5ac2537 100644 --- a/pkg/notify/slack.go +++ b/pkg/notify/slack.go @@ -15,6 +15,7 @@ import ( "terminalShop/pkg/models" ) +// slack client var slackClient = &http.Client{Timeout: 5 * time.Second} // dollars converts cents to dollars -- 2.49.0 From 48d65c48fa039fbe3ddb47d345bc5e000e613c98 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Sun, 24 May 2026 20:14:14 +0200 Subject: [PATCH 07/35] feat(tui): render customer-facing event timeline in order detail veiew (#7) Pulls events off the new order_events table and shows them as a timeline block in the order detail view: Paid, Label created, In transit, Delivered (etc) with timestamps. Filters out ops-only events like payment_critical, label_orphaned, and tracking_marked_manually so the customer never sees internal plumbing. DisplayLabel on OrderEvent decides what's customer-visible and what's redacted, so the same order_events rows can later power an admin view with no filter. --- api/handlers/orders.go | 9 +++++-- api/handlers/view.go | 7 +++++- pkg/models/order.go | 7 +++--- pkg/models/order_event.go | 49 ++++++++++++++++++++++++++++++++++++++- pkg/tui/account_orders.go | 18 ++++++++++++++ 5 files changed, 83 insertions(+), 7 deletions(-) diff --git a/api/handlers/orders.go b/api/handlers/orders.go index 2ceb366..b2bd03b 100644 --- a/api/handlers/orders.go +++ b/api/handlers/orders.go @@ -5,11 +5,12 @@ import ( "net/http" "os" "strconv" + "time" + "terminalShop/pkg/bring" "terminalShop/pkg/notify" "terminalShop/pkg/shipping" "terminalShop/pkg/shippo" - "time" "terminalShop/api/middleware" "terminalShop/pkg/audit" @@ -20,6 +21,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78/refund" + "gorm.io/gorm" ) type OrderHandler struct { @@ -43,7 +45,10 @@ func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromContext(r.Context()) var orders []models.Order - db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders) + db.Where("user_id = ?", userID). + Preload("Items"). + Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }). + Order("created_at desc").Find(&orders) utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ "orders": orders, diff --git a/api/handlers/view.go b/api/handlers/view.go index 110d45f..9bd00ad 100644 --- a/api/handlers/view.go +++ b/api/handlers/view.go @@ -7,6 +7,8 @@ import ( "terminalShop/pkg/database" "terminalShop/pkg/models" "terminalShop/pkg/utils" + + "gorm.io/gorm" ) type ViewHandler struct{} @@ -38,7 +40,10 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) { db.Where("user_id = ?", userID).Find(&cards) var orders []models.Order - db.Where("user_id = ?", userID).Preload("Items").Order("created_at DESC").Find(&orders) + db.Where("user_id = ?", userID). + Preload("Items"). + Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }). + Order("created_at DESC").Find(&orders) var cartItems []models.CartItem if cart.ID != 0 { db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems) diff --git a/pkg/models/order.go b/pkg/models/order.go index 1c78825..9831983 100644 --- a/pkg/models/order.go +++ b/pkg/models/order.go @@ -71,9 +71,10 @@ type Order struct { DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel // Associations - User User `gorm:"foreignKey:UserID" json:"-"` - Card Card `gorm:"foreignKey:CardID" json:"-"` - Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"` + User User `gorm:"foreignKey:UserID" json:"-"` + Card Card `gorm:"foreignKey:CardID" json:"-"` + Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"` + Events []OrderEvent `gorm:"foreignKey:OrderID" json:"events,omitempty"` } // TableName specifies the table name for the Order model. diff --git a/pkg/models/order_event.go b/pkg/models/order_event.go index 95c4fc1..39dc058 100644 --- a/pkg/models/order_event.go +++ b/pkg/models/order_event.go @@ -1,6 +1,9 @@ package models -import "time" +import ( + "encoding/json" + "time" +) type OrderEvent struct { ID uint `gorm:"primaryKey" json:"id"` @@ -14,3 +17,47 @@ type OrderEvent struct { func (OrderEvent) TableName() string { return "order_events" } + +func (e *OrderEvent) PayloadMap() map[string]any { + if e.Payload == "" { + return nil + } + var m map[string]any + if err := json.Unmarshal([]byte(e.Payload), &m); err != nil { + return nil + } + return m +} + +func (e *OrderEvent) DisplayLabel() (string, bool) { + switch e.Type { + case "order_paid": + return "Paid", true + case "label_purchased": + return "Label created", true + case "order_delivered": + return "Delivered", true + case "order_refunded": + return "Refunded", true + case "order_failed": + return "Payment failed", true + case "tracking_updated": + status, _ := e.PayloadMap()["status"].(string) + return trackingLabel(status) + } + return "", false +} + +func trackingLabel(status string) (string, bool) { + switch status { + case "transit": + return "In transit", true + case "failure": + return "Delivery issue", true + case "returned": + return "Returned to sender", true + case "pre_transit": + return "Label scanned by carrier", true + } + return "", false +} diff --git a/pkg/tui/account_orders.go b/pkg/tui/account_orders.go index 44aaf7d..69de3dc 100644 --- a/pkg/tui/account_orders.go +++ b/pkg/tui/account_orders.go @@ -201,6 +201,24 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string { } } + visible := make([]models.OrderEvent, 0, len(order.Events)) + for _, event := range order.Events { + if _, ok := event.DisplayLabel(); ok { + visible = append(visible, event) + } + } + if len(visible) > 0 { + b.WriteString(labelStyle.Render("Timeline")) + b.WriteString("\n") + for _, event := range visible { + label, _ := event.DisplayLabel() + when := event.CreatedAt.Format("Jan 02 3:04 PM") + b.WriteString(valueStyle.Render(fmt.Sprintf(" %-20s %s", label, dimStyle.Render(when)))) + b.WriteString("\n") + } + b.WriteString("\n") + } + // Shipping address b.WriteString(labelStyle.Render("Ship To")) b.WriteString("\n") -- 2.49.0 From b404303f84f160534e252a1af116dcc048743700 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Mon, 25 May 2026 16:45:15 +0200 Subject: [PATCH 08/35] feat(notify): daily slack reminder for paid orders awaiting labels (#8) so the ain was that older paid orders were getting buried in the #orders channel in slack and i would just forget about them. Pin/unpin from earlier helped, but if i missed pinning one or the channel got noisy, it was still easy to lose track. this adds a 24h cron that queries any paid order older than 24h with no label yet and dumps them all into one #orders message. Each line shows the order id, how old it is, total, where it is going, and a permalink back to the orders slack thread so i can hop in and hit Buy Label Ports the idea from terminal.shops cron/unshipped.ts but out stuch state is different( we auto-promote paid->shipped on label-buy so PRE_TRANSIT-stuck can not happen), so the query is "paid + no tracking number + >24h" instead. NO orders get a "Bring blocked" tag since i dont have the norwegian org set up yet and can not actually ship those today. Also fires once 40s after startup so a deploy does not black out the reminder for a full day. --- api/handlers/reconcile.go | 24 ++++++++++++++++++++++ api/main.go | 15 ++++++++++++++ pkg/notify/slack.go | 42 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/api/handlers/reconcile.go b/api/handlers/reconcile.go index 7708842..d34c566 100644 --- a/api/handlers/reconcile.go +++ b/api/handlers/reconcile.go @@ -7,6 +7,7 @@ import ( "terminalShop/pkg/database" "terminalShop/pkg/models" + "terminalShop/pkg/notify" "github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78/paymentintent" @@ -75,3 +76,26 @@ func ReconcileOrders(stripeKey string) { } } } + +// ReconcileUnshipped finds paid order orlder than 24h with no tracking +// number and posts single Slack reminder. Pure read + notif +func ReconcileUnshipped() { + db := database.GetDB() + + var orders []models.Order + cutoff := time.Now().Add(-24 * time.Hour) + err := db.Where( + "status = ? AND (tracking_number = '' OR tracking_number IS NULL) AND created_at < ?", + models.OrderStatusPaid, cutoff, + ).Order("created_at ASC").Find(&orders).Error + if err != nil { + reconcileLog().Error("unshipped query failed", "error", err) + return + } + if len(orders) == 0 { + return + } + + reconcileLog().Info("unshipped orders found", "count", len(orders)) + notify.SlackUnshippedReminder(orders) +} diff --git a/api/main.go b/api/main.go index 731f5e5..cbbc50b 100644 --- a/api/main.go +++ b/api/main.go @@ -113,6 +113,21 @@ 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 + } + } + }() + log.Println("API server started. Press Ctrl+C to shutdown.") // Block until signal received diff --git a/pkg/notify/slack.go b/pkg/notify/slack.go index 5ac2537..aa80957 100644 --- a/pkg/notify/slack.go +++ b/pkg/notify/slack.go @@ -264,4 +264,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) +} -- 2.49.0 From dee6172875ff413212be75019fc4a6348bdb7ac1 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Mon, 25 May 2026 19:09:16 +0200 Subject: [PATCH 09/35] refactor(tui): port to charm v2 (#9) * refactor(tui): port to charm v2 bumped bubbletea, lipgloss, bubbles, wish, and huh to v2. Mostly mechanical chur, new import paths, vewport getter/setter api, view() returns tea.view now, huh.theme became an interface so the theme package needed a small reshape. Render plumbing is gone (no more lipgloss.Render field on the Model or MakeRenderer in main.go). All pages still work over ssh. setting up for the refund composer next * style(tui): gofmt main.go and menu.go --- .gitignore | 2 + go.mod | 55 ++++++++--------- go.sum | 124 +++++++++++++++++--------------------- main.go | 16 ++--- pkg/tui/account.go | 18 +++--- pkg/tui/account_orders.go | 2 +- pkg/tui/breadcrumbs.go | 2 +- pkg/tui/cart.go | 18 +++--- pkg/tui/confirmation.go | 12 ++-- pkg/tui/footer.go | 2 +- pkg/tui/header.go | 6 +- pkg/tui/help.go | 4 +- pkg/tui/menu.go | 4 +- pkg/tui/model.go | 33 ++++------ pkg/tui/payment_form.go | 22 +++---- pkg/tui/shipping_form.go | 20 +++--- pkg/tui/shop.go | 16 ++--- pkg/tui/splash.go | 4 +- pkg/tui/styles.go | 2 +- pkg/tui/theme/huh.go | 63 ++++++++++--------- pkg/tui/theme/theme.go | 87 +++++++++++++------------- pkg/tui/update.go | 2 +- pkg/tui/views.go | 11 +++- 23 files changed, 248 insertions(+), 277 deletions(-) diff --git a/.gitignore b/.gitignore index 0bb5609..90a48be 100644 --- a/.gitignore +++ b/.gitignore @@ -98,6 +98,8 @@ docs/ # Migration state (if using a migration tool that creates state files) migrations/*.state +gh-dash/ + loadtest/run.pid diff --git a/go.mod b/go.mod index 6ea3dde..2d184ab 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,14 @@ module terminalShop -go 1.25.7 +go 1.25.9 require ( - github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/huh v0.8.0 - github.com/charmbracelet/lipgloss v1.1.0 + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.6 + charm.land/huh/v2 v2.0.3 + charm.land/lipgloss/v2 v2.0.3 + charm.land/wish/v2 v2.0.1 github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 - github.com/charmbracelet/wish v1.4.7 github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 @@ -23,7 +23,7 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 - golang.org/x/crypto v0.49.0 + golang.org/x/crypto v0.50.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 @@ -32,38 +32,35 @@ require ( ) require ( + charm.land/log/v2 v2.0.0 // indirect github.com/ClickHouse/ch-go v0.61.5 // indirect github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/keygen v0.5.3 // indirect - github.com/charmbracelet/log v0.4.1 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/conpty v0.1.0 // indirect - github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/keygen v0.5.4 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/conpty v0.1.1 // indirect + github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect - github.com/charmbracelet/x/input v0.3.4 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect - github.com/charmbracelet/x/windows v0.2.0 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/creack/pty v1.1.24 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect - github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect @@ -78,15 +75,11 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/paulmach/orb v0.11.1 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect @@ -104,11 +97,11 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/grpc v1.80.0 // indirect diff --git a/go.sum b/go.sum index 2a61dc4..ee37675 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,15 @@ +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= +charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= +charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= +charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= +charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s= +charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0= +charm.land/wish/v2 v2.0.1 h1:xYOsvQG/bYNRoKUBt0AEyMiCv9rksmCOG/fD5WifAzg= +charm.land/wish/v2 v2.0.1/go.mod h1:uBWdsKTFk9BE0JsdEv8KKF8/abBfRe/hKEGOBm3OKZA= github.com/ClickHouse/ch-go v0.61.5 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4= github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg= github.com/ClickHouse/clickhouse-go/v2 v2.30.0 h1:AG4D/hW39qa58+JHQIFOSnxyL46H6h2lrmGGk17dhFo= @@ -10,10 +22,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= @@ -22,52 +32,38 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= -github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= -github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4= -github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk= -github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA= +github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM= github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc= github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE= -github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc= -github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= -github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= -github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= -github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac= +github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= +github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 h1:4EG8pCHK5fa8dIxv97VHC8hdkJAz6QNm1WB9BuD/WhY= +github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7/go.mod h1:O2BTD/aMVQDmrvqroIO3fB6zXUuU07ZpVt21QTmZjRg= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= -github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0= -github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= -github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= -github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= -github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= -github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= -github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -75,8 +71,6 @@ 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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ= @@ -91,8 +85,8 @@ github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= -github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= -github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= +github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -147,25 +141,17 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU= @@ -247,10 +233,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -273,19 +259,17 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= diff --git a/main.go b/main.go index d88edc0..134b05e 100644 --- a/main.go +++ b/main.go @@ -11,11 +11,11 @@ import ( "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" + "charm.land/wish/v2" + "charm.land/wish/v2/bubbletea" + "charm.land/wish/v2/logging" "github.com/charmbracelet/ssh" - "github.com/charmbracelet/wish" - "github.com/charmbracelet/wish/bubbletea" - "github.com/charmbracelet/wish/logging" "terminalShop/pkg/auth" "terminalShop/pkg/config" @@ -36,9 +36,6 @@ var ( ) func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { - renderer := bubbletea.MakeRenderer(s) - - // Extract SSH public key from session pubKey := s.PublicKey() var fingerprint, pubKeyStr string @@ -47,9 +44,8 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { pubKeyStr = auth.FormatSSHPublicKey(pubKey) } - // Create TUI model with user context (no registration screen needed!) - m := tui.NewModelWithAuth(renderer, fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey) - return m, []tea.ProgramOption{tea.WithAltScreen()} + m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey) + return m, nil } func main() { diff --git a/pkg/tui/account.go b/pkg/tui/account.go index 76a13a1..ec3e60e 100644 --- a/pkg/tui/account.go +++ b/pkg/tui/account.go @@ -1,9 +1,9 @@ package tui import ( - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "terminalShop/pkg/models" ) @@ -33,12 +33,12 @@ func (m Model) updateAccountViewport() Model { } if !m.account.viewportReady { - m.account.detailViewport = viewport.New(detailWidth, availableHeight) + m.account.detailViewport = viewport.New(viewport.WithWidth(detailWidth), viewport.WithHeight(availableHeight)) m.account.detailViewport.KeyMap = modifiedKeyMap m.account.viewportReady = true } else { - m.account.detailViewport.Width = detailWidth - m.account.detailViewport.Height = availableHeight + m.account.detailViewport.SetWidth(detailWidth) + m.account.detailViewport.SetHeight(availableHeight) } return m @@ -128,8 +128,8 @@ func (m Model) scrollToAccountDetailItem() Model { } targetY := headerOffset + (selectedIndex * itemHeight) - vpH := m.account.detailViewport.Height - offset := m.account.detailViewport.YOffset + vpH := m.account.detailViewport.Height() + offset := m.account.detailViewport.YOffset() // 1-item buffer: scroll when the NEXT item would be off-screen, // not when the current item hits the very edge @@ -336,7 +336,7 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { {key: "q", value: "quit"}, } case 1: - m.account.orderYOffset = m.account.detailViewport.YOffset + m.account.orderYOffset = m.account.detailViewport.YOffset() m.account.orderViewState = 2 m.account.detailViewport.GotoTop() m.footer = []footerCommand{ diff --git a/pkg/tui/account_orders.go b/pkg/tui/account_orders.go index 69de3dc..12a588c 100644 --- a/pkg/tui/account_orders.go +++ b/pkg/tui/account_orders.go @@ -6,7 +6,7 @@ import ( "terminalShop/pkg/models" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) func (m Model) OrdersView(width int) string { diff --git a/pkg/tui/breadcrumbs.go b/pkg/tui/breadcrumbs.go index 189d7f7..c7548e3 100644 --- a/pkg/tui/breadcrumbs.go +++ b/pkg/tui/breadcrumbs.go @@ -1,7 +1,7 @@ package tui import ( - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) func (m Model) BuildBreadcrumbs() string { diff --git a/pkg/tui/cart.go b/pkg/tui/cart.go index 082b8f5..018fab8 100644 --- a/pkg/tui/cart.go +++ b/pkg/tui/cart.go @@ -3,9 +3,9 @@ package tui import ( "fmt" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) func (m Model) updateCartViewport() Model { @@ -17,12 +17,12 @@ func (m Model) updateCartViewport() Model { availH = 1 } if !m.cart.viewportReady { - m.cart.viewport = viewport.New(m.widthContent, availH) + m.cart.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH)) m.cart.viewport.KeyMap = viewport.KeyMap{} m.cart.viewportReady = true } else { - m.cart.viewport.Width = m.widthContent - m.cart.viewport.Height = availH + m.cart.viewport.SetWidth(m.widthContent) + m.cart.viewport.SetHeight(availH) } return m } @@ -116,11 +116,11 @@ func (m Model) CartView() string { } itemHeight := 4 + 2*boxPadding + 1 targetY := m.cart.cursor * itemHeight - if targetY < m.cart.viewport.YOffset { + if targetY < m.cart.viewport.YOffset() { m.cart.viewport.SetYOffset(targetY) } - if targetY+itemHeight > m.cart.viewport.YOffset+m.cart.viewport.Height { - m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height + itemHeight) + if targetY+itemHeight > m.cart.viewport.YOffset()+m.cart.viewport.Height() { + m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height() + itemHeight) } } return lipgloss.Place( diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index f6c0a8c..1ba431d 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -5,9 +5,9 @@ import ( "strings" "terminalShop/pkg/models" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) // RenderConfirmation renders the order confirmation view @@ -73,12 +73,12 @@ func (m Model) updateConfirmViewport() Model { availH = 1 } if !m.confirm.viewportReady { - m.confirm.viewport = viewport.New(m.widthContent, availH) + m.confirm.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH)) m.confirm.viewport.KeyMap = viewport.DefaultKeyMap() m.confirm.viewportReady = true } else { - m.confirm.viewport.Width = m.widthContent - m.confirm.viewport.Height = availH + m.confirm.viewport.SetWidth(m.widthContent) + m.confirm.viewport.SetHeight(availH) } return m } diff --git a/pkg/tui/footer.go b/pkg/tui/footer.go index 9710cd9..5709549 100644 --- a/pkg/tui/footer.go +++ b/pkg/tui/footer.go @@ -3,7 +3,7 @@ package tui import ( "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) func (m Model) BuildFooter() string { diff --git a/pkg/tui/header.go b/pkg/tui/header.go index fdf1e15..44a620a 100644 --- a/pkg/tui/header.go +++ b/pkg/tui/header.go @@ -3,8 +3,8 @@ package tui import ( "fmt" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/table" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" ) func (m Model) BuildHeader() string { @@ -44,7 +44,7 @@ func (m Model) BuildHeader() string { tabs = []string{logo, shop, account, cart} } - tbl := table.New().Border(lipgloss.NormalBorder()).BorderStyle(m.renderer.NewStyle().Foreground(m.theme.Border())).Row(tabs...).Width(m.widthContent).StyleFunc(func(row, col int) lipgloss.Style { + tbl := table.New().Border(lipgloss.NormalBorder()).BorderStyle(lipgloss.NewStyle().Foreground(m.theme.Border())).Row(tabs...).Width(m.widthContent).StyleFunc(func(row, col int) lipgloss.Style { return m.theme.Base().Padding(0, 1).AlignHorizontal(lipgloss.Center) }).Render() diff --git a/pkg/tui/help.go b/pkg/tui/help.go index d93c53b..e4327f4 100644 --- a/pkg/tui/help.go +++ b/pkg/tui/help.go @@ -1,8 +1,8 @@ package tui import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) func (m Model) BuildHelpView() string { diff --git a/pkg/tui/menu.go b/pkg/tui/menu.go index ba748f2..f2596f3 100644 --- a/pkg/tui/menu.go +++ b/pkg/tui/menu.go @@ -1,9 +1,9 @@ package tui import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "fmt" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) func (m Model) BuildMenuView() string { diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 9427eb1..9ec38e0 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -7,10 +7,9 @@ import ( "sort" "time" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" "github.com/stripe/stripe-go/v78" stripetoken "github.com/stripe/stripe-go/v78/token" @@ -114,8 +113,7 @@ type Model struct { APIClient *api.Client // Rendering - renderer *lipgloss.Renderer - theme theme.Theme + theme theme.Theme // Checkout flow state (spans multiple pages) CheckingOut bool @@ -240,7 +238,6 @@ func (m Model) CartSwitch() (Model, tea.Cmd) { m = m.SwitchPage(cartPage) m = m.updateCartViewport() if m.IsCartEmpty() { - m.footer = []footerCommand{ {key: "j/k", value: "items"}, {key: "+/-", value: "qty"}, @@ -755,18 +752,12 @@ func (m Model) SavedCardsIsEmpty() bool { return len(m.SavedCards) == 0 } -// NewModel creates a new model using the default renderer. +// NewModel creates a new model. func NewModel(username string) Model { - return newModelWithRenderer(username, lipgloss.DefaultRenderer()) -} - -// newModelWithRenderer creates a new model with the given renderer. -func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model { m := Model{ Username: username, currentPage: splashPage, - renderer: renderer, - theme: theme.BasicTheme(renderer), + theme: theme.BasicTheme(), Coffees: []models.Coffee{ { Name: "Espresso", @@ -833,8 +824,8 @@ func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model { } // NewModelWithAuth creates a new model with user authentication context. -func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model { - m := newModelWithRenderer("", renderer) +func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model { + m := NewModel("") m.Fingerprint = fingerprint m.AuthFingerprintKey = clientSecret m.StripePublicKey = stripePublicKey @@ -860,7 +851,7 @@ func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr } func newViewport(w, h int) viewport.Model { - return viewport.New(w, h) + return viewport.New(viewport.WithWidth(w), viewport.WithHeight(h)) } func (m Model) collectCardCmd() tea.Cmd { @@ -876,8 +867,10 @@ func (m Model) collectCardCmd() tea.Cmd { } } -const ordersPollInterval = 10 * time.Second -const cardPollTimeout = 10 * time.Minute +const ( + ordersPollInterval = 10 * time.Second + cardPollTimeout = 10 * time.Minute +) type OrdersPollTickMsg struct{} diff --git a/pkg/tui/payment_form.go b/pkg/tui/payment_form.go index 64f5a26..724876b 100644 --- a/pkg/tui/payment_form.go +++ b/pkg/tui/payment_form.go @@ -5,10 +5,10 @@ import ( "strings" "time" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + "charm.land/lipgloss/v2" "terminalShop/pkg/models" "terminalShop/pkg/tui/qrfefe" @@ -280,12 +280,12 @@ func (m Model) updatePaymentViewport() Model { availH = 1 } if !m.payment.viewportReady { - m.payment.viewport = viewport.New(m.widthContent, availH) + m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH)) m.payment.viewport.KeyMap = viewport.KeyMap{} m.payment.viewportReady = true } else { - m.payment.viewport.Width = m.widthContent - m.payment.viewport.Height = availH + m.payment.viewport.SetWidth(m.widthContent) + m.payment.viewport.SetHeight(availH) } return m } @@ -301,7 +301,7 @@ func (m Model) PaymentPageView() string { // Bypass viewport — it strips the raw ANSI codes qrfefe emits. return lipgloss.Place( m.widthContainer, - m.payment.viewport.Height, + m.payment.viewport.Height(), lipgloss.Center, lipgloss.Center, m.renderPaymentHttpsView(), ) @@ -310,11 +310,11 @@ func (m Model) PaymentPageView() string { m.payment.viewport.SetContent(content) itemHeight := 3 targetY := m.payment.cardCursor * itemHeight - if targetY < m.payment.viewport.YOffset { + if targetY < m.payment.viewport.YOffset() { m.payment.viewport.SetYOffset(targetY) } - if targetY+itemHeight > m.payment.viewport.YOffset+m.payment.viewport.Height { - m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height + itemHeight + 1) + if targetY+itemHeight > m.payment.viewport.YOffset()+m.payment.viewport.Height() { + m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height() + itemHeight + 1) } if m.payment.cardCursor == len(m.SavedCards) { m.payment.viewport.GotoBottom() diff --git a/pkg/tui/shipping_form.go b/pkg/tui/shipping_form.go index db8afdf..63d997a 100644 --- a/pkg/tui/shipping_form.go +++ b/pkg/tui/shipping_form.go @@ -6,10 +6,10 @@ import ( "terminalShop/pkg/models" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + "charm.land/lipgloss/v2" ) // countries defines the available countries with their dial codes. @@ -262,12 +262,12 @@ func (m Model) updateShippingViewport() Model { availH = 1 } if !m.shipping.viewportReady { - m.shipping.viewport = viewport.New(m.widthContent, availH) + m.shipping.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH)) m.shipping.viewport.KeyMap = viewport.KeyMap{} m.shipping.viewportReady = true } else { - m.shipping.viewport.Width = m.widthContent - m.shipping.viewport.Height = availH + m.shipping.viewport.SetWidth(m.widthContent) + m.shipping.viewport.SetHeight(availH) } return m } @@ -281,11 +281,11 @@ func (m Model) ShippingPageView() string { m.shipping.viewport.SetContent(content) itemHeight := 4 targetY := m.shipping.addressCursor * itemHeight - if targetY < m.shipping.viewport.YOffset { + if targetY < m.shipping.viewport.YOffset() { m.shipping.viewport.SetYOffset(targetY) } - if targetY+itemHeight > m.shipping.viewport.YOffset+m.shipping.viewport.Height { - m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height + itemHeight + 1) + if targetY+itemHeight > m.shipping.viewport.YOffset()+m.shipping.viewport.Height() { + m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height() + itemHeight + 1) } if m.shipping.addressCursor == len(m.SavedAddresses) { m.shipping.viewport.GotoBottom() diff --git a/pkg/tui/shop.go b/pkg/tui/shop.go index 33c6038..3a7aac8 100644 --- a/pkg/tui/shop.go +++ b/pkg/tui/shop.go @@ -4,9 +4,9 @@ import ( "fmt" "strings" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "terminalShop/pkg/models" ) @@ -55,10 +55,10 @@ func (m Model) updateShopViewports() Model { m.shop.detailViewport.KeyMap = modifiedKeyMap m.shop.viewportsReady = true } else { - m.shop.menuViewport.Width = menuWidth - m.shop.menuViewport.Height = availableHeight - m.shop.detailViewport.Width = detailWidth - m.shop.detailViewport.Height = availableHeight + m.shop.menuViewport.SetWidth(menuWidth) + m.shop.menuViewport.SetHeight(availableHeight) + m.shop.detailViewport.SetWidth(detailWidth) + m.shop.detailViewport.SetHeight(availableHeight) } return m } @@ -226,7 +226,7 @@ func (m Model) shopMoveSelected(previous bool) Model { if m.shop.viewportsReady { targetY := (m.shop.selected + 2) * 1 - m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height/2) + m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height()/2) m.shop.detailViewport.GotoTop() } return m diff --git a/pkg/tui/splash.go b/pkg/tui/splash.go index 00a0441..e222ad2 100644 --- a/pkg/tui/splash.go +++ b/pkg/tui/splash.go @@ -5,9 +5,9 @@ import ( "terminalShop/pkg/api" "terminalShop/pkg/models" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) type DelayCompleteMsg struct{} diff --git a/pkg/tui/styles.go b/pkg/tui/styles.go index f5ca40e..c42ad32 100644 --- a/pkg/tui/styles.go +++ b/pkg/tui/styles.go @@ -3,7 +3,7 @@ package tui import ( "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string { diff --git a/pkg/tui/theme/huh.go b/pkg/tui/theme/huh.go index 6135e74..22b4e2b 100644 --- a/pkg/tui/theme/huh.go +++ b/pkg/tui/theme/huh.go @@ -1,51 +1,50 @@ package theme import ( - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/help" + "charm.land/huh/v2" + "charm.land/lipgloss/v2" ) // HuhTheme creates a huh form theme using this project's color palette. -func HuhTheme(t Theme) *huh.Theme { - var th huh.Theme +func HuhTheme(t Theme) huh.Theme { + var s huh.Styles - th.FieldSeparator = t.renderer.NewStyle().SetString("\n\n") + s.FieldSeparator = lipgloss.NewStyle().SetString("\n\n") - f := &th.Focused - f.Base = t.renderer.NewStyle(). + f := &s.Focused + f.Base = lipgloss.NewStyle(). PaddingLeft(1). BorderStyle(lipgloss.ThickBorder()). BorderLeft(true). BorderForeground(t.accent) - f.Title = t.renderer.NewStyle().Foreground(t.body) - f.Description = t.renderer.NewStyle().Foreground(t.body) - f.TextInput.Cursor = t.renderer.NewStyle().Foreground(t.highlight) - f.TextInput.Placeholder = t.renderer.NewStyle().Foreground(t.dim) - f.TextInput.Prompt = t.renderer.NewStyle().Foreground(t.accent) - f.TextInput.Text = t.renderer.NewStyle().Foreground(t.accent) - f.ErrorIndicator = t.renderer.NewStyle().Foreground(t.error) - f.ErrorMessage = t.renderer.NewStyle().Foreground(t.error) + f.Title = lipgloss.NewStyle().Foreground(t.body) + f.Description = lipgloss.NewStyle().Foreground(t.body) + f.TextInput.Cursor = lipgloss.NewStyle().Foreground(t.highlight) + f.TextInput.Placeholder = lipgloss.NewStyle().Foreground(t.dim) + f.TextInput.Prompt = lipgloss.NewStyle().Foreground(t.accent) + f.TextInput.Text = lipgloss.NewStyle().Foreground(t.accent) + f.ErrorIndicator = lipgloss.NewStyle().Foreground(t.error) + f.ErrorMessage = lipgloss.NewStyle().Foreground(t.error) - f.SelectSelector = t.renderer.NewStyle().Foreground(t.accent).SetString("> ") - f.Option = t.renderer.NewStyle().Foreground(t.body) - f.SelectedOption = t.renderer.NewStyle().Foreground(t.highlight).Bold(true) - f.MultiSelectSelector = t.renderer.NewStyle().Foreground(t.accent).SetString("> ") - f.SelectedPrefix = t.renderer.NewStyle().Foreground(t.highlight).SetString("[x] ") - f.UnselectedPrefix = t.renderer.NewStyle().Foreground(t.dim).SetString("[ ] ") - f.UnselectedOption = t.renderer.NewStyle().Foreground(t.body) + f.SelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ") + f.Option = lipgloss.NewStyle().Foreground(t.body) + f.SelectedOption = lipgloss.NewStyle().Foreground(t.highlight).Bold(true) + f.MultiSelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ") + f.SelectedPrefix = lipgloss.NewStyle().Foreground(t.highlight).SetString("[x] ") + f.UnselectedPrefix = lipgloss.NewStyle().Foreground(t.dim).SetString("[ ] ") + f.UnselectedOption = lipgloss.NewStyle().Foreground(t.body) - th.Help = help.New().Styles + s.Help = help.New().Styles - th.Blurred = copyFieldStyles(*f) - th.Blurred.Base = th.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) - th.Blurred.Title = th.Blurred.Title.Foreground(t.body) - th.Blurred.SelectSelector = t.renderer.NewStyle().SetString(" ") - th.Blurred.SelectedOption = t.renderer.NewStyle().Foreground(t.success).Bold(true) - th.Blurred.Option = t.renderer.NewStyle().Foreground(t.dim) + s.Blurred = copyFieldStyles(*f) + s.Blurred.Base = s.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) + s.Blurred.Title = s.Blurred.Title.Foreground(t.body) + s.Blurred.SelectSelector = lipgloss.NewStyle().SetString(" ") + s.Blurred.SelectedOption = lipgloss.NewStyle().Foreground(t.success).Bold(true) + s.Blurred.Option = lipgloss.NewStyle().Foreground(t.dim) - return &th + return huh.ThemeFunc(func(bool) *huh.Styles { return &s }) } -// copyFieldStyles returns a copy of FieldStyles (value type, assignment is a copy). func copyFieldStyles(f huh.FieldStyles) huh.FieldStyles { return f } diff --git a/pkg/tui/theme/theme.go b/pkg/tui/theme/theme.go index d1a1539..617dc76 100644 --- a/pkg/tui/theme/theme.go +++ b/pkg/tui/theme/theme.go @@ -1,56 +1,55 @@ package theme import ( - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" + "image/color" + + "charm.land/huh/v2" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/compat" ) -// Theme holds all colors and styles for the terminal coffee shop TUI. +// Theme holds all colors and styles for the TUI type Theme struct { - renderer *lipgloss.Renderer - - background lipgloss.TerminalColor - border lipgloss.TerminalColor - body lipgloss.TerminalColor - dim lipgloss.TerminalColor - accent lipgloss.TerminalColor - highlight lipgloss.TerminalColor - brand lipgloss.TerminalColor - label lipgloss.TerminalColor - success lipgloss.TerminalColor - error lipgloss.TerminalColor + background color.Color + border color.Color + body color.Color + dim color.Color + accent color.Color + highlight color.Color + brand color.Color + label color.Color + success color.Color + error color.Color base lipgloss.Style - form *huh.Theme + form huh.Theme } -// BasicTheme constructs the default theme for terminal coffee shop. -func BasicTheme(renderer *lipgloss.Renderer) Theme { - t := Theme{renderer: renderer} +func BasicTheme() Theme { + t := Theme{} - t.background = lipgloss.AdaptiveColor{Dark: "#000000", Light: "#FBFCFD"} - t.border = lipgloss.AdaptiveColor{Dark: "#666666", Light: "#D7DBDF"} + t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#000000"), Light: lipgloss.Color("#FBFCFD")} + t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#666666"), Light: lipgloss.Color("#D7DBDF")} t.body = lipgloss.Color("#AAAAAA") t.dim = lipgloss.Color("#666666") - t.accent = lipgloss.AdaptiveColor{Dark: "#FFFFFF", Light: "#11181C"} + t.accent = compat.AdaptiveColor{Dark: lipgloss.Color("#FFFFFF"), Light: lipgloss.Color("#11181C")} t.highlight = lipgloss.Color("#4682B4") t.brand = lipgloss.Color("#4682B4") t.label = lipgloss.Color("#999999") t.success = lipgloss.Color("#00FF00") t.error = lipgloss.Color("196") - t.base = renderer.NewStyle().Foreground(t.body) + t.base = lipgloss.NewStyle().Foreground(t.body) t.form = HuhTheme(t) return t } -// this should resemble something like catppuccin -func Catppuccin(renderer *lipgloss.Renderer) Theme { - t := Theme{renderer: renderer} +func Catppuccin() Theme { + t := Theme{} - t.background = lipgloss.AdaptiveColor{Dark: "#1e1e2e", Light: "#eff1f5"} - t.border = lipgloss.AdaptiveColor{Dark: "#45475A", Light: "#BCC0CC"} + t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#1e1e2e"), Light: lipgloss.Color("#eff1f5")} + t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#45475A"), Light: lipgloss.Color("#BCC0CC")} t.body = lipgloss.Color("#AAAAAA") t.dim = lipgloss.Color("#6C7086") t.accent = lipgloss.Color("#CBA6F7") @@ -60,25 +59,25 @@ func Catppuccin(renderer *lipgloss.Renderer) Theme { t.success = lipgloss.Color("#A6E3A1") t.error = lipgloss.Color("#F38BA8") - t.base = renderer.NewStyle().Foreground(t.body) + t.base = lipgloss.NewStyle().Foreground(t.body) t.form = HuhTheme(t) + return t } -// Color accessors — use in .Foreground() / .Background() calls. -func (t Theme) Background() lipgloss.TerminalColor { return t.background } -func (t Theme) Border() lipgloss.TerminalColor { return t.border } -func (t Theme) Body() lipgloss.TerminalColor { return t.body } -func (t Theme) Dim() lipgloss.TerminalColor { return t.dim } -func (t Theme) Accent() lipgloss.TerminalColor { return t.accent } -func (t Theme) Highlight() lipgloss.TerminalColor { return t.highlight } -func (t Theme) Brand() lipgloss.TerminalColor { return t.brand } -func (t Theme) Label() lipgloss.TerminalColor { return t.label } -func (t Theme) Success() lipgloss.TerminalColor { return t.success } -func (t Theme) Error() lipgloss.TerminalColor { return t.error } +// Color accessors. +func (t Theme) Background() color.Color { return t.background } +func (t Theme) Border() color.Color { return t.border } +func (t Theme) Body() color.Color { return t.body } +func (t Theme) Dim() color.Color { return t.dim } +func (t Theme) Accent() color.Color { return t.accent } +func (t Theme) Highlight() color.Color { return t.highlight } +func (t Theme) Brand() color.Color { return t.brand } +func (t Theme) Label() color.Color { return t.label } +func (t Theme) Success() color.Color { return t.success } +func (t Theme) Error() color.Color { return t.error } -// Style helpers — return a copy of base with the correct foreground. -// Chain .Bold(), .Width(), .Padding() etc. on the returned style. +// Style helpers. func (t Theme) Base() lipgloss.Style { return t.base } func (t Theme) TextBody() lipgloss.Style { return t.Base().Foreground(t.body) } func (t Theme) TextAccent() lipgloss.Style { return t.Base().Foreground(t.accent) } @@ -90,10 +89,8 @@ func (t Theme) TextSuccess() lipgloss.Style { return t.Base().Foreground(t.suc func (t Theme) TextError() lipgloss.Style { return t.Base().Foreground(t.error) } func (t Theme) TextLoading() lipgloss.Style { return t.Base().Foreground(lipgloss.Color("205")) } -// PanelError returns a style for the error banner: dark red bg, red fg. func (t Theme) PanelError() lipgloss.Style { return t.Base().Background(lipgloss.Color("52")).Foreground(lipgloss.Color("196")) } -// Form returns the huh form theme for shipping and payment forms. -func (t Theme) Form() *huh.Theme { return t.form } +func (t Theme) Form() huh.Theme { return t.form } diff --git a/pkg/tui/update.go b/pkg/tui/update.go index 4f831d9..c333d11 100644 --- a/pkg/tui/update.go +++ b/pkg/tui/update.go @@ -6,7 +6,7 @@ import ( "terminalShop/pkg/models" "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { diff --git a/pkg/tui/views.go b/pkg/tui/views.go index 64630d5..9ab5b1e 100644 --- a/pkg/tui/views.go +++ b/pkg/tui/views.go @@ -1,7 +1,8 @@ package tui import ( - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) // ResizeView renders a message when the terminal is too small. @@ -19,7 +20,13 @@ func (m Model) ResizeView() string { ) } -func (m Model) View() string { +func (m Model) View() tea.View { + v := tea.NewView(m.renderString()) + v.AltScreen = true + return v +} + +func (m Model) renderString() string { if m.viewportWidth == 0 { m.updateLayout(120, 30) } -- 2.49.0 From bce94b6d10dc51d85217efc81f3fecbbe02885bd Mon Sep 17 00:00:00 2001 From: marcus06 Date: Mon, 25 May 2026 22:04:03 +0200 Subject: [PATCH 10/35] feat(refund): add refund request flow (#10) * feat(refund): add refund request flow - tui modal composer with reason select + free-form message (pkg/tui/refund.go) - POST /api/v1/orders/{id}/refund-request posts to orders slack thread - 15 min server side cooldown via Order.LastRefundRequestAt (429 + retry-after) - shared refundrequestreasons + can requestrefund hoisted to pkg/models - slack message includes customer name, email, order total, quoted body - tests over invalid-state, missing-message, and cooldown paths * style(tui): run gofmt to fix formatting --- api/handlers/orders.go | 92 ++++++++- api/handlers/orders_test.go | 170 +++++++++++++++++ api/handlers/slack.go | 10 +- api/handlers/webhook.go | 4 +- api/routes/routes.go | 1 + pkg/api/client.go | 50 +++++ pkg/models/order.go | 36 ++++ pkg/notify/slack.go | 20 +- pkg/tui/account.go | 13 +- pkg/tui/account_orders.go | 21 +- pkg/tui/model.go | 8 + pkg/tui/refund.go | 369 ++++++++++++++++++++++++++++++++++++ pkg/tui/update.go | 30 ++- pkg/tui/views.go | 8 + 14 files changed, 811 insertions(+), 21 deletions(-) create mode 100644 api/handlers/orders_test.go create mode 100644 pkg/tui/refund.go diff --git a/api/handlers/orders.go b/api/handlers/orders.go index b2bd03b..74c8748 100644 --- a/api/handlers/orders.go +++ b/api/handlers/orders.go @@ -2,9 +2,12 @@ package handlers import ( "encoding/json" + "fmt" + "log/slog" "net/http" "os" "strconv" + "strings" "time" "terminalShop/pkg/bring" @@ -31,6 +34,16 @@ type OrderHandler struct { bringCustomerNumber string } +type refundRequestPayload struct { + Reason string `json:"reason"` + Message string `json:"message"` +} + +// refundRequestCooldown is the minimum interval between customer-initiated +// refund requests for the same order. Prevents accidental double-clicks and +// repeated submissions from spamming the operator's Slack channel. +const refundRequestCooldown = 15 * time.Minute + func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler { return &OrderHandler{ stripeKey: stripeSecretKey, @@ -40,6 +53,83 @@ 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.SlackPostToOrderThread(order.ID, text); err != nil { + utils.RespondError(w, http.StatusBadGateway, "SLACK_ERROR", "failed to send refund request", nil) + return + } + + // Record the timestamp after a successful Slack post so a Slack outage + // doesn't consume the user's cooldown — they can retry immediately. + if err := db.Model(&order).Update("last_refund_request_at", time.Now()).Error; err != nil { + slog.Warn("refund request: failed to record timestamp", "order_id", order.ID, "error", err) + } + + utils.RespondSuccess(w, http.StatusOK, map[string]any{ + "status": "sent", + }) +} + func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) { db := database.GetDB().WithContext(r.Context()) userID := middleware.UserIDFromContext(r.Context()) @@ -50,7 +140,7 @@ func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) { 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, }) } diff --git a/api/handlers/orders_test.go b/api/handlers/orders_test.go new file mode 100644 index 0000000..d4aafef --- /dev/null +++ b/api/handlers/orders_test.go @@ -0,0 +1,170 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "terminalShop/pkg/database" + "terminalShop/pkg/models" +) + +func setupOrderTestDB(t *testing.T) models.User { + t.Helper() + testDB := filepath.Join(t.TempDir(), "orders.db") + database.ResetForTesting() + t.Cleanup(database.ResetForTesting) + + db, err := database.Connect(testDB) + if err != nil { + t.Fatalf("failed to connect to test database: %v", err) + } + if err := database.Migrate(db); err != nil { + t.Fatalf("failed to migrate: %v", err) + } + if err := database.Seed(db); err != nil { + t.Fatalf("failed to seed: %v", err) + } + + user := models.User{ + SSHKeyFingerprint: "SHA256:ordertestfingerprint", + SSHPublicKey: "ssh-ed25519 AAAA ordertestkey", + Name: "Order Test User", + Email: "orders@example.com", + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + return user +} + +func orderRequest(method, url string, body []byte, userID uint, orderID string) *http.Request { + req := authRequest(method, url, body, userID) + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("id", orderID) + return req.WithContext(contextWithRoute(req.Context(), routeCtx)) +} + +func contextWithRoute(ctx context.Context, routeCtx *chi.Context) context.Context { + return context.WithValue(ctx, chi.RouteCtxKey, routeCtx) +} + +func TestCreateRefundRequestRequiresMessageForOther(t *testing.T) { + user := setupOrderTestDB(t) + + handler := NewOrderHandler("", "", "", "") + body := []byte(`{"reason":"Other","message":" "}`) + req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, "1") + w := httptest.NewRecorder() + + handler.CreateRefundRequest(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } + + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Error.Code != "MESSAGE_REQUIRED" { + t.Errorf("expected MESSAGE_REQUIRED, got %q", resp.Error.Code) + } +} + +func TestCreateRefundRequestRejectsInvalidOrderState(t *testing.T) { + user := setupOrderTestDB(t) + + db := database.GetDB() + order := models.Order{ + UserID: user.ID, + CardID: 1, + Status: models.OrderStatusFailed, + Subtotal: 100, + ShippingCost: 0, + Total: 100, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("failed to create order: %v", err) + } + + handler := NewOrderHandler("", "", "", "") + body := []byte(`{"reason":"Quality issue","message":""}`) + req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID)) + w := httptest.NewRecorder() + + handler.CreateRefundRequest(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } + + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Error.Code != "INVALID_STATE" { + t.Errorf("expected INVALID_STATE, got %q", resp.Error.Code) + } +} + +func TestCreateRefundRequestEnforcesCooldown(t *testing.T) { + user := setupOrderTestDB(t) + + db := database.GetDB() + recent := time.Now().Add(-1 * time.Minute) + order := models.Order{ + UserID: user.ID, + CardID: 1, + Status: models.OrderStatusPaid, + Subtotal: 100, + ShippingCost: 0, + Total: 100, + LastRefundRequestAt: &recent, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("failed to create order: %v", err) + } + + handler := NewOrderHandler("", "", "", "") + body := []byte(`{"reason":"Quality issue","message":""}`) + req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID)) + w := httptest.NewRecorder() + + handler.CreateRefundRequest(w, req) + + if w.Code != http.StatusTooManyRequests { + t.Fatalf("expected status 429, got %d", w.Code) + } + if got := w.Header().Get("Retry-After"); got == "" { + t.Errorf("expected Retry-After header, got empty") + } + + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Error.Code != "RATE_LIMITED" { + t.Errorf("expected RATE_LIMITED, got %q", resp.Error.Code) + } +} diff --git a/api/handlers/slack.go b/api/handlers/slack.go index 83e8aa7..5659176 100644 --- a/api/handlers/slack.go +++ b/api/handlers/slack.go @@ -130,7 +130,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo resp, err := h.httpClient.Do(req) if err != nil { - notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err)) + _ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err)) return } defer func() { _ = resp.Body.Close() }() @@ -151,7 +151,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo _ = json.Unmarshal(bodyBytes, &result) if resp.StatusCode >= 300 { - notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message)) + _ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message)) return } @@ -173,7 +173,7 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay return } if order.TrackingNumber == "" { - notify.SlackPostToOrderThread(orderID, ":x: Cannot mark status, the order has no tracking number yet.") + _ = notify.SlackPostToOrderThread(orderID, ":x: Cannot mark status, the order has no tracking number yet.") return } @@ -198,12 +198,12 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay resp, err := h.httpClient.Do(req) if err != nil { - notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err)) + _ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err)) return } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 300 { - notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode)) + _ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode)) return } diff --git a/api/handlers/webhook.go b/api/handlers/webhook.go index ebe9b02..f67d4fa 100644 --- a/api/handlers/webhook.go +++ b/api/handlers/webhook.go @@ -373,7 +373,9 @@ func (h *WebhookHandler) HandleShippo(w http.ResponseWriter, r *http.Request) { if previousTrackingStatus != newStatus { audit.TrackingUpdated(order.ID, order.Carrier, payload.Data.TrackingNumber, string(newStatus)) - go notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails)) + go func() { + _ = notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails)) + }() } if deliveredTransition { audit.OrderDelivered(order.ID, payload.Data.TrackingNumber) diff --git a/api/routes/routes.go b/api/routes/routes.go index cad2fa4..83715f3 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -126,6 +126,7 @@ func SetupRoutes( // Orders r.Get("/orders", orderHandler.GetOrders) + r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest) r.Post("/orders/{id}/refund", orderHandler.RefundOrder) // Addresses diff --git a/pkg/api/client.go b/pkg/api/client.go index 9ccd4d9..a3742dc 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -636,6 +636,20 @@ type OrderResponse struct { Error *APIError `json:"error,omitempty"` } +// RefundRequest is the customer-facing refund request payload. +type RefundRequest struct { + Reason string `json:"reason"` + Message string `json:"message"` +} + +type refundRequestResponse struct { + Success bool `json:"success"` + Data struct { + Status string `json:"status"` + } `json:"data"` + Error *APIError `json:"error,omitempty"` +} + func (c *Client) GetOrders() ([]models.Order, error) { url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL) @@ -664,6 +678,42 @@ func (c *Client) GetOrders() ([]models.Order, error) { return ordersResp.Data.Orders, nil } +// CreateRefundRequest posts a refund request for an order. +func (c *Client) CreateRefundRequest(orderID uint, params RefundRequest) error { + url := fmt.Sprintf("%s/api/v1/orders/%d/refund-request", c.BaseURL, orderID) + + jsonData, err := json.Marshal(params) + if err != nil { + return fmt.Errorf("failed to marshal refund request: %w", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create refund request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.doRequest(req) + if err != nil { + return fmt.Errorf("failed to send refund request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var refundResp refundRequestResponse + if err := json.NewDecoder(resp.Body).Decode(&refundResp); err != nil { + return fmt.Errorf("failed to decode refund response: %w", err) + } + + if !refundResp.Success { + if refundResp.Error != nil { + return fmt.Errorf("%s: %s", refundResp.Error.Code, refundResp.Error.Message) + } + return fmt.Errorf("failed to send refund request") + } + + return nil +} + // The Addresses API response type AddressResponse struct { Success bool `json:"success"` diff --git a/pkg/models/order.go b/pkg/models/order.go index 9831983..3273c64 100644 --- a/pkg/models/order.go +++ b/pkg/models/order.go @@ -66,6 +66,10 @@ type Order struct { // Slack parent message ts for this order's thread (NULL if Slack notify disabled). SlackThreadTS *string `gorm:"size:64" json:"-"` + // LastRefundRequestAt timestamps the most recent customer-initiated refund + // request for this order. Used to rate-limit repeat submissions. + LastRefundRequestAt *time.Time `json:"-"` + CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel @@ -113,3 +117,35 @@ func (o *Order) TotalInDollars() float64 { func (o *Order) IsActive() bool { return o.Status == OrderStatusPaid || o.Status == OrderStatusShipped } + +// RefundRequestReasons is the canonical list of customer-facing refund reasons +// accepted by both the API and the TUI. +var RefundRequestReasons = []string{ + "Wrong item received", + "Item damaged in transit", + "Item never arrived", + "Quality issue", + "Other", +} + +// RefundRequestReasonOther is the sentinel reason that requires a free-form message. +const RefundRequestReasonOther = "Other" + +// IsValidRefundReason reports whether r is one of the accepted refund reasons. +func IsValidRefundReason(r string) bool { + for _, reason := range RefundRequestReasons { + if reason == r { + return true + } + } + return false +} + +// CanRequestRefund reports whether a customer is allowed to request a refund +// for this order. Paid, shipped, and delivered orders qualify; everything else +// is either still unpaid or already in a terminal state. +func (o *Order) CanRequestRefund() bool { + return o.Status == OrderStatusPaid || + o.Status == OrderStatusShipped || + o.Status == OrderStatusDelivered +} diff --git a/pkg/notify/slack.go b/pkg/notify/slack.go index aa80957..b94d579 100644 --- a/pkg/notify/slack.go +++ b/pkg/notify/slack.go @@ -188,29 +188,33 @@ func SlackUnpinOrder(orderID uint) { } // SlackPostToOrderThread posts a plain-text reply inside the order's thread. -// Looks up the thread ts from the database. Silent no-op if the order has no -// saved thread (Slack was disabled when it was created, or post failed earlier). -func SlackPostToOrderThread(orderID uint, text string) { +// It looks up the thread ts from the database and returns an error when the +// reply cannot be posted. +func SlackPostToOrderThread(orderID uint, text string) error { token := os.Getenv("SLACK_BOT_TOKEN") channel := os.Getenv("SLACK_CHANNEL") if token == "" || channel == "" { - return + return fmt.Errorf("slack is not configured") } var order models.Order if err := database.GetDB().Select("id", "slack_thread_ts").Where("id = ?", orderID).First(&order).Error; err != nil { slog.Warn("failed to look up order for thread post", "order_id", orderID, "error", err) - return + return fmt.Errorf("failed to look up order thread: %w", err) } if order.SlackThreadTS == nil || *order.SlackThreadTS == "" { - return + return fmt.Errorf("order has no slack thread") } - _, _ = postSlackMessage(token, map[string]any{ + if _, ok := postSlackMessage(token, map[string]any{ "channel": channel, "thread_ts": *order.SlackThreadTS, "text": text, - }) + }); !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) { diff --git a/pkg/tui/account.go b/pkg/tui/account.go index ec3e60e..589afbf 100644 --- a/pkg/tui/account.go +++ b/pkg/tui/account.go @@ -288,6 +288,13 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { m.account.detailViewport, cmd = m.account.detailViewport.Update(msg) return m, cmd + case "r": + if m.account.orderViewState == 2 { + if order, ok := m.selectedRefundableOrder(); ok { + return m.OpenRefundComposer(order.ID) + } + } + case "x", "d": if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) { m.account.addressDeleting = &m.account.addressCursor @@ -339,11 +346,15 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { m.account.orderYOffset = m.account.detailViewport.YOffset() m.account.orderViewState = 2 m.account.detailViewport.GotoTop() - m.footer = []footerCommand{ + footer := []footerCommand{ {key: "esc", value: "back"}, {key: "s", value: "shop"}, {key: "q", value: "quit"}, } + if _, ok := m.selectedRefundableOrder(); ok { + footer = append([]footerCommand{{key: "r", value: "refund"}}, footer...) + } + m.footer = footer } } case "addresses": diff --git a/pkg/tui/account_orders.go b/pkg/tui/account_orders.go index 12a588c..dd3b93f 100644 --- a/pkg/tui/account_orders.go +++ b/pkg/tui/account_orders.go @@ -37,6 +37,21 @@ func (m Model) HistoryOrders() []models.Order { return out } +// selectedRefundableOrder returns the order under the cursor in the active +// order-detail view, paired with ok=true, when the user is allowed to file a +// refund request for it. Returns (zero, false) otherwise. +func (m Model) selectedRefundableOrder() (models.Order, bool) { + orders := m.currentOrderList() + if m.account.orderCursor < 0 || m.account.orderCursor >= len(orders) { + return models.Order{}, false + } + order := orders[m.account.orderCursor] + if !order.CanRequestRefund() { + return models.Order{}, false + } + return order, true +} + func (m Model) currentOrderList() []models.Order { if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) { return nil @@ -241,7 +256,11 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string { b.WriteString("\n\n") // Footer hint - b.WriteString(dimStyle.Render("esc: back to orders")) + if order.CanRequestRefund() { + b.WriteString(dimStyle.Render("r: refund esc: back to orders")) + } else { + b.WriteString(dimStyle.Render("esc: back to orders")) + } return b.String() } diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 9ec38e0..9fedead 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -64,6 +64,11 @@ type VisibleError struct { message string } +// VisibleNotice is a typed success message displayed to the user as a banner. +type VisibleNotice struct { + message string +} + type Model struct { // User authentication User *models.User @@ -110,6 +115,7 @@ type Model struct { Loading bool // true when fetching data from API error *VisibleError + notice *VisibleNotice APIClient *api.Client // Rendering @@ -128,6 +134,7 @@ type Model struct { shipping shippingState payment paymentState account accountState + refund refundState review reviewState confirm confirmState @@ -217,6 +224,7 @@ func (m Model) SwitchPage(p page) Model { m.currentPage = p m.switched = true m.error = nil + m.notice = nil return m } diff --git a/pkg/tui/refund.go b/pkg/tui/refund.go new file mode 100644 index 0000000..d5a8550 --- /dev/null +++ b/pkg/tui/refund.go @@ -0,0 +1,369 @@ +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 +) + +// 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.form = m.buildRefundForm(state) + 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 (optional)..." + ta.ShowLineNumbers = true + ta.CharLimit = 65536 + ta.DynamicHeight = true + ta.MinHeight = refundMinTextareaRows + ta.MaxHeight = max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome)) + ta.SetHeight(refundMinTextareaRows) + ta.SetWidth(m.refundTextareaWidth()) + + base := lipgloss.NewStyle() + ta.SetStyles(textarea.Styles{ + Focused: textarea.StyleState{ + Base: base, + Text: base.Foreground(m.theme.Accent()), + LineNumber: base.Foreground(m.theme.Dim()), + CursorLine: base.Background(m.theme.Border()).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.MinHeight = refundMinTextareaRows + state.textarea.MaxHeight = max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome)) + 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. +func (m Model) RenderRefundOverlay() string { + state := &m.refund + contentWidth := m.refundContentWidth() + + title := m.theme.TextAccent(). + Bold(true). + Width(contentWidth). + Render(fmt.Sprintf("Refund request - Order #%d", state.orderID)) + + body := lipgloss.JoinVertical( + lipgloss.Left, + title, + "", + m.renderRefundReason(state), + "", + m.renderRefundTextarea(state), + "", + m.renderRefundFooter(state), + ) + + modal := lipgloss.NewStyle(). + Width(contentWidth). + Border(lipgloss.NormalBorder()). + BorderForeground(m.theme.Border()). + 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("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 (optional)..." + 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() + } + + counter := m.theme.TextDim(). + Width(m.refundTextareaWidth()). + Align(lipgloss.Right). + Render(fmt.Sprintf("%d chars", state.textarea.Length())) + + box := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(border). + Padding(0, 1). + Width(m.refundContentWidth()). + Render(state.textarea.View() + "\n" + counter) + + out := lipgloss.JoinVertical( + lipgloss.Left, + m.theme.TextLabel().Render(label), + box, + ) + 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) + } + return m.theme.TextDim().Render("tab next field | shift+tab prev | ctrl+s submit | esc 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) +} diff --git a/pkg/tui/update.go b/pkg/tui/update.go index c333d11..84888db 100644 --- a/pkg/tui/update.go +++ b/pkg/tui/update.go @@ -2,11 +2,12 @@ package tui import ( "fmt" - "terminalShop/pkg/api" - "terminalShop/pkg/models" "time" tea "charm.land/bubbletea/v2" + + "terminalShop/pkg/api" + "terminalShop/pkg/models" ) func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -36,6 +37,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.payment.form != nil { m.payment.form.form = m.buildPaymentForm(m.payment.form) } + if m.refund.open { + m.refund.form = m.buildRefundForm(&m.refund) + m.resizeRefundTextarea(&m.refund) + } } return m, nil } @@ -51,9 +56,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case VisibleError: m.error = &msg + m.notice = nil + return m, nil + case VisibleNotice: + m.notice = &msg + m.error = nil return m, nil case error: m.error = &VisibleError{message: msg.Error()} + m.notice = nil if m.currentPage == shopPage || m.currentPage == cartPage { return m, m.fetchCartCmd } @@ -167,9 +178,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.shipping.form != nil || m.payment.form != nil { break } - // Dismiss error banner with ESC - if msg.String() == "esc" && m.error != nil { + // Refund overlay owns input when open; dispatched below before page handlers. + if m.refund.open { + break + } + // Dismiss banners with ESC. + if msg.String() == "esc" && (m.error != nil || m.notice != nil) { m.error = nil + m.notice = nil return m, nil } // Global navigation @@ -192,6 +208,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // non-globals. fall through to page dispatch below } + + // Refund overlay intercepts all input after global handling. + if m.refund.open { + return m.RefundUpdate(msg) + } + // Page-specifics switch m.currentPage { case shopPage: diff --git a/pkg/tui/views.go b/pkg/tui/views.go index 9ab5b1e..8aff00b 100644 --- a/pkg/tui/views.go +++ b/pkg/tui/views.go @@ -84,6 +84,10 @@ func (m Model) renderString() string { errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1) errorBanner := errorStyle.Render(m.error.message) content = errorBanner + "\n" + m.buildPageContent(availableContentHeight) + } else if m.notice != nil { + noticeStyle := m.theme.TextSuccess().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Success()).Padding(0, 1).MarginBottom(1) + noticeBanner := noticeStyle.Render(m.notice.message) + content = noticeBanner + "\n" + m.buildPageContent(availableContentHeight) } else { content = m.buildPageContent(availableContentHeight) } @@ -115,6 +119,10 @@ func (m Model) renderString() string { MaxHeight(m.heightContainer). Render(child) + if m.refund.open { + constrained = m.RenderRefundOverlay() + } + // Center the entire container in the terminal viewport return lipgloss.Place( m.viewportWidth, -- 2.49.0 From a21485f91d2fe750cc1bccf7897bb22dfdccd44b Mon Sep 17 00:00:00 2001 From: marcus06 Date: Mon, 25 May 2026 22:34:25 +0200 Subject: [PATCH 11/35] fix(tui): way to much to write (#11) --- api/handlers/orders.go | 2 +- pkg/models/order_display.go | 22 +++++++++++++++++++- pkg/models/order_display_test.go | 10 ++++++++- pkg/notify/slack.go | 20 ++++++++++++++++-- pkg/tui/account_orders.go | 5 ++--- pkg/tui/refund.go | 35 ++++++++++++++++++-------------- 6 files changed, 71 insertions(+), 23 deletions(-) diff --git a/api/handlers/orders.go b/api/handlers/orders.go index 74c8748..1c79114 100644 --- a/api/handlers/orders.go +++ b/api/handlers/orders.go @@ -114,7 +114,7 @@ func (h *OrderHandler) CreateRefundRequest(w http.ResponseWriter, r *http.Reques text += "\n\n" + quoted } - if err := notify.SlackPostToOrderThread(order.ID, text); err != nil { + if err := notify.SlackPostToOrderThreadBroadcast(order.ID, text); err != nil { utils.RespondError(w, http.StatusBadGateway, "SLACK_ERROR", "failed to send refund request", nil) return } diff --git a/pkg/models/order_display.go b/pkg/models/order_display.go index 5a21f9e..b7ddb5e 100644 --- a/pkg/models/order_display.go +++ b/pkg/models/order_display.go @@ -11,10 +11,27 @@ const ( DisplayKindBrand DisplayKind = "brand" // shipped / in transit DisplayKindSuccess DisplayKind = "success" // delivered DisplayKindError DisplayKind = "error" // cancelled, failed, refunded - DisplayKindWarning DisplayKind = "warning" // refund pending, delivery issue + DisplayKindWarning DisplayKind = "warning" // delivery issue, returned + DisplayKindRefund DisplayKind = "refund" // refund requested, awaiting review ) +// refundPending reports whether the order has an open customer refund request +// that has not yet been resolved by a terminal status change. +func (order *Order) refundPending() bool { + if order.LastRefundRequestAt == nil { + return false + } + switch order.Status { + case OrderStatusRefunded, OrderStatusCancelled, OrderStatusFailed: + return false + } + return true +} + func (order *Order) DisplayState() string { + if order.refundPending() { + return "Refund Requested" + } switch order.Status { case OrderStatusPending: return "Awaiting payment" @@ -54,6 +71,9 @@ func (order *Order) DisplayState() string { } func (order *Order) DisplayKind() DisplayKind { + if order.refundPending() { + return DisplayKindRefund + } switch order.Status { case OrderStatusPending: return DisplayKindNeutral diff --git a/pkg/models/order_display_test.go b/pkg/models/order_display_test.go index d81d63e..6321292 100644 --- a/pkg/models/order_display_test.go +++ b/pkg/models/order_display_test.go @@ -1,8 +1,12 @@ package models -import "testing" +import ( + "testing" + "time" +) func TestOrderDisplayState(t *testing.T) { + now := time.Now() tests := []struct { name string order Order @@ -23,6 +27,10 @@ func TestOrderDisplayState(t *testing.T) { {"cancelled", Order{Status: OrderStatusCancelled}, "Cancelled", DisplayKindError}, {"refunded", Order{Status: OrderStatusRefunded}, "Refunded", DisplayKindError}, {"failed", Order{Status: OrderStatusFailed}, "Payment failed", DisplayKindError}, + {"paid with open refund request", Order{Status: OrderStatusPaid, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund}, + {"delivered with open refund request", Order{Status: OrderStatusDelivered, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund}, + {"refunded order ignores prior request", Order{Status: OrderStatusRefunded, LastRefundRequestAt: &now}, "Refunded", DisplayKindError}, + {"cancelled order ignores prior request", Order{Status: OrderStatusCancelled, LastRefundRequestAt: &now}, "Cancelled", DisplayKindError}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/pkg/notify/slack.go b/pkg/notify/slack.go index b94d579..86677fc 100644 --- a/pkg/notify/slack.go +++ b/pkg/notify/slack.go @@ -191,6 +191,17 @@ func SlackUnpinOrder(orderID uint) { // It looks up the thread ts from the database and returns an error when the // reply cannot be posted. func SlackPostToOrderThread(orderID uint, text string) error { + return slackPostToOrderThread(orderID, text, false) +} + +// SlackPostToOrderThreadBroadcast posts a thread reply that is also surfaced +// to the channel (reply_broadcast=true) so channel members get a normal +// notification. Use for messages that need immediate operator attention. +func SlackPostToOrderThreadBroadcast(orderID uint, text string) error { + return slackPostToOrderThread(orderID, text, true) +} + +func slackPostToOrderThread(orderID uint, text string, broadcast bool) error { token := os.Getenv("SLACK_BOT_TOKEN") channel := os.Getenv("SLACK_CHANNEL") if token == "" || channel == "" { @@ -206,11 +217,16 @@ func SlackPostToOrderThread(orderID uint, text string) error { return fmt.Errorf("order has no slack thread") } - if _, ok := postSlackMessage(token, map[string]any{ + payload := map[string]any{ "channel": channel, "thread_ts": *order.SlackThreadTS, "text": text, - }); !ok { + } + 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") } diff --git a/pkg/tui/account_orders.go b/pkg/tui/account_orders.go index dd3b93f..280ad5b 100644 --- a/pkg/tui/account_orders.go +++ b/pkg/tui/account_orders.go @@ -278,10 +278,9 @@ func (m Model) displayStyle(k models.DisplayKind) lipgloss.Style { case models.DisplayKindError: return m.theme.TextError() case models.DisplayKindWarning: - // !TODO add refunds and change this - // No warning color in theme yet, reuse loading pink as visual flag - // for now return m.theme.TextLoading() + case models.DisplayKindRefund: + return m.theme.TextBrand() } return m.theme.TextHighlight() } diff --git a/pkg/tui/refund.go b/pkg/tui/refund.go index d5a8550..ad827a5 100644 --- a/pkg/tui/refund.go +++ b/pkg/tui/refund.go @@ -164,10 +164,8 @@ func (m Model) newRefundTextarea() textarea.Model { ta.Placeholder = "Describe the issue (optional)..." ta.ShowLineNumbers = true ta.CharLimit = 65536 - ta.DynamicHeight = true - ta.MinHeight = refundMinTextareaRows - ta.MaxHeight = max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome)) - ta.SetHeight(refundMinTextareaRows) + ta.DynamicHeight = false + ta.SetHeight(m.refundTextareaHeight()) ta.SetWidth(m.refundTextareaWidth()) base := lipgloss.NewStyle() @@ -200,8 +198,7 @@ func (m Model) newRefundTextarea() textarea.Model { } func (m Model) resizeRefundTextarea(state *refundState) { - state.textarea.MinHeight = refundMinTextareaRows - state.textarea.MaxHeight = max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome)) + state.textarea.SetHeight(m.refundTextareaHeight()) state.textarea.SetWidth(m.refundTextareaWidth()) } @@ -323,22 +320,26 @@ func (m Model) renderRefundTextarea(state *refundState) string { border = m.theme.Highlight() } + wrapper := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + 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(m.refundTextareaWidth()). + Width(contentWidth). Align(lipgloss.Right). Render(fmt.Sprintf("%d chars", state.textarea.Length())) - box := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - BorderForeground(border). - Padding(0, 1). - Width(m.refundContentWidth()). - Render(state.textarea.View() + "\n" + counter) - out := lipgloss.JoinVertical( lipgloss.Left, m.theme.TextLabel().Render(label), - box, + textareaBox, + counter, ) if state.messageErr != "" { out += "\n" + m.theme.TextError().Render(state.messageErr) @@ -367,3 +368,7 @@ func (m Model) refundContentWidth() int { func (m Model) refundTextareaWidth() int { return max(1, m.refundContentWidth()-refundTextareaHPad) } + +func (m Model) refundTextareaHeight() int { + return max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome)) +} -- 2.49.0 From d9404ab31e0fb67812cb3fa293241adf83778c1d Mon Sep 17 00:00:00 2001 From: marcus06 Date: Mon, 25 May 2026 23:18:42 +0200 Subject: [PATCH 12/35] style(tui): update refund page ui (#12) --- pkg/tui/refund.go | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/pkg/tui/refund.go b/pkg/tui/refund.go index ad827a5..8245565 100644 --- a/pkg/tui/refund.go +++ b/pkg/tui/refund.go @@ -27,6 +27,15 @@ const ( 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 @@ -161,7 +170,7 @@ func (m Model) buildRefundForm(state *refundState) *huh.Form { func (m Model) newRefundTextarea() textarea.Model { ta := textarea.New() ta.Prompt = "" - ta.Placeholder = "Describe the issue (optional)..." + ta.Placeholder = "Describe the issue" ta.ShowLineNumbers = true ta.CharLimit = 65536 ta.DynamicHeight = false @@ -169,12 +178,15 @@ func (m Model) newRefundTextarea() textarea.Model { 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(m.theme.Border()).Foreground(m.theme.Accent()), + 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()), @@ -248,14 +260,19 @@ func (m Model) createRefundRequestCmd(orderID uint, gen uint64, reason string, m } // 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). - Render(fmt.Sprintf("Refund request - Order #%d", state.orderID)) + Border(lipgloss.NormalBorder(), false, false, true, false). + BorderForeground(m.theme.Highlight()). + Render(titleText) body := lipgloss.JoinVertical( lipgloss.Left, @@ -269,9 +286,6 @@ func (m Model) RenderRefundOverlay() string { ) modal := lipgloss.NewStyle(). - Width(contentWidth). - Border(lipgloss.NormalBorder()). - BorderForeground(m.theme.Border()). Padding(1, 2). Render(body) @@ -292,7 +306,7 @@ func (m Model) renderRefundReason(state *refundState) string { value = "Select a reason" } - label := m.theme.TextLabel().Render("Reason") + label := m.theme.TextLabel().Render(refundIconTag + " Reason") selected := m.theme.TextAccent(). Border(lipgloss.HiddenBorder()). PaddingLeft(1). @@ -308,7 +322,7 @@ func (m Model) renderRefundReason(state *refundState) string { func (m Model) renderRefundTextarea(state *refundState) string { label := "Message (optional)" - placeholder := "Describe the issue (optional)..." + placeholder := "Describe the issue" if state.reason == models.RefundRequestReasonOther { label = "Message (required)" placeholder = "Describe the issue..." @@ -321,7 +335,7 @@ func (m Model) renderRefundTextarea(state *refundState) string { } wrapper := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). + Border(lipgloss.ThickBorder()). BorderForeground(border) contentWidth := m.refundContentWidth() @@ -337,7 +351,7 @@ func (m Model) renderRefundTextarea(state *refundState) string { out := lipgloss.JoinVertical( lipgloss.Left, - m.theme.TextLabel().Render(label), + m.theme.TextLabel().Render(refundIconComment+" "+label), textareaBox, counter, ) @@ -354,7 +368,14 @@ func (m Model) renderRefundFooter(state *refundState) string { if state.err != "" { return m.theme.TextError().Render(state.err) } - return m.theme.TextDim().Render("tab next field | shift+tab prev | ctrl+s submit | esc cancel") + 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 { -- 2.49.0 From 0fe013c3993f78f6427bb408b51d8b8d1a829fa7 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Wed, 27 May 2026 18:09:50 +0200 Subject: [PATCH 13/35] feat(cart): pass off_session=true on PaymentIntent for SCA exemption eligibility (#13) --- .gitignore | 3 +++ api/handlers/cart.go | 1 + 2 files changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 90a48be..80748f9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ .env.example .env.prod +docs/sca-psd2-compliance.md +accept-a-payment/ + FULFILLMENT.md .tmp-gocache/ diff --git a/api/handlers/cart.go b/api/handlers/cart.go index e44a4db..515b62d 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -395,6 +395,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { Currency: stripe.String(string(stripe.CurrencyUSD)), Customer: stripe.String(user.StripeCustomerID), PaymentMethod: stripe.String(paymentMethodID), + OffSession: stripe.Bool(true), AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{ Enabled: stripe.Bool(true), AllowRedirects: stripe.String("never"), -- 2.49.0 From 95b9fa91b1e8af9cdf743e6ac5f8b59c409c55e2 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Wed, 27 May 2026 19:23:29 +0200 Subject: [PATCH 14/35] feat(sca): add order status endpoint adn SCA error code mapping (#14) * feat(sca): add order status endpoint adn SCA error code mapping adds server-side infra for the upcoming SCA / 3DS challenge flow im working on. No behavior change to existing checkout paths. * fix(sca): suppress unused lint on SCA scaffolding helpers --- api/handlers/cart.go | 35 ++++++++++++++++++++++++++++++++++- api/handlers/orders.go | 23 +++++++++++++++++++++++ api/routes/routes.go | 1 + pkg/api/client.go | 35 ++++++++++++++++++++++++++++++++++- pkg/audit/audit.go | 14 ++++++++++++++ 5 files changed, 106 insertions(+), 2 deletions(-) diff --git a/api/handlers/cart.go b/api/handlers/cart.go index 515b62d..09b61ec 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -430,7 +430,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { case stripe.ErrorTypeCard: middleware.RecordCartConversion("card_declined") audit.OrderFailed(userID, order.ID, total, stripeErr.Msg) - utils.RespondError(w, http.StatusPaymentRequired, "CARD_DECLINED", stripeErr.Msg, nil) + utils.RespondError(w, http.StatusPaymentRequired, mapStripeCardErrCode(stripeErr.Code), stripeErr.Msg, nil) case stripe.ErrorTypeInvalidRequest: middleware.RecordCartConversion("stripe_invalid_request") audit.OrderFailed(userID, order.ID, total, stripeErr.Msg) @@ -490,3 +490,36 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { "order": order, }) } + +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) +} diff --git a/api/handlers/orders.go b/api/handlers/orders.go index 1c79114..8739a80 100644 --- a/api/handlers/orders.go +++ b/api/handlers/orders.go @@ -145,6 +145,29 @@ func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) { }) } +func (h *OrderHandler) GetOrderStatus(w http.ResponseWriter, r *http.Request) { + db := database.GetDB().WithContext(r.Context()) + userID := middleware.UserIDFromContext(r.Context()) + + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil) + return + } + + var order models.Order + if err := db.Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil { + utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil) + return + } + + utils.RespondSuccess(w, http.StatusOK, map[string]any{ + "id": order.ID, + "status": string(order.Status), + }) +} + // RefundOrder issues a full refund for an order via Stripe and marks it as // refunded. Only orders in paid or shipped status can be refunded. func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) { diff --git a/api/routes/routes.go b/api/routes/routes.go index 83715f3..6f050d5 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -126,6 +126,7 @@ func SetupRoutes( // Orders r.Get("/orders", orderHandler.GetOrders) + r.Get("/orders/{id}/status", orderHandler.GetOrderStatus) r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest) r.Post("/orders/{id}/refund", orderHandler.RefundOrder) diff --git a/pkg/api/client.go b/pkg/api/client.go index a3742dc..20f2916 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -56,7 +56,6 @@ type TokenResponse struct { } func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) { - url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL) reqBody := TokenRequest{ @@ -497,6 +496,40 @@ func (c *Client) ConvertCart() (*models.Order, error) { return &convertResp.Data.Order, 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 +} + // GetCards fetches all saved cards for the user. func (c *Client) GetCards() ([]models.Card, error) { url := fmt.Sprintf("%s/api/v1/cards", c.BaseURL) diff --git a/pkg/audit/audit.go b/pkg/audit/audit.go index e2e5644..b2cad6e 100644 --- a/pkg/audit/audit.go +++ b/pkg/audit/audit.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "log/slog" + "terminalShop/pkg/models" "gorm.io/gorm" @@ -21,6 +22,7 @@ const ( eventOrderPaid eventType = "order_paid" eventOrderShipped eventType = "order_shipped" eventOrderFailed eventType = "order_failed" + eventOrderRequiresAction eventType = "order_requires_action" eventOrderRefunded eventType = "order_refunded" eventPaymentCritical eventType = "payment_critical" eventLabelPurchased eventType = "label_purchased" @@ -298,3 +300,15 @@ func attrsToMap(a []any) map[string]any { } return m } + +//nolint:unused // called from ConvertCart in the next update +func OrderRequiresAction(userID, orderID uint, paymentIntentID string) { + e := event{ + Event: eventOrderRequiresAction, + UserID: userID, + OrderID: orderID, + StripeID: paymentIntentID, + } + slog.Info("audit", e.attrs()...) + persist(orderID, eventOrderRequiresAction, e) +} -- 2.49.0 From a8e45bc82b3a5afaa55de149b98056d06aba2e9e Mon Sep 17 00:00:00 2001 From: marcus06 Date: Thu, 28 May 2026 10:28:47 +0200 Subject: [PATCH 15/35] feat(sca): map stripe error codes to user-facing copy in tui (#15) --- pkg/tui/confirmation.go | 24 +++++++++++++++++++++++- pkg/tui/views.go | 4 ++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index 1ba431d..a0f106c 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "strings" + "terminalShop/pkg/models" "charm.land/bubbles/v2/viewport" @@ -192,7 +193,7 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) { m.CheckingOut = false if msg.Err != nil { m = m.SwitchPage(paymentPage) - m.error = &VisibleError{message: fmt.Sprintf("checkout failed: %v", msg.Err)} + m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)} m.review.cardJustAdded = false m.payment.view = 0 m.payment.form = nil @@ -234,3 +235,24 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) { } return m, nil } + +func friendlyCheckoutError(raw error) string { + s := raw.Error() + switch { + 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_EXPIRED"): + return "card expired. Add a new card and try again" + case strings.Contains(s, "CARD_CVC_FAILED"): + return "card cvc incorrect. Check the back of the card and try again" + case strings.Contains(s, "CARD_INSUFFICIENT_FUNDS"): + return "insufficient funds on this card" + case strings.Contains(s, "CARD_PROCESSING_ERROR"): + return "card processing error. Try again in a moment" + case strings.Contains(s, "CARD_DECLINED"): + return "card declined" + } + return "checkout failed, try again." +} diff --git a/pkg/tui/views.go b/pkg/tui/views.go index 8aff00b..a68f512 100644 --- a/pkg/tui/views.go +++ b/pkg/tui/views.go @@ -82,6 +82,10 @@ func (m Model) renderString() string { content = loadingStyle.Render("Loading products from API...") } else if m.error != nil { errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1) + // soft-wrap long messages instead of clipping. + if m.widthContent > 4 { + errorStyle = errorStyle.Width(m.widthContent - 4) + } errorBanner := errorStyle.Render(m.error.message) content = errorBanner + "\n" + m.buildPageContent(availableContentHeight) } else if m.notice != nil { -- 2.49.0 From bba5a0dfa030c52ff1d26d56cd94d743806c25e4 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Thu, 28 May 2026 11:20:16 +0200 Subject: [PATCH 16/35] feat(sca): return 202 + 3ds redirect token when checkout requires auth (#16) --- api/handlers/cart.go | 57 ++++++++++++++++++++++++++++++++++++--- api/handlers/cart_test.go | 32 +++++++++++----------- api/routes/routes.go | 2 +- 3 files changed, 71 insertions(+), 20 deletions(-) diff --git a/api/handlers/cart.go b/api/handlers/cart.go index 09b61ec..ecd4415 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -24,11 +24,12 @@ import ( type CartHandler struct { stripeKey string + appURL string } // NewCartHandler creates a new cart handler with the Stripe secret key -func NewCartHandler(stripeSecretKey string) *CartHandler { - return &CartHandler{stripeKey: stripeSecretKey} +func NewCartHandler(stripeSecretKey, appURL string) *CartHandler { + return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL} } // getOrCreateCart finds the users cart or creates one. @@ -424,8 +425,12 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { stripeDur := time.Since(stripeStart).Seconds() if err != nil { middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur) - db.Model(&order).Update("status", models.OrderStatusFailed) if stripeErr, ok := err.(*stripe.Error); ok { + if stripeErr.Code == stripe.ErrorCodeAuthenticationRequired && stripeErr.PaymentIntent != nil { + h.respondRequiresAction(w, &order, stripeErr.PaymentIntent) + return + } + db.Model(&order).Update("status", models.OrderStatusFailed) switch stripeErr.Type { case stripe.ErrorTypeCard: middleware.RecordCartConversion("card_declined") @@ -442,6 +447,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { } return } + db.Model(&order).Update("status", models.OrderStatusFailed) middleware.RecordCartConversion("stripe_error") audit.OrderFailed(userID, order.ID, total, err.Error()) utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil) @@ -449,6 +455,11 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { } middleware.ObserveStripeRequest("payment_intent_create", "success", stripeDur) + if pi.Status == stripe.PaymentIntentStatusRequiresAction { + h.respondRequiresAction(w, &order, pi) + return + } + // Commit payment ID and clear cart in a single transaction. txErr := db.Transaction(func(tx *gorm.DB) error { order.StripePaymentID = pi.ID @@ -491,6 +502,46 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { }) } +// respondRequiresAction handles a PaymentIntent that needs 3DS auth +// The order stays pending. the webhook flips it to paid once the customer +// completes the bank-hosted challenge, the tui polls /api/v1/orders/{id}/status +func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, pi *stripe.PaymentIntent) { + db := database.GetDB() + + 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 before responding so the reconciliation jon + // and webhook can match the eventual succeeded/failed event to this order + // even if the customer drops off mid-auth + if err := db.Model(order).Update("stripe_payment_id", pi.ID).Error; err != nil { + utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil) + return + } + + token, err := storeRedirect(pi.NextAction.RedirectToURL.URL) + 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: diff --git a/api/handlers/cart_test.go b/api/handlers/cart_test.go index 819f42c..04559ea 100644 --- a/api/handlers/cart_test.go +++ b/api/handlers/cart_test.go @@ -65,7 +65,7 @@ func TestGetCartEmpty(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") req := authRequest("GET", "/api/v1/cart", nil, user.ID) w := httptest.NewRecorder() @@ -104,7 +104,7 @@ func TestSetItemAndGetCart(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") // Add item (CoffeeID=1, Quantity=2) body, _ := json.Marshal(map[string]interface{}{ @@ -160,7 +160,7 @@ func TestSetItemUpdateQuantity(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") // Add item body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2}) @@ -205,7 +205,7 @@ func TestSetItemRemove(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") // Add item body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3}) @@ -244,7 +244,7 @@ func TestSetItemInvalidProduct(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1}) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) @@ -261,7 +261,7 @@ func TestSetItemMissingCoffeeID(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") body, _ := json.Marshal(map[string]interface{}{"quantity": 1}) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) @@ -278,7 +278,7 @@ func TestClearCart(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") // Add two items for _, coffeeID := range []uint{1, 2} { @@ -334,7 +334,7 @@ func TestSetAddress(t *testing.T) { } db.Create(&addr) - handler := NewCartHandler("") + handler := NewCartHandler("", "") body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID}) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) @@ -388,7 +388,7 @@ func TestSetAddressWrongUser(t *testing.T) { } db.Create(&addr) - handler := NewCartHandler("") + handler := NewCartHandler("", "") body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID}) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) @@ -416,7 +416,7 @@ func TestSetCard(t *testing.T) { } db.Create(&card) - handler := NewCartHandler("") + handler := NewCartHandler("", "") body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID}) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) @@ -468,7 +468,7 @@ func TestSetCardWrongUser(t *testing.T) { } db.Create(&card) - handler := NewCartHandler("") + handler := NewCartHandler("", "") body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID}) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) @@ -485,7 +485,7 @@ func TestConvertCartMissingAddress(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") // Add item but no address or card body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1}) @@ -508,7 +508,7 @@ func TestConvertCartMissingCard(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") db := database.GetDB() // Add item and address @@ -539,7 +539,7 @@ func TestConvertCartEmpty(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") // Empty cart convert req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID) @@ -556,7 +556,7 @@ func TestMultipleItems(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("") + handler := NewCartHandler("", "") // Add three different items for i := uint(1); i <= 3; i++ { @@ -605,7 +605,7 @@ func TestCartIsolation(t *testing.T) { } db.Create(&user2) - handler := NewCartHandler("") + handler := NewCartHandler("", "") r := chi.NewRouter() r.Put("/cart/item", handler.SetItem) r.Get("/cart", handler.GetCart) diff --git a/api/routes/routes.go b/api/routes/routes.go index 6f050d5..144fd79 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -46,7 +46,7 @@ func SetupRoutes( healthHandler := handlers.NewHealthHandler(version) productHandler := handlers.NewProductHandler() authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey) - cartHandler := handlers.NewCartHandler(stripeSecretKey) + cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL) cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL) orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber) addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey) -- 2.49.0 From c2b2b6e8341f7f955587157e2bb9b0399c8095a2 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Thu, 28 May 2026 11:37:55 +0200 Subject: [PATCH 17/35] fix: allow redirect next_action so 3ds challenge url surfaces (#17) --- api/handlers/cart.go | 20 +++++++++----------- api/routes/routes.go | 4 ++++ 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/api/handlers/cart.go b/api/handlers/cart.go index ecd4415..c1620e3 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -392,17 +392,15 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { // Charge the card. The idempotency key is tied to the order ID so retries // never produce a double charge. piParams := &stripe.PaymentIntentParams{ - Amount: stripe.Int64(int64(total)), - Currency: stripe.String(string(stripe.CurrencyUSD)), - Customer: stripe.String(user.StripeCustomerID), - PaymentMethod: stripe.String(paymentMethodID), - OffSession: stripe.Bool(true), - AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{ - Enabled: stripe.Bool(true), - AllowRedirects: stripe.String("never"), - }, - Confirm: stripe.Bool(true), - Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)), + Amount: stripe.Int64(int64(total)), + Currency: stripe.String(string(stripe.CurrencyUSD)), + Customer: stripe.String(user.StripeCustomerID), + PaymentMethod: stripe.String(paymentMethodID), + OffSession: stripe.Bool(true), + PaymentMethodTypes: stripe.StringSlice([]string{"card"}), + ReturnURL: stripe.String(h.appURL + "/post-3ds"), + Confirm: stripe.Bool(true), + Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)), Shipping: &stripe.ShippingDetailsParams{ Name: stripe.String(address.Name), Address: &stripe.AddressParams{ diff --git a/api/routes/routes.go b/api/routes/routes.go index 144fd79..670efb8 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -61,6 +61,10 @@ func SetupRoutes( w.Header().Set("Content-Type", "text/html") _, _ = w.Write([]byte(`

Card added.

You can close this tab.

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

Authentication complete.

Return to your terminal.

`)) + }) r.Handle("/metrics", middleware.MetricsHandler()) r.Route("/api/v1", func(r chi.Router) { -- 2.49.0 From 8b2889a5242cf893a353be1b813ff8dbf6e6ea0b Mon Sep 17 00:00:00 2001 From: marcus06 Date: Thu, 28 May 2026 11:53:54 +0200 Subject: [PATCH 18/35] fix(sca): re-fetch payment intent when error response omits next_action (#18) --- api/handlers/cart.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/api/handlers/cart.go b/api/handlers/cart.go index c1620e3..227e7de 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -506,6 +506,16 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, pi *stripe.PaymentIntent) { db := database.GetDB() + // PaymentIntents returned inside a *stripe.Error are partial — next_action + // is often nil. Re-fetch from Stripe to get the canonical PI with the 3DS + // challenge URL populated. + if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" { + fresh, ferr := paymentintent.Get(pi.ID, nil) + if ferr == nil && fresh != nil { + pi = fresh + } + } + 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) -- 2.49.0 From e6301e662f428572ab81dc4cdf4411c67d3c3d79 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Thu, 28 May 2026 12:05:41 +0200 Subject: [PATCH 19/35] fix(sca): re-confirm payment intent on-session to surface 3ds next_action (#19) --- api/handlers/cart.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/api/handlers/cart.go b/api/handlers/cart.go index 227e7de..360b492 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -506,13 +506,18 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, pi *stripe.PaymentIntent) { db := database.GetDB() - // PaymentIntents returned inside a *stripe.Error are partial — next_action - // is often nil. Re-fetch from Stripe to get the canonical PI with the 3DS - // challenge URL populated. + // After an off-session authentication_required decline, the PI sits in + // status=requires_payment_method, not requires_action — there is no + // next_action to read. Re-confirm on-session so Stripe pushes the PI into + // requires_action and populates next_action.redirect_to_url with the 3DS + // challenge URL. if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" { - fresh, ferr := paymentintent.Get(pi.ID, nil) - if ferr == nil && fresh != nil { - pi = fresh + confirmed, cerr := paymentintent.Confirm(pi.ID, &stripe.PaymentIntentConfirmParams{ + OffSession: stripe.Bool(false), + ReturnURL: stripe.String(h.appURL + "/post-3ds"), + }) + if cerr == nil && confirmed != nil { + pi = confirmed } } -- 2.49.0 From 1c5765d25b003682f11cbbae04741a564688403a Mon Sep 17 00:00:00 2001 From: marcus06 Date: Thu, 28 May 2026 12:20:35 +0200 Subject: [PATCH 20/35] fix(sca): pass payment method on reconfirm so 3ds next_action surfaces (#20) --- api/handlers/cart.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/api/handlers/cart.go b/api/handlers/cart.go index 360b492..95786fa 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -425,7 +425,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur) if stripeErr, ok := err.(*stripe.Error); ok { if stripeErr.Code == stripe.ErrorCodeAuthenticationRequired && stripeErr.PaymentIntent != nil { - h.respondRequiresAction(w, &order, stripeErr.PaymentIntent) + h.respondRequiresAction(w, &order, paymentMethodID, stripeErr.PaymentIntent) return } db.Model(&order).Update("status", models.OrderStatusFailed) @@ -454,7 +454,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { middleware.ObserveStripeRequest("payment_intent_create", "success", stripeDur) if pi.Status == stripe.PaymentIntentStatusRequiresAction { - h.respondRequiresAction(w, &order, pi) + h.respondRequiresAction(w, &order, paymentMethodID, pi) return } @@ -503,18 +503,18 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { // respondRequiresAction handles a PaymentIntent that needs 3DS auth // The order stays pending. the webhook flips it to paid once the customer // completes the bank-hosted challenge, the tui polls /api/v1/orders/{id}/status -func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, pi *stripe.PaymentIntent) { +func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, paymentMethodID string, pi *stripe.PaymentIntent) { db := database.GetDB() // After an off-session authentication_required decline, the PI sits in - // status=requires_payment_method, not requires_action — there is no - // next_action to read. Re-confirm on-session so Stripe pushes the PI into - // requires_action and populates next_action.redirect_to_url with the 3DS - // challenge URL. + // 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{ - OffSession: stripe.Bool(false), - ReturnURL: stripe.String(h.appURL + "/post-3ds"), + PaymentMethod: stripe.String(paymentMethodID), + OffSession: stripe.Bool(false), + ReturnURL: stripe.String(h.appURL + "/post-3ds"), }) if cerr == nil && confirmed != nil { pi = confirmed -- 2.49.0 From 7c8a2b562f3efaed5385959ba8f4c36ba90a3342 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Thu, 28 May 2026 12:41:36 +0200 Subject: [PATCH 21/35] feat(sca): handle 3ds checkout end to end with qr poll and race fix (#21) * feat(sca): handle 3ds checkout end to end with qr poll and race fix * fix(sca): drop unused pollOrderStatusCmd helper --- api/handlers/cart.go | 12 ++++-- api/handlers/webhook.go | 10 +++++ pkg/api/client.go | 39 +++++++++++++++-- pkg/tui/confirmation.go | 94 +++++++++++++++++++++++++++++++++++++++++ pkg/tui/model.go | 48 +++++++++++++++++++-- 5 files changed, 193 insertions(+), 10 deletions(-) diff --git a/api/handlers/cart.go b/api/handlers/cart.go index 95786fa..18deca0 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -529,10 +529,14 @@ func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models return } - // persist the paymentIntent id before responding so the reconciliation jon - // and webhook can match the eventual succeeded/failed event to this order - // even if the customer drops off mid-auth - if err := db.Model(order).Update("stripe_payment_id", pi.ID).Error; err != nil { + // Persist the paymentIntent id AND reset status to pending. The + // payment_intent.payment_failed webhook from the initial off-session decline + // often arrives before this response and flips the order to failed — + // override it back so the 3DS-in-flight order shows the correct state. + if err := db.Model(order).Updates(map[string]any{ + "stripe_payment_id": pi.ID, + "status": models.OrderStatusPending, + }).Error; err != nil { utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil) return } diff --git a/api/handlers/webhook.go b/api/handlers/webhook.go index f67d4fa..06737c2 100644 --- a/api/handlers/webhook.go +++ b/api/handlers/webhook.go @@ -167,6 +167,16 @@ func (h *WebhookHandler) handlePaymentIntentFailed(ctx context.Context, event st return } + // authentication_required is recoverable — the cart handler re-confirms the + // PI on-session and the customer completes 3DS via redirect. Marking the + // order failed here would race with the recovery path and surface a + // momentarily-broken state in the TUI. + if pi.LastPaymentError != nil && string(pi.LastPaymentError.Code) == "authentication_required" { + webhookLog().Info("ignoring authentication_required payment_failed; handled via 3ds redirect", + "order_id", orderIDStr, "pi", pi.ID) + return + } + db := database.GetDB().WithContext(ctx) if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending). Update("status", models.OrderStatusFailed).Error; err != nil { diff --git a/pkg/api/client.go b/pkg/api/client.go index 20f2916..5009910 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -280,14 +280,31 @@ type CardResponse struct { } // ConvertCartResponse represents the cart conversion (checkout) API response. +// 200 carries Order populated; 202 (requires_action / 3DS) carries the +// redirect fields and OrderID instead. type ConvertCartResponse struct { Success bool `json:"success"` Data struct { - Order models.Order `json:"order"` + Order *models.Order `json:"order,omitempty"` + OrderID uint `json:"order_id,omitempty"` + PaymentIntentID string `json:"payment_intent_id,omitempty"` + Status string `json:"status,omitempty"` + RedirectToken string `json:"redirect_token,omitempty"` + RedirectURL string `json:"redirect_url,omitempty"` } `json:"data"` Error *APIError `json:"error,omitempty"` } +// CheckoutOutcome is the unified result of ConvertCart. RequiresAction=true +// means the customer needs to complete 3DS at RedirectURL; poll +// GetOrderStatus(OrderID) until the webhook flips the order to paid or failed. +type CheckoutOutcome struct { + Order *models.Order + OrderID uint + RequiresAction bool + RedirectURL string +} + // GetCart fetches the user's current cart. func (c *Client) GetCart() (*CartData, error) { url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL) @@ -467,7 +484,9 @@ func (c *Client) ClearCart() error { } // ConvertCart converts the cart into an order, charging the saved card. -func (c *Client) ConvertCart() (*models.Order, error) { +// Returns a CheckoutOutcome that either carries the completed Order or +// indicates the customer must complete 3DS authentication via RedirectURL. +func (c *Client) ConvertCart() (*CheckoutOutcome, error) { url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL) req, err := http.NewRequest("POST", url, nil) @@ -493,7 +512,21 @@ func (c *Client) ConvertCart() (*models.Order, error) { return nil, fmt.Errorf("failed to convert cart") } - return &convertResp.Data.Order, nil + if convertResp.Data.Status == "requires_action" { + return &CheckoutOutcome{ + OrderID: convertResp.Data.OrderID, + RequiresAction: true, + RedirectURL: convertResp.Data.RedirectURL, + }, nil + } + + if convertResp.Data.Order == nil { + return nil, fmt.Errorf("malformed convert response: missing order") + } + return &CheckoutOutcome{ + Order: convertResp.Data.Order, + OrderID: convertResp.Data.Order.ID, + }, nil } type OrderStatusResponse struct { diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index a0f106c..a40ce67 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -5,6 +5,7 @@ import ( "strings" "terminalShop/pkg/models" + "terminalShop/pkg/tui/qrfefe" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" @@ -169,6 +170,18 @@ func (m Model) ReviewView() string { if m.CheckingOut { return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...") } + if m.review.authFailed { + content := m.theme.TextError().Bold(true).Padding(1).Render("authentication failed — press esc to try again") + return lipgloss.Place( + m.widthContainer, + lipgloss.Height(content), + lipgloss.Center, lipgloss.Center, + content, + ) + } + if m.review.requiresAction { + return m.renderRequiresActionView() + } if m.review.success { content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!") return lipgloss.Place( @@ -187,6 +200,34 @@ 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(" waiting for authentication link...") + } + + qr, qrSize, err := qrfefe.Generate(m.review.redirectURL) + if err != nil || qrSize > m.widthContent { + body := header + "\n\n" + + base.Render("open this url on your phone to authenticate:") + "\n\n" + + accent.Render(m.review.redirectURL) + "\n\n" + + base.Render("waiting for completion...") + return lipgloss.Place(m.widthContainer, lipgloss.Height(body), lipgloss.Center, lipgloss.Center, body) + } + + // Same gotcha as payment_form.go: lipgloss layout helpers corrupt qrfefe ANSI. + return qr + + header + "\n" + + base.Render("scan or visit to complete authentication") + "\n" + + accent.Render(m.review.redirectURL) + "\n\n" + + base.Render("waiting for completion...") +} + func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) { switch msg := msg.(type) { case CheckoutResultMsg: @@ -199,6 +240,12 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.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.cursor = 0 m.ShippingInfo = nil @@ -206,10 +253,57 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) { m.review.success = true return m, nil + case OrderStatusMsg: + if !m.review.requiresAction || msg.OrderID != m.review.orderID { + return m, nil + } + if msg.Err != nil { + // Transient — keep polling. + return m, m.schedulePollOrderStatus(msg.OrderID) + } + switch msg.Status { + case "paid", "shipped", "delivered": + m.Cart = make(map[uint]*models.CartItem) + m.cart.cursor = 0 + m.ShippingInfo = nil + m.OrdersLoaded = false + m.review.requiresAction = false + m.review.redirectURL = "" + m.review.success = true + return m, nil + case "failed", "cancelled": + m.review.requiresAction = false + m.review.redirectURL = "" + m.review.authFailed = true + return m, nil + default: + return m, m.schedulePollOrderStatus(msg.OrderID) + } + case tea.KeyMsg: if m.CheckingOut { return m, nil } + if m.review.authFailed { + if msg.String() == "esc" || msg.String() == "enter" { + 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.requiresAction { + if msg.String() == "esc" { + m.review.requiresAction = false + m.review.redirectURL = "" + m = m.SwitchPage(paymentPage) + m.payment.view = 0 + return m, nil + } + return m, nil + } if m.review.success { m.review.success = false m.review.cardJustAdded = false diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 9fedead..bc9c568 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -202,6 +202,15 @@ type reviewState struct { cardJustAdded bool cardWasDuplicate bool success bool + + // 3DS / SCA flow: when the backend returns 202 requires_action, + // requiresAction is true and redirectURL is the Stripe-hosted challenge + // page. The TUI shows a QR and polls GetOrderStatus(orderID) until the + // order flips to paid or failed. + requiresAction bool + redirectURL string + orderID uint + authFailed bool } type confirmState struct { @@ -418,8 +427,18 @@ func (m Model) Init() tea.Cmd { } type CheckoutResultMsg struct { + OrderID uint + Total int + RequiresAction bool + RedirectURL string + Err error +} + +// OrderStatusMsg carries the result of a poll against /orders/{id}/status. +// Emitted on the 2s tick while waiting for 3DS authentication to complete. +type OrderStatusMsg struct { OrderID uint - Total int + Status string Err error } @@ -631,15 +650,38 @@ func (m Model) checkoutWithSavedCard() tea.Cmd { } // 3. Convert the cart to an order - order, err := m.APIClient.ConvertCart() + outcome, err := m.APIClient.ConvertCart() if err != nil { return CheckoutResultMsg{Err: err} } - return CheckoutResultMsg{OrderID: order.ID, Total: order.Total} + if outcome.RequiresAction { + return CheckoutResultMsg{ + OrderID: outcome.OrderID, + RequiresAction: true, + RedirectURL: outcome.RedirectURL, + } + } + + return CheckoutResultMsg{OrderID: outcome.Order.ID, Total: outcome.Order.Total} } } +// schedulePollOrderStatus sleeps 2s then polls /orders/{id}/status. Used while +// waiting for the customer to complete 3DS authentication. +func (m Model) schedulePollOrderStatus(orderID uint) tea.Cmd { + return tea.Tick(2*time.Second, func(time.Time) tea.Msg { + if m.APIClient == nil { + return OrderStatusMsg{OrderID: orderID, Err: fmt.Errorf("api client unavailable")} + } + status, err := m.APIClient.GetOrderStatus(orderID) + if err != nil { + return OrderStatusMsg{OrderID: orderID, Err: err} + } + return OrderStatusMsg{OrderID: orderID, Status: status} + }) +} + func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, tea.Cmd) { if m.APIClient == nil || m.User == nil { return m, nil -- 2.49.0 From 3a3c7ee80b097be0b68ad643e019e6efe1e7ff30 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Sat, 30 May 2026 14:12:57 +0200 Subject: [PATCH 22/35] feat(sca): persist redirect tokens to sqlite with 10min ttl (#22) --- api/handlers/cards.go | 40 +++++++++++++++++++++++--------------- api/handlers/cart.go | 2 +- pkg/database/migrate.go | 2 ++ pkg/models/pay_redirect.go | 23 ++++++++++++++++++++++ 4 files changed, 50 insertions(+), 17 deletions(-) create mode 100644 pkg/models/pay_redirect.go diff --git a/api/handlers/cards.go b/api/handlers/cards.go index 8f05b08..436ae3d 100644 --- a/api/handlers/cards.go +++ b/api/handlers/cards.go @@ -7,7 +7,7 @@ import ( "fmt" "net/http" "strconv" - "sync" + "time" "github.com/go-chi/chi/v5" "github.com/stripe/stripe-go/v78" @@ -24,35 +24,43 @@ import ( stripeSession "github.com/stripe/stripe-go/v78/checkout/session" ) -// payRedirects maps short tokens to full Stripe checkout URLs. -var ( - payRedirects = map[string]string{} - payRedirectsMu sync.Mutex -) +const RedirectTTL = 10 * time.Minute -func storeRedirect(stripeURL string) (string, error) { +func storeRedirect(stripeURL, purpose string) (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", err } token := hex.EncodeToString(b) - payRedirectsMu.Lock() - payRedirects[token] = stripeURL - payRedirectsMu.Unlock() + now := time.Now() + rec := models.PayRedirect{ + Token: token, + URL: stripeURL, + Purpose: purpose, + CreatedAt: now, + ExpiresAt: now.Add(RedirectTTL), + } + if err := database.GetDB().Create(&rec).Error; err != nil { + return "", err + } return token, nil } // PayRedirect resolves a short token and redirects to the Stripe checkout URL. func PayRedirect(w http.ResponseWriter, r *http.Request) { token := chi.URLParam(r, "token") - payRedirectsMu.Lock() - url, ok := payRedirects[token] - payRedirectsMu.Unlock() - if !ok { + db := database.GetDB().WithContext(r.Context()) + var rec models.PayRedirect + if err := db.Where("token = ?", token).First(&rec).Error; err != nil { http.NotFound(w, r) return } - http.Redirect(w, r, url, http.StatusFound) + if time.Now().After(rec.ExpiresAt) { + db.Where("token = ?", rec.Token).Delete(&models.PayRedirect{}) + http.NotFound(w, r) + return + } + http.Redirect(w, r, rec.URL, http.StatusFound) } // CardHandler handles saved payment method CRUD. @@ -168,7 +176,7 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) { return } - token, err := storeRedirect(sess.URL) + token, err := storeRedirect(sess.URL, models.RedirectPurposeAddCard) if err != nil { utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil) return diff --git a/api/handlers/cart.go b/api/handlers/cart.go index 18deca0..1019eed 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -541,7 +541,7 @@ func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models return } - token, err := storeRedirect(pi.NextAction.RedirectToURL.URL) + token, err := storeRedirect(pi.NextAction.RedirectToURL.URL, models.RedirectPurposeCheckout3DS) if err != nil { utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to allocate redirect token", nil) return diff --git a/pkg/database/migrate.go b/pkg/database/migrate.go index 1ba5131..137d988 100644 --- a/pkg/database/migrate.go +++ b/pkg/database/migrate.go @@ -3,6 +3,7 @@ package database import ( "fmt" "log" + "terminalShop/pkg/models" "gorm.io/gorm" @@ -24,6 +25,7 @@ func Migrate(db *gorm.DB) error { &models.OrderItem{}, // Line items within orders &models.Address{}, // Saved addresses &models.OrderEvent{}, + &models.PayRedirect{}, // short-token -> stripe url store (10 min TTL) ); err != nil { return fmt.Errorf("migration failed: %w", err) } diff --git a/pkg/models/pay_redirect.go b/pkg/models/pay_redirect.go new file mode 100644 index 0000000..b0eb64d --- /dev/null +++ b/pkg/models/pay_redirect.go @@ -0,0 +1,23 @@ +package models + +import "time" + +// PayRedirect maps a short token to a stripe hosted url with a TTL. +// survives api restarts so a customer mid-3Ds authentication still +// lands on the bank-hosted challenge page after a deploy or crash +type PayRedirect struct { + Token string `gorm:"primaryKey;size:64" json:"token"` + URL string `gorm:"type:text;not null" json:"url"` + Purpose string `gorm:"size:32;not null" json:"purpose"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `gorm:"index" json:"expires_at"` +} + +const ( + RedirectPurposeCheckout3DS = "checkout_3ds" + RedirectPurposeAddCard = "add_card" +) + +func (PayRedirect) TableName() string { + return "pay_redirects" +} -- 2.49.0 From c8b7545ca6921fb8c6528df06fcc2f223210dd95 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Sat, 30 May 2026 19:48:20 +0200 Subject: [PATCH 23/35] chore(ci): skip golangci-lint under act to avoid node path issue (#23) --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e52b1e..8df2fa3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: fi - name: Lint + if: ${{ !env.ACT }} uses: golangci/golangci-lint-action@v7 with: version: v2.4.0 -- 2.49.0 From 03009ea6df54e7d3c2053ce70cb181d5630d27a8 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Sat, 30 May 2026 23:19:29 +0200 Subject: [PATCH 24/35] chore(ci): bump checkout and setup-go to node24-ready majors (#24) --- .github/workflows/ci.yml | 4 ++-- .github/workflows/deploy.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8df2fa3..75923d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,9 +8,9 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d0b5e5b..d66ad0d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,8 +8,8 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version: '1.23' - name: Run tests -- 2.49.0 From c3e6eb2b8424e856b2cb5a3d7b74a20a5372c8b5 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Sat, 30 May 2026 23:29:19 +0200 Subject: [PATCH 25/35] fix(ci): pin deploy go-version to go.mod to match ci.yml (#25) --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d66ad0d..5888ac2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,7 +11,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: '1.23' + go-version-file: go.mod - name: Run tests run: go test ./... - name: Deploy to server -- 2.49.0 From 2f65cf0d90870b2d031129d02af3f2190fc8f560 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Mon, 1 Jun 2026 22:40:20 +0200 Subject: [PATCH 26/35] feat(sca): cap per-order total ro prevent oversize charges (#26) --- api/handlers/cart.go | 20 ++++-- api/handlers/cart_test.go | 132 +++++++++++++++++++++++++++++++++----- api/main.go | 2 +- api/routes/routes.go | 3 +- pkg/config/config.go | 20 ++++++ pkg/config/config_test.go | 34 ++++++++++ pkg/tui/confirmation.go | 2 + 7 files changed, 191 insertions(+), 22 deletions(-) create mode 100644 pkg/config/config_test.go diff --git a/api/handlers/cart.go b/api/handlers/cart.go index 1019eed..6b0ad61 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -23,13 +23,14 @@ import ( ) type CartHandler struct { - stripeKey string - appURL string + stripeKey string + appURL string + maxOrderCents int } // NewCartHandler creates a new cart handler with the Stripe secret key -func NewCartHandler(stripeSecretKey, appURL string) *CartHandler { - return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL} +func NewCartHandler(stripeSecretKey, appURL string, maxOrderCents int) *CartHandler { + return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL, maxOrderCents: maxOrderCents} } // getOrCreateCart finds the users cart or creates one. @@ -354,6 +355,17 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { total := subtotal + cart.ShippingCost + if h.maxOrderCents > 0 && total > h.maxOrderCents { + middleware.RecordCartConversion("validation_over_limit") + utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT", + fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100), + map[string]interface{}{ + "total_cents": total, + "limit_cents": h.maxOrderCents, + }) + return + } + if total < 50 { middleware.RecordCartConversion("validation_min_amount") utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil) diff --git a/api/handlers/cart_test.go b/api/handlers/cart_test.go index 04559ea..478d127 100644 --- a/api/handlers/cart_test.go +++ b/api/handlers/cart_test.go @@ -65,7 +65,7 @@ func TestGetCartEmpty(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) req := authRequest("GET", "/api/v1/cart", nil, user.ID) w := httptest.NewRecorder() @@ -104,7 +104,7 @@ func TestSetItemAndGetCart(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) // Add item (CoffeeID=1, Quantity=2) body, _ := json.Marshal(map[string]interface{}{ @@ -160,7 +160,7 @@ func TestSetItemUpdateQuantity(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) // Add item body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2}) @@ -205,7 +205,7 @@ func TestSetItemRemove(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) // Add item body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3}) @@ -244,7 +244,7 @@ func TestSetItemInvalidProduct(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1}) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) @@ -261,7 +261,7 @@ func TestSetItemMissingCoffeeID(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) body, _ := json.Marshal(map[string]interface{}{"quantity": 1}) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) @@ -278,7 +278,7 @@ func TestClearCart(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) // Add two items for _, coffeeID := range []uint{1, 2} { @@ -334,7 +334,7 @@ func TestSetAddress(t *testing.T) { } db.Create(&addr) - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID}) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) @@ -388,7 +388,7 @@ func TestSetAddressWrongUser(t *testing.T) { } db.Create(&addr) - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID}) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) @@ -416,7 +416,7 @@ func TestSetCard(t *testing.T) { } db.Create(&card) - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID}) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) @@ -468,7 +468,7 @@ func TestSetCardWrongUser(t *testing.T) { } db.Create(&card) - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID}) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) @@ -485,7 +485,7 @@ func TestConvertCartMissingAddress(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) // Add item but no address or card body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1}) @@ -508,7 +508,7 @@ func TestConvertCartMissingCard(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) db := database.GetDB() // Add item and address @@ -539,7 +539,7 @@ func TestConvertCartEmpty(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) // Empty cart convert req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID) @@ -556,7 +556,7 @@ func TestMultipleItems(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) // Add three different items for i := uint(1); i <= 3; i++ { @@ -605,7 +605,7 @@ func TestCartIsolation(t *testing.T) { } db.Create(&user2) - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) r := chi.NewRouter() r.Put("/cart/item", handler.SetItem) r.Get("/cart", handler.GetCart) @@ -664,3 +664,103 @@ func TestCartIsolation(t *testing.T) { t.Errorf("User 2 expected coffee_id 2, got %d", resp.Data.Cart.Items[0].CoffeeID) } } + +func TestConvertCartLimit(t *testing.T) { + cases := []struct { + name string + capCents int + quantity int + expectOverCap bool + }{ + {"over cap rejects", 20000, 41, true}, // $205 + {"at cap accepts", 20000, 40, false}, // $200 boundary + {"under cap accepts", 20000, 39, false}, // $195 + {"zero cap disables", 0, 1000, false}, // explicit off-switch + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + db := database.GetDB() + user.StripeCustomerID = "cus_test_overlimit" + if err := db.Save(&user).Error; err != nil { + t.Fatalf("saved user: %v", err) + } + + addr := models.Address{ + UserID: user.ID, Name: "Test", Street: "1 Main St", + City: "PDX", State: "OR", Zip: "97201", Country: "US", + } + if err := db.Create(&addr).Error; err != nil { + t.Fatalf("seed address: %v", err) + } + + card := models.Card{ + UserID: user.ID, StripePaymentID: "pm_test_overlimit", + Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030, + } + if err := db.Create(&card).Error; err != nil { + t.Fatalf("seed card: %v", err) + } + + handler := NewCartHandler("", "", tc.capCents) + + body, _ := json.Marshal(map[string]interface{}{"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]interface{}{"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]interface{}{"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]interface{} `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*500 { + t.Errorf("total_cents in details: want %d, got %v", tc.quantity*500, resp.Error.Details["total_cents"]) + } + } + }) + } +} diff --git a/api/main.go b/api/main.go index cbbc50b..450542b 100644 --- a/api/main.go +++ b/api/main.go @@ -71,7 +71,7 @@ func main() { jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute) // Setup routes - router := routes.SetupRoutes(version, cfg.StripeSecretKey, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL, cfg.SlackSigningSecret, cfg.AdminAPIKey, cfg.APIPort) + router := routes.SetupRoutes(version, cfg.StripeSecretKey, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL, cfg.SlackSigningSecret, cfg.AdminAPIKey, cfg.APIPort, cfg.MaxOrderCents) // Create HTTP server server := &http.Server{ diff --git a/api/routes/routes.go b/api/routes/routes.go index 670efb8..818b164 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -29,6 +29,7 @@ func SetupRoutes( slackSigningSecret string, adminAPIKey string, apiPort string, + maxOrderCents int, ) *chi.Mux { r := chi.NewRouter() @@ -46,7 +47,7 @@ func SetupRoutes( healthHandler := handlers.NewHealthHandler(version) productHandler := handlers.NewProductHandler() authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey) - cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL) + cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL, maxOrderCents) cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL) orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber) addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey) diff --git a/pkg/config/config.go b/pkg/config/config.go index 6ca5bff..8a84fe7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "os" + "strconv" "strings" "github.com/joho/godotenv" @@ -30,6 +31,7 @@ type Config struct { ShippoWebhookSecret string SlackSigningSecret string AdminAPIKey string + MaxOrderCents int } // Load reads configuration from environment variables and validates required secrets. @@ -66,6 +68,7 @@ func Load() (*Config, error) { ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"), SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"), AdminAPIKey: os.Getenv("ADMIN_API_KEY"), + MaxOrderCents: loadMaxOrderCents(), } if err := cfg.validate(); err != nil { @@ -130,3 +133,20 @@ func getEnvOrDefault(key, defaultValue string) string { } return defaultValue } + +// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $200. Explicit 0 disables +// the cap. Garbage or negative values fall back to the default with a warning +// so an operator typo cannot silently disable a fraud control. +func loadMaxOrderCents() int { + const defaultCap = 20000 + raw := os.Getenv("MAX_ORDER_CENTS") + if raw == "" { + return defaultCap + } + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + fmt.Printf("WARNING: MAX_ORDER_CENTS=%q invalid, falling back to default %d\n", raw, defaultCap) + return defaultCap + } + return n +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..7592f6b --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,34 @@ +package config + +import ( + "testing" +) + +func TestLoadMaxOrderCents(t *testing.T) { + cases := []struct { + name string + env string // "" means unset + want int + }{ + {"unset uses default", "", 20000}, + {"explicit zero disables", "0", 0}, + {"valid override", "50000", 50000}, + {"negative falls back to default", "-1", 20000}, + {"garbage falls back to default", "two hundred", 20000}, + {"empty after trim falls back to default", "", 20000}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.env == "" { + t.Setenv("MAX_ORDER_CENTS", "") + } else { + t.Setenv("MAX_ORDER_CENTS", tc.env) + } + got := loadMaxOrderCents() + if got != tc.want { + t.Errorf("loadMaxOrderCents() = %d, want %d", got, tc.want) + } + }) + } +} diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index a40ce67..b1c27d0 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -333,6 +333,8 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) { 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"): -- 2.49.0 From 1096c85b1054b4d375cd85ac415148db99671870 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Tue, 2 Jun 2026 00:02:27 +0200 Subject: [PATCH 27/35] chore(ops): add nightly backup verification scripts (#27) --- .gitignore | 3 + scripts/backups/01-log-tail.sh | 13 +++ scripts/backups/02-snapshots.sh | 14 +++ scripts/backups/03-check.sh | 21 ++++ scripts/backups/04-restore-verify.sh | 57 ++++++++++ scripts/backups/05-freshness.sh | 41 ++++++++ scripts/backups/06-lfs-direct.sh | 59 +++++++++++ scripts/backups/07-lfs-restic-check.sh | 38 +++++++ scripts/backups/check-all.sh | 138 +++++++++++++++++++++++++ scripts/backups/config.env.example | 28 +++++ scripts/backups/lib.sh | 65 ++++++++++++ 11 files changed, 477 insertions(+) create mode 100755 scripts/backups/01-log-tail.sh create mode 100755 scripts/backups/02-snapshots.sh create mode 100755 scripts/backups/03-check.sh create mode 100755 scripts/backups/04-restore-verify.sh create mode 100755 scripts/backups/05-freshness.sh create mode 100755 scripts/backups/06-lfs-direct.sh create mode 100755 scripts/backups/07-lfs-restic-check.sh create mode 100755 scripts/backups/check-all.sh create mode 100644 scripts/backups/config.env.example create mode 100755 scripts/backups/lib.sh diff --git a/.gitignore b/.gitignore index 80748f9..a280a17 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,6 @@ ROADMAP.md *.bak terminalshop.prod.* + +# Backup verification config (real values; example file is committed) +scripts/backups/config.env diff --git a/scripts/backups/01-log-tail.sh b/scripts/backups/01-log-tail.sh new file mode 100755 index 0000000..980b20d --- /dev/null +++ b/scripts/backups/01-log-tail.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Show last 20 lines of the cron log + its mtime. Cheapest "did it run" check. +set -euo pipefail +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +source "$SCRIPT_DIR/lib.sh" + +print_header "Backup log: mtime + last 20 lines" +spinner_start "fetching log from $SSH_HOST..." +ssh "$SSH_HOST" " + sudo stat -c 'Last modified: %y' '$BACKUP_LOG' + echo; sudo tail -20 '$BACKUP_LOG' +" +spinner_stop diff --git a/scripts/backups/02-snapshots.sh b/scripts/backups/02-snapshots.sh new file mode 100755 index 0000000..8dc29a2 --- /dev/null +++ b/scripts/backups/02-snapshots.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# List every restic snapshot with timestamps and tags. +set -euo pipefail +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +source "$SCRIPT_DIR/lib.sh" + +print_header "All restic snapshots" +spinner_start "querying restic via SFTP..." +ssh "$SSH_HOST" " + export RESTIC_PASSWORD_FILE='$RESTIC_PASSWORD_FILE' + export RESTIC_REPOSITORY='$RESTIC_REPO' + sudo -E restic -o sftp.args='-i $RESTIC_SSH_KEY' snapshots +" +spinner_stop diff --git a/scripts/backups/03-check.sh b/scripts/backups/03-check.sh new file mode 100755 index 0000000..e2069b3 --- /dev/null +++ b/scripts/backups/03-check.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# restic repo integrity check. +# ./03-check.sh metadata only (~5s) +# ./03-check.sh --deep + 10% data-pack re-read (~30s) +set -euo pipefail +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +source "$SCRIPT_DIR/lib.sh" + +EXTRA="" +if [ "${1:-}" = "--deep" ]; then + EXTRA="--read-data-subset=10%" +fi + +print_header "restic check ${EXTRA:-(metadata only)}" +spinner_start "running restic check on $SSH_HOST..." +ssh "$SSH_HOST" " + export RESTIC_PASSWORD_FILE='$RESTIC_PASSWORD_FILE' + export RESTIC_REPOSITORY='$RESTIC_REPO' + sudo -E restic -o sftp.args='-i $RESTIC_SSH_KEY' check $EXTRA +" +spinner_stop diff --git a/scripts/backups/04-restore-verify.sh b/scripts/backups/04-restore-verify.sh new file mode 100755 index 0000000..9e19048 --- /dev/null +++ b/scripts/backups/04-restore-verify.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Restore the latest snapshot to /tmp on the remote box, then: +# - PRAGMA integrity_check on the restored DB +# - row-count diff vs the live DB +# Cleans up /tmp dir at the end. +set -euo pipefail +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +source "$SCRIPT_DIR/lib.sh" + +print_header "Restore latest snapshot and verify SQLite" +spinner_start "restoring latest snapshot to /tmp on $SSH_HOST..." + +ssh "$SSH_HOST" \ + "PWFILE='$RESTIC_PASSWORD_FILE' \ + REPO='$RESTIC_REPO' \ + KEY='$RESTIC_SSH_KEY' \ + LIVE='$LIVE_DB' \ + bash -s" <<'REMOTE' +set -e +DIR=/tmp/tshop-restore-test +sudo rm -rf "$DIR" && sudo mkdir -p "$DIR" + +sudo -E RESTIC_PASSWORD_FILE="$PWFILE" RESTIC_REPOSITORY="$REPO" \ + restic -o sftp.args="-i $KEY" restore latest --target "$DIR" + +DB=$(sudo find "$DIR" -name 'terminalshop.db' | head -1) +if [ -z "$DB" ]; then + echo 'restored DB not found' >&2 + exit 1 +fi + +echo +echo '--- restored file ---' +sudo stat -c '%y %s bytes %n' "$DB" + +echo +echo '--- PRAGMA integrity_check ---' +sudo sqlite3 "$DB" 'PRAGMA integrity_check;' + +echo +echo '--- PRAGMA quick_check ---' +sudo sqlite3 "$DB" 'PRAGMA quick_check;' + +echo +echo '--- restored vs live row counts ---' +printf '%-22s %10s %10s\n' table restored live +for t in $(sudo sqlite3 "$DB" 'SELECT name FROM sqlite_master WHERE type="table";'); do + r=$(sudo sqlite3 "$DB" "SELECT COUNT(*) FROM \"$t\";") + l=$(sudo sqlite3 "$LIVE" "SELECT COUNT(*) FROM \"$t\";" 2>/dev/null || echo 'n/a') + printf '%-22s %10s %10s\n' "$t" "$r" "$l" +done + +sudo rm -rf "$DIR" +echo +echo 'OK' +REMOTE +spinner_stop diff --git a/scripts/backups/05-freshness.sh b/scripts/backups/05-freshness.sh new file mode 100755 index 0000000..66790a4 --- /dev/null +++ b/scripts/backups/05-freshness.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Print latest snapshot age. Exit 0 if fresh, 1 if older than STALE_HOURS. +set -euo pipefail +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +source "$SCRIPT_DIR/lib.sh" + +print_header "Latest snapshot freshness" + +spinner_start "querying latest snapshot..." +LATEST=$(ssh "$SSH_HOST" " + export RESTIC_PASSWORD_FILE='$RESTIC_PASSWORD_FILE' + export RESTIC_REPOSITORY='$RESTIC_REPO' + sudo -E restic -o sftp.args='-i $RESTIC_SSH_KEY' snapshots --json +" | jq -r 'max_by(.time) | .time') +spinner_stop + +if [ -z "$LATEST" ] || [ "$LATEST" = "null" ]; then + echo "ERROR: could not parse latest snapshot time" >&2 + exit 2 +fi + +NOW=$(date -u +%s) +# GNU date (Linux) first, then BSD date (macOS) as fallback. +if THEN=$(date -u -d "$LATEST" +%s 2>/dev/null); then + : +else + CLEAN=$(echo "$LATEST" | sed 's/\.[0-9]*Z$/Z/') + THEN=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$CLEAN" +%s) +fi + +AGE_HRS=$(( (NOW - THEN) / 3600 )) +echo "Latest snapshot: $LATEST" +echo "Age: ${AGE_HRS}h" +echo "Threshold: ${STALE_HOURS}h" + +if [ "$AGE_HRS" -lt "$STALE_HOURS" ]; then + print_ok "snapshot within freshness window" +else + print_fail "STALE — investigate" + exit 1 +fi diff --git a/scripts/backups/06-lfs-direct.sh b/scripts/backups/06-lfs-direct.sh new file mode 100755 index 0000000..f781e65 --- /dev/null +++ b/scripts/backups/06-lfs-direct.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Independent filesystem check on lfs-remote. No restic, no password needed. +# Proves the repo dir exists, files are present, recent uploads landed, +# and the backup partition has room. +set -euo pipefail +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +source "$SCRIPT_DIR/lib.sh" + +print_header "lfs-remote filesystem-level repo check" +spinner_start "inspecting $LFS_HOST filesystem..." + +ssh "$LFS_HOST" " +set -e +REPO='$LFS_REPO_PATH' +WARN_PCT=$LFS_DISK_WARN_PCT + +if [ ! -d \"\$REPO\" ]; then + echo 'ERROR: repo dir missing on lfs' >&2 + exit 1 +fi + +echo '--- snapshot pointer count (one file per restic snapshot) ---' +COUNT=\$(find \"\$REPO/snapshots\" -type f 2>/dev/null | wc -l) +echo \"snapshots/: \$COUNT files\" + +echo +echo '--- repo size on disk ---' +du -sh \"\$REPO\" + +echo +echo '--- recent uploads (files newer than 48h) ---' +RECENT=\$(find \"\$REPO\" -type f -mtime -2 2>/dev/null | wc -l) +echo \"files added in last 48h: \$RECENT\" +if [ \"\$RECENT\" -eq 0 ]; then + echo 'WARN: no new files in last 48h' >&2 +fi + +echo +echo '--- most recent snapshot pointer ---' +LATEST=\$(ls -t \"\$REPO/snapshots\"/* 2>/dev/null | head -1) +if [ -n \"\$LATEST\" ]; then + stat -c '%y %n' \"\$LATEST\" +else + echo 'WARN: no snapshot files found' >&2 +fi + +echo +echo '--- disk usage ---' +df -h \"\$REPO\" +USED=\$(df --output=pcent \"\$REPO\" 2>/dev/null | tail -1 | tr -dc 0-9) +if [ -n \"\$USED\" ] && [ \"\$USED\" -gt \"\$WARN_PCT\" ]; then + echo \"FAIL: backup partition \${USED}% full (threshold \${WARN_PCT}%)\" >&2 + exit 1 +fi + +echo +echo 'OK' +" +spinner_stop diff --git a/scripts/backups/07-lfs-restic-check.sh b/scripts/backups/07-lfs-restic-check.sh new file mode 100755 index 0000000..caf5994 --- /dev/null +++ b/scripts/backups/07-lfs-restic-check.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Independent restic check from the lfs-remote side. Reads repo via a +# LOCAL filesystem path (not SFTP), so it proves the data is parseable +# without any involvement from the prod box at restic level. +# +# Needs: +# - restic installed on lfs-remote (see scratchpkg port at /usr/ports/main/restic) +# - SSH access to both prod (to fetch the repo password) and lfs +set -euo pipefail +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +source "$SCRIPT_DIR/lib.sh" + +print_header "lfs-remote restic check (independent vantage point)" + +# Precondition: restic must be on lfs. +if ! ssh "$LFS_HOST" 'command -v restic >/dev/null 2>&1'; then + echo 'ERROR: restic not in PATH on lfs-remote' >&2 + echo "Install via scratchpkg, then re-run." >&2 + exit 2 +fi + +# Pull restic password from prod (needs sudo on prod). +spinner_start "fetching restic password from $SSH_HOST..." +PW=$(ssh "$SSH_HOST" "sudo cat '$RESTIC_PASSWORD_FILE'") +spinner_stop +if [ -z "$PW" ]; then + echo 'ERROR: could not read restic password from prod' >&2 + exit 2 +fi + +# Run restic against the LOCAL repo path on lfs. Password fed via stdin +# so it never lands in argv or a file on lfs. +spinner_start "running restic check from $LFS_HOST..." +ssh "$LFS_HOST" "restic -r '$LFS_REPO_PATH' --password-file=/dev/stdin check" <<<"$PW" +spinner_stop + +echo +print_ok "lfs-side restic check passed" diff --git a/scripts/backups/check-all.sh b/scripts/backups/check-all.sh new file mode 100755 index 0000000..6a6890d --- /dev/null +++ b/scripts/backups/check-all.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Master runner. Executes every backup check in sequence. +# ./check-all.sh stop on first failure; interactive menu when TTY +# ./check-all.sh --keep-going keep running after failures (no menu) +# ./check-all.sh --deep use --deep on 03-check.sh +# +# When stdin and stdout are TTYs, a failed step opens a drill-down menu: +# [r]etry [l]og [u]nlock [s]kip [q]uit +# Non-TTY (cron / piped) skips the menu and behaves like the old runner. +set -uo pipefail +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +KEEP_GOING=0 +DEEP=0 +for arg in "$@"; do + case "$arg" in + --keep-going) KEEP_GOING=1 ;; + --deep) DEEP=1 ;; + -h|--help) + echo "Usage: $0 [--keep-going] [--deep]" + echo " --keep-going don't stop on first failure, suppress interactive menu" + echo " --deep pass --deep to 03-check.sh (10% data re-read)" + exit 0 + ;; + *) + echo "unknown flag: $arg" >&2 + exit 2 + ;; + esac +done + +# Pull in config + color helpers (also gives us SSH_HOST + restic env for unlock). +source "$SCRIPT_DIR/lib.sh" + +declare -a STEP_NAMES=() +declare -a STEP_STATUS=() # OK | FAIL +FAIL=0 + +prompt_after_fail() { + # returns 0=skip, 1=quit, 2=retry + local script="$1" + while :; do + printf '\n%sNext?%s [r]etry [l]og [u]nlock [s]kip [q]uit > ' "$C_BOLD" "$C_RESET" + local choice + read -r choice + case "$choice" in + r|R) return 2 ;; + l|L) "$SCRIPT_DIR/01-log-tail.sh"; echo ;; + u|U) + echo + ssh "$SSH_HOST" " + export RESTIC_PASSWORD_FILE='$RESTIC_PASSWORD_FILE' + export RESTIC_REPOSITORY='$RESTIC_REPO' + sudo -E restic -o sftp.args='-i $RESTIC_SSH_KEY' unlock + " + echo + ;; + s|S) return 0 ;; + q|Q) return 1 ;; + "") ;; + *) printf 'unknown: %s\n' "$choice" ;; + esac + done +} + +run_step() { + local script="$1"; shift + echo + printf '%s########## %s %s ##########%s\n' "$C_CYAN$C_BOLD" "$script" "$*" "$C_RESET" + + local attempts=0 + while :; do + attempts=$((attempts + 1)) + if "$SCRIPT_DIR/$script" "$@"; then + STEP_NAMES+=("$script") + STEP_STATUS+=("OK") + return 0 + fi + + printf '%sFAILED:%s %s\n' "$C_RED$C_BOLD" "$C_RESET" "$script" >&2 + + if [ -t 0 ] && [ -t 1 ] && [ "$KEEP_GOING" -eq 0 ]; then + prompt_after_fail "$script" + case "$?" in + 0) STEP_NAMES+=("$script"); STEP_STATUS+=("FAIL"); FAIL=$((FAIL + 1)); return 0 ;; + 1) STEP_NAMES+=("$script"); STEP_STATUS+=("FAIL"); FAIL=$((FAIL + 1)); print_summary; exit 1 ;; + 2) echo + printf '%s>>>>>> retry %s (attempt %d) <<<<<<%s\n' "$C_CYAN$C_BOLD" "$script" $((attempts + 1)) "$C_RESET" + continue ;; + esac + fi + + # non-interactive path + STEP_NAMES+=("$script") + STEP_STATUS+=("FAIL") + FAIL=$((FAIL + 1)) + if [ "$KEEP_GOING" -eq 0 ]; then + print_summary + exit 1 + fi + return 0 + done +} + +print_summary() { + echo + printf '%s===============================================%s\n' "$C_CYAN$C_BOLD" "$C_RESET" + printf ' %-28s %s\n' "Script" "Result" + printf ' %-28s %s\n' "----------------------------" "--------" + local i + for i in "${!STEP_NAMES[@]}"; do + local name="${STEP_NAMES[$i]}" + local status="${STEP_STATUS[$i]}" + local color sym + case "$status" in + OK) color="$C_GREEN"; sym="✓ OK" ;; + FAIL) color="$C_RED" ; sym="✗ FAIL" ;; + esac + printf ' %-28s %s%s%s\n' "$name" "$color" "$sym" "$C_RESET" + done + printf '%s===============================================%s\n' "$C_CYAN$C_BOLD" "$C_RESET" + if [ "$FAIL" -eq 0 ]; then + printf '%sAll checks passed.%s\n' "$C_GREEN$C_BOLD" "$C_RESET" + else + printf '%s%d check(s) failed.%s\n' "$C_RED$C_BOLD" "$FAIL" "$C_RESET" + fi +} + +run_step 01-log-tail.sh +run_step 02-snapshots.sh +if [ "$DEEP" -eq 1 ]; then run_step 03-check.sh --deep; else run_step 03-check.sh; fi +run_step 04-restore-verify.sh +run_step 05-freshness.sh +run_step 06-lfs-direct.sh +run_step 07-lfs-restic-check.sh + +print_summary +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/scripts/backups/config.env.example b/scripts/backups/config.env.example new file mode 100644 index 0000000..5d93718 --- /dev/null +++ b/scripts/backups/config.env.example @@ -0,0 +1,28 @@ +# Backup verification config. +# Copy to config.env (gitignored) and fill in your real values. +# +# cp config.env.example config.env +# $EDITOR config.env + +# SSH alias for the prod box (resolved via ~/.ssh/config). +SSH_HOST="ubuntu-helsinki" + +# Restic repository URL. Format: sftp:HOST:PATH +# HOST is the Tailscale name of the backup target box. +RESTIC_REPO="sftp:YOUR_TAILNET_HOST.ts.net:/root/backups/tshop" + +# Paths on the REMOTE prod box (not your laptop). +RESTIC_PASSWORD_FILE="/root/.restic-password" +RESTIC_SSH_KEY="/root/.ssh/tshop-backup" +BACKUP_LOG="/var/log/backup-tshop.log" +LIVE_DB="/root/terminalShop/data/terminalshop.db" + +# Freshness alert threshold (hours). 24h cron + 4h slop = 28. +STALE_HOURS="28" + +# lfs-remote = the box that RECEIVES the backups. +# Used by 06-lfs-direct.sh and 07-lfs-restic-check.sh for out-of-band checks. +LFS_HOST="lfs-remote" +LFS_REPO_PATH="/root/backups/tshop" +# 06 fails if the backup partition is more full than this percent. +LFS_DISK_WARN_PCT="80" diff --git a/scripts/backups/lib.sh b/scripts/backups/lib.sh new file mode 100755 index 0000000..9a7e3bc --- /dev/null +++ b/scripts/backups/lib.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Shared loader for the backup verification scripts. +# Each script should: source "$SCRIPT_DIR/lib.sh" + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +if [ ! -f "$SCRIPT_DIR/config.env" ]; then + echo "ERROR: $SCRIPT_DIR/config.env missing." >&2 + echo "Run: cp $SCRIPT_DIR/config.env.example $SCRIPT_DIR/config.env" >&2 + exit 1 +fi + +# shellcheck source=/dev/null +source "$SCRIPT_DIR/config.env" + +# ANSI colors. Emit only when stdout is a TTY and NO_COLOR is unset. +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + C_RED=$'\033[1;31m' + C_GREEN=$'\033[1;32m' + C_YELLOW=$'\033[1;33m' + C_BLUE=$'\033[1;34m' + C_CYAN=$'\033[1;36m' + C_BOLD=$'\033[1m' + C_RESET=$'\033[0m' +else + C_RED=""; C_GREEN=""; C_YELLOW=""; C_BLUE=""; C_CYAN=""; C_BOLD=""; C_RESET="" +fi + +print_header() { printf '\n%s=== %s ===%s\n' "$C_CYAN" "$1" "$C_RESET"; } +print_ok() { printf '%sOK%s%s\n' "$C_GREEN" "$C_RESET" "${1:+ — $1}"; } +print_fail() { printf '%sFAIL%s%s\n' "$C_RED" "$C_RESET" "${1:+ — $1}" >&2; } +print_warn() { printf '%sWARN%s%s\n' "$C_YELLOW" "$C_RESET" "${1:+ — $1}" >&2; } +print_info() { printf '%s%s%s\n' "$C_BLUE" "$1" "$C_RESET"; } + +# Spinner: shows a rotating braille char while a foreground command runs. +# Use as a pair around the silent/blocking call: +# spinner_start "fetching log..." +# ssh ... +# spinner_stop +# Auto-skips when stdout isn't a TTY (cron / piped). Real output from the +# wrapped command will visually overwrite the spinner — by design. +SPINNER_PID="" +spinner_start() { + [ -t 1 ] && [ -z "${NO_COLOR:-}" ] || return 0 + local msg="${1:-working}" + ( + trap 'exit 0' TERM + local chars=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') + local i=0 + while :; do + i=$(( (i + 1) % ${#chars[@]} )) + printf '\r%s%s%s %s' "$C_CYAN" "${chars[$i]}" "$C_RESET" "$msg" + sleep 0.08 + done + ) & + SPINNER_PID=$! + disown "$SPINNER_PID" 2>/dev/null || true +} +spinner_stop() { + [ -n "$SPINNER_PID" ] || return 0 + kill "$SPINNER_PID" 2>/dev/null || true + wait "$SPINNER_PID" 2>/dev/null || true + printf '\r\033[K' + SPINNER_PID="" +} -- 2.49.0 From 728ac24c96d2514de8d85cc410b97d3aee4d7383 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Tue, 2 Jun 2026 10:28:56 +0200 Subject: [PATCH 28/35] fix(sca): return over-limit checkout errors to cart (#28) --- pkg/tui/confirmation.go | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index b1c27d0..fe28bc0 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -233,12 +233,11 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) { case CheckoutResultMsg: m.CheckingOut = false if msg.Err != nil { - m = m.SwitchPage(paymentPage) + var cmd tea.Cmd + m, cmd = m.switchForCheckoutError(msg.Err) m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)} m.review.cardJustAdded = false - m.payment.view = 0 - m.payment.form = nil - return m, nil + return m, cmd } if msg.RequiresAction { m.review.requiresAction = true @@ -330,6 +329,18 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) { return m, nil } +func (m Model) switchForCheckoutError(err error) (Model, tea.Cmd) { + switch { + case strings.Contains(err.Error(), "CART_OVER_LIMIT"): + return m.CartSwitch() + default: + m = m.SwitchPage(paymentPage) + m.payment.view = 0 + m.payment.form = nil + return m, nil + } +} + func friendlyCheckoutError(raw error) string { s := raw.Error() switch { -- 2.49.0 From 67e19eca989485622c477e94add837d9e84ac65f Mon Sep 17 00:00:00 2001 From: marcus06 Date: Tue, 2 Jun 2026 21:49:48 +0200 Subject: [PATCH 29/35] feat(sca): expire inactive saved cards (#29) * feat(sca): expire inactive saved cards * fix(sca): address card ttl review feedback - Correct sql typos breaking GET /cards and /view/init (used_id, id_default) - Stop swallowing backfillCardStorageExpiration error in Migrate - Wrap expireStoredCard local writes in a transaction and run stripe detach + audit only after commit, preventing double-fire on retry - Drop redundant ttl refresh in ConvertCart; webhook is authoritative - Rename validation_expired_at metric label to validation_card_expired - Fix CardStorageExpiresAt doc comment and test failure message typos - Add handler-level tests for expireStoredCards (user scoping + cart deref) Co-Authored-By: Claude Opus 4.7 * refactor(sca): move card expiration to periodic reconciler Read paths (GetCards, GetViewInit) now filter expired cards from query results instead of detaching them inline, so request handlers no longer block on Stripe API calls. Physical deletion runs in a new ReconcileExpiredCards goroutine on an hourly ticker, mirroring the existing ReconcileOrders / ReconcileUnshipped jobs. Per-card action sites (SetCard, SetDefaultCard, ConvertCart) still expire synchronously since they operate on one row the user is acting on. ViewHandler's stripeKey dependency is dropped now that GetViewInit no longer touches Stripe. Co-Authored-By: Claude Opus 4.7 * fix(sca): address card ttl review feedback - guard zero order.CardID before refreshing card storage ttl in payment_intent.succeeded webhook to avoid noisy warn logs on payment paths that did not use a saved card - delete unused Card.RefreshStorageTTL model method; the SQL-only refreshCardStorageTTL helper is the single canonical path - clarify duplicate-card webhook path: storage_expires_at reset is a card-add refresh, not a payment-success refresh, so last_used_at intentionally stays untouched Co-Authored-By: Claude Opus 4.7 * test(sca): cover card ttl read paths and expired-card sync sites --------- Co-authored-by: Claude Opus 4.7 --- api/handlers/card_test.go | 156 ++++++++++++++++++++++++++++++ api/handlers/cards.go | 63 +++++++++++- api/handlers/cards_expire_test.go | 155 +++++++++++++++++++++++++++++ api/handlers/cart.go | 19 ++++ api/handlers/cart_test.go | 103 ++++++++++++++++++++ api/handlers/reconcile.go | 27 ++++++ api/handlers/view.go | 7 +- api/handlers/view_test.go | 70 ++++++++++++++ api/handlers/webhook.go | 17 +++- api/main.go | 16 +++ pkg/audit/audit.go | 12 +++ pkg/database/migrate.go | 13 +++ pkg/database/migrate_test.go | 67 +++++++++++++ pkg/models/card.go | 43 +++++--- pkg/models/card_test.go | 26 +++++ pkg/tui/confirmation.go | 2 + pkg/tui/confirmation_test.go | 24 +++++ 17 files changed, 805 insertions(+), 15 deletions(-) create mode 100644 api/handlers/card_test.go create mode 100644 api/handlers/cards_expire_test.go create mode 100644 api/handlers/view_test.go create mode 100644 pkg/database/migrate_test.go create mode 100644 pkg/models/card_test.go create mode 100644 pkg/tui/confirmation_test.go diff --git a/api/handlers/card_test.go b/api/handlers/card_test.go new file mode 100644 index 0000000..66906f7 --- /dev/null +++ b/api/handlers/card_test.go @@ -0,0 +1,156 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strconv" + "testing" + "time" + + "terminalShop/pkg/database" + "terminalShop/pkg/models" + + "github.com/go-chi/chi/v5" +) + +// TestGetCards_FiltersExpiredRows drives the SQL WHERE claude used by +// GetCards. a column-name typo (e.g used_id instead of user_id) surfaces +// here as a sqlite error, not in prod +func TestGetCards_FiltersExpiredRows(t *testing.T) { + testDB, user := setupCardsExpireTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + db := database.GetDB() + now := time.Now() + past := now.Add(-1 * time.Hour) + future := now.Add(24 * time.Hour) + + expired := models.Card{ + UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111", + Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past, + } + fresh := models.Card{ + UserID: user.ID, StripePaymentID: "pm_fresh", Last4: "2222", + Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &future, + } + legacy := models.Card{ + UserID: user.ID, StripePaymentID: "pm_legacy", Last4: "3333", + Brand: "Visa", ExpMonth: 1, ExpYear: 2030, + } + for _, card := range []*models.Card{&expired, &fresh, &legacy} { + if err := db.Create(card).Error; err != nil { + t.Fatalf("seed card: %v", err) + } + } + + handler := NewCardHandler("", "") + req := authRequest("GET", "/api/v1/cards", nil, user.ID) + w := httptest.NewRecorder() + handler.GetCards(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("GetCards status %d, body %s", w.Code, w.Body.String()) + } + + var resp struct { + Success bool `json:"success"` + Data struct { + Cards []models.Card `json:"cards"` + } `json:"data"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Data.Cards) != 2 { + t.Fatalf("want 2 visible cards (fresh + legacy NULL), got %d", len(resp.Data.Cards)) + } + for _, card := range resp.Data.Cards { + if card.ID == expired.ID { + t.Fatalf("expired card leaked into GetCards response") + } + } +} + +// TestSetDefaultCard_ExpiredReturns410 verifies the sync-expire branch when +// the user acts on a card whose retention deadline already elapsed +func TestSetDefaultCard_ExpiredReturns410(t *testing.T) { + testDB, user := setupCardsExpireTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + db := database.GetDB() + past := time.Now().Add(-1 * time.Hour) + card := models.Card{ + UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111", + Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past, + } + if err := db.Create(&card).Error; err != nil { + t.Fatalf("seed card: %v", err) + } + + handler := NewCardHandler("", "") + idStr := strconv.FormatUint(uint64(card.ID), 10) + req := authRequest("PUT", "/api/v1/cards/"+idStr+"/default", nil, user.ID) + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("id", idStr) + req = req.WithContext(contextWithRoute(req.Context(), routeCtx)) + + w := httptest.NewRecorder() + handler.SetDefaultCard(w, req) + + if w.Code != http.StatusGone { + t.Fatalf("want 410 gone, got %d, body %s", w.Code, w.Body.String()) + } + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + _ = json.NewDecoder(w.Body).Decode(&resp) + if resp.Error.Code != "CARD_STORAGE_EXPIRED" { + t.Fatalf("want CARD_STORAGE_EXPIRED code, got %q", resp.Error.Code) + } + + var soft models.Card + if err := db.Unscoped().First(&soft, card.ID).Error; err != nil { + t.Fatalf("reload card unscoped: %v", err) + } + if !soft.DeletedAt.Valid { + t.Fatalf("expired card was not soft-deleted") + } +} + +// TestRefreshCardStorageTTL_BumpLastUsedAndExpiry exercises the SQL Updates +// map used by both the payment_intent.succeede webhook and the duplicate-card +// add path. Column-name typos in the map surface here +func TestRefreshCardStorageTTL_BumpLastUsedAndExpiry(t *testing.T) { + testDB, user := setupCardsExpireTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + db := database.GetDB() + old := time.Now().Add(-30 * 24 * time.Hour) + card := models.Card{ + UserID: user.ID, StripePaymentID: "pm_refresh", Last4: "4242", + Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &old, + } + if err := db.Create(&card).Error; err != nil { + t.Fatalf("seed card: %v", err) + } + + now := time.Now() + if err := refreshCardStorageTTL(db, card.ID, now); err != nil { + t.Fatalf("refreshCardStorageTTL: %v", err) + } + + var reloaded models.Card + if err := db.First(&reloaded, card.ID).Error; err != nil { + t.Fatalf("reload card: %v", err) + } + if reloaded.LastUsedAt == nil || !reloaded.StorageExpiresAt.After(now.Add(179*24*time.Hour)) { + t.Fatalf("storage_expires_at not extended, got %v", reloaded.StorageExpiresAt) + } +} diff --git a/api/handlers/cards.go b/api/handlers/cards.go index 436ae3d..776d849 100644 --- a/api/handlers/cards.go +++ b/api/handlers/cards.go @@ -5,8 +5,10 @@ import ( "encoding/hex" "encoding/json" "fmt" + "log/slog" "net/http" "strconv" + "strings" "time" "github.com/go-chi/chi/v5" @@ -75,12 +77,17 @@ func NewCardHandler(stripeSecretKey string, appURL string) *CardHandler { } // GetCards returns all saved cards for the authenticated user. +// Cards past their retention deadline are filtered out at read time; +// physical deletion runs in handlers.ReconcileExpiredCards. func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) { db := database.GetDB().WithContext(r.Context()) userID := middleware.UserIDFromContext(r.Context()) var cards []models.Card - db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&cards) + db.Where( + "user_id = ? AND (storage_expires_at IS NULL OR storage_expires_at > ?)", + userID, time.Now(), + ).Order("is_default DESC, created_at DESC").Find(&cards) utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ "cards": cards, @@ -129,6 +136,15 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) { return } + if card.IsStorageExpired(time.Now()) { + if err := expireStoredCard(db, &card, h.stripeKey); err != nil { + utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil) + return + } + respondCardStorageExpired(w) + return + } + if err := db.Model(&models.Card{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil { utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update card", nil) return @@ -336,6 +352,51 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) { }) } +// expireStoredCard removes a card whose retention deadline elapsed. +// Local state (cart references + card row) is committed atomically; +// Stripe detach and audit run only after the local commit so retries +// cannot double-fire the audit event. +func expireStoredCard(db *gorm.DB, card *models.Card, stripeKey string) error { + if err := db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", card.ID, card.UserID).Update("card_id", nil).Error; err != nil { + return fmt.Errorf("failed to clear expired card from cart: %w", err) + } + if err := tx.Delete(card).Error; err != nil { + return fmt.Errorf("failed to delete expired card: %w", err) + } + return nil + }); err != nil { + return err + } + + if stripeKey != "" && strings.HasPrefix(card.StripePaymentID, "pm_") { + stripe.Key = stripeKey + if _, err := paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{}); err != nil { + slog.Warn("failed to detach expired card", "card_id", card.ID, "error", err) + } + } + + audit.CardExpired(card.UserID, card.ID) + return nil +} + +func refreshCardStorageTTL(db *gorm.DB, cardID uint, now time.Time) error { + return db.Model(&models.Card{}).Where("id = ?", cardID).Updates(map[string]any{ + "last_used_at": now, + "storage_expires_at": models.CardStorageExpiresAt(now), + }).Error +} + +func respondCardStorageExpired(w http.ResponseWriter) { + utils.RespondError( + w, + http.StatusGone, + "CARD_STORAGE_EXPIRED", + "saved card expired, add it again", + nil, + ) +} + // ensureStripeCustomer creates a Stripe Customer for the user if they don't // already have one and persists the customer ID. Safe to call concurrently — // a duplicate Stripe customer is harmless; the local record reflects the first diff --git a/api/handlers/cards_expire_test.go b/api/handlers/cards_expire_test.go new file mode 100644 index 0000000..15b66a2 --- /dev/null +++ b/api/handlers/cards_expire_test.go @@ -0,0 +1,155 @@ +package handlers + +import ( + "os" + "testing" + "time" + + "terminalShop/pkg/database" + "terminalShop/pkg/models" +) + +// TestReconcileExpiredCards_RemovesPastDeadlineOnly drives the real SQL path +// so a column-name typo in the WHERE clause surfaces here, not in production. +func TestReconcileExpiredCards_RemovesPastDeadlineOnly(t *testing.T) { + testDB, user := setupCardsExpireTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + db := database.GetDB() + now := time.Now() + past := now.Add(-1 * time.Hour) + future := now.Add(24 * time.Hour) + + expired := models.Card{ + UserID: user.ID, + StripePaymentID: "tok_expired", + Last4: "1111", + Brand: "Visa", + ExpMonth: 1, + ExpYear: 2030, + StorageExpiresAt: &past, + } + fresh := models.Card{ + UserID: user.ID, + StripePaymentID: "tok_fresh", + Last4: "2222", + Brand: "Visa", + ExpMonth: 1, + ExpYear: 2030, + StorageExpiresAt: &future, + } + noTTL := models.Card{ + UserID: user.ID, + StripePaymentID: "tok_legacy", + Last4: "3333", + Brand: "Visa", + ExpMonth: 1, + ExpYear: 2030, + } + for _, c := range []*models.Card{&expired, &fresh, &noTTL} { + if err := db.Create(c).Error; err != nil { + t.Fatalf("seed card: %v", err) + } + } + + cart := models.Cart{UserID: user.ID, CardID: &expired.ID} + if err := db.Create(&cart).Error; err != nil { + t.Fatalf("seed cart: %v", err) + } + + ReconcileExpiredCards("") + + var remaining []models.Card + if err := db.Where("user_id = ?", user.ID).Find(&remaining).Error; err != nil { + t.Fatalf("list cards: %v", err) + } + if len(remaining) != 2 { + t.Fatalf("want 2 cards remaining, got %d", len(remaining)) + } + for _, c := range remaining { + if c.ID == expired.ID { + t.Fatalf("expired card was not deleted") + } + } + + var refreshed models.Cart + if err := db.First(&refreshed, cart.ID).Error; err != nil { + t.Fatalf("reload cart: %v", err) + } + if refreshed.CardID != nil { + t.Fatalf("cart still references expired card: %v", refreshed.CardID) + } +} + +// TestReconcileExpiredCards_SweepsAcrossUsers verifies the sweeper is not +// scoped to one user; it must collect every past-deadline row in one pass. +func TestReconcileExpiredCards_SweepsAcrossUsers(t *testing.T) { + testDB, user := setupCardsExpireTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + db := database.GetDB() + past := time.Now().Add(-1 * time.Hour) + + other := models.User{ + SSHKeyFingerprint: "SHA256:other", + SSHPublicKey: "ssh-ed25519 AAAA other", + Name: "Other", + Email: "other@example.com", + } + if err := db.Create(&other).Error; err != nil { + t.Fatalf("seed other user: %v", err) + } + + for _, uid := range []uint{user.ID, other.ID} { + c := models.Card{ + UserID: uid, + StripePaymentID: "tok_expired", + Last4: "9999", + Brand: "Visa", + ExpMonth: 1, + ExpYear: 2030, + StorageExpiresAt: &past, + } + if err := db.Create(&c).Error; err != nil { + t.Fatalf("seed card: %v", err) + } + } + + ReconcileExpiredCards("") + + var count int64 + if err := db.Model(&models.Card{}).Count(&count).Error; err != nil { + t.Fatalf("count cards: %v", err) + } + if count != 0 { + t.Fatalf("want 0 cards after sweep, got %d", count) + } +} + +func setupCardsExpireTestDB(t *testing.T) (string, models.User) { + t.Helper() + testDB := "test_cards_expire.db" + _ = os.Remove(testDB) + database.ResetForTesting() + + db, err := database.Connect(testDB) + if err != nil { + t.Fatalf("connect: %v", err) + } + if err := database.Migrate(db); err != nil { + t.Fatalf("migrate: %v", err) + } + + user := models.User{ + SSHKeyFingerprint: "SHA256:cards-expire", + SSHPublicKey: "ssh-ed25519 AAAA cardsexpire", + Name: "Test User", + Email: "cards-expire@example.com", + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("seed user: %v", err) + } + return testDB, user +} diff --git a/api/handlers/cart.go b/api/handlers/cart.go index 6b0ad61..f922906 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -210,6 +210,15 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) { return } + if card.IsStorageExpired(time.Now()) { + if err := expireStoredCard(db, &card, h.stripeKey); err != nil { + utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil) + return + } + respondCardStorageExpired(w) + return + } + cart, err := getOrCreateCart(r.Context(), userID) if err != nil { utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil) @@ -296,6 +305,16 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { return } + if card.IsStorageExpired(time.Now()) { + middleware.RecordCartConversion("validation_card_expired") + if err := expireStoredCard(db, &card, h.stripeKey); err != nil { + utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil) + return + } + respondCardStorageExpired(w) + return + } + var user models.User if err := db.First(&user, userID).Error; err != nil { middleware.RecordCartConversion("user_not_found") diff --git a/api/handlers/cart_test.go b/api/handlers/cart_test.go index 478d127..58020d8 100644 --- a/api/handlers/cart_test.go +++ b/api/handlers/cart_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "os" "testing" + "time" "github.com/go-chi/chi/v5" @@ -764,3 +765,105 @@ func TestConvertCartLimit(t *testing.T) { }) } } + +// 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]interface{}{"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]interface{}{"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]interface{}{"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) + } +} diff --git a/api/handlers/reconcile.go b/api/handlers/reconcile.go index d34c566..74dd386 100644 --- a/api/handlers/reconcile.go +++ b/api/handlers/reconcile.go @@ -77,6 +77,33 @@ func ReconcileOrders(stripeKey string) { } } +// ReconcileExpiredCards removes saved cards whose retention deadline elapsed. +// Read paths filter out expired rows so users never see them; this job drives +// the Stripe detach + audit + physical row delete out of band so request +// handlers never block on Stripe API calls during a card sweep. +func ReconcileExpiredCards(stripeKey string) { + db := database.GetDB() + + var cards []models.Card + if err := db.Where( + "storage_expires_at IS NOT NULL AND storage_expires_at <= ?", + time.Now(), + ).Find(&cards).Error; err != nil { + reconcileLog().Error("expired card scan failed", "error", err) + return + } + if len(cards) == 0 { + return + } + + reconcileLog().Info("expiring inactive saved cards", "count", len(cards)) + for i := range cards { + if err := expireStoredCard(db, &cards[i], stripeKey); err != nil { + reconcileLog().Error("expire card failed", "card_id", cards[i].ID, "error", err) + } + } +} + // ReconcileUnshipped finds paid order orlder than 24h with no tracking // number and posts single Slack reminder. Pure read + notif func ReconcileUnshipped() { diff --git a/api/handlers/view.go b/api/handlers/view.go index 9bd00ad..5e13dcc 100644 --- a/api/handlers/view.go +++ b/api/handlers/view.go @@ -2,6 +2,7 @@ package handlers import ( "net/http" + "time" "terminalShop/api/middleware" "terminalShop/pkg/database" @@ -11,6 +12,7 @@ import ( "gorm.io/gorm" ) +// ViewHandler handles bulk TUI bootstrap data type ViewHandler struct{} func NewViewHandler() *ViewHandler { @@ -37,7 +39,10 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) { db.Where("user_id = ?", userID).Find(&addresses) var cards []models.Card - db.Where("user_id = ?", userID).Find(&cards) + db.Where( + "user_id = ? AND (storage_expires_at IS NULL OR storage_expires_at > ?)", + userID, time.Now(), + ).Order("is_default DESC, created_at DESC").Find(&cards) var orders []models.Order db.Where("user_id = ?", userID). diff --git a/api/handlers/view_test.go b/api/handlers/view_test.go new file mode 100644 index 0000000..dd634be --- /dev/null +++ b/api/handlers/view_test.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "terminalShop/pkg/database" + "terminalShop/pkg/models" +) + +// TestGetViewInit_FiltersExpiredCardsAndOrders drives the WHERE + ORDER BY +// clauses in GetViewInit. a column-name typo in either clause (e.g. +// id_default instead of is_default) surfaces here as a sqlite error +func TestGetViewInit_FiltersExpiredCardsAndOrders(t *testing.T) { + testDB, user := setupCardsExpireTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + db := database.GetDB() + now := time.Now() + past := now.Add(-1 * time.Hour) + future := now.Add((24 * time.Hour)) + + expired := models.Card{ + UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111", + Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past, + } + freshDefault := models.Card{ + UserID: user.ID, StripePaymentID: "pm_fresh_d", Last4: "2222", + Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &future, + IsDefault: true, + } + freshOther := models.Card{ + UserID: user.ID, StripePaymentID: "pm_fresh_o", Last4: "3333", + Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &future, + } + for _, card := range []*models.Card{&expired, &freshDefault, &freshOther} { + if err := db.Create(card).Error; err != nil { + t.Fatalf("seed card: %v", err) + } + } + + handler := NewViewHandler() + req := authRequest("GET", "/api/v1/view/init", nil, user.ID) + w := httptest.NewRecorder() + handler.GetViewInit(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("GetViewInit status %d, body: %s", w.Code, w.Body.String()) + } + + var resp struct { + Data struct { + Cards []models.Card `json:"cards"` + } `json:"data"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Data.Cards) != 2 { + t.Fatalf("want 2 visible cards, got %d", len(resp.Data.Cards)) + } + if resp.Data.Cards[0].ID != freshDefault.ID { + t.Fatalf("default card should sort first, got id=%d (want %d)", resp.Data.Cards[0].ID, freshDefault.ID) + } +} diff --git a/api/handlers/webhook.go b/api/handlers/webhook.go index 06737c2..5e8c209 100644 --- a/api/handlers/webhook.go +++ b/api/handlers/webhook.go @@ -150,6 +150,12 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event return } + if order.CardID != 0 { + if err := refreshCardStorageTTL(db, order.CardID, time.Now()); err != nil { + webhookLog().Warn("failed to refresh card storage ttl", "card_id", order.CardID, "error", err) + } + } + audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID) go notify.SlackOrderPaid(&order) webhookLog().Info("order marked paid", "order_id", orderIDStr, "pi", pi.ID) @@ -255,8 +261,14 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, eve webhookLog().Warn("failed to detach duplicate pm", "user_id", user.ID, "error", derr) } // Bump UpdatedAt so the TUI poll signal can detect that the webhook - // fired even though no new row was inserted. - if uerr := db.Model(&dup).Update("updated_at", time.Now()).Error; uerr != nil { + // fired even though no new row was inserted. storage_expires_at is + // reset because the user just re-added the card; this is a card-add + // path, not a payment-success refresh, so last_used_at stays as-is. + now := time.Now() + if uerr := db.Model(&dup).Updates(map[string]any{ + "updated_at": now, + "storage_expires_at": models.CardStorageExpiresAt(now), + }).Error; uerr != nil { webhookLog().Warn("failed to bump dup card updated_at", "card_id", dup.ID, "error", uerr) } webhookLog().Info("skipped duplicate card via local-db match", "user_id", user.ID, "existing_card_id", dup.ID) @@ -272,6 +284,7 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, eve ExpMonth: int(pm.Card.ExpMonth), ExpYear: int(pm.Card.ExpYear), } + card.InitializeStorageTTL(time.Now()) if err := db.Create(&card).Error; err != nil { webhookLog().Error("failed to save card", "user_id", user.ID, "error", err) diff --git a/api/main.go b/api/main.go index 450542b..46f34b9 100644 --- a/api/main.go +++ b/api/main.go @@ -128,6 +128,22 @@ func main() { } }() + // Sweep saved cards whose retention deadline elapsed. Read paths filter + // expired rows from query results, so this sweeper only drives the Stripe + // detach + audit + physical row delete asynchronously. + go func() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ticker.C: + handlers.ReconcileExpiredCards(cfg.StripeSecretKey) + case <-ctx.Done(): + return + } + } + }() + log.Println("API server started. Press Ctrl+C to shutdown.") // Block until signal received diff --git a/pkg/audit/audit.go b/pkg/audit/audit.go index b2cad6e..dbe0f5c 100644 --- a/pkg/audit/audit.go +++ b/pkg/audit/audit.go @@ -18,6 +18,7 @@ type eventType string const ( eventCardAdded eventType = "card_added" eventCardDeleted eventType = "card_deleted" + eventCardExpired eventType = "card_expired" eventOrderCreated eventType = "order_created" eventOrderPaid eventType = "order_paid" eventOrderShipped eventType = "order_shipped" @@ -113,6 +114,17 @@ func CardDeleted(userID, cardID uint) { persist(0, eventCardDeleted, e) } +// CardExpired records that a saved payment method expired from inactivity +func CardExpired(userID, cardID uint) { + e := event{ + Event: eventCardExpired, + UserID: userID, + CardID: cardID, + } + slog.Info("audit", e.attrs()...) + persist(0, eventCardExpired, e) +} + // OrderCreated records that an order record was created (before payment). func OrderCreated(userID, orderID uint, amountCents int) { e := event{ diff --git a/pkg/database/migrate.go b/pkg/database/migrate.go index 137d988..d22a946 100644 --- a/pkg/database/migrate.go +++ b/pkg/database/migrate.go @@ -3,6 +3,7 @@ package database import ( "fmt" "log" + "time" "terminalShop/pkg/models" @@ -30,6 +31,18 @@ func Migrate(db *gorm.DB) error { return fmt.Errorf("migration failed: %w", err) } + if err := backfillCardStorageExpiration(db); err != nil { + return err + } + log.Println("Database migrations completed successfully") return nil } + +func backfillCardStorageExpiration(db *gorm.DB) error { + expiresAt := models.CardStorageExpiresAt(time.Now()) + if err := db.Model(&models.Card{}).Where("storage_expires_at IS NULL").Update("storage_expires_at", expiresAt).Error; err != nil { + return fmt.Errorf("card storage ttl backfill failed: %w", err) + } + return nil +} diff --git a/pkg/database/migrate_test.go b/pkg/database/migrate_test.go new file mode 100644 index 0000000..4832dbe --- /dev/null +++ b/pkg/database/migrate_test.go @@ -0,0 +1,67 @@ +package database + +import ( + "os" + "testing" + "time" + + "terminalShop/pkg/models" +) + +func TestBackfillCardStorageExpiration_FillsNullRowsOnly(t *testing.T) { + testDB := "test_backfill.db" + defer func() { _ = os.Remove(testDB) }() + defer ResetForTesting() + + db, err := Connect(testDB) + if err != nil { + t.Fatalf("connect: %v", err) + } + if err := Migrate(db); err != nil { + t.Fatalf("migrate: %v", err) + } + + user := models.User{ + SSHKeyFingerprint: "SHA256:backfill", + SSHPublicKey: "ssh-ed25519 AAAA backfill", + Name: "Backfill", + Email: "backfill@example.com", + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("seed user: %v", err) + } + + legacy := models.Card{ + UserID: user.ID, StripePaymentID: "pm_legacy", Last4: "0000", + Brand: "Visa", ExpMonth: 1, ExpYear: 2030, + } + if err := db.Create(&legacy).Error; err != nil { + t.Fatalf("seed legacy: %v", err) + } + + fixed := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) + preset := models.Card{ + UserID: user.ID, StripePaymentID: "pm_preset", Last4: "9999", + Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &fixed, + } + if err := db.Create(&preset).Error; err != nil { + t.Fatalf("ssed preset: %v", err) + } + if err := backfillCardStorageExpiration(db); err != nil { + t.Fatalf("seed preset: %v", err) + } + + var reloadedLegacy, reloadedPreset models.Card + if err := db.First(&reloadedLegacy, legacy.ID).Error; err != nil { + t.Fatalf("reload legacy: %v", err) + } + if err := db.First(&reloadedPreset, preset.ID).Error; err != nil { + t.Fatalf("reload preset: %v", err) + } + if reloadedLegacy.StorageExpiresAt == nil { + t.Fatalf("legacy card storage_expires_at still NULL after backfill") + } + if reloadedPreset.StorageExpiresAt == nil || !reloadedPreset.StorageExpiresAt.Equal(fixed) { + t.Fatalf("preset card storage_expires_at was overwritten: %v", reloadedPreset.StorageExpiresAt) + } +} diff --git a/pkg/models/card.go b/pkg/models/card.go index 1b68e09..7ca7bb2 100644 --- a/pkg/models/card.go +++ b/pkg/models/card.go @@ -6,21 +6,25 @@ import ( "gorm.io/gorm" ) +const cardStorageTTL = 180 * 24 * time.Hour + // Card represents a saved payment method linked to a Stripe token. // The actual card details live on Stripe's servers — we only store // a reference and display info (last 4 digits, brand). type Card struct { - ID uint `gorm:"primaryKey" json:"id"` - UserID uint `gorm:"not null;index" json:"user_id"` - StripePaymentID string `gorm:"size:255;not null" json:"-"` // Stripe payment method or token ID - Last4 string `gorm:"size:4;not null" json:"last4"` // Last 4 digits for display - Brand string `gorm:"size:50" json:"brand"` // Visa, Mastercard, etc. - ExpMonth int `gorm:"not null" json:"exp_month"` - ExpYear int `gorm:"not null" json:"exp_year"` - IsDefault bool `gorm:"default:false" json:"is_default"` // User's preferred card - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + StripePaymentID string `gorm:"size:255;not null" json:"-"` // Stripe payment method or token ID + Last4 string `gorm:"size:4;not null" json:"last4"` // Last 4 digits for display + Brand string `gorm:"size:50" json:"brand"` // Visa, Mastercard, etc. + ExpMonth int `gorm:"not null" json:"exp_month"` + ExpYear int `gorm:"not null" json:"exp_year"` + IsDefault bool `gorm:"default:false" json:"is_default"` // User's preferred card + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + StorageExpiresAt *time.Time `gorm:"index" json:"storage_expires_at,omitempty"` // Associations User User `gorm:"foreignKey:UserID" json:"-"` @@ -30,3 +34,20 @@ type Card struct { func (Card) TableName() string { return "cards" } + +// CardStorageExpiresAt returns the inactivity expiration for a saved card +func CardStorageExpiresAt(now time.Time) *time.Time { + expiresAt := now.Add(cardStorageTTL) + return &expiresAt +} + +// InitializeStorageTTL sets the initial retention deadline without marking +// the card as used for payment +func (c *Card) InitializeStorageTTL(now time.Time) { + c.StorageExpiresAt = CardStorageExpiresAt(now) +} + +// IsStorageExpired reports whether the saved card retention deadline elapsed +func (c *Card) IsStorageExpired(now time.Time) bool { + return c.StorageExpiresAt != nil && !c.StorageExpiresAt.After(now) +} diff --git a/pkg/models/card_test.go b/pkg/models/card_test.go new file mode 100644 index 0000000..d741baf --- /dev/null +++ b/pkg/models/card_test.go @@ -0,0 +1,26 @@ +package models + +import ( + "testing" + "time" +) + +func TestCardStorageTTL(t *testing.T) { + now := time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC) + + var card Card + card.InitializeStorageTTL(now) + + if card.LastUsedAt != nil { + t.Fatalf("InitializeStorageTTL should not mark card as used") + } + if card.StorageExpiresAt == nil || !card.StorageExpiresAt.Equal(now.Add(cardStorageTTL)) { + t.Fatalf("unexpected initial expiration: %v", card.StorageExpiresAt) + } + if card.IsStorageExpired(now.Add(cardStorageTTL - time.Second)) { + t.Fatalf("card expired before ttl elapsed") + } + if !card.IsStorageExpired(now.Add(cardStorageTTL)) { + t.Fatalf("card should expire at ttl boundary") + } +} diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index fe28bc0..85939ac 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -350,6 +350,8 @@ func friendlyCheckoutError(raw error) string { 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"): diff --git a/pkg/tui/confirmation_test.go b/pkg/tui/confirmation_test.go new file mode 100644 index 0000000..bc57583 --- /dev/null +++ b/pkg/tui/confirmation_test.go @@ -0,0 +1,24 @@ +package tui + +import ( + "errors" + "strings" + "testing" +) + +func TestFriendlyCheckoutError_CardStorageExpired(t *testing.T) { + got := friendlyCheckoutError(errors.New("CARD_STORAGE_EXPIRED: saved card epired, add it again")) + if !strings.Contains(got, "saved card expired") { + t.Fatalf("want saved-card-expired copy, got %q", got) + } + if strings.Contains(got, "card expired. Add a new card") { + t.Fatalf("CARD_STORAGE_EXPIRED was matched by the CARD_EXPIRED branch: %q", got) + } +} + +func TestFriendlyCheckoutError_CardExpiredStillReachable(t *testing.T) { + got := friendlyCheckoutError(errors.New("CARD_EXPIRED: card expired")) + if !strings.Contains(got, "card expired. Add a new card") { + t.Fatalf("want plain card-expired copy, got %q", got) + } +} -- 2.49.0 From f3b94c32719edfcaa19817fe7b95ca05809f1be9 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Tue, 2 Jun 2026 23:17:03 +0200 Subject: [PATCH 30/35] chore(test): add gotestsum + tparse runner with ci wiring (#30) * chore(test): add gotestsum + tparse runner with ci wiring * chore(tests): change the tests around a little --- .github/workflows/ci.yml | 28 +++++++++++++++++++- .gitignore | 2 ++ Makefile | 55 ++++++++++++++++++++++++++++++++++++++++ go.mod | 17 +++++++++++++ go.sum | 27 ++++++++++++++++++++ main.go | 1 - 6 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 Makefile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75923d9..555a26b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,5 +35,31 @@ jobs: with: version: v2.4.0 + - name: Install tparse + run: go install github.com/mfridman/tparse@v0.18.0 + - name: Test (race detector) - run: go test -race ./... + run: | + set +e + go tool gotestsum \ + --format pkgname \ + --format-icons hivis \ + --hide-summary=skipped,output,errors,failed \ + --jsonfile test-output.json \ + --junitfile test-report.xml \ + --packages="./..." \ + -- -count=1 -race -covermode=atomic -coverprofile=coverage.out + rc=$? + echo + tparse -all -notests -slow 10 -file=test-output.json || true + exit $rc + + - name: Upload test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-report + path: | + test-output.json + test-report.xml + coverage.out diff --git a/.gitignore b/.gitignore index a280a17..7023db7 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ api/api-server *.out coverage.txt coverage.html +test-output.json +test-report.xml # Dependency directories vendor/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..16084c0 --- /dev/null +++ b/Makefile @@ -0,0 +1,55 @@ +.PHONY: test test-pretty test-watch test-ci test-summary cover + +PKGS ?= ./... +JSON_OUT ?= test-output.json +JUNIT ?= test-report.xml +COVER ?= coverage.out + +# Plain go test. Fastest, no extra deps loaded +test: + go test $(PKGS) + +# Live pretty output. Groups by test name, reruns flakes once. +# Hides gotestsum's plain-text summary; tparse renders failures instead. +test-pretty: + @set +e; \ + go tool gotestsum \ + --format testname \ + --format-icons hivis \ + --hide-summary=skipped,output,errors,failed \ + --rerun-fails=1 \ + --jsonfile $(JSON_OUT) \ + --packages="$(PKGS)" \ + -- -count=1; \ + rc=$$?; \ + echo; \ + tparse -all -notests -file=$(JSON_OUT) || true; \ + exit $$rc + +# TDD loop: re-runs affected packages on file save. +test-watch: + go tool gotestsum --watch --format testname --packages="$(PKGS)" + +# CI / pre-push: race + coverage + JSON + JUnit + tparse summary table +test-ci: + @set +e; \ + go tool gotestsum \ + --format pkgname \ + --format-icons hivis \ + --hide-summary=skipped,output,errors,failed \ + --jsonfile $(JSON_OUT) \ + --junitfile $(JUNIT) \ + --packages="$(PKGS)" \ + -- -count=1 -race -covermode=atomic -coverprofile=$(COVER); \ + rc=$$?; \ + echo; \ + tparse -all -notests -slow 10 -file=$(JSON_OUT) || true; \ + exit $$rc + +# Replay last summary without re-running tests +test-summary: + tparse -all -notests -file=$(JSON_OUT) + +# Open HTML coverage report from last `test-ci` run +cover: + go tool cover -html=$(COVER) diff --git a/go.mod b/go.mod index 2d184ab..f5646ab 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bitfield/gotestdox v0.2.2 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -56,15 +57,20 @@ require ( github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/creack/pty v1.1.24 // indirect + github.com/dnephin/pflag v1.0.7 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gotesttools/gotestfmt/v2 v2.5.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/go-version v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -76,6 +82,8 @@ require ( github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect @@ -98,10 +106,13 @@ require ( go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.43.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/grpc v1.80.0 // indirect @@ -109,4 +120,10 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/clickhouse v0.7.0 // indirect gorm.io/driver/mysql v1.5.7 // indirect + gotest.tools/gotestsum v1.13.0 // indirect +) + +tool ( + github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt + gotest.tools/gotestsum ) diff --git a/go.sum b/go.sum index ee37675..7faf317 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE= +github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -69,12 +71,18 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk= +github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ= github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c/go.mod h1:0ghtL4RhKBjyWMkHCWosp2aHSEXShQeQMLQ0qBCytfU= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= @@ -105,8 +113,12 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gotesttools/gotestfmt/v2 v2.5.0 h1:fSU3MnR+E+fvuXdw1l8xbufKhDxY3Tfjsjx/I1WerB4= +github.com/gotesttools/gotestfmt/v2 v2.5.0/go.mod h1:oQJg2KZ2aGoqEbMC2PDaAeBYm0tOkocgixK9FzsCdp4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= @@ -143,6 +155,11 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= @@ -239,6 +256,8 @@ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -259,6 +278,8 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -274,6 +295,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -310,5 +333,9 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc= gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y= +gotest.tools/gotestsum v1.13.0 h1:+Lh454O9mu9AMG1APV4o0y7oDYKyik/3kBOiCqiEpRo= +gotest.tools/gotestsum v1.13.0/go.mod h1:7f0NS5hFb0dWr4NtcsAsF0y1kzjEFfAil0HiBQJE03Q= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/main.go b/main.go index 134b05e..a2cbfdd 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,6 @@ import ( "os" "os/signal" "syscall" - "time" tea "charm.land/bubbletea/v2" -- 2.49.0 From 3420c8dce0da224c9ed564f90a465ad0fdbaa93b Mon Sep 17 00:00:00 2001 From: marcus06 Date: Wed, 3 Jun 2026 23:42:27 +0200 Subject: [PATCH 31/35] feat(sca): add retry-auth endpoint for failed 3ds challenges (#31) * feat(sca): add retry-auth endpoint for failed 3ds challenges * fix(sca): retry-auth unexpected-state branch out of card-decline just fixes up in some of the things the review of the last commit noticed --- api/handlers/cart.go | 152 ++++++++++++++++++++++++++++++++++++++ api/handlers/cart_test.go | 147 ++++++++++++++++++++++++++++++++++++ api/routes/routes.go | 1 + pkg/api/client.go | 52 +++++++++++++ pkg/audit/audit.go | 8 +- 5 files changed, 358 insertions(+), 2 deletions(-) diff --git a/api/handlers/cart.go b/api/handlers/cart.go index f922906..d76ca0f 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -5,8 +5,10 @@ import ( "encoding/json" "fmt" "net/http" + "strconv" "time" + "github.com/go-chi/chi/v5" "github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78/paymentintent" "github.com/stripe/stripe-go/v78/paymentmethod" @@ -28,6 +30,12 @@ type CartHandler struct { maxOrderCents int } +// maxAuthIssuances caps how many times a single ordes's PaymentIntent can be +// pushed into requires_action; one initial challende plus up to three retries +// before we tell the customer to use a different card. Counted via existing +// audit rows (audit.EventOrderRequiresAction) so the cap stays migration-free. +const maxAuthIssuances = 4 + // NewCartHandler creates a new cart handler with the Stripe secret key func NewCartHandler(stripeSecretKey, appURL string, maxOrderCents int) *CartHandler { return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL, maxOrderCents: maxOrderCents} @@ -622,3 +630,147 @@ func appURLForHandler(r *http.Request) string { } return fmt.Sprintf("%s://%s", scheme, host) } + +// RetryAuth re-Confirms an in-flight PaymentIntent that the customer failed +// or abandoned during the 3DS challenge, recovering the existing order rather +// than starting a new checkout. Bound to POST /api/v1/orders/{id}/retry-auth. +// +// Stripe's PI state machine bounces back to requires_payment_method after a +// failed or abandoned 3DS, so paymentintent.Confirm(pi.id, …) mints a fresh +// next_action.redirect_to_url — the same shape respondRequiresAction already +// handles. No idempotency key is set: Confirm mutates an existing PI and +// Stripe collapses by id (see sca-psd2-compliance.md caveat #5). +func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) { + db := database.GetDB().WithContext(r.Context()) + userID := middleware.UserIDFromContext(r.Context()) + + idStr := chi.URLParam(r, "id") + id64, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil) + return + } + orderID := uint(id64) + + var order models.Order + if err := db.Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error; err != nil { + utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil) + return + } + + // Only pending (initial attempt mid-flight) and failed (webhook flipped it + // after a non-recoverable error) are retryable. paid, refundedn shipped, + // delivered, cancelled are terminal - refuse + if order.Status != models.OrderStatusPending && order.Status != models.OrderStatusFailed { + utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "order is already finalized", nil) + return + } + if order.StripePaymentID == "" { + utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "order has no payment intent to retry", nil) + return + } + + // Cap is best-effort: two concurrent retries at issuances=cap-1 will both + // pass this check and both Confirm before either writes a new event row. + // Per-user RateLimitByUser(10/min) on the route bounds the worst case. + // SQLite-WAL has no SELECT ... FOR UPDATE, so a stricter guard would need + // a serializing INSERT - not worth the cimplexity at current traffic. + var issuances int64 + if err := db.Model(&models.OrderEvent{}). + Where("order_id = ? AND type = ?", order.ID, audit.EventOrderRequiresAction). + Count(&issuances).Error; err != nil { + utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to count auth attempts", nil) + return + } + if issuances >= maxAuthIssuances { + middleware.RecordCartConversion("retry_max_attempts") + utils.RespondError(w, http.StatusTooManyRequests, "RETRY_LIMIT", "too many authentication attempts, please use a different card", nil) + return + } + + // scoped card lookup - match the initial-flow policy in ConvertCart so a + // card the user deleted (or that the storage-TTL reconciler removed) + // cannot be silently re-charged through retry. The ownership clause is + // defensive: order.CardID is enforced at SetCard time today but binding + // the retry path to that invariant explicitly costs nothing. + var card models.Card + if err := db.Where("id = ? AND user_id = ?", order.CardID, userID).First(&card).Error; err != nil { + utils.RespondError(w, http.StatusConflict, "CARD_NO_LONGER_AVAILABLE", "saved card is no longer available, add it again to retry", nil) + return + } + if card.IsStorageExpired(time.Now()) { + if err := expireStoredCard(db, &card, h.stripeKey); err != nil { + utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil) + return + } + respondCardStorageExpired(w) + return + } + + stripe.Key = h.stripeKey + + stripeStart := time.Now() + confirmed, cerr := paymentintent.Confirm(order.StripePaymentID, &stripe.PaymentIntentConfirmParams{ + PaymentMethod: stripe.String(card.StripePaymentID), + OffSession: stripe.Bool(false), + ReturnURL: stripe.String(h.appURL + "/post-3ds"), + }) + stripeDur := time.Since(stripeStart).Seconds() + if cerr != nil { + middleware.ObserveStripeRequest("payment_intent_confirm", "error", stripeDur) + if stripeErr, ok := cerr.(*stripe.Error); ok && stripeErr.Type == stripe.ErrorTypeCard { + db.Model(&order).Update("status", models.OrderStatusFailed) + audit.OrderFailed(userID, order.ID, order.Total, stripeErr.Msg) + middleware.RecordCartConversion("retry_card_declined") + utils.RespondError(w, http.StatusPaymentRequired, mapStripeCardErrCode(stripeErr.Code), stripeErr.Msg, nil) + return + } + if stripeErr, ok := cerr.(*stripe.Error); ok && stripeErr.Code == "payment_intent_unexpected_state" { + actual, gerr := paymentintent.Get(order.StripePaymentID, nil) + if gerr != nil { + middleware.RecordCartConversion("retry_stripe_error") + utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment failed", nil) + return + } + switch actual.Status { + case stripe.PaymentIntentStatusSucceeded: + middleware.RecordCartConversion("retry_race_already_done") + utils.RespondSuccess(w, http.StatusAccepted, map[string]any{ + "order_id": order.ID, + "status": "succeeded", + }) + return + case stripe.PaymentIntentStatusCanceled: + db.Model(&order).Update("status", models.OrderStatusFailed) + audit.OrderFailed(userID, order.ID, order.Total, "payment intent canceled") + middleware.RecordCartConversion("retry_pi_canceled") + utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "payment intent is canceled, please start a new order", nil) + return + default: + middleware.RecordCartConversion("retry_pi_unexpected_state") + utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment intent in unexpected state", nil) + return + } + } + + middleware.RecordCartConversion("retry_stripe_error") + utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment failed", nil) + return + } + middleware.ObserveStripeRequest("payment_intent_confirm", "success", stripeDur) + + // frictionless 3DS on retry, stripe approved without a new challenge. + // the payment_intent.succeeded webhook owns the order.status=paid + // transition (matches the initial-flow contract); tell the TUI to keep + // polling /orders/{id}/status until it lands + if confirmed.Status == stripe.PaymentIntentStatusSucceeded { + middleware.RecordCartConversion("retry_frictionless") + utils.RespondSuccess(w, http.StatusAccepted, map[string]any{ + "order_id": order.ID, + "status": "succeeded", + }) + return + } + + h.respondRequiresAction(w, &order, card.StripePaymentID, confirmed) +} diff --git a/api/handlers/cart_test.go b/api/handlers/cart_test.go index 58020d8..c520c02 100644 --- a/api/handlers/cart_test.go +++ b/api/handlers/cart_test.go @@ -3,6 +3,7 @@ package handlers import ( "bytes" "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" @@ -12,6 +13,7 @@ import ( "github.com/go-chi/chi/v5" "terminalShop/api/middleware" + "terminalShop/pkg/audit" "terminalShop/pkg/database" "terminalShop/pkg/models" ) @@ -867,3 +869,148 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) { t.Fatalf("want CARD_STORAGE_EXPIRED, got %q", resp.Error.Code) } } + +// TestRetryAuth exercises the pre-Stripe validation branches of +// CartHandler.RetryAuth. The happy paths (Confirm → requires_action / +// succeeded) hit the live Stripe API and aren't covered here — see +// sca-psd2-compliance.md "Test plan" for manual coverage with the +// 4000 0027 6000 3184 test card. Caveat #14 in that doc still applies: +// NewCartHandler takes 3 args and the seeded user must have a non-empty +// StripeCustomerID so the Convert/Retry paths stay hermetic. +func TestRetryAuth(t *testing.T) { + cases := []struct { + name string + ownedByUser bool + seedStatus models.OrderStatus + seedPayment string + seedEvents int + wantStatus int + wantErrCode string + }{ + { + name: "wrong user returns not found", + ownedByUser: false, seedStatus: models.OrderStatusFailed, + seedPayment: "pi_test_wrong_user", seedEvents: 1, + wantStatus: http.StatusNotFound, wantErrCode: "ORDER_NOT_FOUND", + }, + { + name: "missing payment intent rejects", + ownedByUser: true, seedStatus: models.OrderStatusPending, + seedPayment: "", seedEvents: 0, + wantStatus: http.StatusConflict, wantErrCode: "INVALID_STATE", + }, + { + name: "paid order is not retryable", + ownedByUser: true, seedStatus: models.OrderStatusPaid, + seedPayment: "pi_test_paid", seedEvents: 1, + wantStatus: http.StatusConflict, wantErrCode: "INVALID_STATE", + }, + { + name: "refunded order is not retryable", + ownedByUser: true, seedStatus: models.OrderStatusRefunded, + seedPayment: "pi_test_refunded", seedEvents: 1, + wantStatus: http.StatusConflict, wantErrCode: "INVALID_STATE", + }, + { + name: "retry cap reached returns 429", + ownedByUser: true, seedStatus: models.OrderStatusFailed, + seedPayment: "pi_test_capped", seedEvents: maxAuthIssuances, + wantStatus: http.StatusTooManyRequests, wantErrCode: "RETRY_LIMIT", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + db := database.GetDB() + user.StripeCustomerID = "cus_test_retry" + if err := db.Save(&user).Error; err != nil { + t.Fatalf("save user: %v", err) + } + + ownerID := user.ID + if !tc.ownedByUser { + other := models.User{ + SSHKeyFingerprint: "SHA256:retryother", + SSHPublicKey: "ssh-ed25519 AAAA retryother", + } + if err := db.Create(&other).Error; err != nil { + t.Fatalf("seed other user: %v", err) + } + ownerID = other.ID + } + + card := models.Card{ + UserID: ownerID, StripePaymentID: "pm_test_retry", + Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030, + } + if err := db.Create(&card).Error; err != nil { + t.Fatalf("seed card: %v", err) + } + + order := models.Order{ + UserID: ownerID, + CardID: card.ID, + StripePaymentID: tc.seedPayment, + Status: tc.seedStatus, + Subtotal: 500, Total: 500, + ShippingName: "Test", ShippingStreet: "1 Main St", + ShippingCity: "PDX", ShippingState: "OR", + ShippingZip: "97201", ShippingCountry: "US", + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("seed order: %v", err) + } + + for i := 0; i < tc.seedEvents; i++ { + evt := models.OrderEvent{ + OrderID: order.ID, + Type: audit.EventOrderRequiresAction, + Actor: fmt.Sprintf("user:%d", ownerID), + Payload: `{}`, + } + if err := db.Create(&evt).Error; err != nil { + t.Fatalf("seed event: %v", err) + } + } + + handler := NewCartHandler("", "http://localhost", 0) + req := orderRequest("POST", + fmt.Sprintf("/api/v1/orders/%d/retry-auth", order.ID), nil, user.ID, fmt.Sprintf("%d", order.ID)) + w := httptest.NewRecorder() + handler.RetryAuth(w, req) + + if w.Code != tc.wantStatus { + t.Fatalf("status: want %d, got %d body=%s", tc.wantStatus, w.Code, w.Body.String()) + } + + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + _ = json.NewDecoder(w.Body).Decode(&resp) + if resp.Error.Code != tc.wantErrCode { + t.Fatalf("err code: want %q, got %q, body=%s", tc.wantErrCode, resp.Error.Code, w.Body.String()) + } + }) + } +} + +func TestRetryAuth_OrderNotFound(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + handler := NewCartHandler("", "http://localhost", 0) + req := orderRequest("POST", "/api/v1/orders/9999/retry-auth", nil, user.ID, "9999") + w := httptest.NewRecorder() + handler.RetryAuth(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d body=%s", w.Code, w.Body.String()) + } +} diff --git a/api/routes/routes.go b/api/routes/routes.go index 818b164..9080d60 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -132,6 +132,7 @@ func SetupRoutes( // Orders r.Get("/orders", orderHandler.GetOrders) r.Get("/orders/{id}/status", orderHandler.GetOrderStatus) + r.With(middleware.RateLimitByUser(10, time.Minute)).Post("/orders/{id}/retry-auth", cartHandler.RetryAuth) r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest) r.Post("/orders/{id}/refund", orderHandler.RefundOrder) diff --git a/pkg/api/client.go b/pkg/api/client.go index 5009910..7003cce 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -563,6 +563,58 @@ func (c *Client) GetOrderStatus(id uint) (string, error) { return sResp.Data.Status, nil } +// RetryAuthOutcome is the result of POST /orders/{id}/retry-auth. +// status=="requires_action" -> RedirectURL is a fresh QR target; resume polling. +// Status=="succeeded" -> stripe approved frictionless; keep polling for the +// webhook to flip status=paid (RedirectURL will be empty) +type RetryAuthOutcome struct { + OrderID uint + Status string + RedirectURL string +} + +type retryAuthResponse struct { + Success bool `json:"success"` + Data struct { + OrderID uint `json:"order_id"` + Status string `json:"status"` + RedirectURL string `json:"redirect_url,omitempty"` + } `json:"data"` + Error *APIError `json:"error,omitempty"` +} + +// RetryAuth asks the server to re-Confirm the existing PaymentIntent for the +// order, producing a new 3DS challenge URL. Used by the TUI's authFailed +// screen so the customer can recover without re-entering the checkout flow. +func (c *Client) RetryAuth(orderID uint) (*RetryAuthOutcome, error) { + url := fmt.Sprintf("%s/api/v1/orders/%d/retry-auth", c.BaseURL, orderID) + req, err := http.NewRequest("POST", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create retry request: %w", err) + } + resp, err := c.doRequest(req) + if err != nil { + return nil, fmt.Errorf("failed to send retry request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var rResp retryAuthResponse + if err := json.NewDecoder(resp.Body).Decode(&rResp); err != nil { + return nil, fmt.Errorf("failed to decode retry response: %w", err) + } + if !rResp.Success { + if rResp.Error != nil { + return nil, fmt.Errorf("%s: %s", rResp.Error.Code, rResp.Error.Message) + } + return nil, fmt.Errorf("failed to retry authentication") + } + return &RetryAuthOutcome{ + OrderID: rResp.Data.OrderID, + Status: rResp.Data.Status, + RedirectURL: rResp.Data.RedirectURL, + }, nil +} + // GetCards fetches all saved cards for the user. func (c *Client) GetCards() ([]models.Card, error) { url := fmt.Sprintf("%s/api/v1/cards", c.BaseURL) diff --git a/pkg/audit/audit.go b/pkg/audit/audit.go index dbe0f5c..cb60c12 100644 --- a/pkg/audit/audit.go +++ b/pkg/audit/audit.go @@ -13,6 +13,11 @@ import ( "gorm.io/gorm" ) +// EventOrderRequiresAction is the audit event-type string emitted whenever a +// PaymentIntent is pushed into requires_action. explored so cart-flow handler +// can count retry attempts without hardcoding the string literal. +const EventOrderRequiresAction = "order_requires_action" + type eventType string const ( @@ -23,7 +28,7 @@ const ( eventOrderPaid eventType = "order_paid" eventOrderShipped eventType = "order_shipped" eventOrderFailed eventType = "order_failed" - eventOrderRequiresAction eventType = "order_requires_action" + eventOrderRequiresAction eventType = EventOrderRequiresAction eventOrderRefunded eventType = "order_refunded" eventPaymentCritical eventType = "payment_critical" eventLabelPurchased eventType = "label_purchased" @@ -313,7 +318,6 @@ func attrsToMap(a []any) map[string]any { return m } -//nolint:unused // called from ConvertCart in the next update func OrderRequiresAction(userID, orderID uint, paymentIntentID string) { e := event{ Event: eventOrderRequiresAction, -- 2.49.0 From 732353d9a42251d1de527a4237c03a4ba9acee9f Mon Sep 17 00:00:00 2001 From: marcus06 Date: Thu, 4 Jun 2026 00:09:07 +0200 Subject: [PATCH 32/35] fix(tui): fix the menu always selecting the first entry in the refund page (#32) --- pkg/tui/refund.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/tui/refund.go b/pkg/tui/refund.go index 8245565..6be0b14 100644 --- a/pkg/tui/refund.go +++ b/pkg/tui/refund.go @@ -129,7 +129,7 @@ func (m Model) RefundUpdate(msg tea.Msg) (Model, tea.Cmd) { state.form = f } if state.form.State == huh.StateCompleted { - state.form = m.buildRefundForm(state) + state.reason = state.form.GetString("reason") return m, m.setRefundFocus(state, refundFocusMessage) } return m, cmd @@ -335,7 +335,7 @@ func (m Model) renderRefundTextarea(state *refundState) string { } wrapper := lipgloss.NewStyle(). - Border(lipgloss.ThickBorder()). + Border(lipgloss.RoundedBorder()). BorderForeground(border) contentWidth := m.refundContentWidth() -- 2.49.0 From 64deba7e0399d92a71e7880d11ed9133badfadf7 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Fri, 5 Jun 2026 22:52:54 +0200 Subject: [PATCH 33/35] feat(sca): wire retry-auth keybind on authfailed review screen (#33) --- pkg/tui/confirmation.go | 77 +++++- pkg/tui/confirmation_retry_test.go | 394 +++++++++++++++++++++++++++++ pkg/tui/model.go | 31 +++ 3 files changed, 499 insertions(+), 3 deletions(-) create mode 100644 pkg/tui/confirmation_retry_test.go diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index 85939ac..e68b2c0 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -170,8 +170,20 @@ func (m Model) ReviewView() string { if m.CheckingOut { return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...") } + if m.review.retrying { + return m.theme.TextAccent().Bold(true).Padding(1).Render(" retrying authentication...") + } + if m.review.retryExhausted { + content := m.theme.TextError().Bold(true).Padding(1).Render("too many authentication attempts") + "\n" + m.theme.TextLabel().Padding(0, 1).Render("press esc to start a new order") + return lipgloss.Place( + m.widthContainer, + lipgloss.Height(content), + lipgloss.Center, lipgloss.Center, + content, + ) + } if m.review.authFailed { - content := m.theme.TextError().Bold(true).Padding(1).Render("authentication failed — press esc to try again") + content := m.theme.TextError().Bold(true).Padding(1).Render("authentication failed — press r to retry, esc to go back") return lipgloss.Place( m.widthContainer, lipgloss.Height(content), @@ -208,7 +220,7 @@ func (m Model) renderRequiresActionView() string { header := m.theme.TextAccent().Bold(true).Render("your bank wants to verify this charge") if m.review.redirectURL == "" { - return base.Render(" waiting for authentication link...") + return base.Render(" verifying with your bank...") } qr, qrSize, err := qrfefe.Generate(m.review.redirectURL) @@ -274,17 +286,65 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) { m.review.requiresAction = false m.review.redirectURL = "" m.review.authFailed = true + m.footer = []footerCommand{{key: "r", value: "retry"}, {key: "esc", value: "back"}} return m, nil default: return m, m.schedulePollOrderStatus(msg.OrderID) } + case RetryAuthResultMsg: + if m.review.orderID != 0 && msg.OrderID != m.review.orderID { + return m, nil + } + m.review.retrying = false + if msg.Err != nil { + s := msg.Err.Error() + switch { + case strings.Contains(s, "RETRY_LIMIT"): + m.review.authFailed = false + m.review.retryExhausted = true + m.footer = []footerCommand{{key: "esc", value: "back"}} + return m, nil + case strings.Contains(s, "CARD_NO_LONGER_AVAILABLE"): + m.review.authFailed = false + m.review.requiresAction = false + m.review.redirectURL = "" + m.review.orderID = 0 + m = m.SwitchPage(paymentPage) + m.payment.view = 0 + m.payment.form = nil + m.error = &VisibleError{message: "saved card is no longer available. add it again to try."} + return m, nil + default: + m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)} + return m, nil + } + } + m.review.authFailed = false + m.review.retryExhausted = false + m.review.requiresAction = true + m.review.redirectURL = msg.RedirectURL + m.review.orderID = msg.OrderID + m.footer = []footerCommand{{key: "esc", value: "cancel"}} + return m, m.schedulePollOrderStatus(msg.OrderID) + case tea.KeyMsg: if m.CheckingOut { return m, nil } + if m.review.retrying { + return m, nil + } if m.review.authFailed { - if msg.String() == "esc" || msg.String() == "enter" { + switch msg.String() { + case "r": + if m.review.orderID == 0 { + return m, nil + } + m.review.retrying = true + m.error = nil + return m, m.retryAuthCmd(m.review.orderID) + case "esc": m.review.authFailed = false m = m.SwitchPage(paymentPage) m.payment.view = 0 @@ -293,6 +353,17 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) { } 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 diff --git a/pkg/tui/confirmation_retry_test.go b/pkg/tui/confirmation_retry_test.go new file mode 100644 index 0000000..2dcf44c --- /dev/null +++ b/pkg/tui/confirmation_retry_test.go @@ -0,0 +1,394 @@ +package tui + +import ( + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +// newReviewModel returns a Model sitting on the review page with the default +// layout already applied. Use as the starting point for every retry-auth +// state machine test so transitions assert from a realistic baseline. +func newReviewModel(t *testing.T) Model { + t.Helper() + m := NewModel("test") + m.currentPage = reviewPage + return m +} + +// keyR / keyEsc / keyJ build the bubbletea v2 key events Update expects. +// KeyMsg is an interface; KeyPressMsg satisfies it. Key.String() returns +// Text when non-empty, otherwise the keystroke (which is why esc relies on +// Code: tea.KeyEsc rather than Text). +func keyR() tea.KeyMsg { return tea.KeyPressMsg{Text: "r", Code: 'r'} } +func keyEsc() tea.KeyMsg { return tea.KeyPressMsg{Code: tea.KeyEsc} } +func keyJ() tea.KeyMsg { return tea.KeyPressMsg{Text: "j", Code: 'j'} } + +func TestRetryAuthCmd_NilClient(t *testing.T) { + m := NewModel("test") + m.APIClient = nil + cmd := m.retryAuthCmd(42) + if cmd == nil { + t.Fatal("retryAuthCmd returned nil cmd") + } + msg := cmd() + rm, ok := msg.(RetryAuthResultMsg) + if !ok { + t.Fatalf("expected RetryAuthResultMsg, got %T", msg) + } + if rm.OrderID != 42 { + t.Errorf("OrderID = %d, want 42", rm.OrderID) + } + if rm.Err == nil || !strings.Contains(rm.Err.Error(), "api client unavailable") { + t.Errorf("expected api-client-unavailable err, got %v", rm.Err) + } +} + +func TestReviewView_RetryingShowsCopy(t *testing.T) { + m := newReviewModel(t) + m.review.retrying = true + out := m.ReviewView() + if !strings.Contains(out, "retrying authentication") { + t.Fatalf("missing retrying copy in: %q", out) + } +} + +func TestReviewView_RetryExhaustedShowsCopy(t *testing.T) { + m := newReviewModel(t) + m.review.retryExhausted = true + out := m.ReviewView() + if !strings.Contains(out, "too many authentication attempts") { + t.Fatalf("missing retry-exhausted copy in: %q", out) + } + if !strings.Contains(out, "press esc to start a new order") { + t.Fatalf("missing recovery hint in: %q", out) + } +} + +func TestReviewView_AuthFailedAdvertisesRetry(t *testing.T) { + m := newReviewModel(t) + m.review.authFailed = true + out := m.ReviewView() + if !strings.Contains(out, "press r to retry") { + t.Fatalf("authFailed copy must advertise r: %q", out) + } + if !strings.Contains(out, "esc to go back") { + t.Fatalf("authFailed copy must advertise esc: %q", out) + } +} + +func TestRenderRequiresActionView_FrictionlessShowsVerifying(t *testing.T) { + m := newReviewModel(t) + m.review.requiresAction = true + m.review.redirectURL = "" + out := m.renderRequiresActionView() + if !strings.Contains(out, "verifying with your bank") { + t.Fatalf("frictionless retry-success path must show verifying copy: %q", out) + } +} + +func TestReviewUpdate_AuthFailed_PressR_StartsRetry(t *testing.T) { + m := newReviewModel(t) + m.review.authFailed = true + m.review.orderID = 7 + m.error = &VisibleError{message: "leftover banner"} + + out, cmd := m.ReviewUpdate(keyR()) + if !out.review.retrying { + t.Fatal("expected retrying=true after r press") + } + if out.error != nil { + t.Fatal("stale banner should be cleared when retry starts") + } + if cmd == nil { + t.Fatal("expected non-nil cmd to call retryAuth") + } +} + +func TestReviewUpdate_AuthFailed_PressR_ZeroOrderID_NoOp(t *testing.T) { + m := newReviewModel(t) + m.review.authFailed = true + m.review.orderID = 0 + + out, cmd := m.ReviewUpdate(keyR()) + if out.review.retrying { + t.Fatal("retry must not start without an order id") + } + if cmd != nil { + t.Fatal("expected nil cmd when orderID=0") + } +} + +func TestReviewUpdate_AuthFailed_PressEsc_BouncesToPayment(t *testing.T) { + m := newReviewModel(t) + m.review.authFailed = true + m.review.orderID = 7 + + out, _ := m.ReviewUpdate(keyEsc()) + if out.review.authFailed { + t.Fatal("authFailed must clear on esc") + } + if out.currentPage != paymentPage { + t.Fatalf("currentPage = %v, want paymentPage", out.currentPage) + } +} + +func TestReviewUpdate_AuthFailed_OtherKey_NoOp(t *testing.T) { + m := newReviewModel(t) + m.review.authFailed = true + m.review.orderID = 7 + + out, cmd := m.ReviewUpdate(keyJ()) + if !out.review.authFailed { + t.Fatal("unrelated key must not clear authFailed") + } + if out.review.retrying { + t.Fatal("unrelated key must not start retry") + } + if cmd != nil { + t.Fatal("unrelated key must not produce a cmd") + } +} + +func TestReviewUpdate_Retrying_BlocksKeys(t *testing.T) { + m := newReviewModel(t) + m.review.retrying = true + m.review.authFailed = true + m.review.orderID = 7 + + out, cmd := m.ReviewUpdate(keyR()) + if !out.review.retrying { + t.Fatal("retrying flag should persist while a retry is in flight") + } + if cmd != nil { + t.Fatal("r press while retrying must not spawn a second cmd") + } + + out, cmd = m.ReviewUpdate(keyEsc()) + if out.currentPage != reviewPage { + t.Fatalf("currentPage changed while retrying: %v", out.currentPage) + } + if cmd != nil { + t.Fatal("esc while retrying must not navigate away") + } +} + +func TestReviewUpdate_RetryAuthResult_RequiresAction(t *testing.T) { + m := newReviewModel(t) + m.review.retrying = true + m.review.authFailed = true + m.review.orderID = 7 + + out, cmd := m.ReviewUpdate(RetryAuthResultMsg{ + OrderID: 7, + Status: "requires_action", + RedirectURL: "https://stripe/auth/abc", + }) + + if out.review.retrying { + t.Fatal("retrying must clear when the result arrives") + } + if out.review.authFailed { + t.Fatal("authFailed must clear on retry success") + } + if !out.review.requiresAction { + t.Fatal("expected requiresAction=true so the QR view renders") + } + if out.review.redirectURL != "https://stripe/auth/abc" { + t.Fatalf("redirectURL = %q", out.review.redirectURL) + } + if cmd == nil { + t.Fatal("expected schedulePollOrderStatus cmd") + } + if len(out.footer) != 1 || out.footer[0].key != "esc" || out.footer[0].value != "cancel" { + t.Fatalf("footer = %+v, want only esc=cancel", out.footer) + } +} + +func TestReviewUpdate_RetryAuthResult_FrictionlessSuccess(t *testing.T) { + m := newReviewModel(t) + m.review.retrying = true + m.review.authFailed = true + m.review.orderID = 9 + + out, cmd := m.ReviewUpdate(RetryAuthResultMsg{ + OrderID: 9, + Status: "succeeded", + }) + + if !out.review.requiresAction { + t.Fatal("frictionless retry must set requiresAction=true to render the verifying view") + } + if out.review.redirectURL != "" { + t.Fatalf("redirectURL must stay empty for frictionless retry, got %q", out.review.redirectURL) + } + if cmd == nil { + t.Fatal("expected poll cmd to wait for the webhook") + } +} + +func TestReviewUpdate_RetryAuthResult_RetryLimit(t *testing.T) { + m := newReviewModel(t) + m.review.retrying = true + m.review.authFailed = true + m.review.orderID = 7 + + out, cmd := m.ReviewUpdate(RetryAuthResultMsg{ + OrderID: 7, + Err: errors.New("RETRY_LIMIT: too many authentication attempts, please use a different card"), + }) + + if out.review.retrying { + t.Fatal("retrying must clear after a result") + } + if !out.review.retryExhausted { + t.Fatal("expected retryExhausted=true on RETRY_LIMIT") + } + if out.review.authFailed { + t.Fatal("authFailed must clear when retryExhausted takes over") + } + if cmd != nil { + t.Fatal("RETRY_LIMIT is terminal — no follow-up cmd") + } + if len(out.footer) != 1 || out.footer[0].key != "esc" { + t.Fatalf("footer = %+v, want only esc=back", out.footer) + } +} + +func TestReviewUpdate_RetryAuthResult_CardGone(t *testing.T) { + m := newReviewModel(t) + m.review.retrying = true + m.review.authFailed = true + m.review.orderID = 7 + + out, _ := m.ReviewUpdate(RetryAuthResultMsg{ + OrderID: 7, + Err: errors.New("CARD_NO_LONGER_AVAILABLE: saved card is no longer available, add it again to retry"), + }) + + if out.currentPage != paymentPage { + t.Fatalf("currentPage = %v, want paymentPage", out.currentPage) + } + if out.review.authFailed || out.review.requiresAction { + t.Fatal("auth state must reset when bouncing to payment") + } + if out.review.orderID != 0 { + t.Fatalf("orderID should reset to 0, got %d", out.review.orderID) + } + if out.error == nil || !strings.Contains(out.error.message, "saved card is no longer available") { + t.Fatalf("error banner missing or wrong copy: %+v", out.error) + } +} + +func TestReviewUpdate_RetryAuthResult_Transient(t *testing.T) { + m := newReviewModel(t) + m.review.retrying = true + m.review.authFailed = true + m.review.orderID = 7 + + out, cmd := m.ReviewUpdate(RetryAuthResultMsg{ + OrderID: 7, + Err: errors.New("CARD_DECLINED: card declined"), + }) + + if out.review.retrying { + t.Fatal("retrying must clear after a result") + } + if !out.review.authFailed { + t.Fatal("authFailed must persist so the user can retry again") + } + if out.review.retryExhausted { + t.Fatal("transient errors must not flip retryExhausted") + } + if out.error == nil { + t.Fatal("expected friendly banner for transient error") + } + if cmd != nil { + t.Fatal("transient error must not start a poll") + } +} + +func TestReviewUpdate_RetryAuthResult_OrderIDMismatch(t *testing.T) { + m := newReviewModel(t) + m.review.retrying = true + m.review.authFailed = true + m.review.orderID = 7 + + out, cmd := m.ReviewUpdate(RetryAuthResultMsg{ + OrderID: 99, + Status: "requires_action", + RedirectURL: "https://wrong", + }) + + if !out.review.retrying { + t.Fatal("mismatched orderID must leave retrying unchanged") + } + if out.review.requiresAction { + t.Fatal("mismatched orderID must not flip requiresAction") + } + if out.review.redirectURL == "https://wrong" { + t.Fatal("mismatched orderID must not adopt the foreign URL") + } + if cmd != nil { + t.Fatal("mismatched orderID must not start a poll") + } +} + +func TestReviewUpdate_RetryExhausted_PressEsc_BouncesToPayment(t *testing.T) { + m := newReviewModel(t) + m.review.retryExhausted = true + m.review.orderID = 7 + + out, _ := m.ReviewUpdate(keyEsc()) + if out.review.retryExhausted { + t.Fatal("retryExhausted must clear on esc") + } + if out.review.orderID != 0 { + t.Fatalf("orderID should reset to 0, got %d", out.review.orderID) + } + if out.currentPage != paymentPage { + t.Fatalf("currentPage = %v, want paymentPage", out.currentPage) + } +} + +func TestReviewUpdate_RetryExhausted_OtherKey_NoOp(t *testing.T) { + m := newReviewModel(t) + m.review.retryExhausted = true + + out, cmd := m.ReviewUpdate(keyR()) + if !out.review.retryExhausted { + t.Fatal("unrelated key must not clear retryExhausted") + } + if cmd != nil { + t.Fatal("unrelated key must not produce a cmd") + } +} + +func TestReviewUpdate_OrderStatusFailed_SetsRetryFooter(t *testing.T) { + m := newReviewModel(t) + m.review.requiresAction = true + m.review.orderID = 7 + + out, _ := m.ReviewUpdate(OrderStatusMsg{OrderID: 7, Status: "failed"}) + + if !out.review.authFailed { + t.Fatal("expected authFailed=true after failed status") + } + if out.review.requiresAction { + t.Fatal("requiresAction must clear when poll resolves to failed") + } + hasR, hasEsc := false, false + for _, c := range out.footer { + if c.key == "r" && c.value == "retry" { + hasR = true + } + if c.key == "esc" && c.value == "back" { + hasEsc = true + } + } + if !hasR || !hasEsc { + t.Fatalf("footer missing r/esc retry hints: %+v", out.footer) + } +} diff --git a/pkg/tui/model.go b/pkg/tui/model.go index bc9c568..aaf6b7e 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -211,6 +211,8 @@ type reviewState struct { redirectURL string orderID uint authFailed bool + retrying bool + retryExhausted bool } type confirmState struct { @@ -442,6 +444,18 @@ type OrderStatusMsg struct { Err error } +// RetryAuthResultMsg carries the outcome of POST /orders/{id}/retry-auth. +// Status "requires_action" -> RedirectURL is a fresh challenge URL; resume +// the QR + poll loop. Status "succeeded" -> frictionless retry approved; +// server's APIError string ("RETRY_LIMIT" / "CARD_NO_LONGER_AVAILABLE" / +// transient) - the Update branch matches on substring. +type RetryAuthResultMsg struct { + OrderID uint + Status string + RedirectURL string + Err error +} + // CartSyncedMsg is recieved when the server responds to a cart item update type CartSyncedMsg struct { UpdateID int64 @@ -682,6 +696,23 @@ func (m Model) schedulePollOrderStatus(orderID uint) tea.Cmd { }) } +func (m Model) retryAuthCmd(orderID uint) tea.Cmd { + return func() tea.Msg { + if m.APIClient == nil { + return RetryAuthResultMsg{OrderID: orderID, Err: fmt.Errorf("api client unavailable")} + } + outcome, err := m.APIClient.RetryAuth(orderID) + if err != nil { + return RetryAuthResultMsg{OrderID: orderID, Err: err} + } + return RetryAuthResultMsg{ + OrderID: orderID, + Status: outcome.Status, + RedirectURL: outcome.RedirectURL, + } + } +} + func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, tea.Cmd) { if m.APIClient == nil || m.User == nil { return m, nil -- 2.49.0 From 135f1361e4e924cf5577ddc93f979452ebdac167 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Fri, 5 Jun 2026 23:49:03 +0200 Subject: [PATCH 34/35] fix(products): update pricing on products (#34) * fix(products): update pricing on products * fix(test): pin coffee price in cart-limit fixture TestConvertCartLimit's quantities and expected totals were keyed to the original 500-cent latte price. After the product price bump in 1ce63fb, coffee_id=4 became 2500 cents, so under_cap/at_cap subtests overshot the $200 cap and over_cap's expected total magnitude was wrong by 5x. Override coffee_id=4's price to 500 in test setup so the cap math stays stable regardless of seed changes. --- api/handlers/cart_test.go | 12 ++++++++++-- pkg/database/seed.go | 15 ++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/api/handlers/cart_test.go b/api/handlers/cart_test.go index c520c02..c80a5e7 100644 --- a/api/handlers/cart_test.go +++ b/api/handlers/cart_test.go @@ -709,6 +709,14 @@ func TestConvertCartLimit(t *testing.T) { 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]interface{}{"coffee_id": 4, "quantity": tc.quantity}) @@ -760,8 +768,8 @@ func TestConvertCartLimit(t *testing.T) { if got, _ := resp.Error.Details["limit_cents"].(float64); int(got) != tc.capCents { t.Errorf("limit_cents in details: want %d, got %v", tc.capCents, resp.Error.Details["limit_cents"]) } - if got, _ := resp.Error.Details["total_cents"].(float64); int(got) != tc.quantity*500 { - t.Errorf("total_cents in details: want %d, got %v", tc.quantity*500, resp.Error.Details["total_cents"]) + if got, _ := resp.Error.Details["total_cents"].(float64); int(got) != tc.quantity*pinnedPrice { + t.Errorf("total_cents in details: want %d, got %v", tc.quantity*pinnedPrice, resp.Error.Details["total_cents"]) } } }) diff --git a/pkg/database/seed.go b/pkg/database/seed.go index 812e3d0..4d21fef 100644 --- a/pkg/database/seed.go +++ b/pkg/database/seed.go @@ -4,8 +4,9 @@ import ( "fmt" "log" - "gorm.io/gorm" "terminalShop/pkg/models" + + "gorm.io/gorm" ) // Seed populates the database with initial data @@ -27,7 +28,7 @@ func Seed(db *gorm.DB) error { RoastType: "Dark Roast", Ounces: 2, BeanType: "Arabica", - Price: 350, // $3.50 + Price: 1800, // $18.00 Color: "#8B4513", Description: "Bold and intense coffee with a rich crema", }, @@ -36,7 +37,7 @@ func Seed(db *gorm.DB) error { RoastType: "Medium Roast", Ounces: 8, BeanType: "Arabica", - Price: 400, // $4.00 + Price: 2200, // $22.00 Color: "#A0522D", Description: "Smooth and balanced espresso diluted with hot water", }, @@ -45,7 +46,7 @@ func Seed(db *gorm.DB) error { RoastType: "Medium Roast", Ounces: 6, BeanType: "Arabica", - Price: 450, // $4.50 + Price: 2200, // $22.00 Color: "#C4A574", Description: "Espresso with steamed milk and a thick layer of foam", }, @@ -54,7 +55,7 @@ func Seed(db *gorm.DB) error { RoastType: "Medium Roast", Ounces: 12, BeanType: "Arabica", - Price: 500, // $5.00 + Price: 2500, // $25.00 Color: "#D2B48C", Description: "Creamy espresso with plenty of steamed milk and a touch of foam", }, @@ -63,7 +64,7 @@ func Seed(db *gorm.DB) error { RoastType: "Dark Roast", Ounces: 12, BeanType: "Arabica", - Price: 550, // $5.50 + Price: 3000, // $30.00 Color: "#8B4513", Description: "Chocolate and espresso combined with steamed milk. Sweet and indulgent.", }, @@ -72,7 +73,7 @@ func Seed(db *gorm.DB) error { RoastType: "Dark Roast", Ounces: 3, BeanType: "Robusta Blend", - Price: 425, // $4.25 + Price: 1800, // $18.00 Color: "#DAA520", Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.", }, -- 2.49.0 From f9c9b7dff546cbdca9407daa2dfb74ae3537e07c Mon Sep 17 00:00:00 2001 From: marcusDenslow Date: Sat, 6 Jun 2026 13:53:50 +0200 Subject: [PATCH 35/35] chore(repo): change all instances of interface{} to any as of newer go standards --- api/handlers/addresses.go | 8 ++--- api/handlers/addresses_test.go | 2 +- api/handlers/auth.go | 8 ++--- api/handlers/cards.go | 14 ++++---- api/handlers/cart.go | 24 ++++++------- api/handlers/cart_test.go | 66 +++++++++++++++++----------------- api/handlers/orders.go | 4 +-- api/handlers/products.go | 4 +-- api/handlers/reconcile.go | 4 +-- api/handlers/view.go | 2 +- api/handlers/webhook.go | 4 +-- pkg/api/client.go | 18 +++++----- pkg/auth/jwt.go | 2 +- pkg/utils/response.go | 14 ++++---- 14 files changed, 87 insertions(+), 87 deletions(-) diff --git a/api/handlers/addresses.go b/api/handlers/addresses.go index b62edc9..c3ec6ab 100644 --- a/api/handlers/addresses.go +++ b/api/handlers/addresses.go @@ -42,7 +42,7 @@ func (h *AddressHandler) GetAddresses(w http.ResponseWriter, r *http.Request) { var addresses []models.Address db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&addresses) - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "addresses": addresses, }) } @@ -154,7 +154,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) { return } - utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusCreated, map[string]any{ "address": address, }) } @@ -183,7 +183,7 @@ func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) { return } - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "message": "address successfully deleted", }) } @@ -218,7 +218,7 @@ func (h *AddressHandler) SetDefaultAddress(w http.ResponseWriter, r *http.Reques return } - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "address": address, }) } diff --git a/api/handlers/addresses_test.go b/api/handlers/addresses_test.go index c022c6d..e4d2841 100644 --- a/api/handlers/addresses_test.go +++ b/api/handlers/addresses_test.go @@ -58,7 +58,7 @@ func TestGetAddressesEmpty(t *testing.T) { var resp struct { Success bool `json:"success"` Data struct { - Addresses []interface{} `json:"addresses"` + Addresses []any `json:"addresses"` } `json:"data"` } if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { diff --git a/api/handlers/auth.go b/api/handlers/auth.go index 47137c9..70127d0 100644 --- a/api/handlers/auth.go +++ b/api/handlers/auth.go @@ -108,7 +108,7 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) { } middleware.RecordAuthAttempt("success") - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "access_token": token, "token_type": "Bearer", "expires_in": 1800, // 30 minutes in seconds @@ -146,7 +146,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request) } if len(errors) > 0 { - utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]interface{}{ + utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]any{ "errors": errors, }) return @@ -173,7 +173,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request) return } - utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusCreated, map[string]any{ "user": user.ToPublic(), }) } @@ -195,7 +195,7 @@ func (h *AuthHandler) GetUserBySSHKey(w http.ResponseWriter, r *http.Request) { return } - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "user": user.ToPublic(), }) } diff --git a/api/handlers/cards.go b/api/handlers/cards.go index 776d849..c73c3db 100644 --- a/api/handlers/cards.go +++ b/api/handlers/cards.go @@ -89,12 +89,12 @@ func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) { userID, time.Now(), ).Order("is_default DESC, created_at DESC").Find(&cards) - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "cards": cards, }) } -// func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]interface{}) +// func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]any) // GetCard returns a single saved card by ID. func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) { @@ -114,7 +114,7 @@ func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) { return } - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "card": card, }) } @@ -156,7 +156,7 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) { return } - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "card": card, }) } @@ -197,7 +197,7 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) { utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil) return } - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "url": fmt.Sprintf("%s/pay/%s", h.appURL, token), }) } @@ -302,7 +302,7 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) { audit.CardAdded(userID, card.ID, card.Brand, card.Last4) - utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusCreated, map[string]any{ "card": card, }) } @@ -347,7 +347,7 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) { audit.CardDeleted(userID, card.ID) - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "message": "card deleted", }) } diff --git a/api/handlers/cart.go b/api/handlers/cart.go index d76ca0f..122d8ed 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -55,18 +55,18 @@ func getOrCreateCart(ctx context.Context, userID uint) (*models.Cart, error) { return &cart, nil } -func cartResponse(ctx context.Context, cart *models.Cart) map[string]interface{} { +func cartResponse(ctx context.Context, cart *models.Cart) map[string]any { db := database.GetDB().WithContext(ctx) var items []models.CartItem db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items) subtotal := 0 - itemsData := make([]map[string]interface{}, 0, len(items)) + itemsData := make([]map[string]any, 0, len(items)) for _, item := range items { lineTotal := item.Coffee.Price * item.Quantity subtotal += lineTotal - itemsData = append(itemsData, map[string]interface{}{ + itemsData = append(itemsData, map[string]any{ "id": item.ID, "coffee_id": item.CoffeeID, "quantity": item.Quantity, @@ -75,7 +75,7 @@ func cartResponse(ctx context.Context, cart *models.Cart) map[string]interface{} }) } - return map[string]interface{}{ + return map[string]any{ "items": itemsData, "subtotal": subtotal, "address_id": cart.AddressID, @@ -94,7 +94,7 @@ func (h *CartHandler) GetCart(w http.ResponseWriter, r *http.Request) { return } - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "cart": cartResponse(r.Context(), cart), }) } @@ -157,7 +157,7 @@ func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) { } } - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "cart": cartResponse(r.Context(), cart), }) } @@ -193,7 +193,7 @@ func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) { return } - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "message": "address set", }) } @@ -238,7 +238,7 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) { return } - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "message": "card set", }) } @@ -258,7 +258,7 @@ func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) { return } - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "message": "cart cleared", }) } @@ -386,7 +386,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { middleware.RecordCartConversion("validation_over_limit") utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT", fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100), - map[string]interface{}{ + map[string]any{ "total_cents": total, "limit_cents": h.maxOrderCents, }) @@ -507,7 +507,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil { return err } - return tx.Model(cart).Updates(map[string]interface{}{ + return tx.Model(cart).Updates(map[string]any{ "address_id": nil, "card_id": nil, }).Error @@ -534,7 +534,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { } go notify.SlackOrderPaid(&order) - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "order": order, }) } diff --git a/api/handlers/cart_test.go b/api/handlers/cart_test.go index c80a5e7..ec6b61e 100644 --- a/api/handlers/cart_test.go +++ b/api/handlers/cart_test.go @@ -83,8 +83,8 @@ func TestGetCartEmpty(t *testing.T) { Success bool `json:"success"` Data struct { Cart struct { - Items []interface{} `json:"items"` - Subtotal int `json:"subtotal"` + Items []any `json:"items"` + Subtotal int `json:"subtotal"` } `json:"cart"` } `json:"data"` } @@ -110,7 +110,7 @@ func TestSetItemAndGetCart(t *testing.T) { handler := NewCartHandler("", "", 0) // Add item (CoffeeID=1, Quantity=2) - body, _ := json.Marshal(map[string]interface{}{ + body, _ := json.Marshal(map[string]any{ "coffee_id": 1, "quantity": 2, }) @@ -166,13 +166,13 @@ func TestSetItemUpdateQuantity(t *testing.T) { handler := NewCartHandler("", "", 0) // Add item - body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2}) + body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 2}) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) w := httptest.NewRecorder() handler.SetItem(w, req) // Update quantity - body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5}) + body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5}) req = authRequest("PUT", "/api/v1/cart/item", body, user.ID) w = httptest.NewRecorder() handler.SetItem(w, req) @@ -211,13 +211,13 @@ func TestSetItemRemove(t *testing.T) { handler := NewCartHandler("", "", 0) // Add item - body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3}) + body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 3}) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) w := httptest.NewRecorder() handler.SetItem(w, req) // Remove item (quantity 0) - body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 0}) + body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 0}) req = authRequest("PUT", "/api/v1/cart/item", body, user.ID) w = httptest.NewRecorder() handler.SetItem(w, req) @@ -231,7 +231,7 @@ func TestSetItemRemove(t *testing.T) { Success bool `json:"success"` Data struct { Cart struct { - Items []interface{} `json:"items"` + Items []any `json:"items"` } `json:"cart"` } `json:"data"` } @@ -249,7 +249,7 @@ func TestSetItemInvalidProduct(t *testing.T) { handler := NewCartHandler("", "", 0) - body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1}) + body, _ := json.Marshal(map[string]any{"coffee_id": 999, "quantity": 1}) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) w := httptest.NewRecorder() handler.SetItem(w, req) @@ -266,7 +266,7 @@ func TestSetItemMissingCoffeeID(t *testing.T) { handler := NewCartHandler("", "", 0) - body, _ := json.Marshal(map[string]interface{}{"quantity": 1}) + body, _ := json.Marshal(map[string]any{"quantity": 1}) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) w := httptest.NewRecorder() handler.SetItem(w, req) @@ -285,7 +285,7 @@ func TestClearCart(t *testing.T) { // Add two items for _, coffeeID := range []uint{1, 2} { - body, _ := json.Marshal(map[string]interface{}{"coffee_id": coffeeID, "quantity": 1}) + body, _ := json.Marshal(map[string]any{"coffee_id": coffeeID, "quantity": 1}) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) w := httptest.NewRecorder() handler.SetItem(w, req) @@ -309,7 +309,7 @@ func TestClearCart(t *testing.T) { Success bool `json:"success"` Data struct { Cart struct { - Items []interface{} `json:"items"` + Items []any `json:"items"` } `json:"cart"` } `json:"data"` } @@ -339,7 +339,7 @@ func TestSetAddress(t *testing.T) { handler := NewCartHandler("", "", 0) - body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID}) + body, _ := json.Marshal(map[string]any{"address_id": addr.ID}) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) w := httptest.NewRecorder() handler.SetAddress(w, req) @@ -393,7 +393,7 @@ func TestSetAddressWrongUser(t *testing.T) { handler := NewCartHandler("", "", 0) - body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID}) + body, _ := json.Marshal(map[string]any{"address_id": addr.ID}) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) w := httptest.NewRecorder() handler.SetAddress(w, req) @@ -421,7 +421,7 @@ func TestSetCard(t *testing.T) { handler := NewCartHandler("", "", 0) - body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID}) + body, _ := json.Marshal(map[string]any{"card_id": card.ID}) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) w := httptest.NewRecorder() handler.SetCard(w, req) @@ -473,7 +473,7 @@ func TestSetCardWrongUser(t *testing.T) { handler := NewCartHandler("", "", 0) - body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID}) + body, _ := json.Marshal(map[string]any{"card_id": card.ID}) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) w := httptest.NewRecorder() handler.SetCard(w, req) @@ -491,7 +491,7 @@ func TestConvertCartMissingAddress(t *testing.T) { handler := NewCartHandler("", "", 0) // Add item but no address or card - body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1}) + body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1}) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) w := httptest.NewRecorder() handler.SetItem(w, req) @@ -515,14 +515,14 @@ func TestConvertCartMissingCard(t *testing.T) { db := database.GetDB() // Add item and address - body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1}) + body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1}) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) w := httptest.NewRecorder() handler.SetItem(w, req) addr := models.Address{UserID: user.ID, Name: "Test", Street: "123 St", City: "PDX", State: "OR", Zip: "97201", Country: "US"} db.Create(&addr) - body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID}) + body, _ = json.Marshal(map[string]any{"address_id": addr.ID}) req = authRequest("PUT", "/api/v1/cart/address", body, user.ID) w = httptest.NewRecorder() handler.SetAddress(w, req) @@ -563,7 +563,7 @@ func TestMultipleItems(t *testing.T) { // Add three different items for i := uint(1); i <= 3; i++ { - body, _ := json.Marshal(map[string]interface{}{"coffee_id": i, "quantity": int(i)}) + body, _ := json.Marshal(map[string]any{"coffee_id": i, "quantity": int(i)}) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) w := httptest.NewRecorder() handler.SetItem(w, req) @@ -581,8 +581,8 @@ func TestMultipleItems(t *testing.T) { Success bool `json:"success"` Data struct { Cart struct { - Items []interface{} `json:"items"` - Subtotal int `json:"subtotal"` + Items []any `json:"items"` + Subtotal int `json:"subtotal"` } `json:"cart"` } `json:"data"` } @@ -614,13 +614,13 @@ func TestCartIsolation(t *testing.T) { r.Get("/cart", handler.GetCart) // User 1 adds item - body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5}) + body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5}) req := authRequest("PUT", "/cart/item", body, user1.ID) w := httptest.NewRecorder() r.ServeHTTP(w, req) // User 2 adds different item - body, _ = json.Marshal(map[string]interface{}{"coffee_id": 2, "quantity": 3}) + body, _ = json.Marshal(map[string]any{"coffee_id": 2, "quantity": 3}) req = authRequest("PUT", "/cart/item", body, user2.ID) w = httptest.NewRecorder() r.ServeHTTP(w, req) @@ -719,7 +719,7 @@ func TestConvertCartLimit(t *testing.T) { handler := NewCartHandler("", "", tc.capCents) - body, _ := json.Marshal(map[string]interface{}{"coffee_id": 4, "quantity": tc.quantity}) + body, _ := json.Marshal(map[string]any{"coffee_id": 4, "quantity": tc.quantity}) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) w := httptest.NewRecorder() handler.SetItem(w, req) @@ -728,7 +728,7 @@ func TestConvertCartLimit(t *testing.T) { } // set address - body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID}) + body, _ = json.Marshal(map[string]any{"address_id": addr.ID}) req = authRequest("PUT", "/api/v1/cart/address", body, user.ID) w = httptest.NewRecorder() handler.SetAddress(w, req) @@ -737,7 +737,7 @@ func TestConvertCartLimit(t *testing.T) { } // set card - body, _ = json.Marshal(map[string]interface{}{"card_id": card.ID}) + body, _ = json.Marshal(map[string]any{"card_id": card.ID}) req = authRequest("PUT", "/api/v1/cart/card", body, user.ID) w = httptest.NewRecorder() handler.SetCard(w, req) @@ -753,9 +753,9 @@ func TestConvertCartLimit(t *testing.T) { var resp struct { Success bool `json:"success"` Error struct { - Code string `json:"code"` - Message string `json:"message"` - Details map[string]interface{} `json:"details"` + Code string `json:"code"` + Message string `json:"message"` + Details map[string]any `json:"details"` } `json:"error"` } _ = json.NewDecoder(w.Body).Decode(&resp) @@ -795,7 +795,7 @@ func TestSetCard_ExpiredReturns410(t *testing.T) { } handler := NewCartHandler("", "", 0) - body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID}) + body, _ := json.Marshal(map[string]any{"card_id": card.ID}) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) w := httptest.NewRecorder() handler.SetCard(w, req) @@ -832,7 +832,7 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) { db := database.GetDB() - body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1}) + body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1}) req := authRequest("PUT", "/api/v1/vart/item", body, user.ID) w := httptest.NewRecorder() NewCartHandler("", "", 0).SetItem(w, req) @@ -842,7 +842,7 @@ func TestConvertCart_ExpiredReturns410(t *testing.T) { City: "PDX", State: "OR", Zip: "97201", Country: "US", } db.Create(&addr) - body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID}) + body, _ = json.Marshal(map[string]any{"address_id": addr.ID}) req = authRequest("PUT", "/api/v1/cart/address", body, user.ID) w = httptest.NewRecorder() NewCartHandler("", "", 0).SetAddress(w, req) diff --git a/api/handlers/orders.go b/api/handlers/orders.go index 8739a80..1a3027b 100644 --- a/api/handlers/orders.go +++ b/api/handlers/orders.go @@ -221,7 +221,7 @@ func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) { audit.OrderRefunded(userID, order.ID, order.Total, rf.ID) - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "refund_id": rf.ID, "status": "refunded", }) @@ -287,7 +287,7 @@ func (h *OrderHandler) UpdateTracking(w http.ResponseWriter, r *http.Request) { audit.OrderShipped(order.UserID, order.ID, req.Carrier, req.TrackingNumber) - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "order_id": order.ID, "carrier": req.Carrier, "tracking_number": req.TrackingNumber, diff --git a/api/handlers/products.go b/api/handlers/products.go index 13e642e..b808b48 100644 --- a/api/handlers/products.go +++ b/api/handlers/products.go @@ -30,7 +30,7 @@ func (h *ProductHandler) GetProducts(w http.ResponseWriter, r *http.Request) { return } - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "products": products, }) } @@ -53,7 +53,7 @@ func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) { return } - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "product": product, }) } diff --git a/api/handlers/reconcile.go b/api/handlers/reconcile.go index 74dd386..d905b25 100644 --- a/api/handlers/reconcile.go +++ b/api/handlers/reconcile.go @@ -49,7 +49,7 @@ func ReconcileOrders(stripeKey string) { "order_id", order.ID, "pi", pi.ID) txErr := db.Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&order).Updates(map[string]interface{}{ + if err := tx.Model(&order).Updates(map[string]any{ "status": models.OrderStatusPaid, "stripe_payment_id": pi.ID, }).Error; err != nil { @@ -62,7 +62,7 @@ func ReconcileOrders(stripeKey string) { if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil { return err } - return tx.Model(&cart).Updates(map[string]interface{}{ + return tx.Model(&cart).Updates(map[string]any{ "address_id": nil, "card_id": nil, }).Error diff --git a/api/handlers/view.go b/api/handlers/view.go index 5e13dcc..999e526 100644 --- a/api/handlers/view.go +++ b/api/handlers/view.go @@ -54,7 +54,7 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) { db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems) } - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "user": user.ToPublic(), "products": products, "cart": cartItems, diff --git a/api/handlers/webhook.go b/api/handlers/webhook.go index 5e8c209..2644f84 100644 --- a/api/handlers/webhook.go +++ b/api/handlers/webhook.go @@ -125,7 +125,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event } txErr := db.Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&order).Updates(map[string]interface{}{ + if err := tx.Model(&order).Updates(map[string]any{ "status": models.OrderStatusPaid, "stripe_payment_id": pi.ID, }).Error; err != nil { @@ -139,7 +139,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil { return fmt.Errorf("failed to delete cart item: %w", err) } - return tx.Model(&cart).Updates(map[string]interface{}{ + return tx.Model(&cart).Updates(map[string]any{ "address_id": nil, "card_id": nil, }).Error diff --git a/pkg/api/client.go b/pkg/api/client.go index 7003cce..1c49728 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -104,16 +104,16 @@ func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error) // APIResponse represents the standard API response format type APIResponse struct { - Success bool `json:"success"` - Data map[string]interface{} `json:"data"` - Error *APIError `json:"error,omitempty"` + Success bool `json:"success"` + Data map[string]any `json:"data"` + Error *APIError `json:"error,omitempty"` } // APIError represents an error from the API type APIError struct { - Code string `json:"code"` - Message string `json:"message"` - Details map[string]interface{} `json:"details,omitempty"` + Code string `json:"code"` + Message string `json:"message"` + Details map[string]any `json:"details,omitempty"` } // ProductsResponse represents the products API response @@ -339,7 +339,7 @@ func (c *Client) GetCart() (*CartData, error) { func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) { url := fmt.Sprintf("%s/api/v1/cart/item", c.BaseURL) - body := map[string]interface{}{ + body := map[string]any{ "coffee_id": coffeeID, "quantity": quantity, } @@ -379,7 +379,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) { func (c *Client) SetCartAddress(addressID uint) error { url := fmt.Sprintf("%s/api/v1/cart/address", c.BaseURL) - body := map[string]interface{}{ + body := map[string]any{ "address_id": addressID, } jsonData, err := json.Marshal(body) @@ -418,7 +418,7 @@ func (c *Client) SetCartAddress(addressID uint) error { func (c *Client) SetCartCard(cardID uint) error { url := fmt.Sprintf("%s/api/v1/cart/card", c.BaseURL) - body := map[string]interface{}{ + body := map[string]any{ "card_id": cardID, } jsonData, err := json.Marshal(body) diff --git a/pkg/auth/jwt.go b/pkg/auth/jwt.go index e70a411..7b21b60 100644 --- a/pkg/auth/jwt.go +++ b/pkg/auth/jwt.go @@ -53,7 +53,7 @@ func (m *JWTManager) GenerateToken(userID uint, email, username string) (string, // ValidateToken validates a JWT token and returns the claims func (m *JWTManager) ValidateToken(tokenString string) (*Claims, error) { - token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) { // Verify signing method if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) diff --git a/pkg/utils/response.go b/pkg/utils/response.go index 04615e4..02c48c9 100644 --- a/pkg/utils/response.go +++ b/pkg/utils/response.go @@ -8,8 +8,8 @@ import ( // SuccessResponse represents a successful API response type SuccessResponse struct { - Success bool `json:"success"` - Data interface{} `json:"data"` + Success bool `json:"success"` + Data any `json:"data"` } // ErrorResponse represents an error API response @@ -20,13 +20,13 @@ type ErrorResponse struct { // Error contains error details type Error struct { - Code string `json:"code"` - Message string `json:"message"` - Details map[string]interface{} `json:"details,omitempty"` + Code string `json:"code"` + Message string `json:"message"` + Details map[string]any `json:"details,omitempty"` } // RespondSuccess sends a successful JSON response -func RespondSuccess(w http.ResponseWriter, statusCode int, data interface{}) { +func RespondSuccess(w http.ResponseWriter, statusCode int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) @@ -39,7 +39,7 @@ func RespondSuccess(w http.ResponseWriter, statusCode int, data interface{}) { } // RespondError sends an error JSON response -func RespondError(w http.ResponseWriter, statusCode int, code, message string, details map[string]interface{}) { +func RespondError(w http.ResponseWriter, statusCode int, code, message string, details map[string]any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) -- 2.49.0