Compare commits

...

22 Commits

Author SHA1 Message Date
74685388f3 feat(tui): remake parts of the tui 2026-06-11 11:17:57 +02:00
c9f8b1e674 test(test): smoke test deploy script with dirty box tree (#44) 2026-06-09 19:39:09 +02:00
d592249efb fix: fail deploy loudly on stale pull or HEAD mismatch (#43) 2026-06-09 19:20:08 +02:00
cfc97eb1ff fix/temp disable sshCardAdd button (#42)
* fix: disable add-card-via-ssh, mark coming soo, skip in cursor nav

* style(tui): reword (coming soon) to (WIP)
2026-06-09 14:33:09 +02:00
c112797875 refactor(sca): thread context.Context through reconciler entry points (#41)
* refactor(sca): thread context.Context through reconciler entry points

* test(sca): add mid-loop cancel test for stale 3ds sweep

* refactor(api): rename shutdown ctx to shutdownCtx for clarity

* style(sca): fix typo in ReconcileUnshipped commend
2026-06-09 00:31:56 +02:00
75225548ae feat(sca): emit abandoned_3ds_sweep_total counter from stale 3ds sweep (#40)
* feat(sca): emit abandoned_3ds_sweep_total counter from stale 3ds sweep

* test: assert sweep counter labels in remaining branch tests
2026-06-08 23:11:44 +02:00
53e934be3b chore(cap): raise per-order spend cap default from 200 to 500 (#39) 2026-06-07 23:47:43 +02:00
fc02f331d2 feat(audit): emit cart_rejected event when per-order cap blocks checkout (#38)
* feat(audit): emit cart_rejected event when per-order cap blocks checkout

* refactor(audit): drop dead persist call + harden cart_rejected test capture
2026-06-07 23:25:30 +02:00
bb960c31f8 feat(sca): sweep abandoned 3ds requires_action orders to failed (#37)
* feat(sca): sweep abandoned 3ds requires_action orders to failed

* fix(sca): address pr review on abandoned 3ds sweep

- Drop stripe.Key write in ReconcileStale3DSOrders; wire once in api/main.go after cfg.Load so concurrent reconcilers don't
race the SDK global.
- Add empty-PI short-circuit before paymentintent.Get to bury a future regression in a slog.Warn instead of a Stripe 4xx.
- Swap fmt.Printf for slog.Warn with k/v attrs in loadAbandoned3DSThresholdMinutes so Loki sees structured fields instead of
 literal %q/%d.
- Comment the updated_at predicate (RetryAuth bumps it via GORM auto-stamp, intentional grace-window extension) + the
conditional-UPDATE/map-form choice.
- Tests: processing/requires_capture skip, unknown-status skip, stripe-error skip, empty-PI skip, full env-parse table for
loadAbandoned3DSThresholdMinutes.

* fix(stripe): wire key once at init drop per-call writes

* refactor(stripe): drop dead stripeKey fields and pre-call sdk params
2026-06-07 18:16:33 +02:00
8b753e4f4b feat(sca): add requires_action orde status for 3ds in flight sate (#36)
* feat(sca): add requires_action orde status for 3ds in flight sate

* fix(sca): fix stuff the review commented on
2026-06-06 21:06:25 +02:00
90645958e5 chore(repo): change all instances of interface{} to any as of newer go standards (#35) 2026-06-06 13:59:09 +02:00
135f1361e4 fix(products): update pricing on products (#34)
* fix(products): update pricing on products

* fix(test): pin coffee price in cart-limit fixture

TestConvertCartLimit's quantities and expected totals were keyed to
the original 500-cent latte price. After the product price bump in
1ce63fb, coffee_id=4 became 2500 cents, so under_cap/at_cap subtests
overshot the $200 cap and over_cap's expected total magnitude was
wrong by 5x. Override coffee_id=4's price to 500 in test setup so the
cap math stays stable regardless of seed changes.
2026-06-05 23:49:03 +02:00
64deba7e03 feat(sca): wire retry-auth keybind on authfailed review screen (#33) 2026-06-05 22:52:54 +02:00
732353d9a4 fix(tui): fix the menu always selecting the first entry in the refund page (#32) 2026-06-04 00:09:07 +02:00
3420c8dce0 feat(sca): add retry-auth endpoint for failed 3ds challenges (#31)
* feat(sca): add retry-auth endpoint for failed 3ds challenges

* fix(sca): retry-auth unexpected-state branch out of card-decline

just fixes up in some of the things the review of the last commit
noticed
2026-06-03 23:42:27 +02:00
f3b94c3271 chore(test): add gotestsum + tparse runner with ci wiring (#30)
* chore(test): add gotestsum + tparse runner with ci wiring

* chore(tests): change the tests around a little
2026-06-02 23:17:03 +02:00
67e19eca98 feat(sca): expire inactive saved cards (#29)
* feat(sca): expire inactive saved cards

* fix(sca): address card ttl review feedback

- Correct sql typos breaking GET /cards and /view/init (used_id, id_default)
- Stop swallowing backfillCardStorageExpiration error in Migrate
- Wrap expireStoredCard local writes in a transaction and run stripe
  detach + audit only after commit, preventing double-fire on retry
- Drop redundant ttl refresh in ConvertCart; webhook is authoritative
- Rename validation_expired_at metric label to validation_card_expired
- Fix CardStorageExpiresAt doc comment and test failure message typos
- Add handler-level tests for expireStoredCards (user scoping + cart deref)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(sca): move card expiration to periodic reconciler

Read paths (GetCards, GetViewInit) now filter expired cards from
query results instead of detaching them inline, so request handlers
no longer block on Stripe API calls. Physical deletion runs in a
new ReconcileExpiredCards goroutine on an hourly ticker, mirroring
the existing ReconcileOrders / ReconcileUnshipped jobs. Per-card
action sites (SetCard, SetDefaultCard, ConvertCart) still expire
synchronously since they operate on one row the user is acting on.

ViewHandler's stripeKey dependency is dropped now that GetViewInit
no longer touches Stripe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sca): address card ttl review feedback

- guard zero order.CardID before refreshing card storage ttl in
  payment_intent.succeeded webhook to avoid noisy warn logs on payment
  paths that did not use a saved card
- delete unused Card.RefreshStorageTTL model method; the SQL-only
  refreshCardStorageTTL helper is the single canonical path
- clarify duplicate-card webhook path: storage_expires_at reset is a
  card-add refresh, not a payment-success refresh, so last_used_at
  intentionally stays untouched

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(sca): cover card ttl read paths and expired-card sync sites

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:49:48 +02:00
728ac24c96 fix(sca): return over-limit checkout errors to cart (#28) 2026-06-02 10:28:56 +02:00
1096c85b10 chore(ops): add nightly backup verification scripts (#27) 2026-06-02 00:02:27 +02:00
2f65cf0d90 feat(sca): cap per-order total ro prevent oversize charges (#26) 2026-06-01 22:40:20 +02:00
c3e6eb2b84 fix(ci): pin deploy go-version to go.mod to match ci.yml (#25) 2026-05-30 23:29:19 +02:00
03009ea6df chore(ci): bump checkout and setup-go to node24-ready majors (#24) 2026-05-30 23:19:29 +02:00
72 changed files with 4528 additions and 533 deletions

View File

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

View File

@@ -8,10 +8,10 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '1.23'
go-version-file: go.mod
- name: Run tests
run: go test ./...
- name: Deploy to server
@@ -22,6 +22,15 @@ jobs:
key: ${{ secrets.SERVER_SSH_KEY }}
port: ${{ secrets.SERVER_PORT }}
script: |
set -euo pipefail
cd ~/terminalShop
git pull
git fetch origin master
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
echo "deploy verified: HEAD=$deployed"

5
.gitignore vendored
View File

@@ -33,6 +33,8 @@ api/api-server
*.out
coverage.txt
coverage.html
test-output.json
test-report.xml
# Dependency directories
vendor/
@@ -114,3 +116,6 @@ ROADMAP.md
*.bak
terminalshop.prod.*
# Backup verification config (real values; example file is committed)
scripts/backups/config.env

55
Makefile Normal file
View File

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

View File

@@ -71,3 +71,4 @@ uses SQLite by default so there's nothing to spin up.
## 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.
# something

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -0,0 +1,156 @@
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,8 +5,10 @@ import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentintent"
"github.com/stripe/stripe-go/v78/paymentmethod"
@@ -23,13 +25,20 @@ import (
)
type CartHandler struct {
stripeKey string
appURL string
appURL string
maxOrderCents int
}
// NewCartHandler creates a new cart handler with the Stripe secret key
func NewCartHandler(stripeSecretKey, appURL string) *CartHandler {
return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL}
// maxAuthIssuances caps how many times a single ordes's PaymentIntent can be
// pushed into requires_action; one initial challende plus up to three retries
// before we tell the customer to use a different card. Counted via existing
// audit rows (audit.EventOrderRequiresAction) so the cap stays migration-free.
const maxAuthIssuances = 4
// NewCartHandler creates a new cart handler. 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.
@@ -46,18 +55,18 @@ func getOrCreateCart(ctx context.Context, userID uint) (*models.Cart, error) {
return &cart, nil
}
func cartResponse(ctx context.Context, cart *models.Cart) map[string]interface{} {
func cartResponse(ctx context.Context, cart *models.Cart) map[string]any {
db := database.GetDB().WithContext(ctx)
var items []models.CartItem
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
subtotal := 0
itemsData := make([]map[string]interface{}, 0, len(items))
itemsData := make([]map[string]any, 0, len(items))
for _, item := range items {
lineTotal := item.Coffee.Price * item.Quantity
subtotal += lineTotal
itemsData = append(itemsData, map[string]interface{}{
itemsData = append(itemsData, map[string]any{
"id": item.ID,
"coffee_id": item.CoffeeID,
"quantity": item.Quantity,
@@ -66,7 +75,7 @@ func cartResponse(ctx context.Context, cart *models.Cart) map[string]interface{}
})
}
return map[string]interface{}{
return map[string]any{
"items": itemsData,
"subtotal": subtotal,
"address_id": cart.AddressID,
@@ -85,7 +94,7 @@ func (h *CartHandler) GetCart(w http.ResponseWriter, r *http.Request) {
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"cart": cartResponse(r.Context(), cart),
})
}
@@ -148,7 +157,7 @@ func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
}
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"cart": cartResponse(r.Context(), cart),
})
}
@@ -184,7 +193,7 @@ func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"message": "address set",
})
}
@@ -209,6 +218,15 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
return
}
if card.IsStorageExpired(time.Now()) {
if err := expireStoredCard(db, &card); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
cart, err := getOrCreateCart(r.Context(), userID)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
@@ -220,7 +238,7 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"message": "card set",
})
}
@@ -240,7 +258,7 @@ func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"message": "cart cleared",
})
}
@@ -295,6 +313,16 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
return
}
if card.IsStorageExpired(time.Now()) {
middleware.RecordCartConversion("validation_card_expired")
if err := expireStoredCard(db, &card); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
var user models.User
if err := db.First(&user, userID).Error; err != nil {
middleware.RecordCartConversion("user_not_found")
@@ -302,8 +330,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
return
}
stripe.Key = h.stripeKey
// Ensure the user has a Stripe customer (shared helper with cards handler).
if err := ensureStripeCustomer(db, &user); err != nil {
middleware.RecordCartConversion("stripe_customer_failed")
@@ -354,6 +380,18 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
total := subtotal + cart.ShippingCost
if h.maxOrderCents > 0 && total > h.maxOrderCents {
middleware.RecordCartConversion("validation_over_limit")
audit.CartRejected(userID, total, h.maxOrderCents)
utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT",
fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100),
map[string]any{
"total_cents": total,
"limit_cents": h.maxOrderCents,
})
return
}
if total < 50 {
middleware.RecordCartConversion("validation_min_amount")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil)
@@ -468,7 +506,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return err
}
return tx.Model(cart).Updates(map[string]interface{}{
return tx.Model(cart).Updates(map[string]any{
"address_id": nil,
"card_id": nil,
}).Error
@@ -495,14 +533,15 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
}
go notify.SlackOrderPaid(&order)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"order": order,
})
}
// respondRequiresAction handles a PaymentIntent that needs 3DS auth
// The order stays pending. the webhook flips it to paid once the customer
// completes the bank-hosted challenge, the tui polls /api/v1/orders/{id}/status
// respondRequiresAction handles a PaymentIntent that needs 3DS auth. The order
// transitions to requires_action (see caveat #20 in sca-psd2-compliance.md);
// 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.
func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, paymentMethodID string, pi *stripe.PaymentIntent) {
db := database.GetDB()
@@ -529,13 +568,15 @@ func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models
return
}
// Persist the paymentIntent id AND reset status to pending. The
// payment_intent.payment_failed webhook from the initial off-session decline
// often arrives before this response and flips the order to failed —
// override it back so the 3DS-in-flight order shows the correct state.
// Persist the paymentIntent id AND set status to requires_action so list
// UIs can distinguish "charging" from "awaiting customer 3DS". The
// payment_intent.payment_failed webhook from the initial off-session
// decline often arrives before this response and flips the order to
// failed - override it back so the 3DS-in-flight order shows the
// correct state
if err := db.Model(order).Updates(map[string]any{
"stripe_payment_id": pi.ID,
"status": models.OrderStatusPending,
"status": models.OrderStatusRequiresAction,
}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil)
return
@@ -591,3 +632,149 @@ func appURLForHandler(r *http.Request) string {
}
return fmt.Sprintf("%s://%s", scheme, host)
}
// RetryAuth re-Confirms an in-flight PaymentIntent that the customer failed
// or abandoned during the 3DS challenge, recovering the existing order rather
// than starting a new checkout. Bound to POST /api/v1/orders/{id}/retry-auth.
//
// Stripe's PI state machine bounces back to requires_payment_method after a
// failed or abandoned 3DS, so paymentintent.Confirm(pi.id, …) mints a fresh
// next_action.redirect_to_url — the same shape respondRequiresAction already
// handles. No idempotency key is set: Confirm mutates an existing PI and
// Stripe collapses by id (see sca-psd2-compliance.md caveat #5).
func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id")
id64, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
return
}
orderID := uint(id64)
var order models.Order
if err := db.Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
return
}
// 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,14 +3,19 @@ package handlers
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
@@ -65,7 +70,7 @@ func TestGetCartEmpty(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("", 0)
req := authRequest("GET", "/api/v1/cart", nil, user.ID)
w := httptest.NewRecorder()
@@ -80,8 +85,8 @@ func TestGetCartEmpty(t *testing.T) {
Success bool `json:"success"`
Data struct {
Cart struct {
Items []interface{} `json:"items"`
Subtotal int `json:"subtotal"`
Items []any `json:"items"`
Subtotal int `json:"subtotal"`
} `json:"cart"`
} `json:"data"`
}
@@ -104,10 +109,10 @@ func TestSetItemAndGetCart(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("", 0)
// Add item (CoffeeID=1, Quantity=2)
body, _ := json.Marshal(map[string]interface{}{
body, _ := json.Marshal(map[string]any{
"coffee_id": 1,
"quantity": 2,
})
@@ -160,16 +165,16 @@ func TestSetItemUpdateQuantity(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("", 0)
// Add item
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2})
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 2})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
// Update quantity
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5})
body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5})
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w = httptest.NewRecorder()
handler.SetItem(w, req)
@@ -205,16 +210,16 @@ func TestSetItemRemove(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("", 0)
// Add item
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3})
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 3})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
// Remove item (quantity 0)
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 0})
body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 0})
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w = httptest.NewRecorder()
handler.SetItem(w, req)
@@ -228,7 +233,7 @@ func TestSetItemRemove(t *testing.T) {
Success bool `json:"success"`
Data struct {
Cart struct {
Items []interface{} `json:"items"`
Items []any `json:"items"`
} `json:"cart"`
} `json:"data"`
}
@@ -244,9 +249,9 @@ func TestSetItemInvalidProduct(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("", 0)
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1})
body, _ := json.Marshal(map[string]any{"coffee_id": 999, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
@@ -261,9 +266,9 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("", 0)
body, _ := json.Marshal(map[string]interface{}{"quantity": 1})
body, _ := json.Marshal(map[string]any{"quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
@@ -278,11 +283,11 @@ func TestClearCart(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("", 0)
// Add two items
for _, coffeeID := range []uint{1, 2} {
body, _ := json.Marshal(map[string]interface{}{"coffee_id": coffeeID, "quantity": 1})
body, _ := json.Marshal(map[string]any{"coffee_id": coffeeID, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
@@ -306,7 +311,7 @@ func TestClearCart(t *testing.T) {
Success bool `json:"success"`
Data struct {
Cart struct {
Items []interface{} `json:"items"`
Items []any `json:"items"`
} `json:"cart"`
} `json:"data"`
}
@@ -334,9 +339,9 @@ func TestSetAddress(t *testing.T) {
}
db.Create(&addr)
handler := NewCartHandler("", "")
handler := NewCartHandler("", 0)
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
body, _ := json.Marshal(map[string]any{"address_id": addr.ID})
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w := httptest.NewRecorder()
handler.SetAddress(w, req)
@@ -388,9 +393,9 @@ func TestSetAddressWrongUser(t *testing.T) {
}
db.Create(&addr)
handler := NewCartHandler("", "")
handler := NewCartHandler("", 0)
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
body, _ := json.Marshal(map[string]any{"address_id": addr.ID})
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w := httptest.NewRecorder()
handler.SetAddress(w, req)
@@ -416,9 +421,9 @@ func TestSetCard(t *testing.T) {
}
db.Create(&card)
handler := NewCartHandler("", "")
handler := NewCartHandler("", 0)
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w := httptest.NewRecorder()
handler.SetCard(w, req)
@@ -468,9 +473,9 @@ func TestSetCardWrongUser(t *testing.T) {
}
db.Create(&card)
handler := NewCartHandler("", "")
handler := NewCartHandler("", 0)
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w := httptest.NewRecorder()
handler.SetCard(w, req)
@@ -485,10 +490,10 @@ func TestConvertCartMissingAddress(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("", 0)
// Add item but no address or card
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
@@ -508,18 +513,18 @@ func TestConvertCartMissingCard(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("", 0)
db := database.GetDB()
// Add item and address
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
addr := models.Address{UserID: user.ID, Name: "Test", Street: "123 St", City: "PDX", State: "OR", Zip: "97201", Country: "US"}
db.Create(&addr)
body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID})
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w = httptest.NewRecorder()
handler.SetAddress(w, req)
@@ -539,7 +544,7 @@ func TestConvertCartEmpty(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("", 0)
// Empty cart convert
req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
@@ -556,11 +561,11 @@ func TestMultipleItems(t *testing.T) {
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("", "")
handler := NewCartHandler("", 0)
// Add three different items
for i := uint(1); i <= 3; i++ {
body, _ := json.Marshal(map[string]interface{}{"coffee_id": i, "quantity": int(i)})
body, _ := json.Marshal(map[string]any{"coffee_id": i, "quantity": int(i)})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
@@ -578,8 +583,8 @@ func TestMultipleItems(t *testing.T) {
Success bool `json:"success"`
Data struct {
Cart struct {
Items []interface{} `json:"items"`
Subtotal int `json:"subtotal"`
Items []any `json:"items"`
Subtotal int `json:"subtotal"`
} `json:"cart"`
} `json:"data"`
}
@@ -605,19 +610,19 @@ func TestCartIsolation(t *testing.T) {
}
db.Create(&user2)
handler := NewCartHandler("", "")
handler := NewCartHandler("", 0)
r := chi.NewRouter()
r.Put("/cart/item", handler.SetItem)
r.Get("/cart", handler.GetCart)
// User 1 adds item
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5})
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5})
req := authRequest("PUT", "/cart/item", body, user1.ID)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// User 2 adds different item
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 2, "quantity": 3})
body, _ = json.Marshal(map[string]any{"coffee_id": 2, "quantity": 3})
req = authRequest("PUT", "/cart/item", body, user2.ID)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -664,3 +669,533 @@ func TestCartIsolation(t *testing.T) {
t.Errorf("User 2 expected coffee_id 2, got %d", resp.Data.Cart.Items[0].CoffeeID)
}
}
func TestConvertCartLimit(t *testing.T) {
cases := []struct {
name string
capCents int
quantity int
expectOverCap bool
}{
{"over cap rejects", 20000, 41, true}, // $205
{"at cap accepts", 20000, 40, false}, // $200 boundary
{"under cap accepts", 20000, 39, false}, // $195
{"zero cap disables", 0, 1000, false}, // explicit off-switch
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
user.StripeCustomerID = "cus_test_overlimit"
if err := db.Save(&user).Error; err != nil {
t.Fatalf("saved user: %v", err)
}
addr := models.Address{
UserID: user.ID, Name: "Test", Street: "1 Main St",
City: "PDX", State: "OR", Zip: "97201", Country: "US",
}
if err := db.Create(&addr).Error; err != nil {
t.Fatalf("seed address: %v", err)
}
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_test_overlimit",
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
// Pin coffee_id=4's price so cart-cap math stays stable across
// seed-price changes. The cap test is about (price * quantity)
// vs cap, not which coffee or what the menu currently charges.
const pinnedPrice = 500
if err := db.Model(&models.Coffee{}).Where("id = ?", 4).Update("price", pinnedPrice).Error; err != nil {
t.Fatalf("pin coffee price: %v", err)
}
handler := NewCartHandler("", tc.capCents)
body, _ := json.Marshal(map[string]any{"coffee_id": 4, "quantity": tc.quantity})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
w := httptest.NewRecorder()
handler.SetItem(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SetItem: %d %s", w.Code, w.Body.String())
}
// set address
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
w = httptest.NewRecorder()
handler.SetAddress(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SetAddress: %d %s", w.Code, w.Body.String())
}
// set card
body, _ = json.Marshal(map[string]any{"card_id": card.ID})
req = authRequest("PUT", "/api/v1/cart/card", body, user.ID)
w = httptest.NewRecorder()
handler.SetCard(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SetCard: %d %s", w.Code, w.Body.String())
}
// 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
req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
w = httptest.NewRecorder()
handler.ConvertCart(w, req)
var resp struct {
Success bool `json:"success"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]any `json:"details"`
} `json:"error"`
}
_ = json.NewDecoder(w.Body).Decode(&resp)
isOverCap := w.Code == http.StatusBadRequest && resp.Error.Code == "CART_OVER_LIMIT"
if isOverCap != tc.expectOverCap {
t.Fatalf("expectOverCap=%v got code=%d errCode=%q msg=%q", tc.expectOverCap, w.Code, resp.Error.Code, resp.Error.Message)
}
if tc.expectOverCap {
if got, _ := resp.Error.Details["limit_cents"].(float64); int(got) != tc.capCents {
t.Errorf("limit_cents in details: want %d, got %v", tc.capCents, resp.Error.Details["limit_cents"])
}
if got, _ := resp.Error.Details["total_cents"].(float64); int(got) != tc.quantity*pinnedPrice {
t.Errorf("total_cents in details: want %d, got %v", tc.quantity*pinnedPrice, resp.Error.Details["total_cents"])
}
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

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

View File

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

View File

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

View File

@@ -1,10 +1,13 @@
package handlers
import (
"context"
"fmt"
"log/slog"
"time"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/notify"
@@ -14,15 +17,19 @@ import (
"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") }
// ReconcileOrders finds pending orders with no Stripe ID older than 10 minutes
// 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
// completed.
func ReconcileOrders(stripeKey string) {
stripe.Key = stripeKey
db := database.GetDB()
func ReconcileOrders(ctx context.Context) {
db := database.GetDB().WithContext(ctx)
var orders []models.Order
cutoff := time.Now().Add(-10 * time.Minute)
@@ -36,9 +43,14 @@ func ReconcileOrders(stripeKey string) {
reconcileLog().Info("checking pending orders", "count", len(orders))
for _, order := range orders {
if err := ctx.Err(); err != nil {
reconcileLog().Info("reconcile orders cancelled mid-loop", "error", err)
return
}
params := &stripe.PaymentIntentSearchParams{
SearchParams: stripe.SearchParams{
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
Context: ctx,
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
},
}
@@ -49,7 +61,7 @@ func ReconcileOrders(stripeKey string) {
"order_id", order.ID, "pi", pi.ID)
txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]interface{}{
if err := tx.Model(&order).Updates(map[string]any{
"status": models.OrderStatusPaid,
"stripe_payment_id": pi.ID,
}).Error; err != nil {
@@ -62,7 +74,7 @@ func ReconcileOrders(stripeKey string) {
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return err
}
return tx.Model(&cart).Updates(map[string]interface{}{
return tx.Model(&cart).Updates(map[string]any{
"address_id": nil,
"card_id": nil,
}).Error
@@ -77,10 +89,146 @@ func ReconcileOrders(stripeKey string) {
}
}
// ReconcileUnshipped finds paid order orlder than 24h with no tracking
// number and posts single Slack reminder. Pure read + notif
func ReconcileUnshipped() {
db := database.GetDB()
// ReconcileStale3DSOrders flips requires_action orders that have been
// awaiting the customer's 3DS challenge longer than threshold to failed,
// after re-checking the PaymentIntent with Stripe so a late
// payment_intent.succeeded webhook is respected. Deliberately separate
// 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
cutoff := time.Now().Add(-24 * time.Hour)

View File

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

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

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

View File

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

View File

@@ -0,0 +1,224 @@
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,6 +19,8 @@ import (
"terminalShop/pkg/database"
"terminalShop/pkg/observability"
"terminalShop/pkg/stripeclient"
"github.com/stripe/stripe-go/v78"
)
const version = "v0.1.0"
@@ -49,6 +51,12 @@ func main() {
}
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
if err := database.Seed(db); err != nil {
log.Fatalf("Failed to seed database: %v", err)
@@ -71,7 +79,7 @@ func main() {
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
// Setup routes
router := routes.SetupRoutes(version, cfg.StripeSecretKey, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL, cfg.SlackSigningSecret, cfg.AdminAPIKey, cfg.APIPort)
router := routes.SetupRoutes(version, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL, cfg.SlackSigningSecret, cfg.AdminAPIKey, cfg.APIPort, cfg.MaxOrderCents)
// Create HTTP server
server := &http.Server{
@@ -106,7 +114,7 @@ func main() {
for {
select {
case <-ticker.C:
handlers.ReconcileOrders(cfg.StripeSecretKey)
handlers.ReconcileOrders(ctx)
case <-ctx.Done():
return
}
@@ -114,14 +122,48 @@ func main() {
}()
go func() {
time.Sleep(30 * time.Second)
handlers.ReconcileUnshipped()
select {
case <-time.After(30 * time.Second):
case <-ctx.Done():
return
}
handlers.ReconcileUnshipped(ctx)
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
handlers.ReconcileUnshipped()
handlers.ReconcileUnshipped(ctx)
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():
return
}
@@ -136,10 +178,10 @@ func main() {
log.Println("Server is shutting down...")
// Graceful shutdown with 30 second timeout
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
if err := server.Shutdown(ctx); err != nil {
if err := server.Shutdown(shutdownCtx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}

View File

@@ -70,6 +70,14 @@ var (
[]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(
prometheus.HistogramOpts{
Name: "order_value_dollars",
@@ -173,6 +181,19 @@ func ObserveOrderValueCents(cents int) {
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.
func ObserveStripeRequest(op, outcome string, durSec float64) {
stripeRequestDuration.WithLabelValues(op, outcome).Observe(durSec)

View File

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

18
go.mod
View File

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

27
go.sum
View File

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

15
main.go
View File

@@ -8,7 +8,6 @@ import (
"os"
"os/signal"
"syscall"
"time"
tea "charm.land/bubbletea/v2"
@@ -16,6 +15,7 @@ import (
"charm.land/wish/v2/bubbletea"
"charm.land/wish/v2/logging"
"github.com/charmbracelet/ssh"
"github.com/stripe/stripe-go/v78"
"terminalShop/pkg/auth"
"terminalShop/pkg/config"
@@ -32,7 +32,6 @@ func listenHost() string {
var (
apiURL string
authFingerprintKey string
stripePublicKey string
)
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
@@ -44,7 +43,7 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
pubKeyStr = auth.FormatSSHPublicKey(pubKey)
}
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey)
return m, nil
}
@@ -58,7 +57,15 @@ func main() {
// Set API URL and auth key from config for TUI connections
apiURL = cfg.APIURL
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)
// Create SSH server

View File

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

View File

@@ -13,16 +13,36 @@ import (
"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
const (
eventCardAdded eventType = "card_added"
eventCardDeleted eventType = "card_deleted"
eventCardExpired eventType = "card_expired"
eventOrderCreated eventType = "order_created"
eventOrderPaid eventType = "order_paid"
eventOrderShipped eventType = "order_shipped"
eventOrderFailed eventType = "order_failed"
eventOrderRequiresAction eventType = "order_requires_action"
eventOrderRequiresAction eventType = EventOrderRequiresAction
eventOrder3DSAbandoned eventType = EventOrder3DSAbandoned
eventCartRejected eventType = EventCartRejected
eventOrderRefunded eventType = "order_refunded"
eventPaymentCritical eventType = "payment_critical"
eventLabelPurchased eventType = "label_purchased"
@@ -33,19 +53,21 @@ const (
)
type event struct {
Event eventType
UserID uint
OrderID uint
CardID uint
Amount int
StripeID string
CardLast4 string
CardBrand string
Carrier string
Tracking string
Status string
Error string
Actor string
Event eventType
UserID uint
OrderID uint
CardID uint
Amount int
StripeID string
CardLast4 string
CardBrand string
Carrier string
Tracking string
Status string
Error string
Actor string
TotalCents int
CapCents int
}
func (e event) attrs() []any {
@@ -86,6 +108,12 @@ func (e event) attrs() []any {
if 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
}
@@ -113,6 +141,17 @@ func CardDeleted(userID, cardID uint) {
persist(0, eventCardDeleted, e)
}
// CardExpired records that a saved payment method expired from inactivity
func CardExpired(userID, cardID uint) {
e := event{
Event: eventCardExpired,
UserID: userID,
CardID: cardID,
}
slog.Info("audit", e.attrs()...)
persist(0, eventCardExpired, e)
}
// OrderCreated records that an order record was created (before payment).
func OrderCreated(userID, orderID uint, amountCents int) {
e := event{
@@ -301,7 +340,6 @@ func attrsToMap(a []any) map[string]any {
return m
}
//nolint:unused // called from ConvertCart in the next update
func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
e := event{
Event: eventOrderRequiresAction,
@@ -312,3 +350,33 @@ func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
slog.Info("audit", e.attrs()...)
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()...)
}

41
pkg/audit/audit_test.go Normal file
View File

@@ -0,0 +1,41 @@
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

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

View File

@@ -3,8 +3,10 @@ package config
import (
"errors"
"fmt"
"log/slog"
"net/url"
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
@@ -12,24 +14,26 @@ import (
// Config holds all application configuration
type Config struct {
APIPort string
APIURL string
AppURL string
SSHPort string
Environment string
DatabaseURL string
JWTSecret string
StripeSecretKey string
StripePublicKey string
StripeWebhookSecret string
ShippoAPIKey string
AuthFingerprintKey string
BringAPIUID string
BringAPIKey string
BringCustomerNumber string
ShippoWebhookSecret string
SlackSigningSecret string
AdminAPIKey string
APIPort string
APIURL string
AppURL string
SSHPort string
Environment string
DatabaseURL string
JWTSecret string
StripeSecretKey string
StripePublicKey string
StripeWebhookSecret string
ShippoAPIKey string
AuthFingerprintKey string
BringAPIUID string
BringAPIKey string
BringCustomerNumber string
ShippoWebhookSecret string
SlackSigningSecret string
AdminAPIKey string
MaxOrderCents int
Abandoned3DSThresholdMinutes int
}
// Load reads configuration from environment variables and validates required secrets.
@@ -48,24 +52,26 @@ func Load() (*Config, error) {
apiPort := getEnvOrDefault("API_PORT", "8080")
apiURL := getEnvOrDefault("API_URL", fmt.Sprintf("http://localhost:%s", apiPort))
cfg := &Config{
APIPort: apiPort,
APIURL: apiURL,
AppURL: getEnvOrDefault("APP_URL", apiURL),
SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
JWTSecret: os.Getenv("JWT_SECRET"),
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
BringAPIUID: os.Getenv("BRING_API_UID"),
BringAPIKey: os.Getenv("BRING_API_KEY"),
BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"),
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
APIPort: apiPort,
APIURL: apiURL,
AppURL: getEnvOrDefault("APP_URL", apiURL),
SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
JWTSecret: os.Getenv("JWT_SECRET"),
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
BringAPIUID: os.Getenv("BRING_API_UID"),
BringAPIKey: os.Getenv("BRING_API_KEY"),
BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"),
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
MaxOrderCents: loadMaxOrderCents(),
Abandoned3DSThresholdMinutes: loadAbandoned3DSThresholdMinutes(),
}
if err := cfg.validate(); err != nil {
@@ -130,3 +136,45 @@ func getEnvOrDefault(key, defaultValue string) string {
}
return defaultValue
}
// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $500 — aligns with the
// PSD2 TRA exemption ceiling (above this, Stripe always requests SCA, so the
// cap is only meaningful below it). Explicit 0 disables the cap. Garbage or
// negative values fall back to the default with a warning so an operator
// typo cannot silently disable a fraud control.
func loadMaxOrderCents() int {
const defaultCap = 50000
raw := os.Getenv("MAX_ORDER_CENTS")
if raw == "" {
return defaultCap
}
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
fmt.Printf("WARNING: MAX_ORDER_CENTS=%q invalid, falling back to default %d\n", raw, defaultCap)
return defaultCap
}
return n
}
// 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
}

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

@@ -0,0 +1,78 @@
package config
import (
"io"
"log/slog"
"os"
"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) {
cases := []struct {
name string
env string // "" means unset
want int
}{
{"empty falls back to default", "", 50000},
{"explicit zero disables", "0", 0},
{"valid override", "30000", 30000},
{"negative falls back to default", "-1", 50000},
{"garbage falls back to default", "two hundred", 50000},
{"empty after trim falls back to default", "", 50000},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.env == "" {
t.Setenv("MAX_ORDER_CENTS", "")
} else {
t.Setenv("MAX_ORDER_CENTS", tc.env)
}
got := loadMaxOrderCents()
if got != tc.want {
t.Errorf("loadMaxOrderCents() = %d, want %d", got, tc.want)
}
})
}
}
// 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

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

View File

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

View File

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

View File

@@ -6,21 +6,25 @@ import (
"gorm.io/gorm"
)
const cardStorageTTL = 180 * 24 * time.Hour
// Card represents a saved payment method linked to a Stripe token.
// The actual card details live on Stripe's servers — we only store
// a reference and display info (last 4 digits, brand).
type Card struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
StripePaymentID string `gorm:"size:255;not null" json:"-"` // Stripe payment method or token ID
Last4 string `gorm:"size:4;not null" json:"last4"` // Last 4 digits for display
Brand string `gorm:"size:50" json:"brand"` // Visa, Mastercard, etc.
ExpMonth int `gorm:"not null" json:"exp_month"`
ExpYear int `gorm:"not null" json:"exp_year"`
IsDefault bool `gorm:"default:false" json:"is_default"` // User's preferred card
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
StripePaymentID string `gorm:"size:255;not null" json:"-"` // Stripe payment method or token ID
Last4 string `gorm:"size:4;not null" json:"last4"` // Last 4 digits for display
Brand string `gorm:"size:50" json:"brand"` // Visa, Mastercard, etc.
ExpMonth int `gorm:"not null" json:"exp_month"`
ExpYear int `gorm:"not null" json:"exp_year"`
IsDefault bool `gorm:"default:false" json:"is_default"` // User's preferred card
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
StorageExpiresAt *time.Time `gorm:"index" json:"storage_expires_at,omitempty"`
// Associations
User User `gorm:"foreignKey:UserID" json:"-"`
@@ -30,3 +34,20 @@ type Card struct {
func (Card) TableName() string {
return "cards"
}
// CardStorageExpiresAt returns the inactivity expiration for a saved card
func CardStorageExpiresAt(now time.Time) *time.Time {
expiresAt := now.Add(cardStorageTTL)
return &expiresAt
}
// InitializeStorageTTL sets the initial retention deadline without marking
// the card as used for payment
func (c *Card) InitializeStorageTTL(now time.Time) {
c.StorageExpiresAt = CardStorageExpiresAt(now)
}
// IsStorageExpired reports whether the saved card retention deadline elapsed
func (c *Card) IsStorageExpired(now time.Time) bool {
return c.StorageExpiresAt != nil && !c.StorageExpiresAt.After(now)
}

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

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

View File

@@ -10,13 +10,14 @@ import (
type OrderStatus string
const (
OrderStatusPending OrderStatus = "pending"
OrderStatusPaid OrderStatus = "paid"
OrderStatusShipped OrderStatus = "shipped"
OrderStatusDelivered OrderStatus = "delivered"
OrderStatusCancelled OrderStatus = "cancelled"
OrderStatusRefunded OrderStatus = "refunded"
OrderStatusFailed OrderStatus = "failed"
OrderStatusPending OrderStatus = "pending"
OrderStatusRequiresAction OrderStatus = "requires_action"
OrderStatusPaid OrderStatus = "paid"
OrderStatusShipped OrderStatus = "shipped"
OrderStatusDelivered OrderStatus = "delivered"
OrderStatusCancelled OrderStatus = "cancelled"
OrderStatusRefunded OrderStatus = "refunded"
OrderStatusFailed OrderStatus = "failed"
)
type TrackingStatus string

View File

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

View File

@@ -14,6 +14,7 @@ func TestOrderDisplayState(t *testing.T) {
kind DisplayKind
}{
{"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 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},

View File

@@ -8,36 +8,58 @@ import (
"terminalShop/pkg/models"
)
// updateAccountViewport initializes or resizes the detail viewport for the account page.
// Follows the reference pattern: each page owns its own viewport(s).
func (m Model) updateAccountViewport() Model {
// Match the reference: only subtract header + footer, no extra margins
// (account is a viewport page — views.go renders it without margin padding)
// accountBodyHeight returns the inner content height of the account body
// panels, mirroring the boxed shop layout: header + footer + 2 blank gap
// rows + 2 panel border rows are reserved.
func (m Model) accountBodyHeight() int {
headerHeight := lipgloss.Height(m.BuildHeader())
footerHeight := lipgloss.Height(m.BuildFooter())
verticalMarginHeight := headerHeight + footerHeight
availableHeight := m.heightContainer - verticalMarginHeight
leftWidth := 18
rightWidth := m.widthContent - leftWidth - 2
detailWidth := rightWidth
if m.size < large {
detailWidth = m.widthContent
// In stacked mode, reduce available height by the left panel
leftPanelHeight := len(models.AccountMenuItems) + 1
availableHeight -= leftPanelHeight
h := m.heightContainer - headerHeight - footerHeight - 4
if h < 3 {
h = 3
}
if availableHeight < 3 {
availableHeight = 3
return h
}
// accountDetailWidth returns the width of the unboxed detail area: the chrome
// 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
}
// accountTitleStyle styles a section title for the detail area: centered
// across the full detail width so it sits over the content below it.
func accountTitleStyle(width int) lipgloss.Style {
return pWht.Bold(true).Width(width).Align(lipgloss.Center)
}
// updateAccountViewport initializes or resizes the detail viewport for the account page.
// The viewport spans the unboxed detail area to the right of the menu panel.
func (m Model) updateAccountViewport() Model {
detailW := m.accountDetailWidth()
// +1: no panel on the detail side, but one row is spent on the leading
// newline that aligns the content with the menu panel's first item row.
availableHeight := m.accountBodyHeight() + 1
if m.size < large {
// Stacked: the menu panel sits above the detail area
menuPanelHeight := len(models.AccountMenuItems) + 2
availableHeight -= menuPanelHeight
if availableHeight < 3 {
availableHeight = 3
}
}
if !m.account.viewportReady {
m.account.detailViewport = viewport.New(viewport.WithWidth(detailWidth), viewport.WithHeight(availableHeight))
m.account.detailViewport = viewport.New(viewport.WithWidth(detailW), viewport.WithHeight(availableHeight))
m.account.detailViewport.KeyMap = modifiedKeyMap
m.account.viewportReady = true
} else {
m.account.detailViewport.SetWidth(detailWidth)
m.account.detailViewport.SetWidth(detailW)
m.account.detailViewport.SetHeight(availableHeight)
}
@@ -46,12 +68,7 @@ func (m Model) updateAccountViewport() Model {
// getAccountDetailContent generates the content string for the current detail panel.
func (m Model) getAccountDetailContent() string {
leftWidth := 18
rightWidth := m.widthContent - leftWidth - 2
detailContentWidth := rightWidth - 2
if m.size < large {
detailContentWidth = m.widthContent - 2
}
detailContentWidth := m.accountDetailWidth()
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
return ""
@@ -68,7 +85,7 @@ func (m Model) getAccountDetailContent() string {
case "cards":
return m.CardsView(detailContentWidth)
case "faq":
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
titleStyle := accountTitleStyle(detailContentWidth).MarginBottom(2)
questionStyle := m.theme.TextHighlight().Bold(true)
contentStyle := m.theme.TextBody().Width(detailContentWidth)
faqContent := ""
@@ -143,23 +160,22 @@ func (m Model) scrollToAccountDetailItem() Model {
return m
}
func (m Model) BuildAccountView(availableHeight int) string {
leftWidth := 18
func (m Model) BuildAccountView() string {
_, menuW, _, _ := m.shopLayout()
// Build the left panel (menu items)
leftPanel := ""
// Left panel: menu items, pill highlight like the shop chrome
var menu []string
for i, item := range models.AccountMenuItems {
if m.account.cursor == i {
style := m.theme.Base().Background(m.theme.Highlight()).Foreground(m.theme.Accent()).Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
leftPanel += style.Render(item) + "\n"
menu = append(menu, lipgloss.NewStyle().Width(menuW).
Background(cPill).Foreground(cWhite).Bold(true).
Render(" "+item))
} else {
style := m.theme.TextBody().Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
leftPanel += style.Render(item) + "\n"
menu = append(menu, lipgloss.NewStyle().Width(menuW).
Render(" "+pBody.Render(item)))
}
}
leftContainer := m.theme.Base().
Width(leftWidth)
menuContent := lipgloss.JoinVertical(lipgloss.Left, menu...)
// Generate detail content and set it on the viewport
detailContent := m.getAccountDetailContent()
@@ -168,25 +184,33 @@ func (m Model) BuildAccountView(availableHeight int) string {
// Viewport handles scrolling/clipping — no manual line-slicing needed
detailRendered := m.account.detailViewport.View()
// Responsive layout: side-by-side on large, stacked on small/medium
// Responsive layout: menu panel beside the unboxed detail area on large,
// stacked on small/medium. The detail side draws its own item boxes.
var body string
if m.size < large {
return lipgloss.JoinVertical(
body = lipgloss.JoinVertical(
lipgloss.Left,
leftContainer.Render(leftPanel),
panel(menuW, 0, menuContent),
detailRendered,
)
} else {
bodyH := m.accountBodyHeight()
// Leading newline drops the detail content one row so its title
// lines up with the menu panel's first item, not its top border.
body = lipgloss.JoinHorizontal(
lipgloss.Top,
panel(menuW, bodyH, menuContent),
" ",
"\n"+detailRendered,
)
}
return lipgloss.JoinHorizontal(
lipgloss.Top,
leftContainer.Render(leftPanel),
" ",
detailRendered,
)
// Blank rows separate the body from the header and footer boxes.
return "\n" + body + "\n"
}
func (m Model) AboutView(width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
titleStyle := accountTitleStyle(width).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
// USE this if wanting to add a corp name later
// accentStyle := m.theme.TextHighlight().Bold(true)

View File

@@ -1,7 +1,7 @@
package tui
func (m Model) AddressesView(width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
titleStyle := accountTitleStyle(width).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
lines := titleStyle.Render("Address") + "\n\n"

View File

@@ -5,7 +5,7 @@ import (
)
func (m Model) CardsView(width int) string {
titleSTyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
titleSTyle := accountTitleStyle(width).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
lines := titleSTyle.Render("Cards") + "\n\n"

View File

@@ -66,7 +66,7 @@ func (m Model) currentOrderList() []models.Order {
}
func (m Model) orderListView(title string, orders []models.Order, width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(1)
titleStyle := accountTitleStyle(width).MarginBottom(1)
contentStyle := m.theme.TextBody().Width(width)
if !m.OrdersLoaded {

View File

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

View File

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

View File

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

View File

@@ -6,20 +6,26 @@ import (
"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 {
keybindStyle := m.theme.TextDim().Bold(true)
descStyle := m.theme.TextDim()
_, _, _, wideW := m.shopLayout()
item := func(k, v string) string {
return pBody.Render(k) + " " + pDim.Render(v)
}
var keys string
if m.size == small {
footerText := keybindStyle.Render("m") + " " + descStyle.Render("menu")
return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText)
keys = item("m", "menu")
} else {
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(" · "))
}
parts := []string{}
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)
row := lipgloss.NewStyle().Width(wideW).Align(lipgloss.Center).Render(keys)
return panel(wideW, 0, row)
}

View File

@@ -2,57 +2,72 @@ package tui
import (
"fmt"
"image/color"
"strings"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
)
func (m Model) BuildHeader() string {
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(" ")
mark := bold("t") + cursor
logo := bold("terminal")
menu := bold("m") + base(" ☰")
tab := func(key, name string, active bool) string {
if active {
return accent(key + " " + name)
// 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
}
return accent(key) + base(" "+name)
rSum += r * item.Quantity
gSum += g * item.Quantity
bSum += b * item.Quantity
n += item.Quantity
}
shop := tab("s", "shop", m.currentPage == shopPage)
account := tab("a", "account", m.currentPage == accountPage)
priceStr := fmt.Sprintf(" $%2v", m.CalculateSubtotal()/100)
countStr := fmt.Sprintf(" [%d]", m.CartItemCount())
cartLabel := base(" cart")
if m.inCartFlow() {
cartLabel = accent(" cart")
if n == 0 {
return cWhite
}
cart := accent("c") + cartLabel + accent(priceStr) + base(countStr)
var tabs []string
switch m.size {
case small:
tabs = []string{mark, cart}
case medium:
tabs = []string{menu, logo, cart}
default:
tabs = []string{logo, shop, account, cart}
}
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,
)
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 {
_, _, _, wideW := m.shopLayout()
brand := pWht.Bold(true).Render("terminal")
pill := lipgloss.NewStyle().Background(cPill).Foreground(cWhite).Bold(true).Padding(0, 1)
tab := func(key, label string, active bool) string {
if active {
return pill.Render(label)
}
return pDim.Render(key+" ") + pBody.Render(label)
}
var left string
if m.size == small {
left = brand
} 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() {
cartLabel = pWht.Bold(true).Render("cart ")
}
cart := cartLabel +
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)
if gap < 1 {
gap = 1
}
row := left + strings.Repeat(" ", gap) + cart
return panel(wideW, 0, row)
}

View File

@@ -75,7 +75,6 @@ type Model struct {
Fingerprint string
AccessToken string
AuthFingerprintKey string // shared secret for /auth/token refresh
StripePublicKey string
// One-shop auth command - capture pubKeyStr in closure, not stored on Model
authCmd tea.Cmd
@@ -143,10 +142,7 @@ type Model struct {
}
type shopState struct {
selected int
menuViewport viewport.Model
detailViewport viewport.Model
viewportsReady bool
selected int
}
type splashState struct {
@@ -211,6 +207,8 @@ type reviewState struct {
redirectURL string
orderID uint
authFailed bool
retrying bool
retryExhausted bool
}
type confirmState struct {
@@ -239,7 +237,6 @@ func (m Model) SwitchPage(p page) Model {
func (m Model) ShopSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(shopPage)
m = m.updateShopViewports()
m.footer = []footerCommand{
{key: "j/k", value: "products"},
{key: "+/-", value: "qty"},
@@ -442,6 +439,18 @@ type OrderStatusMsg struct {
Err error
}
// RetryAuthResultMsg carries the outcome of POST /orders/{id}/retry-auth.
// Status "requires_action" -> RedirectURL is a fresh challenge URL; resume
// the QR + poll loop. Status "succeeded" -> frictionless retry approved;
// server's APIError string ("RETRY_LIMIT" / "CARD_NO_LONGER_AVAILABLE" /
// transient) - the Update branch matches on substring.
type RetryAuthResultMsg struct {
OrderID uint
Status string
RedirectURL string
Err error
}
// CartSyncedMsg is recieved when the server responds to a cart item update
type CartSyncedMsg struct {
UpdateID int64
@@ -682,6 +691,23 @@ func (m Model) schedulePollOrderStatus(orderID uint) tea.Cmd {
})
}
func (m Model) retryAuthCmd(orderID uint) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil {
return RetryAuthResultMsg{OrderID: orderID, Err: fmt.Errorf("api client unavailable")}
}
outcome, err := m.APIClient.RetryAuth(orderID)
if err != nil {
return RetryAuthResultMsg{OrderID: orderID, Err: err}
}
return RetryAuthResultMsg{
OrderID: orderID,
Status: outcome.Status,
RedirectURL: outcome.RedirectURL,
}
}
}
func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, tea.Cmd) {
if m.APIClient == nil || m.User == nil {
return m, nil
@@ -705,7 +731,6 @@ func (m Model) saveCardOnlyCmd(form PaymentFormCompleteMsg) tea.Cmd {
if m.APIClient == nil {
return CardSavedForReviewMsg{Err: fmt.Errorf("API client not available")}
}
stripe.Key = m.StripePublicKey
tok, err := stripetoken.New(&stripe.TokenParams{
Card: &stripe.CardParams{
Name: stripe.String(form.CardName),
@@ -874,11 +899,12 @@ func NewModel(username string) Model {
}
// NewModelWithAuth creates a new model with user authentication context.
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
// The publishable Stripe key is wired into stripe.Key once at startup in
// 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.Fingerprint = fingerprint
m.AuthFingerprintKey = clientSecret
m.StripePublicKey = stripePublicKey
m.currentPage = splashPage
if apiURL != "" {
m.APIClient = api.NewClient(apiURL, "")

70
pkg/tui/palette.go Normal file
View File

@@ -0,0 +1,70 @@
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

@@ -355,11 +355,16 @@ func (m Model) RenderCardList() string {
boxes = append(boxes, box)
}
sshFocused := m.payment.cardCursor == len(m.SavedCards)
sshBox := m.CreateBox(m.formatListItem("+ add card via ssh", sshFocused), sshFocused)
// SSH inline card-add is dormant until i have a norwegian org
// number wired to stripe, something i dont have since its $300+. Keep the row visible so the option is
// 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)
httpsFocused := m.payment.cardCursor == len(m.SavedCards)+1
httpsFocused := m.payment.cardCursor == len(m.SavedCards)
httpsBox := m.CreateBox(m.formatListItem("+ add payment via browser", httpsFocused), httpsFocused)
boxes = append(boxes, httpsBox)
@@ -501,7 +506,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.payment.cardCursor--
}
case "down", "j":
if m.payment.cardCursor < len(m.SavedCards)+1 {
if m.payment.cardCursor < len(m.SavedCards) {
m.payment.cardCursor++
}
@@ -511,17 +516,8 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.SelectedCard = &selected
return m.ReviewSwitch()
}
if m.payment.cardCursor == len(m.SavedCards) {
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
// Browser flow, the only reachable add-card path while the SSH
// inline form is dormant (see RenderCardList)
m.payment.view = 2
m.payment.collectURL = nil
m.payment.collectTimeOut = false
@@ -546,16 +542,6 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 {
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)
}
}

View File

@@ -0,0 +1,91 @@
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

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

View File

@@ -0,0 +1,111 @@
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
}
}
}
// 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,163 +4,169 @@ import (
"fmt"
"strings"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"terminalShop/pkg/models"
)
func (m Model) calculateShopWidths() (int, int) {
menuWidth := 14
if len(m.Coffees) == 0 {
if m.size < large {
return m.widthContent, m.widthContent
}
return menuWidth, m.widthContent - menuWidth
}
for _, c := range m.Coffees {
if w := lipgloss.Width(c.Name); w > menuWidth {
menuWidth = w
}
}
if menuWidth > 0 {
menuWidth += 4
const (
// shopBodyInnerHeight fixes the inner height of the shop body panels so
// the detail box never resizes with the description.
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
if contentW > 74 {
contentW = 74
}
wideW = contentW - 4
if m.size < large {
menuWidth = m.widthContent
// Stacked layout: both panels span the full content width.
return contentW, wideW, wideW, wideW
}
detailWidth := m.widthContent - menuWidth
if m.size < large {
detailWidth = m.widthContent
}
return menuWidth, detailWidth
menuW = 16
detailW = contentW - 25
return contentW, menuW, detailW, wideW
}
func (m Model) updateShopViewports() Model {
headerHeight := lipgloss.Height(m.BuildHeader())
footerHeight := lipgloss.Height(m.BuildFooter())
availableHeight := m.heightContainer - headerHeight - footerHeight
if len(m.Coffees) == 0 {
return m
// roastLevel maps a roast type to a 1-5 intensity for the roast meter.
func roastLevel(roastType string) int {
rt := strings.ToLower(roastType)
switch {
case strings.Contains(rt, "dark"):
return 5
case strings.Contains(rt, "light"):
return 2
default:
return 3
}
menuWidth, detailWidth := m.calculateShopWidths()
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
// like the description never change the panel height.
func clampLines(s string, n int) string {
lines := strings.Split(s, "\n")
if len(lines) > n {
lines = lines[:n]
}
var normal, highlighted lipgloss.Style
if m.size < large {
menuWidth = m.widthContent
normal = m.theme.TextBody().
Width(menuWidth - 1).Align(lipgloss.Center)
highlighted = m.theme.TextAccent().
Width(menuWidth - 1).Align(lipgloss.Center).
Background(selectedColor).Bold(true)
} else {
normal = m.theme.TextBody().
Width(menuWidth+2).Padding(0, 1)
highlighted = m.theme.TextAccent().
Width(menuWidth+2).Padding(0, 1).
Background(selectedColor).Bold(true)
for len(lines) < n {
lines = append(lines, "")
}
return strings.Join(lines, "\n")
}
// roastMeter renders the strength dots, e.g. ● ● ● ○ ○
func roastMeter(n int) string {
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
for i, c := range m.Coffees {
if i == m.shop.selected {
b.WriteString(highlighted.Render(c.Name) + "\n")
b.WriteString(lipgloss.NewStyle().Width(w).
Background(lipgloss.Color(c.Color)).
Foreground(contrastColor(c.Color)).
Bold(true).
Render(" · " + c.Name))
} else {
b.WriteString(normal.Render(c.Name) + "\n")
dot := lipgloss.NewStyle().Foreground(lipgloss.Color(c.Color)).Render("·")
b.WriteString(lipgloss.NewStyle().Width(w).
Render(" " + dot + " " + pBody.Render(c.Name)))
}
if i < len(m.Coffees)-1 {
b.WriteString("\n")
}
}
return lipgloss.NewStyle().Padding(0, 1).Render(b.String())
return b.String()
}
func (m Model) getShopDetailContent() string {
if len(m.Coffees) == 0 {
return ""
}
func (m Model) shopDetailContent(w int) string {
coffee := m.Coffees[m.shop.selected]
_, detailWidth := m.calculateShopWidths()
if m.size >= large {
detailWidth -= 2
}
quantity := 0
if item, exists := m.Cart[coffee.ID]; exists {
quantity = item.Quantity
}
detail := lipgloss.JoinVertical(lipgloss.Left,
m.theme.TextAccent().Bold(true).Render(coffee.Name),
m.theme.TextBody().Render(
fmt.Sprintf("%s | %doz | %s", coffee.RoastType, coffee.Ounces, coffee.BeanType),
),
name := pWht.Bold(true).Render(coffee.Name)
sep := pDim.Render(" · ")
spec := pBody.Render(strings.ToUpper(coffee.RoastType)) + sep +
pDim.Render(fmt.Sprintf("%dOZ", coffee.Ounces)) + sep +
pDim.Render(strings.ToUpper(coffee.BeanType))
strength := pDim.Render("roast ") + roastMeter(roastLevel(coffee.RoastType))
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,
"",
lipgloss.NewStyle().Foreground(lipgloss.Color(coffee.Color)).Bold(true).Render(
fmt.Sprintf("$%.2f", float64(coffee.Price)/100),
),
price+" "+stock,
"",
m.theme.TextAccent().Width(detailWidth).Render(coffee.Description),
desc,
"",
fmt.Sprintf("- %d +", quantity),
stepper,
)
return lipgloss.NewStyle().Width(detailWidth).Render(detail)
}
func (m Model) ShopView() string {
if !m.shop.viewportsReady {
m = m.updateShopViewports()
}
if len(m.Coffees) == 0 {
return lipgloss.Place(m.widthContent, m.heightContainer,
lipgloss.Center, lipgloss.Center, "No products available.")
}
m.shop.menuViewport.SetContent(m.getShopMenuContent())
m.shop.detailViewport.SetContent(m.getShopDetailContent())
_, menuW, detailW, _ := m.shopLayout()
mc := m.shopMenuContent(menuW)
dc := m.shopDetailContent(detailW)
var body string
if m.size < large {
return lipgloss.JoinVertical(lipgloss.Top,
m.shop.menuViewport.View(),
m.shop.detailViewport.View(),
body = lipgloss.JoinVertical(lipgloss.Left,
panel(menuW, 0, mc),
panel(detailW, 0, dc),
)
} 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,
m.shop.menuViewport.View(),
" ",
m.shop.detailViewport.View(),
)
// Blank rows separate the body from the header and footer boxes.
return "\n" + body + "\n"
}
func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
if len(m.Coffees) == 0 {
return m, nil
}
@@ -197,16 +203,7 @@ func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) {
}
}
// 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...)
return m, nil
}
func (m Model) shopMoveSelected(previous bool) Model {
@@ -223,11 +220,5 @@ func (m Model) shopMoveSelected(previous bool) Model {
next = len(m.Coffees) - 1
}
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
}

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 {
padded := lipgloss.PlaceHorizontal(totalWidth, position, content)
base := m.theme.Base().Border(lipgloss.NormalBorder()).Width(totalWidth)
base := m.theme.Base().Border(lipgloss.RoundedBorder()).Width(totalWidth)
var style lipgloss.Style
if selected {
style = base.BorderForeground(m.theme.Highlight())
style = base.BorderForeground(cWhite)
} else {
style = base.BorderForeground(m.theme.Border())
style = base.BorderForeground(cLine)
}
return style.Padding(paddingV, paddingH).Render(padded)
}

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

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

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