Compare commits

..

1 Commits

Author SHA1 Message Date
c353821780 chore(ops): add nightly backup verification scripts 2026-06-01 23:59:10 +02:00
84 changed files with 605 additions and 4182 deletions

View File

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

View File

@@ -22,15 +22,6 @@ jobs:
key: ${{ secrets.SERVER_SSH_KEY }} key: ${{ secrets.SERVER_SSH_KEY }}
port: ${{ secrets.SERVER_PORT }} port: ${{ secrets.SERVER_PORT }}
script: | script: |
set -euo pipefail
cd ~/terminalShop cd ~/terminalShop
git fetch origin master git pull
git reset --hard origin/master
deployed=$(git rev-parse HEAD)
expected="${{ github.sha }}"
if [ "$deployed" != "$expected" ]; then
echo "::error::deploy HEAD mismatch: box=$deployed expected=$expected"
exit 1
fi
docker compose -f docker-compose.prod.yml up -d --build docker compose -f docker-compose.prod.yml up -d --build
echo "deploy verified: HEAD=$deployed"

3
.gitignore vendored
View File

@@ -7,7 +7,6 @@
docs/sca-psd2-compliance.md docs/sca-psd2-compliance.md
accept-a-payment/ accept-a-payment/
codemap/
FULFILLMENT.md FULFILLMENT.md
@@ -34,8 +33,6 @@ api/api-server
*.out *.out
coverage.txt coverage.txt
coverage.html coverage.html
test-output.json
test-report.xml
# Dependency directories # Dependency directories
vendor/ vendor/

View File

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

View File

@@ -71,4 +71,3 @@ uses SQLite by default so there's nothing to spin up.
## inspiration ## inspiration
[terminal.shop](https://terminal.shop) for the original idea and most of the patterns in `pkg/tui/`. their source is open and worth reading. [terminal.shop](https://terminal.shop) for the original idea and most of the patterns in `pkg/tui/`. their source is open and worth reading.
# something

View File

@@ -1,3 +0,0 @@
// Command api is the HTTP backend: it boots the router, bolts on all the
// middleware, and answers the requests the TUI throws at it.
package main

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,10 +5,8 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -67,34 +65,29 @@ func PayRedirect(w http.ResponseWriter, r *http.Request) {
// CardHandler handles saved payment method CRUD. // CardHandler handles saved payment method CRUD.
type CardHandler struct { type CardHandler struct {
stripeKey string
appURL string appURL string
} }
// NewCardHandler creates a new card handler. Stripe credentials are wired // NewCardHandler creates a new card handler with the Stripe secret key.
// once at startup via stripe.Key in api/main.go. func NewCardHandler(stripeSecretKey string, appURL string) *CardHandler {
func NewCardHandler(appURL string) *CardHandler { return &CardHandler{stripeKey: stripeSecretKey, appURL: appURL}
return &CardHandler{appURL: appURL}
} }
// GetCards returns all saved cards for the authenticated user. // GetCards returns all saved cards for the authenticated user.
// Cards past their retention deadline are filtered out at read time;
// physical deletion runs in handlers.ReconcileExpiredCards.
func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) { func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context()) db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context()) userID := middleware.UserIDFromContext(r.Context())
var cards []models.Card var cards []models.Card
db.Where( db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&cards)
"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]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"cards": cards, "cards": cards,
}) })
} }
// func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]any) // func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]interface{})
// GetCard returns a single saved card by ID. // GetCard returns a single saved card by ID.
func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) { func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
@@ -114,7 +107,7 @@ func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"card": card, "card": card,
}) })
} }
@@ -136,15 +129,6 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
return return
} }
if card.IsStorageExpired(time.Now()) {
if err := expireStoredCard(db, &card); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
if err := db.Model(&models.Card{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil { if err := db.Model(&models.Card{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update card", nil) utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update card", nil)
return return
@@ -156,7 +140,7 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"card": card, "card": card,
}) })
} }
@@ -171,6 +155,8 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
return return
} }
stripe.Key = h.stripeKey
if err := ensureStripeCustomer(db, &user); err != nil { if err := ensureStripeCustomer(db, &user); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil) utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
return return
@@ -195,7 +181,7 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil) utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"url": fmt.Sprintf("%s/pay/%s", h.appURL, token), "url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
}) })
} }
@@ -232,6 +218,8 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
return return
} }
stripe.Key = h.stripeKey
// Ensure the user has a Stripe customer, creating one if needed. // Ensure the user has a Stripe customer, creating one if needed.
if err := ensureStripeCustomer(db, &user); err != nil { if err := ensureStripeCustomer(db, &user); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil) utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
@@ -298,7 +286,7 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
audit.CardAdded(userID, card.ID, card.Brand, card.Last4) audit.CardAdded(userID, card.ID, card.Brand, card.Last4)
utils.RespondSuccess(w, http.StatusCreated, map[string]any{ utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
"card": card, "card": card,
}) })
} }
@@ -331,6 +319,7 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
// Detach from Stripe (best-effort; don't block deletion on Stripe errors). // Detach from Stripe (best-effort; don't block deletion on Stripe errors).
if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" { if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" {
stripe.Key = h.stripeKey
_, _ = paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{}) _, _ = paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{})
} }
@@ -342,57 +331,11 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
audit.CardDeleted(userID, card.ID) audit.CardDeleted(userID, card.ID)
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "card deleted", "message": "card deleted",
}) })
} }
// expireStoredCard removes a card whose retention deadline elapsed.
// Local state (cart references + card row) is committed atomically;
// Stripe detach and audit run only after the local commit so retries
// cannot double-fire the audit event. Stripe detach is gated on
// stripe.Key being non-empty so tests (which never init the SDK key)
// skip the real network call without an explicit flag.
func expireStoredCard(db *gorm.DB, card *models.Card) 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 stripe.Key != "" && strings.HasPrefix(card.StripePaymentID, "pm_") {
if _, err := paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{}); err != nil {
slog.Warn("failed to detach expired card", "card_id", card.ID, "error", err)
}
}
audit.CardExpired(card.UserID, card.ID)
return nil
}
func refreshCardStorageTTL(db *gorm.DB, cardID uint, now time.Time) error {
return db.Model(&models.Card{}).Where("id = ?", cardID).Updates(map[string]any{
"last_used_at": now,
"storage_expires_at": models.CardStorageExpiresAt(now),
}).Error
}
func respondCardStorageExpired(w http.ResponseWriter) {
utils.RespondError(
w,
http.StatusGone,
"CARD_STORAGE_EXPIRED",
"saved card expired, add it again",
nil,
)
}
// ensureStripeCustomer creates a Stripe Customer for the user if they don't // ensureStripeCustomer creates a Stripe Customer for the user if they don't
// already have one and persists the customer ID. Safe to call concurrently — // already have one and persists the customer ID. Safe to call concurrently —
// a duplicate Stripe customer is harmless; the local record reflects the first // a duplicate Stripe customer is harmless; the local record reflects the first

View File

@@ -1,156 +0,0 @@
package handlers
import (
"context"
"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(context.Background())
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(context.Background())
var count int64
if err := db.Model(&models.Card{}).Count(&count).Error; err != nil {
t.Fatalf("count cards: %v", err)
}
if count != 0 {
t.Fatalf("want 0 cards after sweep, got %d", count)
}
}
func setupCardsExpireTestDB(t *testing.T) (string, models.User) {
t.Helper()
testDB := "test_cards_expire.db"
_ = os.Remove(testDB)
database.ResetForTesting()
db, err := database.Connect(testDB)
if err != nil {
t.Fatalf("connect: %v", err)
}
if err := database.Migrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
user := models.User{
SSHKeyFingerprint: "SHA256:cards-expire",
SSHPublicKey: "ssh-ed25519 AAAA cardsexpire",
Name: "Test User",
Email: "cards-expire@example.com",
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("seed user: %v", err)
}
return testDB, user
}

View File

@@ -5,10 +5,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"time" "time"
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentintent" "github.com/stripe/stripe-go/v78/paymentintent"
"github.com/stripe/stripe-go/v78/paymentmethod" "github.com/stripe/stripe-go/v78/paymentmethod"
@@ -25,20 +23,14 @@ import (
) )
type CartHandler struct { type CartHandler struct {
stripeKey string
appURL string appURL string
maxOrderCents int maxOrderCents int
} }
// maxAuthIssuances caps how many times a single ordes's PaymentIntent can be // NewCartHandler creates a new cart handler with the Stripe secret key
// pushed into requires_action; one initial challende plus up to three retries func NewCartHandler(stripeSecretKey, appURL string, maxOrderCents int) *CartHandler {
// before we tell the customer to use a different card. Counted via existing return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL, maxOrderCents: maxOrderCents}
// audit rows (audit.EventOrderRequiresAction) so the cap stays migration-free.
const maxAuthIssuances = 4
// NewCartHandler creates a new cart handler. Stripe credentials are wired
// once at startup via stripe.Key in api/main.go.
func NewCartHandler(appURL string, maxOrderCents int) *CartHandler {
return &CartHandler{appURL: appURL, maxOrderCents: maxOrderCents}
} }
// getOrCreateCart finds the users cart or creates one. // getOrCreateCart finds the users cart or creates one.
@@ -55,18 +47,18 @@ func getOrCreateCart(ctx context.Context, userID uint) (*models.Cart, error) {
return &cart, nil return &cart, nil
} }
func cartResponse(ctx context.Context, cart *models.Cart) map[string]any { func cartResponse(ctx context.Context, cart *models.Cart) map[string]interface{} {
db := database.GetDB().WithContext(ctx) db := database.GetDB().WithContext(ctx)
var items []models.CartItem var items []models.CartItem
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items) db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
subtotal := 0 subtotal := 0
itemsData := make([]map[string]any, 0, len(items)) itemsData := make([]map[string]interface{}, 0, len(items))
for _, item := range items { for _, item := range items {
lineTotal := item.Coffee.Price * item.Quantity lineTotal := item.Coffee.Price * item.Quantity
subtotal += lineTotal subtotal += lineTotal
itemsData = append(itemsData, map[string]any{ itemsData = append(itemsData, map[string]interface{}{
"id": item.ID, "id": item.ID,
"coffee_id": item.CoffeeID, "coffee_id": item.CoffeeID,
"quantity": item.Quantity, "quantity": item.Quantity,
@@ -75,7 +67,7 @@ func cartResponse(ctx context.Context, cart *models.Cart) map[string]any {
}) })
} }
return map[string]any{ return map[string]interface{}{
"items": itemsData, "items": itemsData,
"subtotal": subtotal, "subtotal": subtotal,
"address_id": cart.AddressID, "address_id": cart.AddressID,
@@ -94,7 +86,7 @@ func (h *CartHandler) GetCart(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"cart": cartResponse(r.Context(), cart), "cart": cartResponse(r.Context(), cart),
}) })
} }
@@ -157,7 +149,7 @@ func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
} }
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"cart": cartResponse(r.Context(), cart), "cart": cartResponse(r.Context(), cart),
}) })
} }
@@ -193,7 +185,7 @@ func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "address set", "message": "address set",
}) })
} }
@@ -218,15 +210,6 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
return return
} }
if card.IsStorageExpired(time.Now()) {
if err := expireStoredCard(db, &card); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
cart, err := getOrCreateCart(r.Context(), userID) cart, err := getOrCreateCart(r.Context(), userID)
if err != nil { if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil) utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
@@ -238,7 +221,7 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "card set", "message": "card set",
}) })
} }
@@ -258,7 +241,7 @@ func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "cart cleared", "message": "cart cleared",
}) })
} }
@@ -313,16 +296,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
return return
} }
if card.IsStorageExpired(time.Now()) {
middleware.RecordCartConversion("validation_card_expired")
if err := expireStoredCard(db, &card); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
var user models.User var user models.User
if err := db.First(&user, userID).Error; err != nil { if err := db.First(&user, userID).Error; err != nil {
middleware.RecordCartConversion("user_not_found") middleware.RecordCartConversion("user_not_found")
@@ -330,6 +303,8 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
return return
} }
stripe.Key = h.stripeKey
// Ensure the user has a Stripe customer (shared helper with cards handler). // Ensure the user has a Stripe customer (shared helper with cards handler).
if err := ensureStripeCustomer(db, &user); err != nil { if err := ensureStripeCustomer(db, &user); err != nil {
middleware.RecordCartConversion("stripe_customer_failed") middleware.RecordCartConversion("stripe_customer_failed")
@@ -382,10 +357,9 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
if h.maxOrderCents > 0 && total > h.maxOrderCents { if h.maxOrderCents > 0 && total > h.maxOrderCents {
middleware.RecordCartConversion("validation_over_limit") middleware.RecordCartConversion("validation_over_limit")
audit.CartRejected(userID, total, h.maxOrderCents)
utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT", utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT",
fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100), fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100),
map[string]any{ map[string]interface{}{
"total_cents": total, "total_cents": total,
"limit_cents": h.maxOrderCents, "limit_cents": h.maxOrderCents,
}) })
@@ -506,7 +480,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil { if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return err return err
} }
return tx.Model(cart).Updates(map[string]any{ return tx.Model(cart).Updates(map[string]interface{}{
"address_id": nil, "address_id": nil,
"card_id": nil, "card_id": nil,
}).Error }).Error
@@ -533,15 +507,14 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
} }
go notify.SlackOrderPaid(&order) go notify.SlackOrderPaid(&order)
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"order": order, "order": order,
}) })
} }
// respondRequiresAction handles a PaymentIntent that needs 3DS auth. The order // respondRequiresAction handles a PaymentIntent that needs 3DS auth
// transitions to requires_action (see caveat #20 in sca-psd2-compliance.md); // The order stays pending. the webhook flips it to paid once the customer
// the payment_intent.succeeded webhook flips it to paid once the customer // completes the bank-hosted challenge, the tui polls /api/v1/orders/{id}/status
// completes the bank-hosted challenge. The TUI polls /api/v1/orders/{id}/status.
func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, paymentMethodID string, pi *stripe.PaymentIntent) { func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, paymentMethodID string, pi *stripe.PaymentIntent) {
db := database.GetDB() db := database.GetDB()
@@ -568,15 +541,13 @@ func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models
return return
} }
// Persist the paymentIntent id AND set status to requires_action so list // Persist the paymentIntent id AND reset status to pending. The
// UIs can distinguish "charging" from "awaiting customer 3DS". The // payment_intent.payment_failed webhook from the initial off-session decline
// payment_intent.payment_failed webhook from the initial off-session // often arrives before this response and flips the order to failed —
// decline often arrives before this response and flips the order to // override it back so the 3DS-in-flight order shows the correct state.
// failed - override it back so the 3DS-in-flight order shows the
// correct state
if err := db.Model(order).Updates(map[string]any{ if err := db.Model(order).Updates(map[string]any{
"stripe_payment_id": pi.ID, "stripe_payment_id": pi.ID,
"status": models.OrderStatusRequiresAction, "status": models.OrderStatusPending,
}).Error; err != nil { }).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil) utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil)
return return
@@ -632,149 +603,3 @@ func appURLForHandler(r *http.Request) string {
} }
return fmt.Sprintf("%s://%s", scheme, host) return fmt.Sprintf("%s://%s", scheme, host)
} }
// RetryAuth re-Confirms an in-flight PaymentIntent that the customer failed
// or abandoned during the 3DS challenge, recovering the existing order rather
// than starting a new checkout. Bound to POST /api/v1/orders/{id}/retry-auth.
//
// Stripe's PI state machine bounces back to requires_payment_method after a
// failed or abandoned 3DS, so paymentintent.Confirm(pi.id, …) mints a fresh
// next_action.redirect_to_url — the same shape respondRequiresAction already
// handles. No idempotency key is set: Confirm mutates an existing PI and
// Stripe collapses by id (see sca-psd2-compliance.md caveat #5).
func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id")
id64, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
return
}
orderID := uint(id64)
var order models.Order
if err := db.Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
return
}
// Retryable source states:
// pending - initial attempt mid-flight (pre-PI confirm page)
// requires_action - customer abandoned or failed the 3DS challenge
// failed - webhook flipped after a non-recoverable error
// terminal states (paid, shipped, delivered, cancelled, refunded) refuse.
if order.Status != models.OrderStatusPending &&
order.Status != models.OrderStatusRequiresAction &&
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); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
stripeStart := time.Now()
confirmed, cerr := paymentintent.Confirm(order.StripePaymentID, &stripe.PaymentIntentConfirmParams{
PaymentMethod: stripe.String(card.StripePaymentID),
OffSession: stripe.Bool(false),
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
})
stripeDur := time.Since(stripeStart).Seconds()
if cerr != nil {
middleware.ObserveStripeRequest("payment_intent_confirm", "error", stripeDur)
if stripeErr, ok := cerr.(*stripe.Error); ok && stripeErr.Type == stripe.ErrorTypeCard {
db.Model(&order).Update("status", models.OrderStatusFailed)
audit.OrderFailed(userID, order.ID, order.Total, stripeErr.Msg)
middleware.RecordCartConversion("retry_card_declined")
utils.RespondError(w, http.StatusPaymentRequired, mapStripeCardErrCode(stripeErr.Code), stripeErr.Msg, nil)
return
}
if stripeErr, ok := cerr.(*stripe.Error); ok && stripeErr.Code == "payment_intent_unexpected_state" {
actual, gerr := paymentintent.Get(order.StripePaymentID, nil)
if gerr != nil {
middleware.RecordCartConversion("retry_stripe_error")
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment failed", nil)
return
}
switch actual.Status {
case stripe.PaymentIntentStatusSucceeded:
middleware.RecordCartConversion("retry_race_already_done")
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
"order_id": order.ID,
"status": "succeeded",
})
return
case stripe.PaymentIntentStatusCanceled:
db.Model(&order).Update("status", models.OrderStatusFailed)
audit.OrderFailed(userID, order.ID, order.Total, "payment intent canceled")
middleware.RecordCartConversion("retry_pi_canceled")
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "payment intent is canceled, please start a new order", nil)
return
default:
middleware.RecordCartConversion("retry_pi_unexpected_state")
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment intent in unexpected state", nil)
return
}
}
middleware.RecordCartConversion("retry_stripe_error")
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment failed", nil)
return
}
middleware.ObserveStripeRequest("payment_intent_confirm", "success", stripeDur)
// frictionless 3DS on retry, stripe approved without a new challenge.
// the payment_intent.succeeded webhook owns the order.status=paid
// transition (matches the initial-flow contract); tell the TUI to keep
// polling /orders/{id}/status until it lands
if confirmed.Status == stripe.PaymentIntentStatusSucceeded {
middleware.RecordCartConversion("retry_frictionless")
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
"order_id": order.ID,
"status": "succeeded",
})
return
}
h.respondRequiresAction(w, &order, card.StripePaymentID, confirmed)
}

View File

@@ -3,19 +3,14 @@ package handlers
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"testing" "testing"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
"terminalShop/api/middleware" "terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database" "terminalShop/pkg/database"
"terminalShop/pkg/models" "terminalShop/pkg/models"
) )
@@ -70,7 +65,7 @@ func TestGetCartEmpty(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
req := authRequest("GET", "/api/v1/cart", nil, user.ID) req := authRequest("GET", "/api/v1/cart", nil, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -85,7 +80,7 @@ func TestGetCartEmpty(t *testing.T) {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
Cart struct { Cart struct {
Items []any `json:"items"` Items []interface{} `json:"items"`
Subtotal int `json:"subtotal"` Subtotal int `json:"subtotal"`
} `json:"cart"` } `json:"cart"`
} `json:"data"` } `json:"data"`
@@ -109,10 +104,10 @@ func TestSetItemAndGetCart(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Add item (CoffeeID=1, Quantity=2) // Add item (CoffeeID=1, Quantity=2)
body, _ := json.Marshal(map[string]any{ body, _ := json.Marshal(map[string]interface{}{
"coffee_id": 1, "coffee_id": 1,
"quantity": 2, "quantity": 2,
}) })
@@ -165,16 +160,16 @@ func TestSetItemUpdateQuantity(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Add item // Add item
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 2}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
// Update quantity // Update quantity
body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5}) body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5})
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID) req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -210,16 +205,16 @@ func TestSetItemRemove(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Add item // Add item
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 3}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
// Remove item (quantity 0) // Remove item (quantity 0)
body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 0}) body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 0})
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID) req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -233,7 +228,7 @@ func TestSetItemRemove(t *testing.T) {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
Cart struct { Cart struct {
Items []any `json:"items"` Items []interface{} `json:"items"`
} `json:"cart"` } `json:"cart"`
} `json:"data"` } `json:"data"`
} }
@@ -249,9 +244,9 @@ func TestSetItemInvalidProduct(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"coffee_id": 999, "quantity": 1}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -266,9 +261,9 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"quantity": 1}) body, _ := json.Marshal(map[string]interface{}{"quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -283,11 +278,11 @@ func TestClearCart(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Add two items // Add two items
for _, coffeeID := range []uint{1, 2} { for _, coffeeID := range []uint{1, 2} {
body, _ := json.Marshal(map[string]any{"coffee_id": coffeeID, "quantity": 1}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": coffeeID, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -311,7 +306,7 @@ func TestClearCart(t *testing.T) {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
Cart struct { Cart struct {
Items []any `json:"items"` Items []interface{} `json:"items"`
} `json:"cart"` } `json:"cart"`
} `json:"data"` } `json:"data"`
} }
@@ -339,9 +334,9 @@ func TestSetAddress(t *testing.T) {
} }
db.Create(&addr) db.Create(&addr)
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"address_id": addr.ID}) body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetAddress(w, req) handler.SetAddress(w, req)
@@ -393,9 +388,9 @@ func TestSetAddressWrongUser(t *testing.T) {
} }
db.Create(&addr) db.Create(&addr)
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"address_id": addr.ID}) body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetAddress(w, req) handler.SetAddress(w, req)
@@ -421,9 +416,9 @@ func TestSetCard(t *testing.T) {
} }
db.Create(&card) db.Create(&card)
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"card_id": card.ID}) body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetCard(w, req) handler.SetCard(w, req)
@@ -473,9 +468,9 @@ func TestSetCardWrongUser(t *testing.T) {
} }
db.Create(&card) db.Create(&card)
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
body, _ := json.Marshal(map[string]any{"card_id": card.ID}) body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetCard(w, req) handler.SetCard(w, req)
@@ -490,10 +485,10 @@ func TestConvertCartMissingAddress(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Add item but no address or card // Add item but no address or card
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -513,18 +508,18 @@ func TestConvertCartMissingCard(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
db := database.GetDB() db := database.GetDB()
// Add item and address // Add item and address
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
addr := models.Address{UserID: user.ID, Name: "Test", Street: "123 St", City: "PDX", State: "OR", Zip: "97201", Country: "US"} addr := models.Address{UserID: user.ID, Name: "Test", Street: "123 St", City: "PDX", State: "OR", Zip: "97201", Country: "US"}
db.Create(&addr) db.Create(&addr)
body, _ = json.Marshal(map[string]any{"address_id": addr.ID}) body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID})
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID) req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
handler.SetAddress(w, req) handler.SetAddress(w, req)
@@ -544,7 +539,7 @@ func TestConvertCartEmpty(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Empty cart convert // Empty cart convert
req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID) req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
@@ -561,11 +556,11 @@ func TestMultipleItems(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
// Add three different items // Add three different items
for i := uint(1); i <= 3; i++ { for i := uint(1); i <= 3; i++ {
body, _ := json.Marshal(map[string]any{"coffee_id": i, "quantity": int(i)}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": i, "quantity": int(i)})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -583,7 +578,7 @@ func TestMultipleItems(t *testing.T) {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
Cart struct { Cart struct {
Items []any `json:"items"` Items []interface{} `json:"items"`
Subtotal int `json:"subtotal"` Subtotal int `json:"subtotal"`
} `json:"cart"` } `json:"cart"`
} `json:"data"` } `json:"data"`
@@ -610,19 +605,19 @@ func TestCartIsolation(t *testing.T) {
} }
db.Create(&user2) db.Create(&user2)
handler := NewCartHandler("", 0) handler := NewCartHandler("", "", 0)
r := chi.NewRouter() r := chi.NewRouter()
r.Put("/cart/item", handler.SetItem) r.Put("/cart/item", handler.SetItem)
r.Get("/cart", handler.GetCart) r.Get("/cart", handler.GetCart)
// User 1 adds item // User 1 adds item
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5})
req := authRequest("PUT", "/cart/item", body, user1.ID) req := authRequest("PUT", "/cart/item", body, user1.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
// User 2 adds different item // User 2 adds different item
body, _ = json.Marshal(map[string]any{"coffee_id": 2, "quantity": 3}) body, _ = json.Marshal(map[string]interface{}{"coffee_id": 2, "quantity": 3})
req = authRequest("PUT", "/cart/item", body, user2.ID) req = authRequest("PUT", "/cart/item", body, user2.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
@@ -711,17 +706,9 @@ func TestConvertCartLimit(t *testing.T) {
t.Fatalf("seed card: %v", err) t.Fatalf("seed card: %v", err)
} }
// Pin coffee_id=4's price so cart-cap math stays stable across handler := NewCartHandler("", "", tc.capCents)
// 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})
body, _ := json.Marshal(map[string]any{"coffee_id": 4, "quantity": tc.quantity})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler.SetItem(w, req) handler.SetItem(w, req)
@@ -730,7 +717,7 @@ func TestConvertCartLimit(t *testing.T) {
} }
// set address // set address
body, _ = json.Marshal(map[string]any{"address_id": addr.ID}) body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID})
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID) req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
handler.SetAddress(w, req) handler.SetAddress(w, req)
@@ -739,7 +726,7 @@ func TestConvertCartLimit(t *testing.T) {
} }
// set card // set card
body, _ = json.Marshal(map[string]any{"card_id": card.ID}) body, _ = json.Marshal(map[string]interface{}{"card_id": card.ID})
req = authRequest("PUT", "/api/v1/cart/card", body, user.ID) req = authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
handler.SetCard(w, req) handler.SetCard(w, req)
@@ -747,19 +734,6 @@ func TestConvertCartLimit(t *testing.T) {
t.Fatalf("SetCard: %d %s", w.Code, w.Body.String()) t.Fatalf("SetCard: %d %s", w.Code, w.Body.String())
} }
// Capture audit slog ONLY for ConvertCart's emit window. Placed
// here (not at top of t.Run) so setup INFO logs from
// database.Connect/Migrate/Seed don't pollute the buffer with
// non-audit records - json.Unmarshal below requires a single
// JSON object, not multi-document input. slog.Default is
// process-global - do NOT add t.Parallel() to subtests here.
var auditBuf bytes.Buffer
if tc.expectOverCap {
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&auditBuf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
}
// Convert // Convert
req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID) req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
w = httptest.NewRecorder() w = httptest.NewRecorder()
@@ -770,7 +744,7 @@ func TestConvertCartLimit(t *testing.T) {
Error struct { Error struct {
Code string `json:"code"` Code string `json:"code"`
Message string `json:"message"` Message string `json:"message"`
Details map[string]any `json:"details"` Details map[string]interface{} `json:"details"`
} `json:"error"` } `json:"error"`
} }
_ = json.NewDecoder(w.Body).Decode(&resp) _ = json.NewDecoder(w.Body).Decode(&resp)
@@ -783,419 +757,10 @@ func TestConvertCartLimit(t *testing.T) {
if got, _ := resp.Error.Details["limit_cents"].(float64); int(got) != tc.capCents { if got, _ := resp.Error.Details["limit_cents"].(float64); int(got) != tc.capCents {
t.Errorf("limit_cents in details: want %d, got %v", tc.capCents, resp.Error.Details["limit_cents"]) t.Errorf("limit_cents in details: want %d, got %v", tc.capCents, resp.Error.Details["limit_cents"])
} }
if got, _ := resp.Error.Details["total_cents"].(float64); int(got) != tc.quantity*pinnedPrice { 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*pinnedPrice, resp.Error.Details["total_cents"]) t.Errorf("total_cents in details: want %d, got %v", tc.quantity*500, resp.Error.Details["total_cents"])
}
if auditBuf.Len() == 0 {
t.Fatalf("audit: expected cart_rejected slog line, got nothing")
}
var rec map[string]any
if err := json.Unmarshal(auditBuf.Bytes(), &rec); err != nil {
t.Fatalf("audit: decode slog record: %v\nraw: %s", err, auditBuf.String())
}
if got := rec["event"]; got != "cart_rejected" {
t.Errorf("audit event: want cart_rejected, got %v", got)
}
if n, _ := rec["user_id"].(float64); uint(n) != user.ID {
t.Errorf("audit user_id: want %d, got %v", user.ID, rec["user_id"])
}
if n, _ := rec["total_cents"].(float64); int(n) != tc.quantity*pinnedPrice {
t.Errorf("audit total_cents: want %d, got %v", tc.quantity*pinnedPrice, rec["total_cents"])
}
if n, _ := rec["cap_cents"].(float64); int(n) != tc.capCents {
t.Errorf("audit cap_cents: want %d, got %v", tc.capCents, rec["cap_cents"])
} }
} }
}) })
} }
} }
// TestSetCard_ExpiredReturns410 verifies that selecting an already-expired
// card on the cart returns 410 + CARD_STORAGE_EXPIRED and soft-deletes the
// card. Exercises cart.go SetCard expired-card branch
func TestSetCard_ExpiredReturns410(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
past := time.Now().Add(-1 * time.Hour)
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
handler := NewCartHandler("", 0)
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w := httptest.NewRecorder()
handler.SetCard(w, req)
if w.Code != http.StatusGone {
t.Fatalf("want 410, got %d, body: %s", w.Code, w.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp.Error.Code != "CARD_STORAGE_EXPIRED" {
t.Fatalf("want CARD_STORAGE_EXPIRED, got %q", resp.Error.Code)
}
var soft models.Card
if err := db.Unscoped().First(&soft, card.ID).Error; err != nil {
t.Fatalf("reload card unscoped: %v", err)
}
if !soft.DeletedAt.Valid {
t.Fatalf("expired card was not soft-deleted")
}
}
// TestConvertCart_ExpiredReturns410 verifies that attempting checkout with an
// expired saved card shor-circuits at the validation step with 410 +
// CARD_STORAGE_EXPIRED before any stripe interaction
func TestConvertCart_ExpiredReturns410(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
req := authRequest("PUT", "/api/v1/vart/item", body, user.ID)
w := httptest.NewRecorder()
NewCartHandler("", 0).SetItem(w, req)
addr := models.Address{
UserID: user.ID, Name: "Test", Street: "123 St",
City: "PDX", State: "OR", Zip: "97201", Country: "US",
}
db.Create(&addr)
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w = httptest.NewRecorder()
NewCartHandler("", 0).SetAddress(w, req)
past := time.Now().Add(-1 * time.Hour)
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
if err := db.Model(&models.Cart{}).Where("user_id = ?", user.ID).Update("card_id", card.ID).Error; err != nil {
t.Fatalf("attach card to card: %v", err)
}
req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
w = httptest.NewRecorder()
NewCartHandler("", 0).ConvertCart(w, req)
if w.Code != http.StatusGone {
t.Fatalf("want 410, got %d, body: %s", w.Code, w.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp.Error.Code != "CARD_STORAGE_EXPIRED" {
t.Fatalf("want CARD_STORAGE_EXPIRED, got %q", resp.Error.Code)
}
}
// TestRetryAuth exercises the pre-Stripe validation branches of
// CartHandler.RetryAuth. The happy paths (Confirm → requires_action /
// succeeded) hit the live Stripe API and aren't covered here — see
// sca-psd2-compliance.md "Test plan" for manual coverage with the
// 4000 0027 6000 3184 test card. Caveat #14 in that doc still applies:
// NewCartHandler takes 2 args (appURL, maxOrderCents) 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: "requires_action honors retry cap",
ownedByUser: true, seedStatus: models.OrderStatusRequiresAction,
seedPayment: "pi_test_req_action_cap", seedEvents: maxAuthIssuances,
wantStatus: http.StatusTooManyRequests, wantErrCode: "RETRY_LIMIT",
},
{
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())
}
}
func TestRespondRequiresAction_SetStatusRequiresAction(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_test_req_action",
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: user.ID, CardID: card.ID,
Status: models.OrderStatusPending,
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)
}
pi := &stripe.PaymentIntent{
ID: "pi_test_action_set",
NextAction: &stripe.PaymentIntentNextAction{
RedirectToURL: &stripe.PaymentIntentNextActionRedirectToURL{
URL: "https://hooks.stripe.com/redirect/test/test_3ds",
},
},
}
handler := NewCartHandler("http://localhost", 0)
w := httptest.NewRecorder()
handler.respondRequiresAction(w, &order, card.StripePaymentID, pi)
if w.Code != http.StatusAccepted {
t.Fatalf("want 202, got %d body=%s", w.Code, w.Body.String())
}
var resp struct {
Data struct {
OrderID uint `json:"order_id"`
PaymentIntentID string `json:"payment_intent_id"`
Status string `json:"status"`
RedirectURL string `json:"redirect_url"`
} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Data.Status != "requires_action" {
t.Fatalf("response status: %q, got %q", "requires_action", resp.Data.Status)
}
if resp.Data.RedirectURL == "" {
t.Fatalf("expected redirect url, got empty")
}
if resp.Data.PaymentIntentID != pi.ID {
t.Fatalf("payment intent id: want %q, got %q", pi.ID, resp.Data.PaymentIntentID)
}
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("order.Status: want %q, got %q", models.OrderStatusRequiresAction, reloaded.Status)
}
if reloaded.StripePaymentID != pi.ID {
t.Fatalf("order.StripePaymentID: want %q, got %q", pi.ID, reloaded.StripePaymentID)
}
}
// TestRespondRequiresAction_OverridesFailedFromWebhookRace simulates the race
// described in sca-psd2-compliance.md note #3 / caveat #4: the
// payment_intent.payment_failed webhook arrives BEFORE respondRequiresAction
// returns and flips the row to OrderStatusFailed. The helper must override
// that back to OrderStatusRequiresAction so the in-flight 3DS state surfaces
// correctly to the TUI.
func TestRespondRequiresAction_OverridesFailedFromWebhookRace(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_test_race",
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: user.ID, CardID: card.ID,
Status: models.OrderStatusFailed,
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)
}
pi := &stripe.PaymentIntent{
ID: "pi_test_race",
NextAction: &stripe.PaymentIntentNextAction{
RedirectToURL: &stripe.PaymentIntentNextActionRedirectToURL{
URL: "https://hooks.stripe.com/redirect/test_race",
},
},
}
handler := NewCartHandler("http://localhost", 0)
w := httptest.NewRecorder()
handler.respondRequiresAction(w, &order, card.StripePaymentID, pi)
if w.Code != http.StatusAccepted {
t.Fatalf("want 202, got %d body=%s", w.Code, w.Body.String())
}
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("override failed: want %q, got %q", models.OrderStatusRequiresAction, reloaded.Status)
}
}

View File

@@ -1,3 +0,0 @@
// Package handlers is where the real work happens, one function per endpoint:
// carts, orders, products, addresses, cards, webhooks, and the rest.
package handlers

View File

@@ -28,6 +28,7 @@ import (
) )
type OrderHandler struct { type OrderHandler struct {
stripeKey string
bringAPIUID string bringAPIUID string
bringAPIKey string bringAPIKey string
bringCustomerNumber string bringCustomerNumber string
@@ -43,10 +44,9 @@ type refundRequestPayload struct {
// repeated submissions from spamming the operator's Slack channel. // repeated submissions from spamming the operator's Slack channel.
const refundRequestCooldown = 15 * time.Minute const refundRequestCooldown = 15 * time.Minute
// NewOrderHandler creates an order handler. Stripe credentials are wired func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
// once at startup via stripe.Key in api/main.go.
func NewOrderHandler(bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
return &OrderHandler{ return &OrderHandler{
stripeKey: stripeSecretKey,
bringAPIUID: bringAPIUID, bringAPIUID: bringAPIUID,
bringAPIKey: bringAPIKey, bringAPIKey: bringAPIKey,
bringCustomerNumber: bringCustomerNumber, bringCustomerNumber: bringCustomerNumber,
@@ -200,6 +200,8 @@ func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
return return
} }
stripe.Key = h.stripeKey
rf, err := refund.New(&stripe.RefundParams{ rf, err := refund.New(&stripe.RefundParams{
PaymentIntent: stripe.String(order.StripePaymentID), PaymentIntent: stripe.String(order.StripePaymentID),
}) })
@@ -219,7 +221,7 @@ func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
audit.OrderRefunded(userID, order.ID, order.Total, rf.ID) audit.OrderRefunded(userID, order.ID, order.Total, rf.ID)
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"refund_id": rf.ID, "refund_id": rf.ID,
"status": "refunded", "status": "refunded",
}) })
@@ -285,7 +287,7 @@ func (h *OrderHandler) UpdateTracking(w http.ResponseWriter, r *http.Request) {
audit.OrderShipped(order.UserID, order.ID, req.Carrier, req.TrackingNumber) audit.OrderShipped(order.UserID, order.ID, req.Carrier, req.TrackingNumber)
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"order_id": order.ID, "order_id": order.ID,
"carrier": req.Carrier, "carrier": req.Carrier,
"tracking_number": req.TrackingNumber, "tracking_number": req.TrackingNumber,

View File

@@ -60,7 +60,7 @@ func contextWithRoute(ctx context.Context, routeCtx *chi.Context) context.Contex
func TestCreateRefundRequestRequiresMessageForOther(t *testing.T) { func TestCreateRefundRequestRequiresMessageForOther(t *testing.T) {
user := setupOrderTestDB(t) user := setupOrderTestDB(t)
handler := NewOrderHandler("", "", "") handler := NewOrderHandler("", "", "", "")
body := []byte(`{"reason":"Other","message":" "}`) body := []byte(`{"reason":"Other","message":" "}`)
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, "1") req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, "1")
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -100,7 +100,7 @@ func TestCreateRefundRequestRejectsInvalidOrderState(t *testing.T) {
t.Fatalf("failed to create order: %v", err) t.Fatalf("failed to create order: %v", err)
} }
handler := NewOrderHandler("", "", "") handler := NewOrderHandler("", "", "", "")
body := []byte(`{"reason":"Quality issue","message":""}`) body := []byte(`{"reason":"Quality issue","message":""}`)
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID)) req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -142,7 +142,7 @@ func TestCreateRefundRequestEnforcesCooldown(t *testing.T) {
t.Fatalf("failed to create order: %v", err) t.Fatalf("failed to create order: %v", err)
} }
handler := NewOrderHandler("", "", "") handler := NewOrderHandler("", "", "", "")
body := []byte(`{"reason":"Quality issue","message":""}`) body := []byte(`{"reason":"Quality issue","message":""}`)
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID)) req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
w := httptest.NewRecorder() w := httptest.NewRecorder()

View File

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

View File

@@ -1,13 +1,10 @@
package handlers package handlers
import ( import (
"context"
"fmt" "fmt"
"log/slog" "log/slog"
"time" "time"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database" "terminalShop/pkg/database"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"terminalShop/pkg/notify" "terminalShop/pkg/notify"
@@ -17,19 +14,15 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// paymentIntentGetFn lets tests stub the Stripe Get call without spinning
// up an httptest backend. Production callers go straight through to the
// real paymentintent.Get.
var paymentIntentGetFn = paymentintent.Get
func reconcileLog() *slog.Logger { return slog.With("component", "reconcile") } func reconcileLog() *slog.Logger { return slog.With("component", "reconcile") }
// ReconcileOrders finds pending orders with no Stripe ID older than 10 minutes // ReconcileOrders finds pending orders with no Stripe ID older than 10 minutes
// and checks whether Stripe has a succeeded PaymentIntent for them. This fixes // and checks whether Stripe has a succeeded PaymentIntent for them. This fixes
// the case where the server crashed after charging but before the DB transaction // the case where the server crashed after charging but before the DB transaction
// completed. // completed.
func ReconcileOrders(ctx context.Context) { func ReconcileOrders(stripeKey string) {
db := database.GetDB().WithContext(ctx) stripe.Key = stripeKey
db := database.GetDB()
var orders []models.Order var orders []models.Order
cutoff := time.Now().Add(-10 * time.Minute) cutoff := time.Now().Add(-10 * time.Minute)
@@ -43,13 +36,8 @@ func ReconcileOrders(ctx context.Context) {
reconcileLog().Info("checking pending orders", "count", len(orders)) reconcileLog().Info("checking pending orders", "count", len(orders))
for _, order := range orders { for _, order := range orders {
if err := ctx.Err(); err != nil {
reconcileLog().Info("reconcile orders cancelled mid-loop", "error", err)
return
}
params := &stripe.PaymentIntentSearchParams{ params := &stripe.PaymentIntentSearchParams{
SearchParams: stripe.SearchParams{ SearchParams: stripe.SearchParams{
Context: ctx,
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID), Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
}, },
} }
@@ -61,7 +49,7 @@ func ReconcileOrders(ctx context.Context) {
"order_id", order.ID, "pi", pi.ID) "order_id", order.ID, "pi", pi.ID)
txErr := db.Transaction(func(tx *gorm.DB) error { txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]any{ if err := tx.Model(&order).Updates(map[string]interface{}{
"status": models.OrderStatusPaid, "status": models.OrderStatusPaid,
"stripe_payment_id": pi.ID, "stripe_payment_id": pi.ID,
}).Error; err != nil { }).Error; err != nil {
@@ -74,7 +62,7 @@ func ReconcileOrders(ctx context.Context) {
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil { if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return err return err
} }
return tx.Model(&cart).Updates(map[string]any{ return tx.Model(&cart).Updates(map[string]interface{}{
"address_id": nil, "address_id": nil,
"card_id": nil, "card_id": nil,
}).Error }).Error
@@ -89,146 +77,10 @@ func ReconcileOrders(ctx context.Context) {
} }
} }
// ReconcileStale3DSOrders flips requires_action orders that have been // ReconcileUnshipped finds paid order orlder than 24h with no tracking
// awaiting the customer's 3DS challenge longer than threshold to failed, // number and posts single Slack reminder. Pure read + notif
// after re-checking the PaymentIntent with Stripe so a late func ReconcileUnshipped() {
// payment_intent.succeeded webhook is respected. Deliberately separate db := database.GetDB()
// from ReconcileOrders: the empty-PI predicate there is load-bearing for
// the crash-mid-create case (see sca-psd2-compliance.md caveat #20).
// stripe.Key is wired once at startup in api/main.go.
func ReconcileStale3DSOrders(ctx context.Context, threshold time.Duration) {
db := database.GetDB().WithContext(ctx)
var orders []models.Order
// Predicate keys off updated_at, not created_at: respondRequiresAction
// and RetryAuth both bump updated_at via GORM auto-stamping, so every
// customer-driven retry extends the grace window. Intentional - sweeping
// a row the customer just re-armed would race the new challenge.
cutoff := time.Now().Add(-threshold)
if err := db.Where("status = ? AND updated_at < ?",
models.OrderStatusRequiresAction, cutoff).Find(&orders).Error; err != nil {
reconcileLog().Error("stale 3ds scan failed", "error", err)
return
}
if len(orders) == 0 {
return
}
reconcileLog().Info("checking stale requires_action orders", "count", len(orders))
piParams := &stripe.PaymentIntentParams{Params: stripe.Params{Context: ctx}}
for i := range orders {
if err := ctx.Err(); err != nil {
reconcileLog().Info("stale 3ds sweep cancelled mid-loop", "error", err)
return
}
order := &orders[i]
if order.StripePaymentID == "" {
// respondRequiresAction writes stripe_payment_id and status in
// one UPDATE so this should be unreachable; guard anyway so a
// future regression noisily logs instead of 4xx'ing Stripe.
reconcileLog().Warn("requires_action row missing PI id; skipping", "order_id", order.ID)
middleware.RecordAbandoned3DSSweep("missing_pi")
continue
}
actual, err := paymentIntentGetFn(order.StripePaymentID, piParams)
if err != nil {
reconcileLog().Error("stripe get failed",
"order_id", order.ID, "pi", order.StripePaymentID, "error", err)
middleware.RecordAbandoned3DSSweep("stripe_error")
continue
}
switch actual.Status {
case stripe.PaymentIntentStatusSucceeded:
// Stripe says paid but our row still says requires_action, the
// payment_intent.succeeded webhook has not landed yet (or was
// dropped). Defer to handlePaymentIntentsucceeded; it owns the
// paid transition + cart cleanup atomically. The next sweep
// tick re-checks and surfaces the dropped webhook if it persists
reconcileLog().Warn("stripe reports succeeded for requires_action row; deferring to webhook",
"order_id", order.ID, "pi", order.StripePaymentID)
middleware.RecordAbandoned3DSSweep("succeeded_skip")
case stripe.PaymentIntentStatusProcessing,
stripe.PaymentIntentStatusRequiresCapture:
reconcileLog().Info("payment intent still transitioning; skipping",
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", string(actual.Status))
middleware.RecordAbandoned3DSSweep("processing_skip")
case stripe.PaymentIntentStatusRequiresAction,
stripe.PaymentIntentStatusRequiresPaymentMethod,
stripe.PaymentIntentStatusRequiresConfirmation,
stripe.PaymentIntentStatusCanceled:
flipStale3DSToFailed(db, order, string(actual.Status))
default:
reconcileLog().Warn("unrecognized stripe status; skipping",
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", string(actual.Status))
middleware.RecordAbandoned3DSSweep("unrecognized_status")
}
}
}
// flipStale3DSToFailed transitions a single stale requires_action row
// to failed. The UPDATE is conditional on status = requires_action so a
// payment_intent.succeeded webhook that landed between the stripe get and
// this write (caveat #20 race) wins and we silently no-op
func flipStale3DSToFailed(db *gorm.DB, order *models.Order, stripeStatus string) {
// Map form so the single-field UPDATE is explicit at the call site.
// GORM still auto-stamps updated_at; the row exits the sweep predicate
// naturally on the next tick because status no longer matches anyway.
res := db.Model(&models.Order{}).
Where("id = ? AND status = ?", order.ID, models.OrderStatusRequiresAction).
Updates(map[string]any{"status": models.OrderStatusFailed})
if res.Error != nil {
reconcileLog().Error("flip stale 3ds failed", "order_id", order.ID, "error", res.Error)
middleware.RecordAbandoned3DSSweep("flip_failed")
return
}
if res.RowsAffected == 0 {
reconcileLog().Info("stale 3ds row already transitioned; sweep no-op",
"order_id", order.ID)
middleware.RecordAbandoned3DSSweep("flip_noop")
return
}
audit.Order3DSAbandoned(order.UserID, order.ID, order.StripePaymentID)
reconcileLog().Info("3ds abandoned order flipped to failed",
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", stripeStatus)
middleware.RecordAbandoned3DSSweep("flipped")
}
// 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(ctx context.Context) {
db := database.GetDB().WithContext(ctx)
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 := ctx.Err(); err != nil {
reconcileLog().Info("card sweep cancelled mid-loop", "error", err)
return
}
if err := expireStoredCard(db, &cards[i]); err != nil {
reconcileLog().Error("expire card failed", "card_id", cards[i].ID, "error", err)
}
}
}
// ReconcileUnshipped finds paid orders older than 24h with no tracking
// number and posts a single Slack reminder. Pure read + notify.
func ReconcileUnshipped(ctx context.Context) {
db := database.GetDB().WithContext(ctx)
var orders []models.Order var orders []models.Order
cutoff := time.Now().Add(-24 * time.Hour) cutoff := time.Now().Add(-24 * time.Hour)

View File

@@ -1,520 +0,0 @@
package handlers
import (
"context"
"fmt"
"os"
"testing"
"time"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stripe/stripe-go/v78"
)
// TestReconcileStale3DSOrders_FlipsAbandonedToFailed verifies the predicate
// and Stripe-Get branching: a row past the threshold whose PaymentIntent
// Stripe still reports as requires_payment_method (the customer never
// completed the challenge) transitions to failed and emits the new
// order_3ds_abandoned audit row.
func TestReconcileStale3DSOrders_FlipsAbandonedToFailed(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_stale_abandoned",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
flipped := middleware.Abandoned3DSSweepCounter().WithLabelValues("flipped")
before := testutil.ToFloat64(flipped)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusFailed {
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
}
var events []models.OrderEvent
if err := db.Where("order_id = ? AND type = ?",
order.ID, audit.EventOrder3DSAbandoned).Find(&events).Error; err != nil {
t.Fatalf("list events: %v", err)
}
if len(events) != 1 {
t.Fatalf("want 1 order_3ds_abandoned audit row, got %d", len(events))
}
if got := testutil.ToFloat64(flipped) - before; got != 1 {
t.Fatalf("want 1 flipped sweep counter increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_SkipsRecent verifies the threshold predicate
// excludes rows still within the grace window. A fresh requires_action row
// must not even reach the Stripe Get call.
func TestReconcileStale3DSOrders_SkipsRecent(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_fresh_3ds",
time.Now().Add(-5*time.Minute))
called := false
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
called = true
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
if called {
t.Fatalf("stripe Get called for in-window row")
}
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("status changed unexpectedly: %q", reloaded.Status)
}
}
// TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded covers the
// webhook-race guard from caveat #20. If Stripe-side already says succeeded,
// the sweep must defer to handlePaymentIntentSucceeded rather than overwrite
// the authoritative end state.
func TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_succeeded_late_webhook",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusSucceeded,
}, nil
}
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("succeeded_skip")
before := testutil.ToFloat64(skip)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("must defer to webhook on succeeded; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(skip) - before; got != 1 {
t.Fatalf("want 1 succeeded_skip sweep counter increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_GuardsAgainstWebhookRace covers the conditional
// UPDATE. If the row flipped to paid between the Stripe Get and the flip,
// the WHERE status = requires_action clause prevents clobber.
func TestReconcileStale3DSOrders_GuardsAgainstWebhookRace(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_race_paid",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
db.Model(&models.Order{}).Where("id = ?", order.ID).
Update("status", models.OrderStatusPaid)
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
noop := middleware.Abandoned3DSSweepCounter().WithLabelValues("flip_noop")
before := testutil.ToFloat64(noop)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusPaid {
t.Fatalf("conditional update must preserve paid; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(noop) - before; got != 1 {
t.Fatalf("want 1 flip_noop sweep counter increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_FlipsCanceledPI covers the canceled-PI branch:
// Stripe-terminal-canceled maps to OrderStatusFailed (matches RetryAuth's
// existing precedent — see api/handlers/cart.go RetryAuth canceled branch).
func TestReconcileStale3DSOrders_FlipsCanceledPI(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_canceled_stale",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusCanceled,
}, nil
}
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusFailed {
t.Fatalf("canceled PI must flip to failed; got %q", reloaded.Status)
}
}
// TestReconcileStale3DSOrders_DoesNotConsumeRetryCap regression-tests the
// caveat #17 decoupling: the new sweep emits EventOrder3DSAbandoned, never
// EventOrderRequiresAction, so a swept-then-stuck order does not silently
// shrink the retry ceiling for any future user-driven retry attempts.
func TestReconcileStale3DSOrders_DoesNotConsumeRetryCap(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_decoupled_cap",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var retryCount int64
if err := db.Model(&models.OrderEvent{}).
Where("order_id = ? AND type = ?", order.ID, audit.EventOrderRequiresAction).
Count(&retryCount).Error; err != nil {
t.Fatalf("count retry events: %v", err)
}
if retryCount != 0 {
t.Fatalf("sweep must not emit EventOrderRequiresAction; got %d", retryCount)
}
}
// TestReconcileStale3DSOrders_SkipsTransitioningStatuses covers the
// processing and requires_capture branches: Stripe still owns the PI's
// next move, so the sweep must not flip even past the threshold.
func TestReconcileStale3DSOrders_SkipsTransitioningStatuses(t *testing.T) {
cases := []struct {
name string
status stripe.PaymentIntentStatus
}{
{"processing", stripe.PaymentIntentStatusProcessing},
{"requires_capture", stripe.PaymentIntentStatusRequiresCapture},
}
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()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID,
"pi_transitioning_"+tc.name, time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{ID: id, Status: tc.status}, nil
}
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("processing_skip")
before := testutil.ToFloat64(skip)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("%s must stay requires_action; got %q", tc.name, reloaded.Status)
}
if got := testutil.ToFloat64(skip) - before; got != 1 {
t.Fatalf("want 1 processing_skip increment, got %v", got)
}
})
}
}
// TestReconcileStale3DSOrders_SkipsUnknownStatus covers the switch's default
// arm: an unrecognized Stripe status logs and skips rather than guessing a
// transition. Defends against new PI statuses appearing in future SDKs.
func TestReconcileStale3DSOrders_SkipsUnknownStatus(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_unknown_status",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatus("some_new_future_status"),
}, nil
}
unknown := middleware.Abandoned3DSSweepCounter().WithLabelValues("unrecognized_status")
before := testutil.ToFloat64(unknown)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("unknown status must not flip; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(unknown) - before; got != 1 {
t.Fatalf("want 1 unrecognized_status increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_SkipsOnStripeError verifies a Stripe Get
// failure logs and leaves the row alone. Transient network errors must not
// look like an abandoned 3DS.
func TestReconcileStale3DSOrders_SkipsOnStripeError(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_stripe_error",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(_ string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return nil, fmt.Errorf("simulated stripe outage")
}
stripeErr := middleware.Abandoned3DSSweepCounter().WithLabelValues("stripe_error")
before := testutil.ToFloat64(stripeErr)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("stripe error must not flip; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(stripeErr) - before; got != 1 {
t.Fatalf("want 1 stripe_error sweep counter increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID guards the
// defensive empty-PI short-circuit: even past the threshold, a row missing
// stripe_payment_id must not reach paymentintent.Get (which would 4xx).
func TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_will_be_blanked",
time.Now().Add(-1*time.Hour))
if err := db.Model(&order).UpdateColumn("stripe_payment_id", "").Error; err != nil {
t.Fatalf("blank PI id: %v", err)
}
called := false
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(_ string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
called = true
return &stripe.PaymentIntent{Status: stripe.PaymentIntentStatusRequiresPaymentMethod}, nil
}
missing := middleware.Abandoned3DSSweepCounter().WithLabelValues("missing_pi")
before := testutil.ToFloat64(missing)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
if called {
t.Fatalf("stripe Get called for empty-PI row")
}
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("empty-PI guard must leave status alone; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(missing) - before; got != 1 {
t.Fatalf("want 1 missing_pi increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_BailsMidLoopOnCancel verifies the per-iteration
// ctx.Err() check stops the sweep once the supervising context cancels, so a
// SIGTERM during a large stale-3DS batch does not block the binary past the
// 30s graceful-shutdown window in api/main.go
func TestReconcileStale3DSOrders_BailsMidLoopOnCancel(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
seedRequiresActionOrder(t, user.ID, "pi_first", time.Now().Add(-1*time.Hour))
seedRequiresActionOrder(t, user.ID, "pi_second", time.Now().Add(-1*time.Hour))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
calls := 0
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
calls++
if calls == 1 {
cancel()
}
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
ReconcileStale3DSOrders(ctx, 30*time.Minute)
if calls != 1 {
t.Fatalf("want exactly 1 stripe Get call (loop bails before row 2); got %d", calls)
}
var stillPending int64
if err := db.Model(&models.Order{}).Where("status = ?", models.OrderStatusRequiresAction).Count(&stillPending).Error; err != nil {
t.Fatalf("count requires_action rows: %v", err)
}
if stillPending < 1 {
t.Fatalf("want at least 1 row left in requires_action after mid-loop cancel; got %d", stillPending)
}
}
// seedRequiresActionOrder inserts a card + order pair in the requires_action
// state. updatedAt is forced via UpdateColumn so GORM's auto-stamping of
// UpdatedAt on Create does not bump the row out of the sweep predicate.
func seedRequiresActionOrder(t *testing.T, userID uint, piID string, updatedAt time.Time) models.Order {
t.Helper()
db := database.GetDB()
card := models.Card{
UserID: userID,
StripePaymentID: "pm_test_" + piID,
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: userID, CardID: card.ID,
StripePaymentID: piID,
Status: models.OrderStatusRequiresAction,
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)
}
if err := db.Model(&order).UpdateColumn("updated_at", updatedAt).Error; err != nil {
t.Fatalf("backdate updated_at: %v", err)
}
return order
}

View File

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

View File

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

View File

@@ -28,13 +28,13 @@ func webhookLog() *slog.Logger { return slog.With("component", "webhook") }
// WebhookHandler handles inbound Stripe webhook events. // WebhookHandler handles inbound Stripe webhook events.
type WebhookHandler struct { type WebhookHandler struct {
webhookSecret string webhookSecret string
stripeKey string
shippoSecret string shippoSecret string
} }
// NewWebhookHandler creates a new webhook handler. Stripe credentials are // NewWebhookHandler creates a new webhook handler.
// wired once at startup via stripe.Key in api/main.go. func NewWebhookHandler(webhookSecret, stripeKey, shippoSecret string) *WebhookHandler {
func NewWebhookHandler(webhookSecret, shippoSecret string) *WebhookHandler { return &WebhookHandler{webhookSecret: webhookSecret, stripeKey: stripeKey, shippoSecret: shippoSecret}
return &WebhookHandler{webhookSecret: webhookSecret, shippoSecret: shippoSecret}
} }
// HandleStripe validates the Stripe signature and routes the event. // HandleStripe validates the Stripe signature and routes the event.
@@ -125,7 +125,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
} }
txErr := db.Transaction(func(tx *gorm.DB) error { txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]any{ if err := tx.Model(&order).Updates(map[string]interface{}{
"status": models.OrderStatusPaid, "status": models.OrderStatusPaid,
"stripe_payment_id": pi.ID, "stripe_payment_id": pi.ID,
}).Error; err != nil { }).Error; err != nil {
@@ -139,7 +139,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil { if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return fmt.Errorf("failed to delete cart item: %w", err) return fmt.Errorf("failed to delete cart item: %w", err)
} }
return tx.Model(&cart).Updates(map[string]any{ return tx.Model(&cart).Updates(map[string]interface{}{
"address_id": nil, "address_id": nil,
"card_id": nil, "card_id": nil,
}).Error }).Error
@@ -150,12 +150,6 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
return return
} }
if order.CardID != 0 {
if err := refreshCardStorageTTL(db, order.CardID, time.Now()); err != nil {
webhookLog().Warn("failed to refresh card storage ttl", "card_id", order.CardID, "error", err)
}
}
audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID) audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID)
go notify.SlackOrderPaid(&order) go notify.SlackOrderPaid(&order)
webhookLog().Info("order marked paid", "order_id", orderIDStr, "pi", pi.ID) webhookLog().Info("order marked paid", "order_id", orderIDStr, "pi", pi.ID)
@@ -184,7 +178,7 @@ func (h *WebhookHandler) handlePaymentIntentFailed(ctx context.Context, event st
} }
db := database.GetDB().WithContext(ctx) db := database.GetDB().WithContext(ctx)
if err := db.Model(&models.Order{}).Where("id = ? AND status IN (?, ?)", orderIDStr, models.OrderStatusPending, models.OrderStatusRequiresAction). if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
Update("status", models.OrderStatusFailed).Error; err != nil { Update("status", models.OrderStatusFailed).Error; err != nil {
webhookLog().Error("failed to mark order failed", "order_id", orderIDStr, "error", err) webhookLog().Error("failed to mark order failed", "order_id", orderIDStr, "error", err)
} }
@@ -212,6 +206,8 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, eve
return return
} }
stripe.Key = h.stripeKey
si, err := setupintent.Get(sess.SetupIntent.ID, nil) si, err := setupintent.Get(sess.SetupIntent.ID, nil)
if err != nil { if err != nil {
webhookLog().Error("failed to get setup intent", "setup_intent", sess.SetupIntent.ID, "error", err) webhookLog().Error("failed to get setup intent", "setup_intent", sess.SetupIntent.ID, "error", err)
@@ -259,14 +255,8 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, eve
webhookLog().Warn("failed to detach duplicate pm", "user_id", user.ID, "error", derr) webhookLog().Warn("failed to detach duplicate pm", "user_id", user.ID, "error", derr)
} }
// Bump UpdatedAt so the TUI poll signal can detect that the webhook // Bump UpdatedAt so the TUI poll signal can detect that the webhook
// fired even though no new row was inserted. storage_expires_at is // fired even though no new row was inserted.
// reset because the user just re-added the card; this is a card-add if uerr := db.Model(&dup).Update("updated_at", time.Now()).Error; uerr != nil {
// path, not a payment-success refresh, so last_used_at stays as-is.
now := time.Now()
if uerr := db.Model(&dup).Updates(map[string]any{
"updated_at": now,
"storage_expires_at": models.CardStorageExpiresAt(now),
}).Error; uerr != nil {
webhookLog().Warn("failed to bump dup card updated_at", "card_id", dup.ID, "error", uerr) webhookLog().Warn("failed to bump dup card updated_at", "card_id", dup.ID, "error", uerr)
} }
webhookLog().Info("skipped duplicate card via local-db match", "user_id", user.ID, "existing_card_id", dup.ID) webhookLog().Info("skipped duplicate card via local-db match", "user_id", user.ID, "existing_card_id", dup.ID)
@@ -282,7 +272,6 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, eve
ExpMonth: int(pm.Card.ExpMonth), ExpMonth: int(pm.Card.ExpMonth),
ExpYear: int(pm.Card.ExpYear), ExpYear: int(pm.Card.ExpYear),
} }
card.InitializeStorageTTL(time.Now())
if err := db.Create(&card).Error; err != nil { if err := db.Create(&card).Error; err != nil {
webhookLog().Error("failed to save card", "user_id", user.ID, "error", err) webhookLog().Error("failed to save card", "user_id", user.ID, "error", err)

View File

@@ -1,224 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"os"
"testing"
"github.com/stripe/stripe-go/v78"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// buildPaymentFailedEvent constructs a stripe.Event whose Data.Raw decodes to
// a PaymentIntent referencing the given order via metadata. errCode is set on
// last_payment_error to drive the authentication_required short-circuit
// branch in handlePaymentIntentFailed (pass "" to omit the error block).
func buildPaymentFailedEvent(t *testing.T, piID string, orderID uint, errCode string) stripe.Event {
t.Helper()
pi := map[string]any{
"id": piID,
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", orderID)},
}
if errCode != "" {
pi["last_payment_error"] = map[string]any{"code": errCode}
}
raw, err := json.Marshal(pi)
if err != nil {
t.Fatalf("marshal pi: %v", err)
}
return stripe.Event{
Type: "payment_intent.payment_failed",
Data: &stripe.EventData{Raw: raw},
}
}
func seedOrderForWebhook(t *testing.T, userID uint, status models.OrderStatus, piID string) models.Order {
t.Helper()
db := database.GetDB()
card := models.Card{
UserID: userID,
StripePaymentID: fmt.Sprintf("pm_test_%s", piID),
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: userID, CardID: card.ID,
StripePaymentID: piID,
Status: status,
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)
}
return order
}
// TestHandlePaymentIntentFailed_RequiresActionFlipsToFailed verifies the
// widened predicate (IN (pending, requires_action)) catches a row in the new
// requires_action state. Without this case, a card_declined webhook arriving
// after respondRequiresAction wrote requires_action would leave the row
// stuck forever.
func TestHandlePaymentIntentFailed_RequiresActionFlipsToFailed(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_failed")
h := NewWebhookHandler("", "")
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
h.handlePaymentIntentFailed(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusFailed {
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
}
}
// TestHandlePaymentIntentFailed_RequiresActionAuthRequiredShortCircuits
// verifies the existing short-circuit (note #3 in sca-psd2-compliance.md)
// still fires when the row is in requires_action and the error code is
// authentication_required. Without the short-circuit, the recovery flow
// would race the webhook and surface a momentarily-failed state to the TUI.
func TestHandlePaymentIntentFailed_RequiresActionAuthRequiredShortCircuits(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_authreq")
h := NewWebhookHandler("", "")
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "authentication_required")
h.handlePaymentIntentFailed(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("auth_required must not flip requires_action; got %q", reloaded.Status)
}
}
// TestHandlePaymentIntentFailed_PendingStillFlips guards the pre-existing
// pending -> failed transition against a regression where the widened
// predicate accidentally dropped pending from the source set.
func TestHandlePaymentIntentFailed_PendingStillFlips(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusPending, "pi_test_pending_failed")
h := NewWebhookHandler("", "")
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
h.handlePaymentIntentFailed(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusFailed {
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
}
}
// TestHandlePaymentIntentFailed_PaidNotTouched locks the upper boundary of
// the predicate: a paid row must NEVER be flipped to failed by a stale
// payment_failed webhook. Without this guard, a future maintainer could
// widen the IN clause to include paid (e.g. for some refund-retry flow)
// and silently break idempotency.
func TestHandlePaymentIntentFailed_PaidNotTouched(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusPaid, "pi_test_paid_untouched")
h := NewWebhookHandler("", "")
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
h.handlePaymentIntentFailed(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusPaid {
t.Fatalf("paid order was modified by payment_failed webhook: got %q", reloaded.Status)
}
}
// TestHandlePaymentIntentFailed_MissingMetadataNoOp covers the no-order
// short-circuit so the test file exercises every early-return branch in
// handlePaymentIntentFailed touched by the predicate work.
func TestHandlePaymentIntentFailed_MissingMetadataNoOp(t *testing.T) {
testDB, _ := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
pi := map[string]any{"id": "pi_test_no_meta"}
raw, err := json.Marshal(pi)
if err != nil {
t.Fatalf("marshal pi: %v", err)
}
evt := stripe.Event{
Type: "payment_intent.payment_failed",
Data: &stripe.EventData{Raw: raw},
}
h := NewWebhookHandler("", "")
h.handlePaymentIntentFailed(context.Background(), evt) // must not panic
}
func TestHandlePaymentIntentSucceeded_RequiresActionFlipsToPaid(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_paid")
pi := map[string]any{
"id": order.StripePaymentID,
"amount": order.Total,
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
}
raw, err := json.Marshal(pi)
if err != nil {
t.Fatalf("marshal pi: %v", err)
}
evt := stripe.Event{
Type: "payment_intent.succeeded",
Data: &stripe.EventData{Raw: raw},
}
h := NewWebhookHandler("", "")
h.handlePaymentIntentSucceeded(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusPaid {
t.Fatalf("status: want %q, got %q", models.OrderStatusPaid, reloaded.Status)
}
if reloaded.StripePaymentID != order.StripePaymentID {
t.Fatalf("stripe_payment_id: want %q, got %q", order.StripePaymentID, reloaded.StripePaymentID)
}
}

View File

@@ -19,8 +19,6 @@ import (
"terminalShop/pkg/database" "terminalShop/pkg/database"
"terminalShop/pkg/observability" "terminalShop/pkg/observability"
"terminalShop/pkg/stripeclient" "terminalShop/pkg/stripeclient"
"github.com/stripe/stripe-go/v78"
) )
const version = "v0.1.0" const version = "v0.1.0"
@@ -51,12 +49,6 @@ func main() {
} }
audit.SetDB(db) audit.SetDB(db)
// wire the stripe SDK's package-level key once at startup so concurrent
// reconcilers and the request path don't race on stripe.Key writes.
// NOTE: assumes single-process — if you ever embed both the SSH and API
// binaries together, the last writer wins and one of them silently breaks.
stripe.Key = cfg.StripeSecretKey
// Seed database with initial data // Seed database with initial data
if err := database.Seed(db); err != nil { if err := database.Seed(db); err != nil {
log.Fatalf("Failed to seed database: %v", err) log.Fatalf("Failed to seed database: %v", err)
@@ -79,7 +71,7 @@ func main() {
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute) jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
// Setup routes // Setup routes
router := routes.SetupRoutes(version, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL, cfg.SlackSigningSecret, cfg.AdminAPIKey, cfg.APIPort, cfg.MaxOrderCents) router := routes.SetupRoutes(version, cfg.StripeSecretKey, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL, cfg.SlackSigningSecret, cfg.AdminAPIKey, cfg.APIPort, cfg.MaxOrderCents)
// Create HTTP server // Create HTTP server
server := &http.Server{ server := &http.Server{
@@ -114,7 +106,7 @@ func main() {
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
handlers.ReconcileOrders(ctx) handlers.ReconcileOrders(cfg.StripeSecretKey)
case <-ctx.Done(): case <-ctx.Done():
return return
} }
@@ -122,48 +114,14 @@ func main() {
}() }()
go func() { go func() {
select { time.Sleep(30 * time.Second)
case <-time.After(30 * time.Second): handlers.ReconcileUnshipped()
case <-ctx.Done():
return
}
handlers.ReconcileUnshipped(ctx)
ticker := time.NewTicker(24 * time.Hour) ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
handlers.ReconcileUnshipped(ctx) handlers.ReconcileUnshipped()
case <-ctx.Done():
return
}
}
}()
// Sweep saved cards whose retention deadline elapsed. Read paths filter
// expired rows from query results, so this sweeper only drives the Stripe
// detach + audit + physical row delete asynchronously.
go func() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
handlers.ReconcileExpiredCards(ctx)
case <-ctx.Done():
return
}
}
}()
go func() {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
threshold := time.Duration(cfg.Abandoned3DSThresholdMinutes) * time.Minute
for {
select {
case <-ticker.C:
handlers.ReconcileStale3DSOrders(ctx, threshold)
case <-ctx.Done(): case <-ctx.Done():
return return
} }
@@ -178,10 +136,10 @@ func main() {
log.Println("Server is shutting down...") log.Println("Server is shutting down...")
// Graceful shutdown with 30 second timeout // Graceful shutdown with 30 second timeout
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel() defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil { if err := server.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err) log.Fatalf("Server forced to shutdown: %v", err)
} }

View File

@@ -1,4 +0,0 @@
// Package middleware holds the stuff that runs on every request before a
// handler ever sees it: auth, CORS, logging, rate limiting, metrics, and a
// recovery net for panics.
package middleware

View File

@@ -70,14 +70,6 @@ var (
[]string{"outcome"}, []string{"outcome"},
) )
abandoned3DSSweepTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "abandoned_3ds_sweep_total",
Help: "ReconcileStale3DSOrders outcomes per row processed (flipped, flip_noop, flip_failed, succeeded_skip, processing_skip, stripe_error, missing_pi, unrecognized_status)",
},
[]string{"outcome"},
)
orderValueDollars = promauto.NewHistogram( orderValueDollars = promauto.NewHistogram(
prometheus.HistogramOpts{ prometheus.HistogramOpts{
Name: "order_value_dollars", Name: "order_value_dollars",
@@ -181,19 +173,6 @@ func ObserveOrderValueCents(cents int) {
orderValueDollars.Observe(float64(cents) / 100.0) orderValueDollars.Observe(float64(cents) / 100.0)
} }
// RecordAbandoned3DSSweep records the outcome of one row processed by
// ReconcileStale3DSOrders. Bounded enumeration documented on the counter's
// Help string.
func RecordAbandoned3DSSweep(outcome string) {
abandoned3DSSweepTotal.WithLabelValues(outcome).Inc()
}
// Abandoned3DSSweepCounter exposes the sweep counter so tests can snapshot
// per-label values via prometheus testutil. Not used in production code.
func Abandoned3DSSweepCounter() *prometheus.CounterVec {
return abandoned3DSSweepTotal
}
// ObserveStripeRequest records latency of a Stripe API call. // ObserveStripeRequest records latency of a Stripe API call.
func ObserveStripeRequest(op, outcome string, durSec float64) { func ObserveStripeRequest(op, outcome string, durSec float64) {
stripeRequestDuration.WithLabelValues(op, outcome).Observe(durSec) stripeRequestDuration.WithLabelValues(op, outcome).Observe(durSec)

View File

@@ -1,3 +0,0 @@
// Package routes is the switchboard: it maps every URL to the handler that
// knows what to do with it.
package routes

View File

@@ -16,6 +16,7 @@ import (
// SetupRoutes configures all API routes // SetupRoutes configures all API routes
func SetupRoutes( func SetupRoutes(
version string, version string,
stripeSecretKey string,
stripeWebhookSecret string, stripeWebhookSecret string,
jwtManager *auth.JWTManager, jwtManager *auth.JWTManager,
authFingerprintKey string, authFingerprintKey string,
@@ -46,12 +47,12 @@ func SetupRoutes(
healthHandler := handlers.NewHealthHandler(version) healthHandler := handlers.NewHealthHandler(version)
productHandler := handlers.NewProductHandler() productHandler := handlers.NewProductHandler()
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey) authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
cartHandler := handlers.NewCartHandler(appURL, maxOrderCents) cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL, maxOrderCents)
cardHandler := handlers.NewCardHandler(appURL) cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
orderHandler := handlers.NewOrderHandler(bringAPIUID, bringAPIKey, bringCustomerNumber) orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber)
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey) addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
viewHandler := handlers.NewViewHandler() viewHandler := handlers.NewViewHandler()
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, shippoWebhookSecret) webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, stripeSecretKey, shippoWebhookSecret)
slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort, shippoWebhookSecret) slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort, shippoWebhookSecret)
// Short payment redirect and success page — no auth required. // Short payment redirect and success page — no auth required.
@@ -131,7 +132,6 @@ func SetupRoutes(
// Orders // Orders
r.Get("/orders", orderHandler.GetOrders) r.Get("/orders", orderHandler.GetOrders)
r.Get("/orders/{id}/status", orderHandler.GetOrderStatus) r.Get("/orders/{id}/status", orderHandler.GetOrderStatus)
r.With(middleware.RateLimitByUser(10, time.Minute)).Post("/orders/{id}/retry-auth", cartHandler.RetryAuth)
r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest) r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest)
r.Post("/orders/{id}/refund", orderHandler.RefundOrder) r.Post("/orders/{id}/refund", orderHandler.RefundOrder)

View File

@@ -1,3 +0,0 @@
// Command reconcile-cards hunts down saved cards that Stripe no longer knows
// about and detaches them. Run it with -dry-run first if you're nervous.
package main

18
go.mod
View File

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

27
go.sum
View File

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

15
main.go
View File

@@ -8,6 +8,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time" "time"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
@@ -15,7 +16,6 @@ import (
"charm.land/wish/v2/bubbletea" "charm.land/wish/v2/bubbletea"
"charm.land/wish/v2/logging" "charm.land/wish/v2/logging"
"github.com/charmbracelet/ssh" "github.com/charmbracelet/ssh"
"github.com/stripe/stripe-go/v78"
"terminalShop/pkg/auth" "terminalShop/pkg/auth"
"terminalShop/pkg/config" "terminalShop/pkg/config"
@@ -32,6 +32,7 @@ func listenHost() string {
var ( var (
apiURL string apiURL string
authFingerprintKey string authFingerprintKey string
stripePublicKey string
) )
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
@@ -43,7 +44,7 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
pubKeyStr = auth.FormatSSHPublicKey(pubKey) pubKeyStr = auth.FormatSSHPublicKey(pubKey)
} }
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey) m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
return m, nil return m, nil
} }
@@ -57,15 +58,7 @@ func main() {
// Set API URL and auth key from config for TUI connections // Set API URL and auth key from config for TUI connections
apiURL = cfg.APIURL apiURL = cfg.APIURL
authFingerprintKey = cfg.AuthFingerprintKey authFingerprintKey = cfg.AuthFingerprintKey
stripePublicKey = cfg.StripePublicKey
// Wire the publishable key into stripe-go's package-level global once.
// TUI uses the legacy Tokens API (stripetoken.New) which accepts the
// publishable key; per-session writes would race across concurrent SSH
// sessions even though they'd all write the same value.
// NOTE: assumes single-process — if you ever embed both the SSH and API
// binaries together, the last writer wins and one of them silently breaks.
stripe.Key = cfg.StripePublicKey
log.Printf("TUI will connect to API at: %s", apiURL) log.Printf("TUI will connect to API at: %s", apiURL)
// Create SSH server // Create SSH server

View File

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

View File

@@ -1,3 +0,0 @@
// Package api is the client side of the house: it talks to our HTTP backend
// so other Go code doesn't have to hand-roll requests.
package api

View File

@@ -13,36 +13,16 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// EventOrderRequiresAction is the audit event-type string emitted whenever a
// PaymentIntent is pushed into requires_action. Exposed so cart-flow handler
// can count retry attempts without hardcoding the string literal.
const EventOrderRequiresAction = "order_requires_action"
// EventOrder3DSAbandoned is the audit event-type string emitted by the
// abandoned-3DS sweep when it flips a stale requires_action row to failed.
// Distinct from EventOrderRequiresAction (caveat #17) so the retry-cap count
// stays decoupled from sweep activity
const EventOrder3DSAbandoned = "order_3ds_abandoned"
// EventCartRejected is the audit event-type string emitted when the per-order
// spend cap blocks a checkout BEFORE an order row is created. The event lives
// in slog only, persist() no-ops on zero order id, which is the correct shape
// here (cap rejection is upstream of order state). See caveat #13.
const EventCartRejected = "cart_rejected"
type eventType string type eventType string
const ( const (
eventCardAdded eventType = "card_added" eventCardAdded eventType = "card_added"
eventCardDeleted eventType = "card_deleted" eventCardDeleted eventType = "card_deleted"
eventCardExpired eventType = "card_expired"
eventOrderCreated eventType = "order_created" eventOrderCreated eventType = "order_created"
eventOrderPaid eventType = "order_paid" eventOrderPaid eventType = "order_paid"
eventOrderShipped eventType = "order_shipped" eventOrderShipped eventType = "order_shipped"
eventOrderFailed eventType = "order_failed" eventOrderFailed eventType = "order_failed"
eventOrderRequiresAction eventType = EventOrderRequiresAction eventOrderRequiresAction eventType = "order_requires_action"
eventOrder3DSAbandoned eventType = EventOrder3DSAbandoned
eventCartRejected eventType = EventCartRejected
eventOrderRefunded eventType = "order_refunded" eventOrderRefunded eventType = "order_refunded"
eventPaymentCritical eventType = "payment_critical" eventPaymentCritical eventType = "payment_critical"
eventLabelPurchased eventType = "label_purchased" eventLabelPurchased eventType = "label_purchased"
@@ -66,8 +46,6 @@ type event struct {
Status string Status string
Error string Error string
Actor string Actor string
TotalCents int
CapCents int
} }
func (e event) attrs() []any { func (e event) attrs() []any {
@@ -108,12 +86,6 @@ func (e event) attrs() []any {
if e.Actor != "" { if e.Actor != "" {
a = append(a, "actor", e.Actor) a = append(a, "actor", e.Actor)
} }
if e.TotalCents != 0 {
a = append(a, "total_cents", e.TotalCents)
}
if e.CapCents != 0 {
a = append(a, "cap_cents", e.CapCents)
}
return a return a
} }
@@ -141,17 +113,6 @@ func CardDeleted(userID, cardID uint) {
persist(0, eventCardDeleted, e) persist(0, eventCardDeleted, e)
} }
// CardExpired records that a saved payment method expired from inactivity
func CardExpired(userID, cardID uint) {
e := event{
Event: eventCardExpired,
UserID: userID,
CardID: cardID,
}
slog.Info("audit", e.attrs()...)
persist(0, eventCardExpired, e)
}
// OrderCreated records that an order record was created (before payment). // OrderCreated records that an order record was created (before payment).
func OrderCreated(userID, orderID uint, amountCents int) { func OrderCreated(userID, orderID uint, amountCents int) {
e := event{ e := event{
@@ -340,6 +301,7 @@ func attrsToMap(a []any) map[string]any {
return m return m
} }
//nolint:unused // called from ConvertCart in the next update
func OrderRequiresAction(userID, orderID uint, paymentIntentID string) { func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
e := event{ e := event{
Event: eventOrderRequiresAction, Event: eventOrderRequiresAction,
@@ -350,33 +312,3 @@ func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
slog.Info("audit", e.attrs()...) slog.Info("audit", e.attrs()...)
persist(orderID, eventOrderRequiresAction, e) persist(orderID, eventOrderRequiresAction, e)
} }
// Order3DSAbandoned records the abandoned-3DS sweep flipping a stale
// requires_action row to failed because Stripe still reports the underlying
// PaymentIntent as outstanding past the sweep threshold.
func Order3DSAbandoned(userID, orderID uint, paymentIntentID string) {
e := event{
Event: eventOrder3DSAbandoned,
UserID: userID,
OrderID: orderID,
StripeID: paymentIntentID,
}
slog.Info("audit", e.attrs()...)
persist(orderID, eventOrder3DSAbandoned, e)
}
// CartRejected records the per-order cap blocking a checkout BEFORE an
// order row is created. Slog-only by design: cap rejection runs upstream
// of order creation so there is no order id to scope a persistent
// OrderEvent row. persist() is intentionally not called - future refactors
// of the persist() zero-id guard must not silently start writing rows here.
// Counterpart to the validation_over_limit Prometheus counter.
func CartRejected(userID uint, totalCents, capCents int) {
e := event{
Event: eventCartRejected,
UserID: userID,
TotalCents: totalCents,
CapCents: capCents,
}
slog.Info("audit", e.attrs()...)
}

View File

@@ -1,41 +0,0 @@
package audit
import (
"bytes"
"encoding/json"
"log/slog"
"strings"
"testing"
)
func TestCartRejected(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
CartRejected(42, 25000, 20000)
raw := strings.TrimSpace(buf.String())
if raw == "" {
t.Fatalf("CartRejected: no slog output captured")
}
var rec map[string]any
if err := json.Unmarshal([]byte(raw), &rec); err != nil {
t.Fatalf("decode slog record: %v\nraw: %s", err, raw)
}
if got, want := rec["event"], "cart_rejected"; got != want {
t.Errorf("event: want %q, got %v", want, got)
}
if n, _ := rec["user_id"].(float64); int(n) != 42 {
t.Errorf("user_id want 42, got %v", rec["user_id"])
}
if n, _ := rec["total_cents"].(float64); int(n) != 25000 {
t.Errorf("total_cents: want 25000, got %v", rec["total_cents"])
}
if n, _ := rec["cap_cents"].(float64); int(n) != 20000 {
t.Errorf("cap_cents: want 20000, got %v", rec["cap_cents"])
}
}

View File

@@ -1,3 +0,0 @@
// Package auth sorts out who you are: logging in with an SSH key and minting
// the JWT you carry around afterwards.
package auth

View File

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

View File

@@ -1,3 +0,0 @@
// Package bring talks to Bring, the Norwegian postal service, for booking
// shipments and printing labels.
package bring

View File

@@ -3,7 +3,6 @@ package config
import ( import (
"errors" "errors"
"fmt" "fmt"
"log/slog"
"net/url" "net/url"
"os" "os"
"strconv" "strconv"
@@ -33,7 +32,6 @@ type Config struct {
SlackSigningSecret string SlackSigningSecret string
AdminAPIKey string AdminAPIKey string
MaxOrderCents int MaxOrderCents int
Abandoned3DSThresholdMinutes int
} }
// Load reads configuration from environment variables and validates required secrets. // Load reads configuration from environment variables and validates required secrets.
@@ -71,7 +69,6 @@ func Load() (*Config, error) {
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"), SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
AdminAPIKey: os.Getenv("ADMIN_API_KEY"), AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
MaxOrderCents: loadMaxOrderCents(), MaxOrderCents: loadMaxOrderCents(),
Abandoned3DSThresholdMinutes: loadAbandoned3DSThresholdMinutes(),
} }
if err := cfg.validate(); err != nil { if err := cfg.validate(); err != nil {
@@ -137,13 +134,11 @@ func getEnvOrDefault(key, defaultValue string) string {
return defaultValue return defaultValue
} }
// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $500 — aligns with the // loadMaxOrderCents reads MAX_ORDER_CENTS. Default $200. Explicit 0 disables
// PSD2 TRA exemption ceiling (above this, Stripe always requests SCA, so the // the cap. Garbage or negative values fall back to the default with a warning
// cap is only meaningful below it). Explicit 0 disables the cap. Garbage or // so an operator typo cannot silently disable a fraud control.
// negative values fall back to the default with a warning so an operator
// typo cannot silently disable a fraud control.
func loadMaxOrderCents() int { func loadMaxOrderCents() int {
const defaultCap = 50000 const defaultCap = 20000
raw := os.Getenv("MAX_ORDER_CENTS") raw := os.Getenv("MAX_ORDER_CENTS")
if raw == "" { if raw == "" {
return defaultCap return defaultCap
@@ -155,26 +150,3 @@ func loadMaxOrderCents() int {
} }
return n return n
} }
// loadAbandoned3DSThresholdMinutes read ABANDONED_3DS_THRESHOLD_MINUTES.
// The abandoned-3DS sweep only flips requires_action rows whose updated_at iconfigs
// older than this many minutes. Default 30 min. Values below 1 fall back to
// the default so a typo cannot collapse the grace window the TUI polls inside.
func loadAbandoned3DSThresholdMinutes() int {
const defaultMinutes = 30
raw := os.Getenv("ABANDONED_3DS_THRESHOLD_MINUTES")
if raw == "" {
return defaultMinutes
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
slog.Warn("invalid ABANDONED_3DS_THRESHOLD_MINUTES, falling back",
"raw", raw, "default", defaultMinutes)
return defaultMinutes
}
if n < 10 {
slog.Warn("ABANDONED_3DS_THRESHOLD_MINUTES below 10 may race in-flight 3ds challenges",
"value", n)
}
return n
}

View File

@@ -1,35 +1,21 @@
package config package config
import ( import (
"io"
"log/slog"
"os"
"testing" "testing"
) )
// TestMain silences the package's slog warnings so the negative/garbage
// env-var cases plus the sub-10 valid-minimum case don't spam test output.
// The actual fall-back behavior is still asserted via the case tables.
func TestMain(m *testing.M) {
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
code := m.Run()
slog.SetDefault(prev)
os.Exit(code)
}
func TestLoadMaxOrderCents(t *testing.T) { func TestLoadMaxOrderCents(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
env string // "" means unset env string // "" means unset
want int want int
}{ }{
{"empty falls back to default", "", 50000}, {"unset uses default", "", 20000},
{"explicit zero disables", "0", 0}, {"explicit zero disables", "0", 0},
{"valid override", "30000", 30000}, {"valid override", "50000", 50000},
{"negative falls back to default", "-1", 50000}, {"negative falls back to default", "-1", 20000},
{"garbage falls back to default", "two hundred", 50000}, {"garbage falls back to default", "two hundred", 20000},
{"empty after trim falls back to default", "", 50000}, {"empty after trim falls back to default", "", 20000},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -46,33 +32,3 @@ func TestLoadMaxOrderCents(t *testing.T) {
}) })
} }
} }
// TestLoadAbandoned3DSThresholdMinutes covers the env-parsing surface for
// the sweep threshold. Zero and negative fall back to the default (unlike
// MaxOrderCents where zero is a deliberate off-switch) because a zero
// threshold would sweep every requires_action row on the first tick and
// race the customer's in-progress challenge.
func TestLoadAbandoned3DSThresholdMinutes(t *testing.T) {
cases := []struct {
name string
env string // "" means unset
want int
}{
{"empty falls back to default", "", 30},
{"valid override", "45", 45},
{"zero falls back to default", "0", 30},
{"negative falls back to default", "-5", 30},
{"garbage falls back to default", "half an hour", 30},
{"valid minimum (warns)", "1", 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("ABANDONED_3DS_THRESHOLD_MINUTES", tc.env)
got := loadAbandoned3DSThresholdMinutes()
if got != tc.want {
t.Errorf("loadAbandoned3DSThresholdMinutes() = %d, want %d", got, tc.want)
}
})
}
}

View File

@@ -1,3 +0,0 @@
// Package config reads everything out of the environment so secrets and
// settings stay out of the source code.
package config

View File

@@ -1,3 +0,0 @@
// Package database opens the SQLite connection, runs the migrations, and
// drops in some seed data so the shop isn't empty on a fresh boot.
package database

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,6 @@ type Coffee struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:255;not null" json:"name"` Name string `gorm:"size:255;not null" json:"name"`
RoastType string `gorm:"size:100" json:"roast_type"` RoastType string `gorm:"size:100" json:"roast_type"`
RoastLevel int `json:"roast_level"` // 1-5 roast-intensity
Ounces int `json:"ounces"` Ounces int `json:"ounces"`
BeanType string `gorm:"size:100" json:"bean_type"` BeanType string `gorm:"size:100" json:"bean_type"`
Price int `gorm:"not null" json:"price"` Price int `gorm:"not null" json:"price"`

View File

@@ -1,3 +0,0 @@
// Package models is the shared vocabulary of the app: coffee, carts, orders,
// addresses, cards, users, and the other nouns everything passes around.
package models

View File

@@ -11,7 +11,6 @@ type OrderStatus string
const ( const (
OrderStatusPending OrderStatus = "pending" OrderStatusPending OrderStatus = "pending"
OrderStatusRequiresAction OrderStatus = "requires_action"
OrderStatusPaid OrderStatus = "paid" OrderStatusPaid OrderStatus = "paid"
OrderStatusShipped OrderStatus = "shipped" OrderStatusShipped OrderStatus = "shipped"
OrderStatusDelivered OrderStatus = "delivered" OrderStatusDelivered OrderStatus = "delivered"

View File

@@ -35,8 +35,6 @@ func (order *Order) DisplayState() string {
switch order.Status { switch order.Status {
case OrderStatusPending: case OrderStatusPending:
return "Awaiting payment" return "Awaiting payment"
case OrderStatusRequiresAction:
return "Awaiting authentication"
case OrderStatusPaid: case OrderStatusPaid:
if order.TrackingNumber == "" { if order.TrackingNumber == "" {
return "Paid * awaiting label" return "Paid * awaiting label"
@@ -79,8 +77,6 @@ func (order *Order) DisplayKind() DisplayKind {
switch order.Status { switch order.Status {
case OrderStatusPending: case OrderStatusPending:
return DisplayKindNeutral return DisplayKindNeutral
case OrderStatusRequiresAction:
return DisplayKindNeutral
case OrderStatusPaid: case OrderStatusPaid:
return DisplayKindAccent return DisplayKindAccent
case OrderStatusShipped: case OrderStatusShipped:

View File

@@ -14,7 +14,6 @@ func TestOrderDisplayState(t *testing.T) {
kind DisplayKind kind DisplayKind
}{ }{
{"pending", Order{Status: OrderStatusPending}, "Awaiting payment", DisplayKindNeutral}, {"pending", Order{Status: OrderStatusPending}, "Awaiting payment", DisplayKindNeutral},
{"requires_action", Order{Status: OrderStatusRequiresAction}, "Awaiting authentication", DisplayKindNeutral},
{"paid no tracking", Order{Status: OrderStatusPaid}, "Paid * awaiting label", DisplayKindAccent}, {"paid no tracking", Order{Status: OrderStatusPaid}, "Paid * awaiting label", DisplayKindAccent},
{"paid with tracking, no event", Order{Status: OrderStatusPaid, TrackingNumber: "12999"}, "Paid", DisplayKindAccent}, {"paid with tracking, no event", Order{Status: OrderStatusPaid, TrackingNumber: "12999"}, "Paid", DisplayKindAccent},
{"Paid pre-transit", Order{Status: OrderStatusPaid, TrackingNumber: "12999", TrackingStatus: TrackingStatusPreTransit}, "Paid * ready to ship", DisplayKindAccent}, {"Paid pre-transit", Order{Status: OrderStatusPaid, TrackingNumber: "12999", TrackingStatus: TrackingStatusPreTransit}, "Paid * ready to ship", DisplayKindAccent},

View File

@@ -1,3 +0,0 @@
// Package observability sets up tracing and structured logs so that when
// something breaks at 3am you stand a chance of figuring out why.
package observability

View File

@@ -1,3 +0,0 @@
// Package shipping holds the plain shipping types that the rest of the app
// passes back and forth.
package shipping

View File

@@ -1,2 +0,0 @@
// Package shippo talks to the Shippo API for shipping rates and labels.
package shippo

View File

@@ -1,3 +0,0 @@
// Package stripeclient wires up the Stripe SDK once at startup (with tracing
// baked in) so everyone else can just make calls.
package stripeclient

View File

@@ -1,8 +1,6 @@
package tui package tui
import ( import (
"strings"
"charm.land/bubbles/v2/viewport" "charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
@@ -10,54 +8,36 @@ import (
"terminalShop/pkg/models" "terminalShop/pkg/models"
) )
// accountBodyHeight returns the inner content height of the account body // updateAccountViewport initializes or resizes the detail viewport for the account page.
// panels. The body is quantized to whole order cards so the menu panel's // Follows the reference pattern: each page owns its own viewport(s).
// bottom border lines up exactly with the last visible card; the two gap func (m Model) updateAccountViewport() Model {
// rows around the body host the up/down scroll arrows. // Match the reference: only subtract header + footer, no extra margins
func (m Model) accountBodyHeight() int { // (account is a viewport page — views.go renders it without margin padding)
headerHeight := lipgloss.Height(m.BuildHeader()) headerHeight := lipgloss.Height(m.BuildHeader())
footerHeight := lipgloss.Height(m.BuildFooter()) footerHeight := lipgloss.Height(m.BuildFooter())
budget := m.heightContainer - headerHeight - footerHeight - 2 verticalMarginHeight := headerHeight + footerHeight
cards := budget / orderCardHeight
if cards < 1 {
cards = 1
}
return cards*orderCardHeight - 2
}
// accountDetailWidth returns the width of the unboxed detail area: the chrome availableHeight := m.heightContainer - verticalMarginHeight
// width minus the menu panel and the gap. The detail side has no outer panel
// (its content draws its own boxes), so it reclaims the border + padding.
func (m Model) accountDetailWidth() int {
contentW, menuW, _, _ := m.shopLayout()
if m.size < large {
return contentW
}
return contentW - (menuW + 4) - 1
}
// updateAccountViewport initializes or resizes the detail viewport for the account page. leftWidth := 18
// The viewport spans the unboxed detail area to the right of the menu panel. rightWidth := m.widthContent - leftWidth - 2
func (m Model) updateAccountViewport() Model { detailWidth := rightWidth
detailW := m.accountDetailWidth()
// +2: no panel on the detail side, so the viewport spans the same rows
// as the menu panel including its border rows — tops and bottoms align.
availableHeight := m.accountBodyHeight() + 2
if m.size < large { if m.size < large {
// Stacked: the menu panel sits above the detail area detailWidth = m.widthContent
menuPanelHeight := len(models.AccountMenuItems) + 2 // In stacked mode, reduce available height by the left panel
availableHeight -= menuPanelHeight leftPanelHeight := len(models.AccountMenuItems) + 1
availableHeight -= leftPanelHeight
}
if availableHeight < 3 { if availableHeight < 3 {
availableHeight = 3 availableHeight = 3
} }
}
if !m.account.viewportReady { if !m.account.viewportReady {
m.account.detailViewport = viewport.New(viewport.WithWidth(detailW), viewport.WithHeight(availableHeight)) m.account.detailViewport = viewport.New(viewport.WithWidth(detailWidth), viewport.WithHeight(availableHeight))
m.account.detailViewport.KeyMap = modifiedKeyMap m.account.detailViewport.KeyMap = modifiedKeyMap
m.account.viewportReady = true m.account.viewportReady = true
} else { } else {
m.account.detailViewport.SetWidth(detailW) m.account.detailViewport.SetWidth(detailWidth)
m.account.detailViewport.SetHeight(availableHeight) m.account.detailViewport.SetHeight(availableHeight)
} }
@@ -66,7 +46,12 @@ func (m Model) updateAccountViewport() Model {
// getAccountDetailContent generates the content string for the current detail panel. // getAccountDetailContent generates the content string for the current detail panel.
func (m Model) getAccountDetailContent() string { func (m Model) getAccountDetailContent() string {
detailContentWidth := m.accountDetailWidth() leftWidth := 18
rightWidth := m.widthContent - leftWidth - 2
detailContentWidth := rightWidth - 2
if m.size < large {
detailContentWidth = m.widthContent - 2
}
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) { if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
return "" return ""
@@ -83,6 +68,7 @@ func (m Model) getAccountDetailContent() string {
case "cards": case "cards":
return m.CardsView(detailContentWidth) return m.CardsView(detailContentWidth)
case "faq": case "faq":
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
questionStyle := m.theme.TextHighlight().Bold(true) questionStyle := m.theme.TextHighlight().Bold(true)
contentStyle := m.theme.TextBody().Width(detailContentWidth) contentStyle := m.theme.TextBody().Width(detailContentWidth)
faqContent := "" faqContent := ""
@@ -93,7 +79,7 @@ func (m Model) getAccountDetailContent() string {
faqContent += "\n\n" faqContent += "\n\n"
} }
} }
detailView := faqContent detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent
if !m.account.faqFocused { if !m.account.faqFocused {
hintStyle := m.theme.TextDim() hintStyle := m.theme.TextDim()
detailView += "\n\n" + hintStyle.Render("enter: scroll faq") detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
@@ -117,25 +103,19 @@ func (m Model) scrollToAccountDetailItem() Model {
switch { switch {
case m.account.orderViewState == 1: case m.account.orderViewState == 1:
// Order list uses whole-card windowing instead of viewport scroll: // No title in list mode, cards joined with JoinVertical (no gaps)
// shift the window just enough to keep the cursor's card visible. // Card: border(2) + content(2) + PaddingLeft only = 4 lines
page := m.orderListPageSize()
if m.account.orderCursor < m.account.orderWindowStart {
m.account.orderWindowStart = m.account.orderCursor
}
if m.account.orderCursor >= m.account.orderWindowStart+page {
m.account.orderWindowStart = m.account.orderCursor - page + 1
}
m.account.detailViewport.GotoTop()
return m
case m.account.addressListFocused:
// Titles were removed from the detail views, lists start at the top
headerOffset = 0 headerOffset = 0
itemHeight = 4
itemCount = len(m.currentOrderList())
selectedIndex = m.account.orderCursor
case m.account.addressListFocused:
headerOffset = 3
itemHeight = 5 itemHeight = 5
itemCount = len(m.SavedAddresses) itemCount = len(m.SavedAddresses)
selectedIndex = m.account.addressCursor selectedIndex = m.account.addressCursor
case m.account.cardListFocused: case m.account.cardListFocused:
headerOffset = 0 headerOffset = 3
itemHeight = 4 itemHeight = 4
itemCount = len(m.SavedCards) itemCount = len(m.SavedCards)
selectedIndex = m.account.cardCursor selectedIndex = m.account.cardCursor
@@ -163,30 +143,23 @@ func (m Model) scrollToAccountDetailItem() Model {
return m return m
} }
func (m Model) BuildAccountView() string { func (m Model) BuildAccountView(availableHeight int) string {
_, menuW, _, _ := m.shopLayout() leftWidth := 18
// Left panel: menu items. The highlight is blue while the user navigates // Build the left panel (menu items)
// the menu, and falls back to the gray pill when focus has moved into the leftPanel := ""
// detail area (browsing orders, managing addresses/cards, scrolling faq).
focusedInside := m.account.orderViewState > 0 || m.account.addressListFocused ||
m.account.cardListFocused || m.account.faqFocused
pillBg := m.theme.Highlight()
if focusedInside {
pillBg = cPill
}
var menu []string
for i, item := range models.AccountMenuItems { for i, item := range models.AccountMenuItems {
if m.account.cursor == i { if m.account.cursor == i {
menu = append(menu, lipgloss.NewStyle().Width(menuW). style := m.theme.Base().Background(m.theme.Highlight()).Foreground(m.theme.Accent()).Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
Background(pillBg).Foreground(cWhite).Bold(true). leftPanel += style.Render(item) + "\n"
Render(" "+item))
} else { } else {
menu = append(menu, lipgloss.NewStyle().Width(menuW). style := m.theme.TextBody().Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
Render(" "+pBody.Render(item))) leftPanel += style.Render(item) + "\n"
} }
} }
menuContent := lipgloss.JoinVertical(lipgloss.Left, menu...)
leftContainer := m.theme.Base().
Width(leftWidth)
// Generate detail content and set it on the viewport // Generate detail content and set it on the viewport
detailContent := m.getAccountDetailContent() detailContent := m.getAccountDetailContent()
@@ -195,43 +168,25 @@ func (m Model) BuildAccountView() string {
// Viewport handles scrolling/clipping — no manual line-slicing needed // Viewport handles scrolling/clipping — no manual line-slicing needed
detailRendered := m.account.detailViewport.View() detailRendered := m.account.detailViewport.View()
// Responsive layout: menu panel beside the unboxed detail area on large, // Responsive layout: side-by-side on large, stacked on small/medium
// stacked on small/medium. The detail side draws its own item boxes.
var body string
if m.size < large { if m.size < large {
body = lipgloss.JoinVertical( return lipgloss.JoinVertical(
lipgloss.Left, lipgloss.Left,
panel(menuW, 0, menuContent), leftContainer.Render(leftPanel),
detailRendered, detailRendered,
) )
} else { }
bodyH := m.accountBodyHeight()
body = lipgloss.JoinHorizontal( return lipgloss.JoinHorizontal(
lipgloss.Top, lipgloss.Top,
panel(menuW, bodyH, menuContent), leftContainer.Render(leftPanel),
" ", " ",
detailRendered, detailRendered,
) )
} }
// Blank rows separate the body from the header and footer boxes. When the
// windowed order list has hidden items, the arrows occupy those gap rows,
// centered over the card column, so the cards themselves always span
// exactly the menu panel's rows — top and bottom borders aligned.
topGap := ""
if above := m.orderHiddenAbove(); above > 0 && m.size >= large {
topGap = strings.Repeat(" ", menuW+5) +
orderScrollIndicator("↑", above, m.accountDetailWidth())
}
bottomGap := ""
if below := m.orderHiddenBelow(); below > 0 && m.size >= large {
bottomGap = strings.Repeat(" ", menuW+5) +
orderScrollIndicator("↓", below, m.accountDetailWidth())
}
return topGap + "\n" + body + "\n" + bottomGap
}
func (m Model) AboutView(width int) string { func (m Model) AboutView(width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width) contentStyle := m.theme.TextBody().Width(width)
// USE this if wanting to add a corp name later // USE this if wanting to add a corp name later
// accentStyle := m.theme.TextHighlight().Bold(true) // accentStyle := m.theme.TextHighlight().Bold(true)
@@ -253,7 +208,7 @@ func (m Model) AboutView(width int) string {
width, width,
)) + "\n" )) + "\n"
return aboutContent return titleStyle.Render("About") + "\n\n" + aboutContent
} }
func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
@@ -380,7 +335,6 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
case 0: case 0:
m.account.orderViewState = 1 m.account.orderViewState = 1
m.account.orderCursor = 0 m.account.orderCursor = 0
m.account.orderWindowStart = 0
m.account.detailViewport.GotoTop() m.account.detailViewport.GotoTop()
m.footer = []footerCommand{ m.footer = []footerCommand{
{key: "j/k", value: "orders"}, {key: "j/k", value: "orders"},

View File

@@ -1,9 +1,10 @@
package tui package tui
func (m Model) AddressesView(width int) string { func (m Model) AddressesView(width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width) contentStyle := m.theme.TextBody().Width(width)
lines := "" lines := titleStyle.Render("Address") + "\n\n"
if len(m.SavedAddresses) == 0 { if len(m.SavedAddresses) == 0 {
lines += contentStyle.Render("No saved addresses") lines += contentStyle.Render("No saved addresses")
} else { } else {

View File

@@ -5,9 +5,10 @@ import (
) )
func (m Model) CardsView(width int) string { func (m Model) CardsView(width int) string {
titleSTyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width) contentStyle := m.theme.TextBody().Width(width)
lines := "" lines := titleSTyle.Render("Cards") + "\n\n"
if m.SavedCardsIsEmpty() { if m.SavedCardsIsEmpty() {
lines += contentStyle.Render("No saved cards") lines += contentStyle.Render("No saved cards")
} else { } else {

View File

@@ -10,11 +10,11 @@ import (
) )
func (m Model) OrdersView(width int) string { func (m Model) OrdersView(width int) string {
return m.orderListView(m.HistoryOrders(), width) return m.orderListView("Order History", m.HistoryOrders(), width)
} }
func (m Model) ActiveOrdersView(width int) string { func (m Model) ActiveOrdersView(width int) string {
return m.orderListView(m.ActiveOrders(), width) return m.orderListView("Active Orders", m.ActiveOrders(), width)
} }
func (m Model) ActiveOrders() []models.Order { func (m Model) ActiveOrders() []models.Order {
@@ -65,95 +65,41 @@ func (m Model) currentOrderList() []models.Order {
return nil return nil
} }
// orderCardHeight is the rendered height of one order card: func (m Model) orderListView(title string, orders []models.Order, width int) string {
// border (2) + content lines (2), no vertical padding. titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(1)
const orderCardHeight = 4
// orderListPageSize returns how many whole order cards fit in the detail
// area. Both scroll arrows render outside the list, in the gap rows above
// and below the body, so the cards use the full viewport height.
func (m Model) orderListPageSize() int {
n := m.account.detailViewport.Height() / orderCardHeight
if n < 1 {
n = 1
}
return n
}
// orderHiddenAbove returns how many order cards are scrolled off above the
// windowed list, or 0 when the windowed order list isn't showing.
func (m Model) orderHiddenAbove() int {
if m.account.orderViewState != 1 || !m.OrdersLoaded {
return 0
}
orders := m.currentOrderList()
if len(orders) == 0 {
return 0
}
page := m.orderListPageSize()
start := m.account.orderWindowStart
if start > len(orders)-page {
start = len(orders) - page
}
if start < 0 {
start = 0
}
return start
}
// orderHiddenBelow returns how many order cards are scrolled off below the
// windowed list, or 0 when the windowed order list isn't showing.
func (m Model) orderHiddenBelow() int {
if m.account.orderViewState == 2 || !m.OrdersLoaded {
return 0
}
orders := m.currentOrderList()
if len(orders) == 0 {
return 0
}
below := len(orders) - (m.orderHiddenAbove() + m.orderListPageSize())
if below < 0 {
below = 0
}
return below
}
// orderScrollIndicator renders the dim "more items" arrow row, centered
// across the card width.
func orderScrollIndicator(arrow string, count, width int) string {
return pDim.Width(width).Align(lipgloss.Center).
Render(fmt.Sprintf("%s %d more", arrow, count))
}
func (m Model) orderListView(orders []models.Order, width int) string {
contentStyle := m.theme.TextBody().Width(width) contentStyle := m.theme.TextBody().Width(width)
if !m.OrdersLoaded { if !m.OrdersLoaded {
return contentStyle.Render("Loading orders...") return titleStyle.Render(title) + "\n" + contentStyle.Render("Loading orders...")
} }
if len(orders) == 0 { if len(orders) == 0 {
return contentStyle.Render("No orders yet") return titleStyle.Render(title) + "\n" + contentStyle.Render("No orders yet")
} }
if m.account.orderViewState == 2 { if m.account.orderViewState == 2 {
return m.buildOrderDetailView(orders[m.account.orderCursor], width) return m.buildOrderDetailView(orders[m.account.orderCursor], width)
} }
// Whole-card windowing: render only fully-visible cards, never clipping a boxWidth := width - 2
// card mid-border. Both scroll arrows render in BuildAccountView's gap
// rows, so the cards span exactly the menu panel's rows. // Build cards and join vertically — no manual "\n" gaps means fixed spacing
page := m.orderListPageSize() var cards []string
start := m.orderHiddenAbove() for i, order := range orders {
end := start + page isSelected := m.account.orderViewState == 1 && i == m.account.orderCursor
if end > len(orders) { cards = append(cards, m.buildOrderCard(order, boxWidth, isSelected))
end = len(orders) }
cardList := lipgloss.JoinVertical(lipgloss.Left, cards...)
// In preview mode (viewState 0), show title + hint
if m.account.orderViewState == 0 {
hintStyle := m.theme.TextDim()
return titleStyle.Render(title) + "\n" +
cardList + "\n" +
hintStyle.Render("enter: browse orders")
} }
var rows []string // In list browsing mode (viewState 1), just show cards — no title
for i := start; i < end; i++ { // (the left panel menu already shows "order history" as selected)
isSelected := m.account.orderViewState == 1 && i == m.account.orderCursor return cardList
rows = append(rows, m.buildOrderCard(orders[i], width, isSelected))
}
return lipgloss.JoinVertical(lipgloss.Left, rows...)
} }
// buildOrderCard renders a single order as a bordered box with fixed height. // buildOrderCard renders a single order as a bordered box with fixed height.

View File

@@ -9,42 +9,36 @@ import (
) )
func (m Model) updateCartViewport() Model { func (m Model) updateCartViewport() Model {
contentW, _, _, _ := m.shopLayout()
headerH := lipgloss.Height(m.BuildHeader()) headerH := lipgloss.Height(m.BuildHeader())
breadH := lipgloss.Height(m.BuildBreadcrumbs()) breadH := lipgloss.Height(m.BuildBreadcrumbs())
footerH := lipgloss.Height(m.BuildFooter()) footerH := lipgloss.Height(m.BuildFooter())
// 2 = blank gap rows around the cart list availH := m.heightContainer - headerH - footerH - breadH
availH := m.heightContainer - headerH - footerH - breadH - 2
if availH < 1 { if availH < 1 {
availH = 1 availH = 1
} }
if !m.cart.viewportReady { if !m.cart.viewportReady {
m.cart.viewport = viewport.New(viewport.WithWidth(contentW), viewport.WithHeight(availH)) m.cart.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
m.cart.viewport.KeyMap = viewport.KeyMap{} m.cart.viewport.KeyMap = viewport.KeyMap{}
m.cart.viewportReady = true m.cart.viewportReady = true
} else { } else {
m.cart.viewport.SetWidth(contentW) m.cart.viewport.SetWidth(m.widthContent)
m.cart.viewport.SetHeight(availH) m.cart.viewport.SetHeight(availH)
} }
return m return m
} }
func (m Model) generateCartContent() string { func (m Model) generateCartContent() string {
contentW, _, _, _ := m.shopLayout()
if m.IsCartEmpty() { if m.IsCartEmpty() {
emptyStyle := m.theme.TextDim(). emptyStyle := m.theme.TextDim().
Align(lipgloss.Center). Align(lipgloss.Center).
Width(contentW). Width(m.widthContent).
Padding(2, 0) Padding(2, 0)
render := emptyStyle.Render("Your cart is empty\n\nPress s to go back to shop") render := emptyStyle.Render("Your cart is empty\n\nPress s to go back to shop")
return render return render
} }
cartItems := "" cartItems := ""
// Cart cards span the full chrome width so they line up with the boxWidth := m.widthContent - 4
// header and footer boxes.
boxWidth := contentW
itemSlice := m.GetCartItemsSlice() itemSlice := m.GetCartItemsSlice()
boxPadding := 0 boxPadding := 0
@@ -100,7 +94,7 @@ func (m Model) generateCartContent() string {
boxContent = m.theme.TextAccent().Render(boxContent) boxContent = m.theme.TextAccent().Render(boxContent)
} }
box := m.createBoxInner(boxContent, isSelected, lipgloss.Left, 2, boxPadding, boxWidth) box := m.createBoxInner(boxContent, isSelected, lipgloss.Left, 2, boxPadding, boxWidth)
cartItems += box cartItems += m.theme.Base().Width(m.widthContent).Align(lipgloss.Center).Render(box)
if idx < len(itemSlice)-1 { if idx < len(itemSlice)-1 {
cartItems += "\n" cartItems += "\n"
@@ -129,9 +123,12 @@ func (m Model) CartView() string {
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height() + itemHeight) m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height() + itemHeight)
} }
} }
// Blank rows separate the list from the chrome; no Place wrapper so the return lipgloss.Place(
// cards stay left-aligned with the header and footer boxes. m.widthContainer,
return "\n" + m.cart.viewport.View() + "\n" lipgloss.Height(m.cart.viewport.View()),
lipgloss.Center, lipgloss.Center,
m.cart.viewport.View(),
)
} }
func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) { func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) {

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
// Package tui is the terminal UI you see over SSH: the shop, the cart, your
// account, and the checkout flow, all drawn with Bubbletea.
package tui

View File

@@ -6,26 +6,20 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
) )
// BuildFooter renders the bottom chrome bar with the current page keybinds.
// Layout ported from the design prototype at ~/programming/test/shop.go.
func (m Model) BuildFooter() string { func (m Model) BuildFooter() string {
_, _, _, wideW := m.shopLayout() keybindStyle := m.theme.TextDim().Bold(true)
descStyle := m.theme.TextDim()
item := func(k, v string) string {
return pBody.Render(k) + " " + pDim.Render(v)
}
var keys string
if m.size == small { if m.size == small {
keys = item("m", "menu") footerText := keybindStyle.Render("m") + " " + descStyle.Render("menu")
} else { return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText)
parts := make([]string, 0, len(m.footer))
for _, cmd := range m.footer {
parts = append(parts, item(cmd.key, cmd.value))
}
keys = strings.Join(parts, pDim.Render(" · "))
} }
row := lipgloss.NewStyle().Width(wideW).Align(lipgloss.Center).Render(keys) parts := []string{}
return panel(wideW, 0, row) for _, cmd := range m.footer {
parts = append(parts, keybindStyle.Render(cmd.key)+" "+descStyle.Render(cmd.value))
}
footerText := strings.Join(parts, " ")
return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText)
} }

View File

@@ -2,72 +2,57 @@ package tui
import ( import (
"fmt" "fmt"
"image/color"
"strings"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
) )
// cartBlendColor mixes the colors of all cart items, weighted by quantity,
// so the header price tints toward whatever dominates the cart.
func (m Model) cartBlendColor() color.Color {
var rSum, gSum, bSum, n int
for _, item := range m.Cart {
r, g, b, ok := parseHexColor(item.Coffee.Color)
if !ok {
continue
}
rSum += r * item.Quantity
gSum += g * item.Quantity
bSum += b * item.Quantity
n += item.Quantity
}
if n == 0 {
return cWhite
}
return lipgloss.Color(fmt.Sprintf("#%02X%02X%02X", rSum/n, gSum/n, bSum/n))
}
// BuildHeader renders the top chrome bar: brand, tab pills, and cart summary.
// Layout ported from the design prototype at ~/programming/test/shop.go.
func (m Model) BuildHeader() string { func (m Model) BuildHeader() string {
_, _, _, wideW := m.shopLayout() bold := m.theme.TextAccent().Bold(true).Render
accent := m.theme.TextAccent().Render
base := m.theme.Base().Render
cursor := m.theme.Base().Background(m.theme.Brand()).Render(" ")
brand := pWht.Bold(true).Render("terminal") mark := bold("t") + cursor
logo := bold("terminal")
menu := bold("m") + base(" ☰")
pill := lipgloss.NewStyle().Background(cPill).Foreground(cWhite).Bold(true).Padding(0, 1) tab := func(key, name string, active bool) string {
tab := func(key, label string, active bool) string {
if active { if active {
return pill.Render(label) return accent(key + " " + name)
} }
return pDim.Render(key+" ") + pBody.Render(label) return accent(key) + base(" "+name)
} }
shop := tab("s", "shop", m.currentPage == shopPage)
account := tab("a", "account", m.currentPage == accountPage)
var left string priceStr := fmt.Sprintf(" $%2v", m.CalculateSubtotal()/100)
if m.size == small { countStr := fmt.Sprintf(" [%d]", m.CartItemCount())
left = brand cartLabel := base(" cart")
} else {
left = strings.Join([]string{
brand,
tab("s", "shop", m.currentPage == shopPage),
tab("a", "account", m.currentPage == accountPage),
}, " ")
}
cartLabel := pDim.Render("cart ")
if m.inCartFlow() { if m.inCartFlow() {
cartLabel = pWht.Bold(true).Render("cart ") cartLabel = accent(" cart")
} }
cart := cartLabel + cart := accent("c") + cartLabel + accent(priceStr) + base(countStr)
lipgloss.NewStyle().Foreground(m.cartBlendColor()).Bold(true).
Render(fmt.Sprintf("$%d", m.CalculateSubtotal()/100)) +
pDim.Render(fmt.Sprintf(" · %d", m.CartItemCount()))
gap := wideW - lipgloss.Width(left) - lipgloss.Width(cart) var tabs []string
if gap < 1 { switch m.size {
gap = 1 case small:
tabs = []string{mark, cart}
case medium:
tabs = []string{menu, logo, cart}
default:
tabs = []string{logo, shop, account, cart}
} }
row := left + strings.Repeat(" ", gap) + cart
return panel(wideW, 0, row) 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()
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(tbl),
lipgloss.Center,
lipgloss.Center,
tbl,
)
} }

View File

@@ -75,6 +75,7 @@ type Model struct {
Fingerprint string Fingerprint string
AccessToken string AccessToken string
AuthFingerprintKey string // shared secret for /auth/token refresh AuthFingerprintKey string // shared secret for /auth/token refresh
StripePublicKey string
// One-shop auth command - capture pubKeyStr in closure, not stored on Model // One-shop auth command - capture pubKeyStr in closure, not stored on Model
authCmd tea.Cmd authCmd tea.Cmd
@@ -143,6 +144,9 @@ type Model struct {
type shopState struct { type shopState struct {
selected int selected int
menuViewport viewport.Model
detailViewport viewport.Model
viewportsReady bool
} }
type splashState struct { type splashState struct {
@@ -182,7 +186,6 @@ type accountState struct {
cursor int cursor int
orderViewState int orderViewState int
orderCursor int orderCursor int
orderWindowStart int // first fully-visible card in the windowed order list
orderYOffset int // saved scroll pos when entering order detail orderYOffset int // saved scroll pos when entering order detail
faqFocused bool faqFocused bool
addressListFocused bool addressListFocused bool
@@ -208,8 +211,6 @@ type reviewState struct {
redirectURL string redirectURL string
orderID uint orderID uint
authFailed bool authFailed bool
retrying bool
retryExhausted bool
} }
type confirmState struct { type confirmState struct {
@@ -238,6 +239,7 @@ func (m Model) SwitchPage(p page) Model {
func (m Model) ShopSwitch() (Model, tea.Cmd) { func (m Model) ShopSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(shopPage) m = m.SwitchPage(shopPage)
m = m.updateShopViewports()
m.footer = []footerCommand{ m.footer = []footerCommand{
{key: "j/k", value: "products"}, {key: "j/k", value: "products"},
{key: "+/-", value: "qty"}, {key: "+/-", value: "qty"},
@@ -262,12 +264,13 @@ func (m Model) CartSwitch() (Model, tea.Cmd) {
{key: "q", value: "quit"}, {key: "q", value: "quit"},
} }
} else { } else {
// Keep this list short enough to fit on one footer line.
m.footer = []footerCommand{ m.footer = []footerCommand{
{key: "j/k", value: "items"}, {key: "j/k", value: "items"},
{key: "+/-", value: "qty"}, {key: "+/-", value: "qty"},
{key: "enter", value: "checkout"}, {key: "p/enter", value: "checkout"},
{key: "s", value: "shop"}, {key: "s", value: "shop"},
{key: "a", value: "account"},
{key: "pgup/pgdn", value: "scroll"},
{key: "?", value: "help"}, {key: "?", value: "help"},
{key: "q", value: "quit"}, {key: "q", value: "quit"},
} }
@@ -439,18 +442,6 @@ type OrderStatusMsg struct {
Err error Err error
} }
// RetryAuthResultMsg carries the outcome of POST /orders/{id}/retry-auth.
// Status "requires_action" -> RedirectURL is a fresh challenge URL; resume
// the QR + poll loop. Status "succeeded" -> frictionless retry approved;
// server's APIError string ("RETRY_LIMIT" / "CARD_NO_LONGER_AVAILABLE" /
// transient) - the Update branch matches on substring.
type RetryAuthResultMsg struct {
OrderID uint
Status string
RedirectURL string
Err error
}
// CartSyncedMsg is recieved when the server responds to a cart item update // CartSyncedMsg is recieved when the server responds to a cart item update
type CartSyncedMsg struct { type CartSyncedMsg struct {
UpdateID int64 UpdateID int64
@@ -691,23 +682,6 @@ func (m Model) schedulePollOrderStatus(orderID uint) tea.Cmd {
}) })
} }
func (m Model) retryAuthCmd(orderID uint) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil {
return RetryAuthResultMsg{OrderID: orderID, Err: fmt.Errorf("api client unavailable")}
}
outcome, err := m.APIClient.RetryAuth(orderID)
if err != nil {
return RetryAuthResultMsg{OrderID: orderID, Err: err}
}
return RetryAuthResultMsg{
OrderID: orderID,
Status: outcome.Status,
RedirectURL: outcome.RedirectURL,
}
}
}
func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, tea.Cmd) { func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, tea.Cmd) {
if m.APIClient == nil || m.User == nil { if m.APIClient == nil || m.User == nil {
return m, nil return m, nil
@@ -731,6 +705,7 @@ func (m Model) saveCardOnlyCmd(form PaymentFormCompleteMsg) tea.Cmd {
if m.APIClient == nil { if m.APIClient == nil {
return CardSavedForReviewMsg{Err: fmt.Errorf("API client not available")} return CardSavedForReviewMsg{Err: fmt.Errorf("API client not available")}
} }
stripe.Key = m.StripePublicKey
tok, err := stripetoken.New(&stripe.TokenParams{ tok, err := stripetoken.New(&stripe.TokenParams{
Card: &stripe.CardParams{ Card: &stripe.CardParams{
Name: stripe.String(form.CardName), Name: stripe.String(form.CardName),
@@ -837,7 +812,6 @@ func NewModel(username string) Model {
{ {
Name: "Espresso", Name: "Espresso",
RoastType: "Dark Roast", RoastType: "Dark Roast",
RoastLevel: 5,
Ounces: 2, Ounces: 2,
BeanType: "Arabica", BeanType: "Arabica",
Price: 350, // $3.50 Price: 350, // $3.50
@@ -847,7 +821,6 @@ func NewModel(username string) Model {
{ {
Name: "Latte", Name: "Latte",
RoastType: "Medium Roast", RoastType: "Medium Roast",
RoastLevel: 2,
Ounces: 12, Ounces: 12,
BeanType: "Arabica Blend", BeanType: "Arabica Blend",
Price: 500, // $5.00 Price: 500, // $5.00
@@ -857,7 +830,6 @@ func NewModel(username string) Model {
{ {
Name: "Cappuccino", Name: "Cappuccino",
RoastType: "Medium Roast", RoastType: "Medium Roast",
RoastLevel: 3,
Ounces: 8, Ounces: 8,
BeanType: "Italian Blend", BeanType: "Italian Blend",
Price: 450, // $4.50 Price: 450, // $4.50
@@ -867,7 +839,6 @@ func NewModel(username string) Model {
{ {
Name: "Americano", Name: "Americano",
RoastType: "Dark Roast", RoastType: "Dark Roast",
RoastLevel: 4,
Ounces: 16, Ounces: 16,
BeanType: "Arabica", BeanType: "Arabica",
Price: 400, // $4.00 Price: 400, // $4.00
@@ -877,7 +848,6 @@ func NewModel(username string) Model {
{ {
Name: "Mocha", Name: "Mocha",
RoastType: "Medium Roast", RoastType: "Medium Roast",
RoastLevel: 3,
Ounces: 16, Ounces: 16,
BeanType: "Colombian", BeanType: "Colombian",
Price: 550, // $5.50 Price: 550, // $5.50
@@ -887,7 +857,6 @@ func NewModel(username string) Model {
{ {
Name: "Macchiato", Name: "Macchiato",
RoastType: "Dark Roast", RoastType: "Dark Roast",
RoastLevel: 4,
Ounces: 3, Ounces: 3,
BeanType: "Robusta Blend", BeanType: "Robusta Blend",
Price: 425, // $4.25 Price: 425, // $4.25
@@ -905,12 +874,11 @@ func NewModel(username string) Model {
} }
// NewModelWithAuth creates a new model with user authentication context. // NewModelWithAuth creates a new model with user authentication context.
// The publishable Stripe key is wired into stripe.Key once at startup in func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
// the SSH binary's main, so it is not threaded through here.
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string) Model {
m := NewModel("") m := NewModel("")
m.Fingerprint = fingerprint m.Fingerprint = fingerprint
m.AuthFingerprintKey = clientSecret m.AuthFingerprintKey = clientSecret
m.StripePublicKey = stripePublicKey
m.currentPage = splashPage m.currentPage = splashPage
if apiURL != "" { if apiURL != "" {
m.APIClient = api.NewClient(apiURL, "") m.APIClient = api.NewClient(apiURL, "")
@@ -932,6 +900,10 @@ func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clien
return m return m
} }
func newViewport(w, h int) viewport.Model {
return viewport.New(viewport.WithWidth(w), viewport.WithHeight(h))
}
func (m Model) collectCardCmd() tea.Cmd { func (m Model) collectCardCmd() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if m.APIClient == nil { if m.APIClient == nil {

View File

@@ -1,70 +0,0 @@
package tui
import (
"image/color"
"strconv"
"strings"
"charm.land/lipgloss/v2"
)
// Palette and base styles for the boxed shop chrome.
// Ported from the design prototype at ~/programming/test/shop.go.
var (
cWhite = lipgloss.Color("#EAEAEA")
cBody = lipgloss.Color("#9A9A9A")
cDim = lipgloss.Color("#5A5A5A")
cLine = lipgloss.Color("#3A3A3A")
cInk = lipgloss.Color("#161616") // text on a light colored block
cGreen = lipgloss.Color("#4FB477")
cRed = lipgloss.Color("#E06C6C")
cGold = lipgloss.Color("#D0962E") // single accent
cPill = lipgloss.Color("#2E2E2E") // active-tab pill bg
pBody = lipgloss.NewStyle().Foreground(cBody)
pDim = lipgloss.NewStyle().Foreground(cDim)
pWht = lipgloss.NewStyle().Foreground(cWhite)
)
// parseHexColor splits a "#RRGGBB" string into its channel values.
func parseHexColor(s string) (r, g, b int, ok bool) {
s = strings.TrimPrefix(s, "#")
if len(s) != 6 {
return 0, 0, 0, false
}
v, err := strconv.ParseUint(s, 16, 32)
if err != nil {
return 0, 0, 0, false
}
return int(v >> 16), int((v >> 8) & 0xFF), int(v & 0xFF), true
}
// contrastColor returns ink for light backgrounds and white for dark ones,
// using perceived luminance so text stays readable on any product color.
func contrastColor(hex string) color.Color {
r, g, b, ok := parseHexColor(hex)
if !ok {
return cWhite
}
lum := (299*r + 587*g + 114*b) / 1000
if lum > 140 {
return cInk
}
return cWhite
}
// panel wraps content in a rounded box. textW is the inner text width and
// innerH the inner content height; the total render size is textW+4 by
// innerH+2. Ported from the design prototype at ~/programming/test/shop.go,
// adjusted for lipgloss v2 where Width/Height include the border.
func panel(textW, innerH int, content string) string {
s := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(cLine).
Padding(0, 1).
Width(textW + 4)
if innerH > 0 {
s = s.Height(innerH + 2)
}
return s.Render(content)
}

View File

@@ -276,7 +276,9 @@ func (m Model) updatePaymentViewport() Model {
breadH := lipgloss.Height(m.BuildBreadcrumbs()) breadH := lipgloss.Height(m.BuildBreadcrumbs())
footerH := lipgloss.Height(m.BuildFooter()) footerH := lipgloss.Height(m.BuildFooter())
availH := m.heightContainer - headerH - footerH - breadH availH := m.heightContainer - headerH - footerH - breadH
availH = max(availH, 1) if availH < 1 {
availH = 1
}
if !m.payment.viewportReady { if !m.payment.viewportReady {
m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH)) m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
m.payment.viewport.KeyMap = viewport.KeyMap{} m.payment.viewport.KeyMap = viewport.KeyMap{}
@@ -301,7 +303,7 @@ func (m Model) PaymentPageView() string {
m.widthContainer, m.widthContainer,
m.payment.viewport.Height(), m.payment.viewport.Height(),
lipgloss.Center, lipgloss.Center, lipgloss.Center, lipgloss.Center,
m.renderPaymentHTTPSView(), m.renderPaymentHttpsView(),
) )
} else if m.payment.view == 0 && m.payment.form == nil { } else if m.payment.view == 0 && m.payment.form == nil {
content = m.RenderCardList() content = m.RenderCardList()
@@ -353,16 +355,11 @@ func (m Model) RenderCardList() string {
boxes = append(boxes, box) boxes = append(boxes, box)
} }
// SSH inline card-add is dormant until i have a norwegian org sshFocused := m.payment.cardCursor == len(m.SavedCards)
// number wired to stripe, something i dont have since its $300+. Keep the row visible so the option is sshBox := m.CreateBox(m.formatListItem("+ add card via ssh", sshFocused), sshFocused)
// discoverable, but render it dimmed and exclude it from cursor space
// (handled in PaymentUpdate below) so it can not be picked
dim := m.theme.TextDim().Render
sshLabel := dim("+ add card via ssh") + " " + dim("(WIP)")
sshBox := m.CreateBox(m.formatListItem(sshLabel, false), false)
boxes = append(boxes, sshBox) boxes = append(boxes, sshBox)
httpsFocused := m.payment.cardCursor == len(m.SavedCards) httpsFocused := m.payment.cardCursor == len(m.SavedCards)+1
httpsBox := m.CreateBox(m.formatListItem("+ add payment via browser", httpsFocused), httpsFocused) httpsBox := m.CreateBox(m.formatListItem("+ add payment via browser", httpsFocused), httpsFocused)
boxes = append(boxes, httpsBox) boxes = append(boxes, httpsBox)
@@ -372,7 +369,7 @@ func (m Model) RenderCardList() string {
return lipgloss.JoinVertical(lipgloss.Left, parts...) return lipgloss.JoinVertical(lipgloss.Left, parts...)
} }
func (m Model) renderPaymentHTTPSView() string { func (m Model) renderPaymentHttpsView() string {
baseStyle := m.theme.TextLabel() baseStyle := m.theme.TextLabel()
accentStyle := m.theme.TextHighlight() accentStyle := m.theme.TextHighlight()
@@ -504,7 +501,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.payment.cardCursor-- m.payment.cardCursor--
} }
case "down", "j": case "down", "j":
if m.payment.cardCursor < len(m.SavedCards) { if m.payment.cardCursor < len(m.SavedCards)+1 {
m.payment.cardCursor++ m.payment.cardCursor++
} }
@@ -514,8 +511,17 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.SelectedCard = &selected m.SelectedCard = &selected
return m.ReviewSwitch() return m.ReviewSwitch()
} }
// Browser flow, the only reachable add-card path while the SSH if m.payment.cardCursor == len(m.SavedCards) {
// inline form is dormant (see RenderCardList) m.payment.view = 1
m.payment.form = m.InitPaymentForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, m.payment.form.form.Init()
}
// Browser payment
m.payment.view = 2 m.payment.view = 2
m.payment.collectURL = nil m.payment.collectURL = nil
m.payment.collectTimeOut = false m.payment.collectTimeOut = false
@@ -540,6 +546,16 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 { if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 {
m.payment.cardCursor-- m.payment.cardCursor--
} }
if len(m.SavedCards) == 0 {
m.payment.view = 1
m.payment.form = m.InitPaymentForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, tea.Batch(m.payment.form.form.Init(), m.deleteCardCmd(card.ID))
}
return m, m.deleteCardCmd(card.ID) return m, m.deleteCardCmd(card.ID)
} }
} }

View File

@@ -1,91 +0,0 @@
package tui
import (
"strings"
"testing"
"terminalShop/pkg/models"
tea "charm.land/bubbletea/v2"
)
// newPaymentCardListModel parks a Model on the payment page card-list view
// with n synthetic saved cards. Baseline for assertint that the dormant
// "+ add card via ssh" row is dimmed and unreachable by cursor navigation.
func newPaymentCardListModel(t *testing.T, n int) Model {
t.Helper()
m := NewModel("test")
m = m.updateLayout(120, 40)
m.currentPage = paymentPage
m.payment.view = 0
m.payment.form = nil
m.payment.cardCursor = 0
for i := range n {
m.SavedCards = append(m.SavedCards, models.Card{
ID: uint(i + 1),
Brand: "visa",
Last4: "4242",
ExpMonth: 12,
ExpYear: 2030,
})
}
return m
}
func keyEnter() tea.KeyMsg { return tea.KeyPressMsg{Code: tea.KeyEnter} }
func TestRenderCardList_SshOptionComingSoon(t *testing.T) {
m := newPaymentCardListModel(t, 1)
out := m.RenderCardList()
if !strings.Contains(out, "add card via ssh") {
t.Fatalf("ssh option missing from card list output:\n%s", out)
}
if !strings.Contains(out, "WIP") {
t.Fatalf("want 'WIP' marked on ssh option:\n%s", out)
}
}
func TestPaymentUpdate_DownNavigationStopsAtBrowserSlot(t *testing.T) {
m := newPaymentCardListModel(t, 2)
for range 10 {
var cmd tea.Cmd
m, cmd = m.PaymentUpdate(keyJ())
_ = cmd
}
want := len(m.SavedCards) // browser slot; ssh slot in unreachable
if m.payment.cardCursor != want {
t.Fatalf("cursor stopped at %d, want %d (browser slot)", m.payment.cardCursor, want)
}
}
func TestPaymentUpdate_EnterOnBrowserSlotLaunchesBrowserFlow(t *testing.T) {
m := newPaymentCardListModel(t, 1)
m.payment.cardCursor = len(m.SavedCards) // browser slot
newM, cmd := m.PaymentUpdate(keyEnter())
if newM.payment.view != 2 {
t.Fatalf("payment.view = %d, want 2 (browser flow)", newM.payment.view)
}
if newM.payment.form != nil {
t.Fatalf("payment.form must be nil after enter; ssh inline form is dormant")
}
if cmd == nil {
t.Fatalf("expected collectCardCmd, got nil")
}
}
func TestPaymentUpdate_EnterWithZeroCardsLaunchesBrowserFlow(t *testing.T) {
m := newPaymentCardListModel(t, 0)
if m.payment.cardCursor != 0 {
t.Fatalf("default cursor = %d, want 0 ", m.payment.cardCursor)
}
newM, cmd := m.PaymentUpdate(keyEnter())
if newM.payment.view != 2 {
t.Fatalf("payment.view = %d, want 2 (browser flow)", newM.payment.view)
}
if newM.payment.form != nil {
t.Fatalf("payment.form must be nil; ssh inline form is dormant")
}
if cmd == nil {
t.Fatalf("expected collectCardCmd, got nil")
}
}

View File

@@ -1,3 +0,0 @@
// Package qrfefe turns text into a QR code drawn with terminal block
// characters, handy for showing links you can scan right off the screen.
package qrfefe

View File

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

View File

@@ -1,194 +0,0 @@
package tui
import (
"regexp"
"strings"
"testing"
"terminalShop/pkg/models"
)
var ansiRE = regexp.MustCompile("\x1b\\[[0-9;]*m")
// TestRenderShopSmoke prints the rendered shop frame so chrome alignment can
// be inspected, and asserts that the header, body, and footer boxes share the
// same left and right edges.
func TestRenderShopSmoke(t *testing.T) {
m := NewModel("smoke")
m.Loading = false
m.Cart[m.Coffees[0].ID] = &models.CartItem{CoffeeID: m.Coffees[0].ID, Coffee: m.Coffees[0], Quantity: 4}
m = m.updateLayout(100, 40)
m, _ = m.ShopSwitch()
out := m.renderString()
plain := ansiRE.ReplaceAllString(out, "")
t.Log("\n" + plain)
// Collect left/right border columns of every box-drawing row.
var lefts, rights []int
for _, line := range strings.Split(plain, "\n") {
runes := []rune(line)
first, last := -1, -1
for i, r := range runes {
if strings.ContainsRune("╭╮╰╯│", r) {
if first == -1 {
first = i
}
last = i
}
}
if first != -1 {
lefts = append(lefts, first)
rights = append(rights, last)
}
}
if len(lefts) == 0 {
t.Fatal("no box-drawing rows found")
}
for i := range lefts {
if lefts[i] != lefts[0] {
t.Errorf("left edge misaligned: row %d at col %d, expected %d", i, lefts[i], lefts[0])
break
}
if rights[i] != rights[0] {
t.Errorf("right edge misaligned: row %d at col %d, expected %d", i, rights[i], rights[0])
break
}
}
}
// TestRenderAccountSmoke verifies the account page boxes stay within the
// header/footer chrome bounds. The detail area is unboxed, so inner boxes may
// be narrower than the chrome but must never poke outside it.
func TestRenderAccountSmoke(t *testing.T) {
m := NewModel("smoke")
m.Loading = false
m = m.updateLayout(100, 40)
m, _ = m.AccountSwitch()
plain := ansiRE.ReplaceAllString(m.renderString(), "")
t.Log("\n" + plain)
var lefts, rights []int
for _, line := range strings.Split(plain, "\n") {
runes := []rune(line)
first, last := -1, -1
for i, r := range runes {
if strings.ContainsRune("╭╮╰╯│", r) {
if first == -1 {
first = i
}
last = i
}
}
if first != -1 {
lefts = append(lefts, first)
rights = append(rights, last)
}
}
if len(lefts) == 0 {
t.Fatal("no box-drawing rows found")
}
// Row 0 is the header box: it defines the chrome bounds.
for i := range lefts {
if lefts[i] < lefts[0] || rights[i] > rights[0] {
t.Errorf("box outside chrome: row %d spans %d-%d, chrome is %d-%d",
i, lefts[i], rights[i], lefts[0], rights[0])
break
}
}
}
// TestRenderOrderWindowing verifies the windowed order list: whole cards
// only, with ↑/↓ arrow rows marking hidden items.
func TestRenderOrderWindowing(t *testing.T) {
m := NewModel("smoke")
m.Loading = false
m.OrdersLoaded = true
for i := 1; i <= 9; i++ {
m.Orders = append(m.Orders, models.Order{
ID: uint(9000 + i),
Status: models.OrderStatusPaid,
Total: 1000 + i*100,
})
}
m = m.updateLayout(100, 40)
m, _ = m.AccountSwitch()
// Preview mode: window starts at the top, arrow only below.
plain := ansiRE.ReplaceAllString(m.renderString(), "")
t.Log("\n" + plain)
if !strings.Contains(plain, "↓ 4 more") {
t.Errorf("preview mode: expected '↓ 4 more' indicator")
}
if strings.Contains(plain, "↑") {
t.Errorf("preview mode: unexpected up arrow")
}
// Browse mode scrolled to the middle: arrows on both ends.
m.account.orderViewState = 1
m.account.orderCursor = 6
m = m.scrollToAccountDetailItem()
plain = ansiRE.ReplaceAllString(m.renderString(), "")
t.Log("\n" + plain)
if !strings.Contains(plain, "↑ 2 more") || !strings.Contains(plain, "↓ 2 more") {
t.Errorf("browse mode: expected '↑ 2 more' and '↓ 2 more' indicators")
}
}
// TestRenderCartSmoke verifies the cart page cards line up with the chrome.
func TestRenderCartSmoke(t *testing.T) {
m := NewModel("smoke")
m.Loading = false
// Seed coffees carry no IDs, so assign them for distinct cart keys
for i := range m.Coffees {
m.Coffees[i].ID = uint(i + 1)
}
m.Cart[m.Coffees[0].ID] = &models.CartItem{CoffeeID: m.Coffees[0].ID, Coffee: m.Coffees[0], Quantity: 2}
m.Cart[m.Coffees[1].ID] = &models.CartItem{CoffeeID: m.Coffees[1].ID, Coffee: m.Coffees[1], Quantity: 1}
m = m.updateLayout(100, 40)
m, _ = m.CartSwitch()
plain := ansiRE.ReplaceAllString(m.renderString(), "")
t.Log("\n" + plain)
var lefts, rights []int
for _, line := range strings.Split(plain, "\n") {
runes := []rune(line)
first, last := -1, -1
for i, r := range runes {
if strings.ContainsRune("╭╮╰╯│", r) {
if first == -1 {
first = i
}
last = i
}
}
if first != -1 {
lefts = append(lefts, first)
rights = append(rights, last)
}
}
if len(lefts) == 0 {
t.Fatal("no box-drawing rows found")
}
// Every box row (header, cards, footer) must share the chrome edges.
for i := range lefts {
if lefts[i] != lefts[0] || rights[i] != rights[0] {
t.Errorf("box edge misaligned: row %d spans %d-%d, expected %d-%d",
i, lefts[i], rights[i], lefts[0], rights[0])
break
}
}
}
// TestRenderShopSmokeStacked prints the stacked (medium/small) layouts.
func TestRenderShopSmokeStacked(t *testing.T) {
for _, size := range [][2]int{{60, 32}, {45, 30}} {
m := NewModel("smoke")
m.Loading = false
m = m.updateLayout(size[0], size[1])
m, _ = m.ShopSwitch()
t.Logf("--- %dx%d ---\n%s", size[0], size[1], ansiRE.ReplaceAllString(m.renderString(), ""))
}
}

View File

@@ -4,173 +4,163 @@ import (
"fmt" "fmt"
"strings" "strings"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"terminalShop/pkg/models" "terminalShop/pkg/models"
) )
const ( func (m Model) calculateShopWidths() (int, int) {
// shopBodyInnerHeight fixes the inner height of the shop body panels so menuWidth := 14
// the detail box never resizes with the description. if len(m.Coffees) == 0 {
shopBodyInnerHeight = 13
// shopDescLines pins the description region to an exact line count.
shopDescLines = 3
)
// shopLayout returns the shared chrome widths, all derived from one content
// width so the header, body panels, and footer line up exactly.
// Math ported from the design prototype at ~/programming/test/shop.go:
// menu(menuW+4) + gap(1) + detail(detailW+4) = contentW = wideW + 4.
func (m Model) shopLayout() (contentW, menuW, detailW, wideW int) {
contentW = m.widthContent
contentW = min(contentW, 74)
wideW = contentW - 4
if m.size < large { if m.size < large {
// Stacked layout: both panels span the full content width. return m.widthContent, m.widthContent
return contentW, wideW, wideW, wideW
} }
menuW = 16 return menuWidth, m.widthContent - menuWidth
detailW = contentW - 25 }
return contentW, menuW, detailW, wideW for _, c := range m.Coffees {
if w := lipgloss.Width(c.Name); w > menuWidth {
menuWidth = w
}
}
if menuWidth > 0 {
menuWidth += 4
}
if m.size < large {
menuWidth = m.widthContent
}
detailWidth := m.widthContent - menuWidth
if m.size < large {
detailWidth = m.widthContent
}
return menuWidth, detailWidth
} }
// roastLevel maps a roast type to a 1-5 intensity for the roast meter. func (m Model) updateShopViewports() Model {
// It is the fallback for products stored before the roast_level column existed. headerHeight := lipgloss.Height(m.BuildHeader())
func roastLevel(roastType string) int { footerHeight := lipgloss.Height(m.BuildFooter())
rt := strings.ToLower(roastType) availableHeight := m.heightContainer - headerHeight - footerHeight
switch {
case strings.Contains(rt, "dark"): if len(m.Coffees) == 0 {
return 5 return m
case strings.Contains(rt, "light"): }
return 2
default: menuWidth, detailWidth := m.calculateShopWidths()
return 3
if !m.shop.viewportsReady {
m.shop.menuViewport = newViewport(menuWidth, availableHeight)
m.shop.menuViewport.KeyMap = viewport.KeyMap{} // menu nav is manual
m.shop.detailViewport = newViewport(detailWidth, availableHeight)
m.shop.detailViewport.KeyMap = modifiedKeyMap
m.shop.viewportsReady = true
} else {
m.shop.menuViewport.SetWidth(menuWidth)
m.shop.menuViewport.SetHeight(availableHeight)
m.shop.detailViewport.SetWidth(detailWidth)
m.shop.detailViewport.SetHeight(availableHeight)
}
return m
}
func (m Model) getShopMenuContent() string {
menuWidth, _ := m.calculateShopWidths()
selectedColor := m.theme.Highlight()
if m.shop.selected >= 0 && m.shop.selected < len(m.Coffees) {
if c := m.Coffees[m.shop.selected].Color; c != "" {
selectedColor = lipgloss.Color(c)
} }
} }
// clampLines pads or truncates rendered text to exactly n lines so regions var normal, highlighted lipgloss.Style
// like the description never change the panel height. if m.size < large {
func clampLines(s string, n int) string { menuWidth = m.widthContent
lines := strings.Split(s, "\n") normal = m.theme.TextBody().
if len(lines) > n { Width(menuWidth - 1).Align(lipgloss.Center)
lines = lines[:n] highlighted = m.theme.TextAccent().
} Width(menuWidth - 1).Align(lipgloss.Center).
for len(lines) < n { Background(selectedColor).Bold(true)
lines = append(lines, "") } else {
} normal = m.theme.TextBody().
return strings.Join(lines, "\n") Width(menuWidth+2).Padding(0, 1)
highlighted = m.theme.TextAccent().
Width(menuWidth+2).Padding(0, 1).
Background(selectedColor).Bold(true)
} }
// roastMeter renders the strength dots, e.g. ● ● ● ○ ○
func roastMeter(n int) string {
n = max(0, min(n, 5))
on := lipgloss.NewStyle().Foreground(cGold).Render(strings.Repeat("● ", n))
off := pDim.Render(strings.Repeat("○ ", 5-n))
return strings.TrimRight(on+off, " ")
}
func (m Model) shopMenuContent(w int) string {
var b strings.Builder var b strings.Builder
for i, c := range m.Coffees { for i, c := range m.Coffees {
if i == m.shop.selected { if i == m.shop.selected {
b.WriteString(lipgloss.NewStyle().Width(w). b.WriteString(highlighted.Render(c.Name) + "\n")
Background(lipgloss.Color(c.Color)).
Foreground(contrastColor(c.Color)).
Bold(true).
Render(" · " + c.Name))
} else { } else {
dot := lipgloss.NewStyle().Foreground(lipgloss.Color(c.Color)).Render("·") b.WriteString(normal.Render(c.Name) + "\n")
b.WriteString(lipgloss.NewStyle().Width(w).
Render(" " + dot + " " + pBody.Render(c.Name)))
}
if i < len(m.Coffees)-1 {
b.WriteString("\n")
} }
} }
return b.String() return lipgloss.NewStyle().Padding(0, 1).Render(b.String())
} }
func (m Model) shopDetailContent(w int) string { func (m Model) getShopDetailContent() string {
if len(m.Coffees) == 0 {
return ""
}
coffee := m.Coffees[m.shop.selected] coffee := m.Coffees[m.shop.selected]
_, detailWidth := m.calculateShopWidths()
if m.size >= large {
detailWidth -= 2
}
quantity := 0 quantity := 0
if item, exists := m.Cart[coffee.ID]; exists { if item, exists := m.Cart[coffee.ID]; exists {
quantity = item.Quantity quantity = item.Quantity
} }
name := pWht.Bold(true).Render(coffee.Name) detail := lipgloss.JoinVertical(lipgloss.Left,
m.theme.TextAccent().Bold(true).Render(coffee.Name),
sep := pDim.Render(" · ") m.theme.TextBody().Render(
spec := pBody.Render(strings.ToUpper(coffee.RoastType)) + sep + fmt.Sprintf("%s | %doz | %s", coffee.RoastType, coffee.Ounces, coffee.BeanType),
pDim.Render(fmt.Sprintf("%dOZ", coffee.Ounces)) + sep + ),
pDim.Render(strings.ToUpper(coffee.BeanType))
level := coffee.RoastLevel
if level < 1 || level > 5 {
level = roastLevel(coffee.RoastType)
}
strength := pDim.Render("roast ") + roastMeter(level)
price := lipgloss.NewStyle().Foreground(lipgloss.Color(coffee.Color)).Bold(true).
Render(fmt.Sprintf("$%d.%02d", coffee.Price/100, coffee.Price%100)) +
pDim.Render(" usd")
// Stock is not modeled in the backend yet, so the indicator is static.
stock := lipgloss.NewStyle().Foreground(cGreen).Render("● in stock")
desc := clampLines(pBody.Width(w).Render(coffee.Description), shopDescLines)
// stepper: git-diff coloring — red minus, green plus
minus := lipgloss.NewStyle().Foreground(cRed).Bold(true).Render("−")
plus := lipgloss.NewStyle().Foreground(cGreen).Bold(true).Render("+")
count := lipgloss.NewStyle().Foreground(cWhite).Bold(true).
Padding(0, 2).Render(fmt.Sprintf("%d", quantity))
stepper := pDim.Render("qty ") + minus + count + plus
return lipgloss.JoinVertical(lipgloss.Left,
name,
spec,
strength,
"", "",
price+" "+stock, lipgloss.NewStyle().Foreground(lipgloss.Color(coffee.Color)).Bold(true).Render(
fmt.Sprintf("$%.2f", float64(coffee.Price)/100),
),
"", "",
desc, m.theme.TextAccent().Width(detailWidth).Render(coffee.Description),
"", "",
stepper, fmt.Sprintf("- %d +", quantity),
) )
return lipgloss.NewStyle().Width(detailWidth).Render(detail)
} }
func (m Model) ShopView() string { func (m Model) ShopView() string {
if !m.shop.viewportsReady {
m = m.updateShopViewports()
}
if len(m.Coffees) == 0 { if len(m.Coffees) == 0 {
return lipgloss.Place(m.widthContent, m.heightContainer, return lipgloss.Place(m.widthContent, m.heightContainer,
lipgloss.Center, lipgloss.Center, "No products available.") lipgloss.Center, lipgloss.Center, "No products available.")
} }
_, menuW, detailW, _ := m.shopLayout() m.shop.menuViewport.SetContent(m.getShopMenuContent())
m.shop.detailViewport.SetContent(m.getShopDetailContent())
mc := m.shopMenuContent(menuW)
dc := m.shopDetailContent(detailW)
var body string
if m.size < large { if m.size < large {
body = lipgloss.JoinVertical(lipgloss.Left, return lipgloss.JoinVertical(lipgloss.Top,
panel(menuW, 0, mc), m.shop.menuViewport.View(),
panel(detailW, 0, dc), m.shop.detailViewport.View(),
)
} else {
bodyH := max(shopBodyInnerHeight, lipgloss.Height(mc), lipgloss.Height(dc))
body = lipgloss.JoinHorizontal(lipgloss.Top,
panel(menuW, bodyH, mc),
" ",
panel(detailW, bodyH, dc),
) )
} }
return lipgloss.JoinHorizontal(lipgloss.Top,
// Blank rows separate the body from the header and footer boxes. m.shop.menuViewport.View(),
return "\n" + body + "\n" " ",
m.shop.detailViewport.View(),
)
} }
func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) { func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
if len(m.Coffees) == 0 { if len(m.Coffees) == 0 {
return m, nil return m, nil
} }
@@ -207,7 +197,16 @@ func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) {
} }
} }
return m, nil // Pass messages to the detail viewport so pgup/pgdn scroll works
m.shop.detailViewport, cmd = m.shop.detailViewport.Update(msg)
cmds = append(cmds, cmd)
if m.shop.viewportsReady {
m.shop.menuViewport.SetContent(m.getShopMenuContent())
m.shop.detailViewport.SetContent(m.getShopDetailContent())
}
return m, tea.Batch(cmds...)
} }
func (m Model) shopMoveSelected(previous bool) Model { func (m Model) shopMoveSelected(previous bool) Model {
@@ -217,8 +216,18 @@ func (m Model) shopMoveSelected(previous bool) Model {
} else { } else {
next++ next++
} }
next = max(next, 0) if next < 0 {
next = min(next, len(m.Coffees)-1) next = 0
}
if next >= len(m.Coffees) {
next = len(m.Coffees) - 1
}
m.shop.selected = next m.shop.selected = next
if m.shop.viewportsReady {
targetY := (m.shop.selected + 2) * 1
m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height()/2)
m.shop.detailViewport.GotoTop()
}
return m return m
} }

View File

@@ -8,13 +8,13 @@ import (
func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string { func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string {
padded := lipgloss.PlaceHorizontal(totalWidth, position, content) padded := lipgloss.PlaceHorizontal(totalWidth, position, content)
base := m.theme.Base().Border(lipgloss.RoundedBorder()).Width(totalWidth) base := m.theme.Base().Border(lipgloss.NormalBorder()).Width(totalWidth)
var style lipgloss.Style var style lipgloss.Style
if selected { if selected {
style = base.BorderForeground(cWhite) style = base.BorderForeground(m.theme.Highlight())
} else { } else {
style = base.BorderForeground(cLine) style = base.BorderForeground(m.theme.Border())
} }
return style.Padding(paddingV, paddingH).Render(padded) return style.Padding(paddingV, paddingH).Render(padded)
} }

View File

@@ -1,3 +0,0 @@
// Package theme keeps the colors and styles in one spot so the whole UI
// looks like it was built by the same person.
package theme

View File

@@ -16,6 +16,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if tick.seq == m.resizeSeq { if tick.seq == m.resizeSeq {
m = m.updateLayout(m.pendingWidth, m.pendingHeight) m = m.updateLayout(m.pendingWidth, m.pendingHeight)
switch m.currentPage { switch m.currentPage {
case shopPage:
m = m.updateShopViewports()
case cartPage: case cartPage:
m = m.updateCartViewport() m = m.updateCartViewport()
case shippingPage: case shippingPage:

View File

@@ -62,8 +62,7 @@ func (m Model) renderString() string {
if breadcrumbs != "" { if breadcrumbs != "" {
breadcrumbsHeight = lipgloss.Height(breadcrumbs) breadcrumbsHeight = lipgloss.Height(breadcrumbs)
} }
footer := m.BuildFooter() footerHeight := 1
footerHeight := lipgloss.Height(footer)
marginTop := 1 marginTop := 1
marginBottom := 1 marginBottom := 1
bufferSpace := 1 bufferSpace := 1
@@ -88,15 +87,17 @@ func (m Model) renderString() string {
errorStyle = errorStyle.Width(m.widthContent - 4) errorStyle = errorStyle.Width(m.widthContent - 4)
} }
errorBanner := errorStyle.Render(m.error.message) errorBanner := errorStyle.Render(m.error.message)
content = errorBanner + "\n" + m.buildPageContent() content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
} else if m.notice != nil { } else if m.notice != nil {
noticeStyle := m.theme.TextSuccess().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Success()).Padding(0, 1).MarginBottom(1) noticeStyle := m.theme.TextSuccess().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Success()).Padding(0, 1).MarginBottom(1)
noticeBanner := noticeStyle.Render(m.notice.message) noticeBanner := noticeStyle.Render(m.notice.message)
content = noticeBanner + "\n" + m.buildPageContent() content = noticeBanner + "\n" + m.buildPageContent(availableContentHeight)
} else { } else {
content = m.buildPageContent() content = m.buildPageContent(availableContentHeight)
} }
footer := m.BuildFooter()
isViewportPage := m.currentPage == shopPage || m.currentPage == cartPage || isViewportPage := m.currentPage == shopPage || m.currentPage == cartPage ||
m.currentPage == shippingPage || m.currentPage == paymentPage || m.currentPage == shippingPage || m.currentPage == paymentPage ||
m.currentPage == confirmPage || m.currentPage == accountPage m.currentPage == confirmPage || m.currentPage == accountPage
@@ -136,10 +137,10 @@ func (m Model) renderString() string {
) )
} }
func (m Model) buildPageContent() string { func (m Model) buildPageContent(height int) string {
switch m.currentPage { switch m.currentPage {
case accountPage: case accountPage:
return m.BuildAccountView() return m.BuildAccountView(height)
case shippingPage: case shippingPage:
return m.ShippingPageView() return m.ShippingPageView()
case paymentPage: case paymentPage:

View File

@@ -1,3 +0,0 @@
// Package utils is the catch-all drawer for tiny HTTP helpers that didn't
// earn a package of their own.
package utils

View File

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

View File

@@ -1,3 +0,0 @@
// Package validate gives user input a once-over before the rest of the app
// trusts it.
package validate