Compare commits
46 Commits
comment-cl
...
docs/packa
| Author | SHA1 | Date | |
|---|---|---|---|
| ebbf66e76e | |||
| aa34043e9b | |||
| 5dd22fb8be | |||
| 52d533703b | |||
| c9f8b1e674 | |||
| d592249efb | |||
| cfc97eb1ff | |||
| c112797875 | |||
| 75225548ae | |||
| 53e934be3b | |||
| fc02f331d2 | |||
| bb960c31f8 | |||
| 8b753e4f4b | |||
| 90645958e5 | |||
| 135f1361e4 | |||
| 64deba7e03 | |||
| 732353d9a4 | |||
| 3420c8dce0 | |||
| f3b94c3271 | |||
| 67e19eca98 | |||
| 728ac24c96 | |||
| 1096c85b10 | |||
| 2f65cf0d90 | |||
| c3e6eb2b84 | |||
| 03009ea6df | |||
| c8b7545ca6 | |||
| 3a3c7ee80b | |||
| 7c8a2b562f | |||
| 1c5765d25b | |||
| e6301e662f | |||
| 8b2889a524 | |||
| c2b2b6e834 | |||
| bba5a0dfa0 | |||
| a8e45bc82b | |||
| 95b9fa91b1 | |||
| 0fe013c399 | |||
| d9404ab31e | |||
| a21485f91d | |||
| bce94b6d10 | |||
| dee6172875 | |||
| b404303f84 | |||
| 48d65c48fa | |||
| c78021f520 | |||
| c429c1966e | |||
| 50e1ba32f3 | |||
| 85b0c53c0a |
33
.github/workflows/ci.yml
vendored
33
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -30,9 +30,36 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Lint
|
||||
if: ${{ !env.ACT }}
|
||||
uses: golangci/golangci-lint-action@v7
|
||||
with:
|
||||
version: v2.4.0
|
||||
|
||||
- name: Install tparse
|
||||
run: go install github.com/mfridman/tparse@v0.18.0
|
||||
|
||||
- name: Test (race detector)
|
||||
run: 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
|
||||
|
||||
17
.github/workflows/deploy.yml
vendored
17
.github/workflows/deploy.yml
vendored
@@ -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"
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -5,6 +5,10 @@
|
||||
.env.example
|
||||
.env.prod
|
||||
|
||||
docs/sca-psd2-compliance.md
|
||||
accept-a-payment/
|
||||
codemap/
|
||||
|
||||
|
||||
FULFILLMENT.md
|
||||
.tmp-gocache/
|
||||
@@ -30,6 +34,8 @@ api/api-server
|
||||
*.out
|
||||
coverage.txt
|
||||
coverage.html
|
||||
test-output.json
|
||||
test-report.xml
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
@@ -98,6 +104,8 @@ docs/
|
||||
# Migration state (if using a migration tool that creates state files)
|
||||
migrations/*.state
|
||||
|
||||
gh-dash/
|
||||
|
||||
|
||||
|
||||
loadtest/run.pid
|
||||
@@ -109,3 +117,6 @@ ROADMAP.md
|
||||
|
||||
*.bak
|
||||
terminalshop.prod.*
|
||||
|
||||
# Backup verification config (real values; example file is committed)
|
||||
scripts/backups/config.env
|
||||
|
||||
55
Makefile
Normal file
55
Makefile
Normal 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)
|
||||
@@ -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
|
||||
|
||||
3
api/doc.go
Normal file
3
api/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Command api is the HTTP backend: it boots the router, bolts on all the
|
||||
// middleware, and answers the requests the TUI throws at it.
|
||||
package main
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
156
api/handlers/card_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
@@ -24,62 +26,75 @@ import (
|
||||
stripeSession "github.com/stripe/stripe-go/v78/checkout/session"
|
||||
)
|
||||
|
||||
// payRedirects maps short tokens to full Stripe checkout URLs.
|
||||
var (
|
||||
payRedirects = map[string]string{}
|
||||
payRedirectsMu sync.Mutex
|
||||
)
|
||||
const RedirectTTL = 10 * time.Minute
|
||||
|
||||
func storeRedirect(stripeURL string) (string, error) {
|
||||
func storeRedirect(stripeURL, purpose string) (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := hex.EncodeToString(b)
|
||||
payRedirectsMu.Lock()
|
||||
payRedirects[token] = stripeURL
|
||||
payRedirectsMu.Unlock()
|
||||
now := time.Now()
|
||||
rec := models.PayRedirect{
|
||||
Token: token,
|
||||
URL: stripeURL,
|
||||
Purpose: purpose,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(RedirectTTL),
|
||||
}
|
||||
if err := database.GetDB().Create(&rec).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// PayRedirect resolves a short token and redirects to the Stripe checkout URL.
|
||||
func PayRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
token := chi.URLParam(r, "token")
|
||||
payRedirectsMu.Lock()
|
||||
url, ok := payRedirects[token]
|
||||
payRedirectsMu.Unlock()
|
||||
if !ok {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
var rec models.PayRedirect
|
||||
if err := db.Where("token = ?", token).First(&rec).Error; err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, url, http.StatusFound)
|
||||
if time.Now().After(rec.ExpiresAt) {
|
||||
db.Where("token = ?", rec.Token).Delete(&models.PayRedirect{})
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, rec.URL, http.StatusFound)
|
||||
}
|
||||
|
||||
// CardHandler handles saved payment method CRUD.
|
||||
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) {
|
||||
@@ -99,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,
|
||||
})
|
||||
}
|
||||
@@ -121,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
|
||||
@@ -132,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,
|
||||
})
|
||||
}
|
||||
@@ -147,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
|
||||
@@ -168,19 +190,19 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
token, err := storeRedirect(sess.URL)
|
||||
token, err := storeRedirect(sess.URL, models.RedirectPurposeAddCard)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
|
||||
return
|
||||
}
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
// SaveCardRequest holds the raw card fields submitted by the TUI.
|
||||
// Tokenization happens server-side so the Stripe secret key never
|
||||
// touches the SSH client or TUI layer..
|
||||
// touches the SSH client or TUI layer.
|
||||
type SaveCardRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
@@ -210,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)
|
||||
@@ -278,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,
|
||||
})
|
||||
}
|
||||
@@ -311,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{})
|
||||
}
|
||||
|
||||
@@ -323,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
|
||||
|
||||
156
api/handlers/cards_expire_test.go
Normal file
156
api/handlers/cards_expire_test.go
Normal 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
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/paymentintent"
|
||||
"github.com/stripe/stripe-go/v78/paymentmethod"
|
||||
@@ -23,12 +25,20 @@ import (
|
||||
)
|
||||
|
||||
type CartHandler struct {
|
||||
stripeKey string
|
||||
appURL string
|
||||
maxOrderCents int
|
||||
}
|
||||
|
||||
// NewCartHandler creates a new cart handler with the Stripe secret key
|
||||
func NewCartHandler(stripeSecretKey string) *CartHandler {
|
||||
return &CartHandler{stripeKey: stripeSecretKey}
|
||||
// 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.
|
||||
@@ -45,18 +55,18 @@ func getOrCreateCart(ctx context.Context, userID uint) (*models.Cart, error) {
|
||||
return &cart, nil
|
||||
}
|
||||
|
||||
func cartResponse(ctx context.Context, cart *models.Cart) map[string]interface{} {
|
||||
func cartResponse(ctx context.Context, cart *models.Cart) map[string]any {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var items []models.CartItem
|
||||
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
|
||||
|
||||
subtotal := 0
|
||||
itemsData := make([]map[string]interface{}, 0, len(items))
|
||||
itemsData := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
lineTotal := item.Coffee.Price * item.Quantity
|
||||
subtotal += lineTotal
|
||||
itemsData = append(itemsData, map[string]interface{}{
|
||||
itemsData = append(itemsData, map[string]any{
|
||||
"id": item.ID,
|
||||
"coffee_id": item.CoffeeID,
|
||||
"quantity": item.Quantity,
|
||||
@@ -65,7 +75,7 @@ func cartResponse(ctx context.Context, cart *models.Cart) map[string]interface{}
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
return map[string]any{
|
||||
"items": itemsData,
|
||||
"subtotal": subtotal,
|
||||
"address_id": cart.AddressID,
|
||||
@@ -84,7 +94,7 @@ func (h *CartHandler) GetCart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"cart": cartResponse(r.Context(), cart),
|
||||
})
|
||||
}
|
||||
@@ -147,7 +157,7 @@ func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"cart": cartResponse(r.Context(), cart),
|
||||
})
|
||||
}
|
||||
@@ -183,7 +193,7 @@ func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"message": "address set",
|
||||
})
|
||||
}
|
||||
@@ -208,6 +218,15 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if card.IsStorageExpired(time.Now()) {
|
||||
if err := expireStoredCard(db, &card); err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
|
||||
return
|
||||
}
|
||||
respondCardStorageExpired(w)
|
||||
return
|
||||
}
|
||||
|
||||
cart, err := getOrCreateCart(r.Context(), userID)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
@@ -219,7 +238,7 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"message": "card set",
|
||||
})
|
||||
}
|
||||
@@ -239,7 +258,7 @@ func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"message": "cart cleared",
|
||||
})
|
||||
}
|
||||
@@ -294,6 +313,16 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if card.IsStorageExpired(time.Now()) {
|
||||
middleware.RecordCartConversion("validation_card_expired")
|
||||
if err := expireStoredCard(db, &card); 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")
|
||||
@@ -301,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")
|
||||
@@ -353,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)
|
||||
@@ -391,16 +430,15 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
// Charge the card. The idempotency key is tied to the order ID so retries
|
||||
// never produce a double charge.
|
||||
piParams := &stripe.PaymentIntentParams{
|
||||
Amount: stripe.Int64(int64(total)),
|
||||
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
||||
Customer: stripe.String(user.StripeCustomerID),
|
||||
PaymentMethod: stripe.String(paymentMethodID),
|
||||
AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{
|
||||
Enabled: stripe.Bool(true),
|
||||
AllowRedirects: stripe.String("never"),
|
||||
},
|
||||
Confirm: stripe.Bool(true),
|
||||
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
|
||||
Amount: stripe.Int64(int64(total)),
|
||||
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
||||
Customer: stripe.String(user.StripeCustomerID),
|
||||
PaymentMethod: stripe.String(paymentMethodID),
|
||||
OffSession: stripe.Bool(true),
|
||||
PaymentMethodTypes: stripe.StringSlice([]string{"card"}),
|
||||
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
|
||||
Confirm: stripe.Bool(true),
|
||||
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
|
||||
Shipping: &stripe.ShippingDetailsParams{
|
||||
Name: stripe.String(address.Name),
|
||||
Address: &stripe.AddressParams{
|
||||
@@ -423,13 +461,17 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
stripeDur := time.Since(stripeStart).Seconds()
|
||||
if err != nil {
|
||||
middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur)
|
||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||
if stripeErr, ok := err.(*stripe.Error); ok {
|
||||
if stripeErr.Code == stripe.ErrorCodeAuthenticationRequired && stripeErr.PaymentIntent != nil {
|
||||
h.respondRequiresAction(w, &order, paymentMethodID, stripeErr.PaymentIntent)
|
||||
return
|
||||
}
|
||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||
switch stripeErr.Type {
|
||||
case stripe.ErrorTypeCard:
|
||||
middleware.RecordCartConversion("card_declined")
|
||||
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
|
||||
utils.RespondError(w, http.StatusPaymentRequired, "CARD_DECLINED", stripeErr.Msg, nil)
|
||||
utils.RespondError(w, http.StatusPaymentRequired, mapStripeCardErrCode(stripeErr.Code), stripeErr.Msg, nil)
|
||||
case stripe.ErrorTypeInvalidRequest:
|
||||
middleware.RecordCartConversion("stripe_invalid_request")
|
||||
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
|
||||
@@ -441,6 +483,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||
middleware.RecordCartConversion("stripe_error")
|
||||
audit.OrderFailed(userID, order.ID, total, err.Error())
|
||||
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
|
||||
@@ -448,6 +491,11 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
middleware.ObserveStripeRequest("payment_intent_create", "success", stripeDur)
|
||||
|
||||
if pi.Status == stripe.PaymentIntentStatusRequiresAction {
|
||||
h.respondRequiresAction(w, &order, paymentMethodID, pi)
|
||||
return
|
||||
}
|
||||
|
||||
// Commit payment ID and clear cart in a single transaction.
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
order.StripePaymentID = pi.ID
|
||||
@@ -458,7 +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
|
||||
@@ -485,7 +533,248 @@ 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
|
||||
// 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()
|
||||
|
||||
// After an off-session authentication_required decline, the PI sits in
|
||||
// status=requires_payment_method with payment_method detached. Re-confirm
|
||||
// on-session with the same PM so Stripe pushes the PI into requires_action
|
||||
// and populates next_action.redirect_to_url with the 3DS challenge URL.
|
||||
if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" {
|
||||
confirmed, cerr := paymentintent.Confirm(pi.ID, &stripe.PaymentIntentConfirmParams{
|
||||
PaymentMethod: stripe.String(paymentMethodID),
|
||||
OffSession: stripe.Bool(false),
|
||||
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
|
||||
})
|
||||
if cerr == nil && confirmed != nil {
|
||||
pi = confirmed
|
||||
}
|
||||
}
|
||||
|
||||
if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" {
|
||||
middleware.RecordCartConversion("sca_missing_next_action")
|
||||
db.Model(order).Update("status", models.OrderStatusFailed)
|
||||
audit.OrderFailed(order.UserID, order.ID, order.Total, "stripe returned requires_action without redirect url")
|
||||
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "stripe did not return a 3ds url", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Persist the paymentIntent id AND 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.OrderStatusRequiresAction,
|
||||
}).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := storeRedirect(pi.NextAction.RedirectToURL.URL, models.RedirectPurposeCheckout3DS)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to allocate redirect token", nil)
|
||||
return
|
||||
}
|
||||
|
||||
middleware.RecordCartConversion("requires_action")
|
||||
audit.OrderRequiresAction(order.UserID, order.ID, pi.ID)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
|
||||
"order_id": order.ID,
|
||||
"payment_intent_id": pi.ID,
|
||||
"status": "requires_action",
|
||||
"redirect_token": token,
|
||||
"redirect_url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
|
||||
})
|
||||
}
|
||||
|
||||
func mapStripeCardErrCode(code stripe.ErrorCode) string {
|
||||
switch code {
|
||||
case stripe.ErrorCodeAuthenticationRequired:
|
||||
return "AUTHENTICATION_REQUIRED"
|
||||
case "authentication_not_supported":
|
||||
return "AUTHENTICATION_NOT_SUPPORTED"
|
||||
case "card_declined", "":
|
||||
return "CARD_DECLINED"
|
||||
case "expired_card":
|
||||
return "CARD_EXPIRED"
|
||||
case "incorrect_cvc":
|
||||
return "CARD_CVC_FAILED"
|
||||
case "insufficient_funds":
|
||||
return "CARD_INSUFFICIENT_FUNDS"
|
||||
case "processing_error":
|
||||
return "CARD_PROCESSING_ERROR"
|
||||
}
|
||||
return "CARD_DECLINED"
|
||||
}
|
||||
|
||||
//nolint:unused // wired into Convert cart later
|
||||
func appURLForHandler(r *http.Request) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
host := r.Host
|
||||
if fh := r.Header.Get("X-Forwarded-Host"); fh != "" {
|
||||
host = fh
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, host)
|
||||
}
|
||||
|
||||
// RetryAuth re-Confirms an in-flight PaymentIntent that the customer failed
|
||||
// or abandoned during the 3DS challenge, recovering the existing order rather
|
||||
// than starting a new checkout. Bound to POST /api/v1/orders/{id}/retry-auth.
|
||||
//
|
||||
// Stripe's PI state machine bounces back to requires_payment_method after a
|
||||
// failed or abandoned 3DS, so paymentintent.Confirm(pi.id, …) mints a fresh
|
||||
// next_action.redirect_to_url — the same shape respondRequiresAction already
|
||||
// handles. No idempotency key is set: Confirm mutates an existing PI and
|
||||
// Stripe collapses by id (see sca-psd2-compliance.md caveat #5).
|
||||
func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id64, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
|
||||
return
|
||||
}
|
||||
orderID := uint(id64)
|
||||
|
||||
var order models.Order
|
||||
if err := db.Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
3
api/handlers/doc.go
Normal file
3
api/handlers/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package handlers is where the real work happens, one function per endpoint:
|
||||
// carts, orders, products, addresses, cards, webhooks, and the rest.
|
||||
package handlers
|
||||
@@ -2,14 +2,18 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/bring"
|
||||
"terminalShop/pkg/notify"
|
||||
"terminalShop/pkg/shipping"
|
||||
"terminalShop/pkg/shippo"
|
||||
"time"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/audit"
|
||||
@@ -20,36 +24,150 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/refund"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type OrderHandler struct {
|
||||
stripeKey string
|
||||
bringAPIUID string
|
||||
bringAPIKey string
|
||||
bringCustomerNumber string
|
||||
}
|
||||
|
||||
func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
|
||||
type refundRequestPayload struct {
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// refundRequestCooldown is the minimum interval between customer-initiated
|
||||
// refund requests for the same order. Prevents accidental double-clicks and
|
||||
// repeated submissions from spamming the operator's Slack channel.
|
||||
const refundRequestCooldown = 15 * time.Minute
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRefundRequest posts a customer refund request into the order's Slack
|
||||
// thread. It does not issue a Stripe refund.
|
||||
func (h *OrderHandler) CreateRefundRequest(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var req refundRequestPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.Message = strings.TrimSpace(req.Message)
|
||||
|
||||
if !models.IsValidRefundReason(req.Reason) {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_REASON", "invalid refund reason", nil)
|
||||
return
|
||||
}
|
||||
if req.Reason == models.RefundRequestReasonOther && req.Message == "" {
|
||||
utils.RespondError(w, http.StatusBadRequest, "MESSAGE_REQUIRED", "message is required when reason is other", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var order models.Order
|
||||
if err := db.Preload("User").Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
|
||||
return
|
||||
}
|
||||
if !order.CanRequestRefund() {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_STATE",
|
||||
"only paid, shipped, or delivered orders can request refunds", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if order.LastRefundRequestAt != nil {
|
||||
elapsed := time.Since(*order.LastRefundRequestAt)
|
||||
if elapsed < refundRequestCooldown {
|
||||
retryAfter := int((refundRequestCooldown - elapsed).Seconds()) + 1
|
||||
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
||||
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED",
|
||||
fmt.Sprintf("another refund request was submitted recently; try again in %d min", retryAfter/60+1),
|
||||
nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
text := fmt.Sprintf(":money_with_wings: *Refund request* — %s\n*From:* %s <%s>\n*Total:* $%.2f",
|
||||
req.Reason, order.User.Name, order.User.Email, order.TotalInDollars())
|
||||
if req.Message != "" {
|
||||
quoted := "> " + strings.ReplaceAll(req.Message, "\n", "\n> ")
|
||||
text += "\n\n" + quoted
|
||||
}
|
||||
|
||||
if err := notify.SlackPostToOrderThreadBroadcast(order.ID, text); err != nil {
|
||||
utils.RespondError(w, http.StatusBadGateway, "SLACK_ERROR", "failed to send refund request", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Record the timestamp after a successful Slack post so a Slack outage
|
||||
// doesn't consume the user's cooldown — they can retry immediately.
|
||||
if err := db.Model(&order).Update("last_refund_request_at", time.Now()).Error; err != nil {
|
||||
slog.Warn("refund request: failed to record timestamp", "order_id", order.ID, "error", err)
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"status": "sent",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var orders []models.Order
|
||||
db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders)
|
||||
db.Where("user_id = ?", userID).
|
||||
Preload("Items").
|
||||
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
|
||||
Order("created_at desc").Find(&orders)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"orders": orders,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *OrderHandler) GetOrderStatus(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var order models.Order
|
||||
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"id": order.ID,
|
||||
"status": string(order.Status),
|
||||
})
|
||||
}
|
||||
|
||||
// RefundOrder issues a full refund for an order via Stripe and marks it as
|
||||
// refunded. Only orders in paid or shipped status can be refunded.
|
||||
func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -82,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),
|
||||
})
|
||||
@@ -103,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",
|
||||
})
|
||||
@@ -169,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,
|
||||
|
||||
170
api/handlers/orders_test.go
Normal file
170
api/handlers/orders_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
func setupOrderTestDB(t *testing.T) models.User {
|
||||
t.Helper()
|
||||
testDB := filepath.Join(t.TempDir(), "orders.db")
|
||||
database.ResetForTesting()
|
||||
t.Cleanup(database.ResetForTesting)
|
||||
|
||||
db, err := database.Connect(testDB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to test database: %v", err)
|
||||
}
|
||||
if err := database.Migrate(db); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
if err := database.Seed(db); err != nil {
|
||||
t.Fatalf("failed to seed: %v", err)
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
SSHKeyFingerprint: "SHA256:ordertestfingerprint",
|
||||
SSHPublicKey: "ssh-ed25519 AAAA ordertestkey",
|
||||
Name: "Order Test User",
|
||||
Email: "orders@example.com",
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
func orderRequest(method, url string, body []byte, userID uint, orderID string) *http.Request {
|
||||
req := authRequest(method, url, body, userID)
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("id", orderID)
|
||||
return req.WithContext(contextWithRoute(req.Context(), routeCtx))
|
||||
}
|
||||
|
||||
func contextWithRoute(ctx context.Context, routeCtx *chi.Context) context.Context {
|
||||
return context.WithValue(ctx, chi.RouteCtxKey, routeCtx)
|
||||
}
|
||||
|
||||
func TestCreateRefundRequestRequiresMessageForOther(t *testing.T) {
|
||||
user := setupOrderTestDB(t)
|
||||
|
||||
handler := NewOrderHandler("", "", "")
|
||||
body := []byte(`{"reason":"Other","message":" "}`)
|
||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, "1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateRefundRequest(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Error.Code != "MESSAGE_REQUIRED" {
|
||||
t.Errorf("expected MESSAGE_REQUIRED, got %q", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRefundRequestRejectsInvalidOrderState(t *testing.T) {
|
||||
user := setupOrderTestDB(t)
|
||||
|
||||
db := database.GetDB()
|
||||
order := models.Order{
|
||||
UserID: user.ID,
|
||||
CardID: 1,
|
||||
Status: models.OrderStatusFailed,
|
||||
Subtotal: 100,
|
||||
ShippingCost: 0,
|
||||
Total: 100,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("failed to create order: %v", err)
|
||||
}
|
||||
|
||||
handler := NewOrderHandler("", "", "")
|
||||
body := []byte(`{"reason":"Quality issue","message":""}`)
|
||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateRefundRequest(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Error.Code != "INVALID_STATE" {
|
||||
t.Errorf("expected INVALID_STATE, got %q", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRefundRequestEnforcesCooldown(t *testing.T) {
|
||||
user := setupOrderTestDB(t)
|
||||
|
||||
db := database.GetDB()
|
||||
recent := time.Now().Add(-1 * time.Minute)
|
||||
order := models.Order{
|
||||
UserID: user.ID,
|
||||
CardID: 1,
|
||||
Status: models.OrderStatusPaid,
|
||||
Subtotal: 100,
|
||||
ShippingCost: 0,
|
||||
Total: 100,
|
||||
LastRefundRequestAt: &recent,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("failed to create order: %v", err)
|
||||
}
|
||||
|
||||
handler := NewOrderHandler("", "", "")
|
||||
body := []byte(`{"reason":"Quality issue","message":""}`)
|
||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateRefundRequest(w, req)
|
||||
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected status 429, got %d", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Retry-After"); got == "" {
|
||||
t.Errorf("expected Retry-After header, got empty")
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Error.Code != "RATE_LIMITED" {
|
||||
t.Errorf("expected RATE_LIMITED, got %q", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/notify"
|
||||
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/paymentintent"
|
||||
"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)
|
||||
@@ -35,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),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -48,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 {
|
||||
@@ -61,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
|
||||
@@ -75,3 +88,162 @@ func ReconcileOrders(stripeKey string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
err := db.Where(
|
||||
"status = ? AND (tracking_number = '' OR tracking_number IS NULL) AND created_at < ?",
|
||||
models.OrderStatusPaid, cutoff,
|
||||
).Order("created_at ASC").Find(&orders).Error
|
||||
if err != nil {
|
||||
reconcileLog().Error("unshipped query failed", "error", err)
|
||||
return
|
||||
}
|
||||
if len(orders) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
reconcileLog().Info("unshipped orders found", "count", len(orders))
|
||||
notify.SlackUnshippedReminder(orders)
|
||||
}
|
||||
|
||||
520
api/handlers/reconcile_test.go
Normal file
520
api/handlers/reconcile_test.go
Normal 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
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
|
||||
|
||||
resp, err := h.httpClient.Do(req)
|
||||
if err != nil {
|
||||
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err))
|
||||
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err))
|
||||
return
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
@@ -151,7 +151,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
|
||||
_ = json.Unmarshal(bodyBytes, &result)
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message))
|
||||
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay
|
||||
return
|
||||
}
|
||||
if order.TrackingNumber == "" {
|
||||
notify.SlackPostToOrderThread(orderID, ":x: Cannot mark status, the order has no tracking number yet.")
|
||||
_ = notify.SlackPostToOrderThread(orderID, ":x: Cannot mark status, the order has no tracking number yet.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -198,12 +198,12 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay
|
||||
|
||||
resp, err := h.httpClient.Do(req)
|
||||
if err != nil {
|
||||
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err))
|
||||
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err))
|
||||
return
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode >= 300 {
|
||||
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode))
|
||||
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,17 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ViewHandler handles bulk TUI bootstrap data
|
||||
type ViewHandler struct{}
|
||||
|
||||
func NewViewHandler() *ViewHandler {
|
||||
@@ -35,16 +39,22 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
|
||||
db.Where("user_id = ?", userID).Find(&addresses)
|
||||
|
||||
var cards []models.Card
|
||||
db.Where("user_id = ?", userID).Find(&cards)
|
||||
db.Where(
|
||||
"user_id = ? AND (storage_expires_at IS NULL OR storage_expires_at > ?)",
|
||||
userID, time.Now(),
|
||||
).Order("is_default DESC, created_at DESC").Find(&cards)
|
||||
|
||||
var orders []models.Order
|
||||
db.Where("user_id = ?", userID).Preload("Items").Order("created_at DESC").Find(&orders)
|
||||
db.Where("user_id = ?", userID).
|
||||
Preload("Items").
|
||||
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
|
||||
Order("created_at DESC").Find(&orders)
|
||||
var cartItems []models.CartItem
|
||||
if cart.ID != 0 {
|
||||
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems)
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"user": user.ToPublic(),
|
||||
"products": products,
|
||||
"cart": cartItems,
|
||||
|
||||
70
api/handlers/view_test.go
Normal file
70
api/handlers/view_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -167,8 +173,18 @@ func (h *WebhookHandler) handlePaymentIntentFailed(ctx context.Context, event st
|
||||
return
|
||||
}
|
||||
|
||||
// authentication_required is recoverable — the cart handler re-confirms the
|
||||
// PI on-session and the customer completes 3DS via redirect. Marking the
|
||||
// order failed here would race with the recovery path and surface a
|
||||
// momentarily-broken state in the TUI.
|
||||
if pi.LastPaymentError != nil && string(pi.LastPaymentError.Code) == "authentication_required" {
|
||||
webhookLog().Info("ignoring authentication_required payment_failed; handled via 3ds redirect",
|
||||
"order_id", orderIDStr, "pi", pi.ID)
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
|
||||
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)
|
||||
}
|
||||
@@ -196,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)
|
||||
@@ -245,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)
|
||||
@@ -262,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)
|
||||
@@ -373,7 +394,9 @@ func (h *WebhookHandler) HandleShippo(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if previousTrackingStatus != newStatus {
|
||||
audit.TrackingUpdated(order.ID, order.Carrier, payload.Data.TrackingNumber, string(newStatus))
|
||||
go notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails))
|
||||
go func() {
|
||||
_ = notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails))
|
||||
}()
|
||||
}
|
||||
if deliveredTransition {
|
||||
audit.OrderDelivered(order.ID, payload.Data.TrackingNumber)
|
||||
|
||||
224
api/handlers/webhook_test.go
Normal file
224
api/handlers/webhook_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
67
api/main.go
67
api/main.go
@@ -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,56 @@ func main() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileOrders(cfg.StripeSecretKey)
|
||||
handlers.ReconcileOrders(ctx)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
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(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
|
||||
}
|
||||
@@ -121,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)
|
||||
}
|
||||
|
||||
|
||||
4
api/middleware/doc.go
Normal file
4
api/middleware/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// Package middleware holds the stuff that runs on every request before a
|
||||
// handler ever sees it: auth, CORS, logging, rate limiting, metrics, and a
|
||||
// recovery net for panics.
|
||||
package middleware
|
||||
@@ -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)
|
||||
|
||||
3
api/routes/doc.go
Normal file
3
api/routes/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package routes is the switchboard: it maps every URL to the handler that
|
||||
// knows what to do with it.
|
||||
package routes
|
||||
@@ -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)
|
||||
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.
|
||||
@@ -61,6 +61,10 @@ func SetupRoutes(
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`))
|
||||
})
|
||||
r.Get("/post-3ds", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Authentication complete.</h2><p>Return to your terminal.</p></body></html>`))
|
||||
})
|
||||
r.Handle("/metrics", middleware.MetricsHandler())
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
@@ -126,6 +130,9 @@ func SetupRoutes(
|
||||
|
||||
// Orders
|
||||
r.Get("/orders", orderHandler.GetOrders)
|
||||
r.Get("/orders/{id}/status", orderHandler.GetOrderStatus)
|
||||
r.With(middleware.RateLimitByUser(10, time.Minute)).Post("/orders/{id}/retry-auth", cartHandler.RetryAuth)
|
||||
r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest)
|
||||
r.Post("/orders/{id}/refund", orderHandler.RefundOrder)
|
||||
|
||||
// Addresses
|
||||
|
||||
3
cmd/reconcile-cards/doc.go
Normal file
3
cmd/reconcile-cards/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Command reconcile-cards hunts down saved cards that Stripe no longer knows
|
||||
// about and detaches them. Run it with -dry-run first if you're nervous.
|
||||
package main
|
||||
71
go.mod
71
go.mod
@@ -1,14 +1,14 @@
|
||||
module terminalShop
|
||||
|
||||
go 1.25.7
|
||||
go 1.25.9
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/huh v0.8.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.6
|
||||
charm.land/huh/v2 v2.0.3
|
||||
charm.land/lipgloss/v2 v2.0.3
|
||||
charm.land/wish/v2 v2.0.1
|
||||
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
|
||||
github.com/charmbracelet/wish v1.4.7
|
||||
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/cors v1.2.2
|
||||
@@ -23,7 +23,7 @@ require (
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
@@ -32,42 +32,45 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
charm.land/log/v2 v2.0.0 // indirect
|
||||
github.com/ClickHouse/ch-go v0.61.5 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bitfield/gotestdox v0.2.2 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/keygen v0.5.3 // indirect
|
||||
github.com/charmbracelet/log v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/conpty v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/keygen v0.5.4 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||
github.com/charmbracelet/x/conpty v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/input v0.3.4 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/dnephin/pflag v1.0.7 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gotesttools/gotestfmt/v2 v2.5.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/hashicorp/go-version v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
@@ -78,15 +81,14 @@ require (
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/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-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/paulmach/orb v0.11.1 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
@@ -104,11 +106,14 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/term v0.42.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
@@ -116,4 +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
|
||||
)
|
||||
|
||||
145
go.sum
145
go.sum
@@ -1,3 +1,15 @@
|
||||
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
|
||||
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
|
||||
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
|
||||
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
|
||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
|
||||
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
|
||||
charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s=
|
||||
charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0=
|
||||
charm.land/wish/v2 v2.0.1 h1:xYOsvQG/bYNRoKUBt0AEyMiCv9rksmCOG/fD5WifAzg=
|
||||
charm.land/wish/v2 v2.0.1/go.mod h1:uBWdsKTFk9BE0JsdEv8KKF8/abBfRe/hKEGOBm3OKZA=
|
||||
github.com/ClickHouse/ch-go v0.61.5 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4=
|
||||
github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 h1:AG4D/hW39qa58+JHQIFOSnxyL46H6h2lrmGGk17dhFo=
|
||||
@@ -10,77 +22,67 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE=
|
||||
github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
|
||||
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||
github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4=
|
||||
github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk=
|
||||
github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA=
|
||||
github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM=
|
||||
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc=
|
||||
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE=
|
||||
github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc=
|
||||
github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
|
||||
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 h1:4EG8pCHK5fa8dIxv97VHC8hdkJAz6QNm1WB9BuD/WhY=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7/go.mod h1:O2BTD/aMVQDmrvqroIO3fB6zXUuU07ZpVt21QTmZjRg=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0=
|
||||
github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
|
||||
github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
|
||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
|
||||
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk=
|
||||
github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ=
|
||||
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c/go.mod h1:0ghtL4RhKBjyWMkHCWosp2aHSEXShQeQMLQ0qBCytfU=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
@@ -91,8 +93,8 @@ github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -111,8 +113,12 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gotesttools/gotestfmt/v2 v2.5.0 h1:fSU3MnR+E+fvuXdw1l8xbufKhDxY3Tfjsjx/I1WerB4=
|
||||
github.com/gotesttools/gotestfmt/v2 v2.5.0/go.mod h1:oQJg2KZ2aGoqEbMC2PDaAeBYm0tOkocgixK9FzsCdp4=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
|
||||
@@ -147,25 +153,22 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
|
||||
@@ -247,12 +250,14 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -273,23 +278,25 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -326,5 +333,9 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc=
|
||||
gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y=
|
||||
gotest.tools/gotestsum v1.13.0 h1:+Lh454O9mu9AMG1APV4o0y7oDYKyik/3kBOiCqiEpRo=
|
||||
gotest.tools/gotestsum v1.13.0/go.mod h1:7f0NS5hFb0dWr4NtcsAsF0y1kzjEFfAil0HiBQJE03Q=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
|
||||
29
main.go
29
main.go
@@ -8,14 +8,14 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/wish/v2"
|
||||
"charm.land/wish/v2/bubbletea"
|
||||
"charm.land/wish/v2/logging"
|
||||
"github.com/charmbracelet/ssh"
|
||||
"github.com/charmbracelet/wish"
|
||||
"github.com/charmbracelet/wish/bubbletea"
|
||||
"github.com/charmbracelet/wish/logging"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
|
||||
"terminalShop/pkg/auth"
|
||||
"terminalShop/pkg/config"
|
||||
@@ -32,13 +32,9 @@ func listenHost() string {
|
||||
var (
|
||||
apiURL string
|
||||
authFingerprintKey string
|
||||
stripePublicKey string
|
||||
)
|
||||
|
||||
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||
renderer := bubbletea.MakeRenderer(s)
|
||||
|
||||
// Extract SSH public key from session
|
||||
pubKey := s.PublicKey()
|
||||
|
||||
var fingerprint, pubKeyStr string
|
||||
@@ -47,9 +43,8 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||
pubKeyStr = auth.FormatSSHPublicKey(pubKey)
|
||||
}
|
||||
|
||||
// Create TUI model with user context (no registration screen needed!)
|
||||
m := tui.NewModelWithAuth(renderer, fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
|
||||
return m, []tea.ProgramOption{tea.WithAltScreen()}
|
||||
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -62,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
|
||||
|
||||
@@ -56,7 +56,6 @@ type TokenResponse struct {
|
||||
}
|
||||
|
||||
func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) {
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL)
|
||||
|
||||
reqBody := TokenRequest{
|
||||
@@ -105,16 +104,16 @@ func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error)
|
||||
|
||||
// APIResponse represents the standard API response format
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Data map[string]any `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// APIError represents an error from the API
|
||||
type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// ProductsResponse represents the products API response
|
||||
@@ -281,14 +280,31 @@ type CardResponse struct {
|
||||
}
|
||||
|
||||
// ConvertCartResponse represents the cart conversion (checkout) API response.
|
||||
// 200 carries Order populated; 202 (requires_action / 3DS) carries the
|
||||
// redirect fields and OrderID instead.
|
||||
type ConvertCartResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Order models.Order `json:"order"`
|
||||
Order *models.Order `json:"order,omitempty"`
|
||||
OrderID uint `json:"order_id,omitempty"`
|
||||
PaymentIntentID string `json:"payment_intent_id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
RedirectToken string `json:"redirect_token,omitempty"`
|
||||
RedirectURL string `json:"redirect_url,omitempty"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CheckoutOutcome is the unified result of ConvertCart. RequiresAction=true
|
||||
// means the customer needs to complete 3DS at RedirectURL; poll
|
||||
// GetOrderStatus(OrderID) until the webhook flips the order to paid or failed.
|
||||
type CheckoutOutcome struct {
|
||||
Order *models.Order
|
||||
OrderID uint
|
||||
RequiresAction bool
|
||||
RedirectURL string
|
||||
}
|
||||
|
||||
// GetCart fetches the user's current cart.
|
||||
func (c *Client) GetCart() (*CartData, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL)
|
||||
@@ -323,7 +339,7 @@ func (c *Client) GetCart() (*CartData, error) {
|
||||
func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/item", c.BaseURL)
|
||||
|
||||
body := map[string]interface{}{
|
||||
body := map[string]any{
|
||||
"coffee_id": coffeeID,
|
||||
"quantity": quantity,
|
||||
}
|
||||
@@ -363,7 +379,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
|
||||
func (c *Client) SetCartAddress(addressID uint) error {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/address", c.BaseURL)
|
||||
|
||||
body := map[string]interface{}{
|
||||
body := map[string]any{
|
||||
"address_id": addressID,
|
||||
}
|
||||
jsonData, err := json.Marshal(body)
|
||||
@@ -402,7 +418,7 @@ func (c *Client) SetCartAddress(addressID uint) error {
|
||||
func (c *Client) SetCartCard(cardID uint) error {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/card", c.BaseURL)
|
||||
|
||||
body := map[string]interface{}{
|
||||
body := map[string]any{
|
||||
"card_id": cardID,
|
||||
}
|
||||
jsonData, err := json.Marshal(body)
|
||||
@@ -468,7 +484,9 @@ func (c *Client) ClearCart() error {
|
||||
}
|
||||
|
||||
// ConvertCart converts the cart into an order, charging the saved card.
|
||||
func (c *Client) ConvertCart() (*models.Order, error) {
|
||||
// Returns a CheckoutOutcome that either carries the completed Order or
|
||||
// indicates the customer must complete 3DS authentication via RedirectURL.
|
||||
func (c *Client) ConvertCart() (*CheckoutOutcome, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL)
|
||||
|
||||
req, err := http.NewRequest("POST", url, nil)
|
||||
@@ -494,7 +512,107 @@ func (c *Client) ConvertCart() (*models.Order, error) {
|
||||
return nil, fmt.Errorf("failed to convert cart")
|
||||
}
|
||||
|
||||
return &convertResp.Data.Order, nil
|
||||
if convertResp.Data.Status == "requires_action" {
|
||||
return &CheckoutOutcome{
|
||||
OrderID: convertResp.Data.OrderID,
|
||||
RequiresAction: true,
|
||||
RedirectURL: convertResp.Data.RedirectURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if convertResp.Data.Order == nil {
|
||||
return nil, fmt.Errorf("malformed convert response: missing order")
|
||||
}
|
||||
return &CheckoutOutcome{
|
||||
Order: convertResp.Data.Order,
|
||||
OrderID: convertResp.Data.Order.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type OrderStatusResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
ID uint `json:"id"`
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) GetOrderStatus(id uint) (string, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/orders/%d/status", c.BaseURL, id)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch order status: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var sResp OrderStatusResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&sResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode order status response: %w", err)
|
||||
}
|
||||
if !sResp.Success {
|
||||
if sResp.Error != nil {
|
||||
return "", fmt.Errorf("%s: %s", sResp.Error.Code, sResp.Error.Message)
|
||||
}
|
||||
return "", fmt.Errorf("failed to fetch order status")
|
||||
}
|
||||
return sResp.Data.Status, nil
|
||||
}
|
||||
|
||||
// RetryAuthOutcome is the result of POST /orders/{id}/retry-auth.
|
||||
// status=="requires_action" -> RedirectURL is a fresh QR target; resume polling.
|
||||
// Status=="succeeded" -> stripe approved frictionless; keep polling for the
|
||||
// webhook to flip status=paid (RedirectURL will be empty)
|
||||
type RetryAuthOutcome struct {
|
||||
OrderID uint
|
||||
Status string
|
||||
RedirectURL string
|
||||
}
|
||||
|
||||
type retryAuthResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
OrderID uint `json:"order_id"`
|
||||
Status string `json:"status"`
|
||||
RedirectURL string `json:"redirect_url,omitempty"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RetryAuth asks the server to re-Confirm the existing PaymentIntent for the
|
||||
// order, producing a new 3DS challenge URL. Used by the TUI's authFailed
|
||||
// screen so the customer can recover without re-entering the checkout flow.
|
||||
func (c *Client) RetryAuth(orderID uint) (*RetryAuthOutcome, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/orders/%d/retry-auth", c.BaseURL, orderID)
|
||||
req, err := http.NewRequest("POST", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create retry request: %w", err)
|
||||
}
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send retry request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var rResp retryAuthResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode retry response: %w", err)
|
||||
}
|
||||
if !rResp.Success {
|
||||
if rResp.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", rResp.Error.Code, rResp.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to retry authentication")
|
||||
}
|
||||
return &RetryAuthOutcome{
|
||||
OrderID: rResp.Data.OrderID,
|
||||
Status: rResp.Data.Status,
|
||||
RedirectURL: rResp.Data.RedirectURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCards fetches all saved cards for the user.
|
||||
@@ -636,6 +754,20 @@ type OrderResponse struct {
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RefundRequest is the customer-facing refund request payload.
|
||||
type RefundRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type refundRequestResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) GetOrders() ([]models.Order, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL)
|
||||
|
||||
@@ -664,6 +796,42 @@ func (c *Client) GetOrders() ([]models.Order, error) {
|
||||
return ordersResp.Data.Orders, nil
|
||||
}
|
||||
|
||||
// CreateRefundRequest posts a refund request for an order.
|
||||
func (c *Client) CreateRefundRequest(orderID uint, params RefundRequest) error {
|
||||
url := fmt.Sprintf("%s/api/v1/orders/%d/refund-request", c.BaseURL, orderID)
|
||||
|
||||
jsonData, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal refund request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create refund request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send refund request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var refundResp refundRequestResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&refundResp); err != nil {
|
||||
return fmt.Errorf("failed to decode refund response: %w", err)
|
||||
}
|
||||
|
||||
if !refundResp.Success {
|
||||
if refundResp.Error != nil {
|
||||
return fmt.Errorf("%s: %s", refundResp.Error.Code, refundResp.Error.Message)
|
||||
}
|
||||
return fmt.Errorf("failed to send refund request")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// The Addresses API response
|
||||
type AddressResponse struct {
|
||||
Success bool `json:"success"`
|
||||
|
||||
3
pkg/api/doc.go
Normal file
3
pkg/api/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package api is the client side of the house: it talks to our HTTP backend
|
||||
// so other Go code doesn't have to hand-roll requests.
|
||||
package api
|
||||
@@ -7,20 +7,42 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// EventOrderRequiresAction is the audit event-type string emitted whenever a
|
||||
// PaymentIntent is pushed into requires_action. 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 = EventOrderRequiresAction
|
||||
eventOrder3DSAbandoned eventType = EventOrder3DSAbandoned
|
||||
eventCartRejected eventType = EventCartRejected
|
||||
eventOrderRefunded eventType = "order_refunded"
|
||||
eventPaymentCritical eventType = "payment_critical"
|
||||
eventLabelPurchased eventType = "label_purchased"
|
||||
@@ -31,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 {
|
||||
@@ -84,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
|
||||
}
|
||||
|
||||
@@ -111,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{
|
||||
@@ -298,3 +339,44 @@ func attrsToMap(a []any) map[string]any {
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
|
||||
e := event{
|
||||
Event: eventOrderRequiresAction,
|
||||
UserID: userID,
|
||||
OrderID: orderID,
|
||||
StripeID: paymentIntentID,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventOrderRequiresAction, e)
|
||||
}
|
||||
|
||||
// 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
41
pkg/audit/audit_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
3
pkg/auth/doc.go
Normal file
3
pkg/auth/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package auth sorts out who you are: logging in with an SSH key and minting
|
||||
// the JWT you carry around afterwards.
|
||||
package auth
|
||||
@@ -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"])
|
||||
|
||||
3
pkg/bring/doc.go
Normal file
3
pkg/bring/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package bring talks to Bring, the Norwegian postal service, for booking
|
||||
// shipments and printing labels.
|
||||
package bring
|
||||
@@ -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
78
pkg/config/config_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
3
pkg/config/doc.go
Normal file
3
pkg/config/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package config reads everything out of the environment so secrets and
|
||||
// settings stay out of the source code.
|
||||
package config
|
||||
3
pkg/database/doc.go
Normal file
3
pkg/database/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package database opens the SQLite connection, runs the migrations, and
|
||||
// drops in some seed data so the shop isn't empty on a fresh boot.
|
||||
package database
|
||||
@@ -3,6 +3,8 @@ package database
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -24,10 +26,23 @@ func Migrate(db *gorm.DB) error {
|
||||
&models.OrderItem{}, // Line items within orders
|
||||
&models.Address{}, // Saved addresses
|
||||
&models.OrderEvent{},
|
||||
&models.PayRedirect{}, // short-token -> stripe url store (10 min TTL)
|
||||
); err != nil {
|
||||
return fmt.Errorf("migration failed: %w", err)
|
||||
}
|
||||
|
||||
if err := backfillCardStorageExpiration(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println("Database migrations completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func backfillCardStorageExpiration(db *gorm.DB) error {
|
||||
expiresAt := models.CardStorageExpiresAt(time.Now())
|
||||
if err := db.Model(&models.Card{}).Where("storage_expires_at IS NULL").Update("storage_expires_at", expiresAt).Error; err != nil {
|
||||
return fmt.Errorf("card storage ttl backfill failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
67
pkg/database/migrate_test.go
Normal file
67
pkg/database/migrate_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Seed populates the database with initial data
|
||||
@@ -25,54 +26,60 @@ func Seed(db *gorm.DB) error {
|
||||
{
|
||||
Name: "Espresso",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 5,
|
||||
Ounces: 2,
|
||||
BeanType: "Arabica",
|
||||
Price: 350, // $3.50
|
||||
Price: 1800, // $18.00
|
||||
Color: "#8B4513",
|
||||
Description: "Bold and intense coffee with a rich crema",
|
||||
},
|
||||
{
|
||||
Name: "Americano",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 3,
|
||||
Ounces: 8,
|
||||
BeanType: "Arabica",
|
||||
Price: 400, // $4.00
|
||||
Price: 2200, // $22.00
|
||||
Color: "#A0522D",
|
||||
Description: "Smooth and balanced espresso diluted with hot water",
|
||||
},
|
||||
{
|
||||
Name: "Cappuccino",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 3,
|
||||
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",
|
||||
},
|
||||
{
|
||||
Name: "Latte",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 2,
|
||||
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",
|
||||
},
|
||||
{
|
||||
Name: "Mocha",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 4,
|
||||
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.",
|
||||
},
|
||||
{
|
||||
Name: "Macchiato",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 4,
|
||||
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.",
|
||||
},
|
||||
|
||||
@@ -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
26
pkg/models/card_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ type Coffee struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
RoastType string `gorm:"size:100" json:"roast_type"`
|
||||
RoastLevel int `json:"roast_level"` // 1-5 roast-intensity
|
||||
Ounces int `json:"ounces"`
|
||||
BeanType string `gorm:"size:100" json:"bean_type"`
|
||||
Price int `gorm:"not null" json:"price"`
|
||||
|
||||
3
pkg/models/doc.go
Normal file
3
pkg/models/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package models is the shared vocabulary of the app: coffee, carts, orders,
|
||||
// addresses, cards, users, and the other nouns everything passes around.
|
||||
package models
|
||||
@@ -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
|
||||
@@ -66,14 +67,19 @@ type Order struct {
|
||||
// Slack parent message ts for this order's thread (NULL if Slack notify disabled).
|
||||
SlackThreadTS *string `gorm:"size:64" json:"-"`
|
||||
|
||||
// LastRefundRequestAt timestamps the most recent customer-initiated refund
|
||||
// request for this order. Used to rate-limit repeat submissions.
|
||||
LastRefundRequestAt *time.Time `json:"-"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel
|
||||
|
||||
// Associations
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
Card Card `gorm:"foreignKey:CardID" json:"-"`
|
||||
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
Card Card `gorm:"foreignKey:CardID" json:"-"`
|
||||
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
|
||||
Events []OrderEvent `gorm:"foreignKey:OrderID" json:"events,omitempty"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for the Order model.
|
||||
@@ -112,3 +118,35 @@ func (o *Order) TotalInDollars() float64 {
|
||||
func (o *Order) IsActive() bool {
|
||||
return o.Status == OrderStatusPaid || o.Status == OrderStatusShipped
|
||||
}
|
||||
|
||||
// RefundRequestReasons is the canonical list of customer-facing refund reasons
|
||||
// accepted by both the API and the TUI.
|
||||
var RefundRequestReasons = []string{
|
||||
"Wrong item received",
|
||||
"Item damaged in transit",
|
||||
"Item never arrived",
|
||||
"Quality issue",
|
||||
"Other",
|
||||
}
|
||||
|
||||
// RefundRequestReasonOther is the sentinel reason that requires a free-form message.
|
||||
const RefundRequestReasonOther = "Other"
|
||||
|
||||
// IsValidRefundReason reports whether r is one of the accepted refund reasons.
|
||||
func IsValidRefundReason(r string) bool {
|
||||
for _, reason := range RefundRequestReasons {
|
||||
if reason == r {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CanRequestRefund reports whether a customer is allowed to request a refund
|
||||
// for this order. Paid, shipped, and delivered orders qualify; everything else
|
||||
// is either still unpaid or already in a terminal state.
|
||||
func (o *Order) CanRequestRefund() bool {
|
||||
return o.Status == OrderStatusPaid ||
|
||||
o.Status == OrderStatusShipped ||
|
||||
o.Status == OrderStatusDelivered
|
||||
}
|
||||
|
||||
@@ -11,13 +11,32 @@ const (
|
||||
DisplayKindBrand DisplayKind = "brand" // shipped / in transit
|
||||
DisplayKindSuccess DisplayKind = "success" // delivered
|
||||
DisplayKindError DisplayKind = "error" // cancelled, failed, refunded
|
||||
DisplayKindWarning DisplayKind = "warning" // refund pending, delivery issue
|
||||
DisplayKindWarning DisplayKind = "warning" // delivery issue, returned
|
||||
DisplayKindRefund DisplayKind = "refund" // refund requested, awaiting review
|
||||
)
|
||||
|
||||
// refundPending reports whether the order has an open customer refund request
|
||||
// that has not yet been resolved by a terminal status change.
|
||||
func (order *Order) refundPending() bool {
|
||||
if order.LastRefundRequestAt == nil {
|
||||
return false
|
||||
}
|
||||
switch order.Status {
|
||||
case OrderStatusRefunded, OrderStatusCancelled, OrderStatusFailed:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (order *Order) DisplayState() string {
|
||||
if order.refundPending() {
|
||||
return "Refund Requested"
|
||||
}
|
||||
switch order.Status {
|
||||
case OrderStatusPending:
|
||||
return "Awaiting payment"
|
||||
case OrderStatusRequiresAction:
|
||||
return "Awaiting authentication"
|
||||
case OrderStatusPaid:
|
||||
if order.TrackingNumber == "" {
|
||||
return "Paid * awaiting label"
|
||||
@@ -54,9 +73,14 @@ func (order *Order) DisplayState() string {
|
||||
}
|
||||
|
||||
func (order *Order) DisplayKind() DisplayKind {
|
||||
if order.refundPending() {
|
||||
return DisplayKindRefund
|
||||
}
|
||||
switch order.Status {
|
||||
case OrderStatusPending:
|
||||
return DisplayKindNeutral
|
||||
case OrderStatusRequiresAction:
|
||||
return DisplayKindNeutral
|
||||
case OrderStatusPaid:
|
||||
return DisplayKindAccent
|
||||
case OrderStatusShipped:
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOrderDisplayState(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
order Order
|
||||
@@ -10,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},
|
||||
@@ -23,6 +28,10 @@ func TestOrderDisplayState(t *testing.T) {
|
||||
{"cancelled", Order{Status: OrderStatusCancelled}, "Cancelled", DisplayKindError},
|
||||
{"refunded", Order{Status: OrderStatusRefunded}, "Refunded", DisplayKindError},
|
||||
{"failed", Order{Status: OrderStatusFailed}, "Payment failed", DisplayKindError},
|
||||
{"paid with open refund request", Order{Status: OrderStatusPaid, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund},
|
||||
{"delivered with open refund request", Order{Status: OrderStatusDelivered, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund},
|
||||
{"refunded order ignores prior request", Order{Status: OrderStatusRefunded, LastRefundRequestAt: &now}, "Refunded", DisplayKindError},
|
||||
{"cancelled order ignores prior request", Order{Status: OrderStatusCancelled, LastRefundRequestAt: &now}, "Cancelled", DisplayKindError},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type OrderEvent struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
@@ -14,3 +17,47 @@ type OrderEvent struct {
|
||||
func (OrderEvent) TableName() string {
|
||||
return "order_events"
|
||||
}
|
||||
|
||||
func (e *OrderEvent) PayloadMap() map[string]any {
|
||||
if e.Payload == "" {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(e.Payload), &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (e *OrderEvent) DisplayLabel() (string, bool) {
|
||||
switch e.Type {
|
||||
case "order_paid":
|
||||
return "Paid", true
|
||||
case "label_purchased":
|
||||
return "Label created", true
|
||||
case "order_delivered":
|
||||
return "Delivered", true
|
||||
case "order_refunded":
|
||||
return "Refunded", true
|
||||
case "order_failed":
|
||||
return "Payment failed", true
|
||||
case "tracking_updated":
|
||||
status, _ := e.PayloadMap()["status"].(string)
|
||||
return trackingLabel(status)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func trackingLabel(status string) (string, bool) {
|
||||
switch status {
|
||||
case "transit":
|
||||
return "In transit", true
|
||||
case "failure":
|
||||
return "Delivery issue", true
|
||||
case "returned":
|
||||
return "Returned to sender", true
|
||||
case "pre_transit":
|
||||
return "Label scanned by carrier", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
23
pkg/models/pay_redirect.go
Normal file
23
pkg/models/pay_redirect.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// PayRedirect maps a short token to a stripe hosted url with a TTL.
|
||||
// survives api restarts so a customer mid-3Ds authentication still
|
||||
// lands on the bank-hosted challenge page after a deploy or crash
|
||||
type PayRedirect struct {
|
||||
Token string `gorm:"primaryKey;size:64" json:"token"`
|
||||
URL string `gorm:"type:text;not null" json:"url"`
|
||||
Purpose string `gorm:"size:32;not null" json:"purpose"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
}
|
||||
|
||||
const (
|
||||
RedirectPurposeCheckout3DS = "checkout_3ds"
|
||||
RedirectPurposeAddCard = "add_card"
|
||||
)
|
||||
|
||||
func (PayRedirect) TableName() string {
|
||||
return "pay_redirects"
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package notify provides a simple interface to send notifications to Slack.
|
||||
package notify
|
||||
|
||||
import (
|
||||
@@ -14,8 +15,10 @@ import (
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// slack client
|
||||
var slackClient = &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
// dollars converts cents to dollars
|
||||
func dollars(cents int) string {
|
||||
return fmt.Sprintf("$%.2f", float64(cents)/100)
|
||||
}
|
||||
@@ -185,29 +188,49 @@ func SlackUnpinOrder(orderID uint) {
|
||||
}
|
||||
|
||||
// SlackPostToOrderThread posts a plain-text reply inside the order's thread.
|
||||
// Looks up the thread ts from the database. Silent no-op if the order has no
|
||||
// saved thread (Slack was disabled when it was created, or post failed earlier).
|
||||
func SlackPostToOrderThread(orderID uint, text string) {
|
||||
// It looks up the thread ts from the database and returns an error when the
|
||||
// reply cannot be posted.
|
||||
func SlackPostToOrderThread(orderID uint, text string) error {
|
||||
return slackPostToOrderThread(orderID, text, false)
|
||||
}
|
||||
|
||||
// SlackPostToOrderThreadBroadcast posts a thread reply that is also surfaced
|
||||
// to the channel (reply_broadcast=true) so channel members get a normal
|
||||
// notification. Use for messages that need immediate operator attention.
|
||||
func SlackPostToOrderThreadBroadcast(orderID uint, text string) error {
|
||||
return slackPostToOrderThread(orderID, text, true)
|
||||
}
|
||||
|
||||
func slackPostToOrderThread(orderID uint, text string, broadcast bool) error {
|
||||
token := os.Getenv("SLACK_BOT_TOKEN")
|
||||
channel := os.Getenv("SLACK_CHANNEL")
|
||||
if token == "" || channel == "" {
|
||||
return
|
||||
return fmt.Errorf("slack is not configured")
|
||||
}
|
||||
|
||||
var order models.Order
|
||||
if err := database.GetDB().Select("id", "slack_thread_ts").Where("id = ?", orderID).First(&order).Error; err != nil {
|
||||
slog.Warn("failed to look up order for thread post", "order_id", orderID, "error", err)
|
||||
return
|
||||
return fmt.Errorf("failed to look up order thread: %w", err)
|
||||
}
|
||||
if order.SlackThreadTS == nil || *order.SlackThreadTS == "" {
|
||||
return
|
||||
return fmt.Errorf("order has no slack thread")
|
||||
}
|
||||
|
||||
_, _ = postSlackMessage(token, map[string]any{
|
||||
payload := map[string]any{
|
||||
"channel": channel,
|
||||
"thread_ts": *order.SlackThreadTS,
|
||||
"text": text,
|
||||
})
|
||||
}
|
||||
if broadcast {
|
||||
payload["reply_broadcast"] = true
|
||||
}
|
||||
|
||||
if _, ok := postSlackMessage(token, payload); !ok {
|
||||
slog.Warn("failed to post slack thread reply", "order_id", orderID)
|
||||
return fmt.Errorf("failed to post slack thread reply")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SlackPostLabelPurchased(orderID uint, labelURL string, costCents int) {
|
||||
@@ -261,4 +284,44 @@ func markStatusActionBlock(orderID uint) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
var _ = time.Second
|
||||
func SlackUnshippedReminder(orders []models.Order) {
|
||||
token := os.Getenv("SLACK_BOT_TOKEN")
|
||||
channel := os.Getenv("SLACK_CHANNEL")
|
||||
if token == "" || channel == "" || len(orders) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
workspace := os.Getenv("SLACK_WORKSPACE_SUBDOMAIN")
|
||||
|
||||
lines := []string{fmt.Sprintf(":hourglass_flowing_sand: %d order(s) awaiting label", len(orders))}
|
||||
for _, order := range orders {
|
||||
lines = append(lines, " "+unshippedOrderLine(order, channel, workspace))
|
||||
}
|
||||
|
||||
_, _ = postSlackMessage(token, map[string]any{
|
||||
"channel": channel,
|
||||
"text": strings.Join(lines, "\n"),
|
||||
})
|
||||
}
|
||||
|
||||
func unshippedOrderLine(order models.Order, channel, workspace string) string {
|
||||
age := time.Since(order.CreatedAt).Round(time.Hour)
|
||||
location := order.ShippingCountry
|
||||
if order.ShippingCity != "" {
|
||||
location = order.ShippingCity + ", " + order.ShippingCountry
|
||||
}
|
||||
|
||||
label := fmt.Sprintf("Order #%d", order.ID)
|
||||
if order.SlackThreadTS != nil && *order.SlackThreadTS != "" && workspace != "" {
|
||||
ts := strings.ReplaceAll(*order.SlackThreadTS, ".", "")
|
||||
permalink := fmt.Sprintf("https://%s.slack.com/archives/%s/p%s", workspace, channel, ts)
|
||||
label = fmt.Sprintf("<%s|Order #%d>", permalink, order.ID)
|
||||
}
|
||||
|
||||
tag := ""
|
||||
if order.ShippingCountry == "NO" {
|
||||
tag = " _(NO * Bring blocked)_"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s * %s old * %s * %s%s", label, age, dollars(order.Total), location, tag)
|
||||
}
|
||||
|
||||
3
pkg/observability/doc.go
Normal file
3
pkg/observability/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package observability sets up tracing and structured logs so that when
|
||||
// something breaks at 3am you stand a chance of figuring out why.
|
||||
package observability
|
||||
3
pkg/shipping/doc.go
Normal file
3
pkg/shipping/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package shipping holds the plain shipping types that the rest of the app
|
||||
// passes back and forth.
|
||||
package shipping
|
||||
2
pkg/shippo/doc.go
Normal file
2
pkg/shippo/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package shippo talks to the Shippo API for shipping rates and labels.
|
||||
package shippo
|
||||
3
pkg/stripeclient/doc.go
Normal file
3
pkg/stripeclient/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package stripeclient wires up the Stripe SDK once at startup (with tracing
|
||||
// baked in) so everyone else can just make calls.
|
||||
package stripeclient
|
||||
@@ -1,44 +1,64 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"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. The body is quantized to whole order cards so the menu panel's
|
||||
// bottom border lines up exactly with the last visible card; the two gap
|
||||
// rows around the body host the up/down scroll arrows.
|
||||
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
|
||||
budget := m.heightContainer - headerHeight - footerHeight - 2
|
||||
cards := budget / orderCardHeight
|
||||
if cards < 1 {
|
||||
cards = 1
|
||||
}
|
||||
if availableHeight < 3 {
|
||||
availableHeight = 3
|
||||
return cards*orderCardHeight - 2
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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()
|
||||
// +2: no panel on the detail side, so the viewport spans the same rows
|
||||
// as the menu panel including its border rows — tops and bottoms align.
|
||||
availableHeight := m.accountBodyHeight() + 2
|
||||
if m.size < large {
|
||||
// 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(detailWidth, 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.Width = detailWidth
|
||||
m.account.detailViewport.Height = availableHeight
|
||||
m.account.detailViewport.SetWidth(detailW)
|
||||
m.account.detailViewport.SetHeight(availableHeight)
|
||||
}
|
||||
|
||||
return m
|
||||
@@ -46,12 +66,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 +83,6 @@ func (m Model) getAccountDetailContent() string {
|
||||
case "cards":
|
||||
return m.CardsView(detailContentWidth)
|
||||
case "faq":
|
||||
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
||||
questionStyle := m.theme.TextHighlight().Bold(true)
|
||||
contentStyle := m.theme.TextBody().Width(detailContentWidth)
|
||||
faqContent := ""
|
||||
@@ -79,7 +93,7 @@ func (m Model) getAccountDetailContent() string {
|
||||
faqContent += "\n\n"
|
||||
}
|
||||
}
|
||||
detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent
|
||||
detailView := faqContent
|
||||
if !m.account.faqFocused {
|
||||
hintStyle := m.theme.TextDim()
|
||||
detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
|
||||
@@ -103,19 +117,25 @@ func (m Model) scrollToAccountDetailItem() Model {
|
||||
|
||||
switch {
|
||||
case m.account.orderViewState == 1:
|
||||
// No title in list mode, cards joined with JoinVertical (no gaps)
|
||||
// Card: border(2) + content(2) + PaddingLeft only = 4 lines
|
||||
headerOffset = 0
|
||||
itemHeight = 4
|
||||
itemCount = len(m.currentOrderList())
|
||||
selectedIndex = m.account.orderCursor
|
||||
// Order list uses whole-card windowing instead of viewport scroll:
|
||||
// shift the window just enough to keep the cursor's card visible.
|
||||
page := m.orderListPageSize()
|
||||
if m.account.orderCursor < m.account.orderWindowStart {
|
||||
m.account.orderWindowStart = m.account.orderCursor
|
||||
}
|
||||
if m.account.orderCursor >= m.account.orderWindowStart+page {
|
||||
m.account.orderWindowStart = m.account.orderCursor - page + 1
|
||||
}
|
||||
m.account.detailViewport.GotoTop()
|
||||
return m
|
||||
case m.account.addressListFocused:
|
||||
headerOffset = 3
|
||||
// Titles were removed from the detail views, lists start at the top
|
||||
headerOffset = 0
|
||||
itemHeight = 5
|
||||
itemCount = len(m.SavedAddresses)
|
||||
selectedIndex = m.account.addressCursor
|
||||
case m.account.cardListFocused:
|
||||
headerOffset = 3
|
||||
headerOffset = 0
|
||||
itemHeight = 4
|
||||
itemCount = len(m.SavedCards)
|
||||
selectedIndex = m.account.cardCursor
|
||||
@@ -128,8 +148,8 @@ func (m Model) scrollToAccountDetailItem() Model {
|
||||
}
|
||||
|
||||
targetY := headerOffset + (selectedIndex * itemHeight)
|
||||
vpH := m.account.detailViewport.Height
|
||||
offset := m.account.detailViewport.YOffset
|
||||
vpH := m.account.detailViewport.Height()
|
||||
offset := m.account.detailViewport.YOffset()
|
||||
|
||||
// 1-item buffer: scroll when the NEXT item would be off-screen,
|
||||
// not when the current item hits the very edge
|
||||
@@ -143,23 +163,30 @@ 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. The highlight is blue while the user navigates
|
||||
// the menu, and falls back to the gray pill when focus has moved into the
|
||||
// detail area (browsing orders, managing addresses/cards, scrolling faq).
|
||||
focusedInside := m.account.orderViewState > 0 || m.account.addressListFocused ||
|
||||
m.account.cardListFocused || m.account.faqFocused
|
||||
pillBg := m.theme.Highlight()
|
||||
if focusedInside {
|
||||
pillBg = cPill
|
||||
}
|
||||
var menu []string
|
||||
for i, item := range models.AccountMenuItems {
|
||||
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(pillBg).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 +195,43 @@ 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()
|
||||
body = lipgloss.JoinHorizontal(
|
||||
lipgloss.Top,
|
||||
panel(menuW, bodyH, menuContent),
|
||||
" ",
|
||||
detailRendered,
|
||||
)
|
||||
}
|
||||
|
||||
return lipgloss.JoinHorizontal(
|
||||
lipgloss.Top,
|
||||
leftContainer.Render(leftPanel),
|
||||
" ",
|
||||
detailRendered,
|
||||
)
|
||||
// Blank rows separate the body from the header and footer boxes. When the
|
||||
// windowed order list has hidden items, the arrows occupy those gap rows,
|
||||
// centered over the card column, so the cards themselves always span
|
||||
// exactly the menu panel's rows — top and bottom borders aligned.
|
||||
topGap := ""
|
||||
if above := m.orderHiddenAbove(); above > 0 && m.size >= large {
|
||||
topGap = strings.Repeat(" ", menuW+5) +
|
||||
orderScrollIndicator("↑", above, m.accountDetailWidth())
|
||||
}
|
||||
bottomGap := ""
|
||||
if below := m.orderHiddenBelow(); below > 0 && m.size >= large {
|
||||
bottomGap = strings.Repeat(" ", menuW+5) +
|
||||
orderScrollIndicator("↓", below, m.accountDetailWidth())
|
||||
}
|
||||
return topGap + "\n" + body + "\n" + bottomGap
|
||||
}
|
||||
|
||||
func (m Model) AboutView(width int) string {
|
||||
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
||||
contentStyle := m.theme.TextBody().Width(width)
|
||||
// USE this if wanting to add a corp name later
|
||||
// accentStyle := m.theme.TextHighlight().Bold(true)
|
||||
@@ -208,7 +253,7 @@ func (m Model) AboutView(width int) string {
|
||||
width,
|
||||
)) + "\n"
|
||||
|
||||
return titleStyle.Render("About") + "\n\n" + aboutContent
|
||||
return aboutContent
|
||||
}
|
||||
|
||||
func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
@@ -288,6 +333,13 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
m.account.detailViewport, cmd = m.account.detailViewport.Update(msg)
|
||||
return m, cmd
|
||||
|
||||
case "r":
|
||||
if m.account.orderViewState == 2 {
|
||||
if order, ok := m.selectedRefundableOrder(); ok {
|
||||
return m.OpenRefundComposer(order.ID)
|
||||
}
|
||||
}
|
||||
|
||||
case "x", "d":
|
||||
if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) {
|
||||
m.account.addressDeleting = &m.account.addressCursor
|
||||
@@ -328,6 +380,7 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
case 0:
|
||||
m.account.orderViewState = 1
|
||||
m.account.orderCursor = 0
|
||||
m.account.orderWindowStart = 0
|
||||
m.account.detailViewport.GotoTop()
|
||||
m.footer = []footerCommand{
|
||||
{key: "j/k", value: "orders"},
|
||||
@@ -336,14 +389,18 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
{key: "q", value: "quit"},
|
||||
}
|
||||
case 1:
|
||||
m.account.orderYOffset = m.account.detailViewport.YOffset
|
||||
m.account.orderYOffset = m.account.detailViewport.YOffset()
|
||||
m.account.orderViewState = 2
|
||||
m.account.detailViewport.GotoTop()
|
||||
m.footer = []footerCommand{
|
||||
footer := []footerCommand{
|
||||
{key: "esc", value: "back"},
|
||||
{key: "s", value: "shop"},
|
||||
{key: "q", value: "quit"},
|
||||
}
|
||||
if _, ok := m.selectedRefundableOrder(); ok {
|
||||
footer = append([]footerCommand{{key: "r", value: "refund"}}, footer...)
|
||||
}
|
||||
m.footer = footer
|
||||
}
|
||||
}
|
||||
case "addresses":
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package tui
|
||||
|
||||
func (m Model) AddressesView(width int) string {
|
||||
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
||||
contentStyle := m.theme.TextBody().Width(width)
|
||||
|
||||
lines := titleStyle.Render("Address") + "\n\n"
|
||||
lines := ""
|
||||
if len(m.SavedAddresses) == 0 {
|
||||
lines += contentStyle.Render("No saved addresses")
|
||||
} else {
|
||||
|
||||
@@ -5,10 +5,9 @@ import (
|
||||
)
|
||||
|
||||
func (m Model) CardsView(width int) string {
|
||||
titleSTyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
||||
contentStyle := m.theme.TextBody().Width(width)
|
||||
|
||||
lines := titleSTyle.Render("Cards") + "\n\n"
|
||||
lines := ""
|
||||
if m.SavedCardsIsEmpty() {
|
||||
lines += contentStyle.Render("No saved cards")
|
||||
} else {
|
||||
|
||||
@@ -6,15 +6,15 @@ import (
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func (m Model) OrdersView(width int) string {
|
||||
return m.orderListView("Order History", m.HistoryOrders(), width)
|
||||
return m.orderListView(m.HistoryOrders(), width)
|
||||
}
|
||||
|
||||
func (m Model) ActiveOrdersView(width int) string {
|
||||
return m.orderListView("Active Orders", m.ActiveOrders(), width)
|
||||
return m.orderListView(m.ActiveOrders(), width)
|
||||
}
|
||||
|
||||
func (m Model) ActiveOrders() []models.Order {
|
||||
@@ -37,6 +37,21 @@ func (m Model) HistoryOrders() []models.Order {
|
||||
return out
|
||||
}
|
||||
|
||||
// selectedRefundableOrder returns the order under the cursor in the active
|
||||
// order-detail view, paired with ok=true, when the user is allowed to file a
|
||||
// refund request for it. Returns (zero, false) otherwise.
|
||||
func (m Model) selectedRefundableOrder() (models.Order, bool) {
|
||||
orders := m.currentOrderList()
|
||||
if m.account.orderCursor < 0 || m.account.orderCursor >= len(orders) {
|
||||
return models.Order{}, false
|
||||
}
|
||||
order := orders[m.account.orderCursor]
|
||||
if !order.CanRequestRefund() {
|
||||
return models.Order{}, false
|
||||
}
|
||||
return order, true
|
||||
}
|
||||
|
||||
func (m Model) currentOrderList() []models.Order {
|
||||
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
|
||||
return nil
|
||||
@@ -50,41 +65,95 @@ func (m Model) currentOrderList() []models.Order {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) orderListView(title string, orders []models.Order, width int) string {
|
||||
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(1)
|
||||
// orderCardHeight is the rendered height of one order card:
|
||||
// border (2) + content lines (2), no vertical padding.
|
||||
const orderCardHeight = 4
|
||||
|
||||
// orderListPageSize returns how many whole order cards fit in the detail
|
||||
// area. Both scroll arrows render outside the list, in the gap rows above
|
||||
// and below the body, so the cards use the full viewport height.
|
||||
func (m Model) orderListPageSize() int {
|
||||
n := m.account.detailViewport.Height() / orderCardHeight
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// orderHiddenAbove returns how many order cards are scrolled off above the
|
||||
// windowed list, or 0 when the windowed order list isn't showing.
|
||||
func (m Model) orderHiddenAbove() int {
|
||||
if m.account.orderViewState != 1 || !m.OrdersLoaded {
|
||||
return 0
|
||||
}
|
||||
orders := m.currentOrderList()
|
||||
if len(orders) == 0 {
|
||||
return 0
|
||||
}
|
||||
page := m.orderListPageSize()
|
||||
start := m.account.orderWindowStart
|
||||
if start > len(orders)-page {
|
||||
start = len(orders) - page
|
||||
}
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
// orderHiddenBelow returns how many order cards are scrolled off below the
|
||||
// windowed list, or 0 when the windowed order list isn't showing.
|
||||
func (m Model) orderHiddenBelow() int {
|
||||
if m.account.orderViewState == 2 || !m.OrdersLoaded {
|
||||
return 0
|
||||
}
|
||||
orders := m.currentOrderList()
|
||||
if len(orders) == 0 {
|
||||
return 0
|
||||
}
|
||||
below := len(orders) - (m.orderHiddenAbove() + m.orderListPageSize())
|
||||
if below < 0 {
|
||||
below = 0
|
||||
}
|
||||
return below
|
||||
}
|
||||
|
||||
// orderScrollIndicator renders the dim "more items" arrow row, centered
|
||||
// across the card width.
|
||||
func orderScrollIndicator(arrow string, count, width int) string {
|
||||
return pDim.Width(width).Align(lipgloss.Center).
|
||||
Render(fmt.Sprintf("%s %d more", arrow, count))
|
||||
}
|
||||
|
||||
func (m Model) orderListView(orders []models.Order, width int) string {
|
||||
contentStyle := m.theme.TextBody().Width(width)
|
||||
|
||||
if !m.OrdersLoaded {
|
||||
return titleStyle.Render(title) + "\n" + contentStyle.Render("Loading orders...")
|
||||
return contentStyle.Render("Loading orders...")
|
||||
}
|
||||
if len(orders) == 0 {
|
||||
return titleStyle.Render(title) + "\n" + contentStyle.Render("No orders yet")
|
||||
return contentStyle.Render("No orders yet")
|
||||
}
|
||||
if m.account.orderViewState == 2 {
|
||||
return m.buildOrderDetailView(orders[m.account.orderCursor], width)
|
||||
}
|
||||
|
||||
boxWidth := width - 2
|
||||
// Whole-card windowing: render only fully-visible cards, never clipping a
|
||||
// card mid-border. Both scroll arrows render in BuildAccountView's gap
|
||||
// rows, so the cards span exactly the menu panel's rows.
|
||||
page := m.orderListPageSize()
|
||||
start := m.orderHiddenAbove()
|
||||
end := start + page
|
||||
if end > len(orders) {
|
||||
end = len(orders)
|
||||
}
|
||||
|
||||
// Build cards and join vertically — no manual "\n" gaps means fixed spacing
|
||||
var cards []string
|
||||
for i, order := range orders {
|
||||
var rows []string
|
||||
for i := start; i < end; i++ {
|
||||
isSelected := m.account.orderViewState == 1 && i == m.account.orderCursor
|
||||
cards = append(cards, m.buildOrderCard(order, boxWidth, isSelected))
|
||||
rows = append(rows, m.buildOrderCard(orders[i], width, isSelected))
|
||||
}
|
||||
cardList := lipgloss.JoinVertical(lipgloss.Left, cards...)
|
||||
|
||||
// In preview mode (viewState 0), show title + hint
|
||||
if m.account.orderViewState == 0 {
|
||||
hintStyle := m.theme.TextDim()
|
||||
return titleStyle.Render(title) + "\n" +
|
||||
cardList + "\n" +
|
||||
hintStyle.Render("enter: browse orders")
|
||||
}
|
||||
|
||||
// In list browsing mode (viewState 1), just show cards — no title
|
||||
// (the left panel menu already shows "order history" as selected)
|
||||
return cardList
|
||||
return lipgloss.JoinVertical(lipgloss.Left, rows...)
|
||||
}
|
||||
|
||||
// buildOrderCard renders a single order as a bordered box with fixed height.
|
||||
@@ -201,6 +270,24 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string {
|
||||
}
|
||||
}
|
||||
|
||||
visible := make([]models.OrderEvent, 0, len(order.Events))
|
||||
for _, event := range order.Events {
|
||||
if _, ok := event.DisplayLabel(); ok {
|
||||
visible = append(visible, event)
|
||||
}
|
||||
}
|
||||
if len(visible) > 0 {
|
||||
b.WriteString(labelStyle.Render("Timeline"))
|
||||
b.WriteString("\n")
|
||||
for _, event := range visible {
|
||||
label, _ := event.DisplayLabel()
|
||||
when := event.CreatedAt.Format("Jan 02 3:04 PM")
|
||||
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-20s %s", label, dimStyle.Render(when))))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Shipping address
|
||||
b.WriteString(labelStyle.Render("Ship To"))
|
||||
b.WriteString("\n")
|
||||
@@ -223,7 +310,11 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string {
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Footer hint
|
||||
b.WriteString(dimStyle.Render("esc: back to orders"))
|
||||
if order.CanRequestRefund() {
|
||||
b.WriteString(dimStyle.Render("r: refund esc: back to orders"))
|
||||
} else {
|
||||
b.WriteString(dimStyle.Render("esc: back to orders"))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -241,10 +332,9 @@ func (m Model) displayStyle(k models.DisplayKind) lipgloss.Style {
|
||||
case models.DisplayKindError:
|
||||
return m.theme.TextError()
|
||||
case models.DisplayKindWarning:
|
||||
// !TODO add refunds and change this
|
||||
// No warning color in theme yet, reuse loading pink as visual flag
|
||||
// for now
|
||||
return m.theme.TextLoading()
|
||||
case models.DisplayKindRefund:
|
||||
return m.theme.TextBrand()
|
||||
}
|
||||
return m.theme.TextHighlight()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func (m Model) BuildBreadcrumbs() string {
|
||||
|
||||
@@ -3,42 +3,48 @@ package tui
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func (m Model) updateCartViewport() Model {
|
||||
contentW, _, _, _ := m.shopLayout()
|
||||
headerH := lipgloss.Height(m.BuildHeader())
|
||||
breadH := lipgloss.Height(m.BuildBreadcrumbs())
|
||||
footerH := lipgloss.Height(m.BuildFooter())
|
||||
availH := m.heightContainer - headerH - footerH - breadH
|
||||
// 2 = blank gap rows around the cart list
|
||||
availH := m.heightContainer - headerH - footerH - breadH - 2
|
||||
if availH < 1 {
|
||||
availH = 1
|
||||
}
|
||||
if !m.cart.viewportReady {
|
||||
m.cart.viewport = viewport.New(m.widthContent, availH)
|
||||
m.cart.viewport = viewport.New(viewport.WithWidth(contentW), viewport.WithHeight(availH))
|
||||
m.cart.viewport.KeyMap = viewport.KeyMap{}
|
||||
m.cart.viewportReady = true
|
||||
} else {
|
||||
m.cart.viewport.Width = m.widthContent
|
||||
m.cart.viewport.Height = availH
|
||||
m.cart.viewport.SetWidth(contentW)
|
||||
m.cart.viewport.SetHeight(availH)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) generateCartContent() string {
|
||||
contentW, _, _, _ := m.shopLayout()
|
||||
|
||||
if m.IsCartEmpty() {
|
||||
emptyStyle := m.theme.TextDim().
|
||||
Align(lipgloss.Center).
|
||||
Width(m.widthContent).
|
||||
Width(contentW).
|
||||
Padding(2, 0)
|
||||
render := emptyStyle.Render("Your cart is empty\n\nPress s to go back to shop")
|
||||
return render
|
||||
}
|
||||
|
||||
cartItems := ""
|
||||
boxWidth := m.widthContent - 4
|
||||
// Cart cards span the full chrome width so they line up with the
|
||||
// header and footer boxes.
|
||||
boxWidth := contentW
|
||||
itemSlice := m.GetCartItemsSlice()
|
||||
|
||||
boxPadding := 0
|
||||
@@ -94,7 +100,7 @@ func (m Model) generateCartContent() string {
|
||||
boxContent = m.theme.TextAccent().Render(boxContent)
|
||||
}
|
||||
box := m.createBoxInner(boxContent, isSelected, lipgloss.Left, 2, boxPadding, boxWidth)
|
||||
cartItems += m.theme.Base().Width(m.widthContent).Align(lipgloss.Center).Render(box)
|
||||
cartItems += box
|
||||
|
||||
if idx < len(itemSlice)-1 {
|
||||
cartItems += "\n"
|
||||
@@ -116,19 +122,16 @@ func (m Model) CartView() string {
|
||||
}
|
||||
itemHeight := 4 + 2*boxPadding + 1
|
||||
targetY := m.cart.cursor * itemHeight
|
||||
if targetY < m.cart.viewport.YOffset {
|
||||
if targetY < m.cart.viewport.YOffset() {
|
||||
m.cart.viewport.SetYOffset(targetY)
|
||||
}
|
||||
if targetY+itemHeight > m.cart.viewport.YOffset+m.cart.viewport.Height {
|
||||
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height + itemHeight)
|
||||
if targetY+itemHeight > m.cart.viewport.YOffset()+m.cart.viewport.Height() {
|
||||
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height() + itemHeight)
|
||||
}
|
||||
}
|
||||
return lipgloss.Place(
|
||||
m.widthContainer,
|
||||
lipgloss.Height(m.cart.viewport.View()),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
m.cart.viewport.View(),
|
||||
)
|
||||
// Blank rows separate the list from the chrome; no Place wrapper so the
|
||||
// cards stay left-aligned with the header and footer boxes.
|
||||
return "\n" + m.cart.viewport.View() + "\n"
|
||||
}
|
||||
|
||||
func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
|
||||
@@ -3,11 +3,13 @@ package tui
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/tui/qrfefe"
|
||||
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// RenderConfirmation renders the order confirmation view
|
||||
@@ -73,12 +75,12 @@ func (m Model) updateConfirmViewport() Model {
|
||||
availH = 1
|
||||
}
|
||||
if !m.confirm.viewportReady {
|
||||
m.confirm.viewport = viewport.New(m.widthContent, availH)
|
||||
m.confirm.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||
m.confirm.viewport.KeyMap = viewport.DefaultKeyMap()
|
||||
m.confirm.viewportReady = true
|
||||
} else {
|
||||
m.confirm.viewport.Width = m.widthContent
|
||||
m.confirm.viewport.Height = availH
|
||||
m.confirm.viewport.SetWidth(m.widthContent)
|
||||
m.confirm.viewport.SetHeight(availH)
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -168,6 +170,30 @@ func (m Model) ReviewView() string {
|
||||
if m.CheckingOut {
|
||||
return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...")
|
||||
}
|
||||
if m.review.retrying {
|
||||
return m.theme.TextAccent().Bold(true).Padding(1).Render(" retrying authentication...")
|
||||
}
|
||||
if m.review.retryExhausted {
|
||||
content := m.theme.TextError().Bold(true).Padding(1).Render("too many authentication attempts") + "\n" + m.theme.TextLabel().Padding(0, 1).Render("press esc to start a new order")
|
||||
return lipgloss.Place(
|
||||
m.widthContainer,
|
||||
lipgloss.Height(content),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
content,
|
||||
)
|
||||
}
|
||||
if m.review.authFailed {
|
||||
content := m.theme.TextError().Bold(true).Padding(1).Render("authentication failed — press r to retry, esc to go back")
|
||||
return lipgloss.Place(
|
||||
m.widthContainer,
|
||||
lipgloss.Height(content),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
content,
|
||||
)
|
||||
}
|
||||
if m.review.requiresAction {
|
||||
return m.renderRequiresActionView()
|
||||
}
|
||||
if m.review.success {
|
||||
content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!")
|
||||
return lipgloss.Place(
|
||||
@@ -186,17 +212,50 @@ func (m Model) ReviewView() string {
|
||||
)
|
||||
}
|
||||
|
||||
// renderRequiresActionView shows a QR for the 3DS challenge URL while the TUI
|
||||
// polls the order status. Mirrors the add-card QR layout in payment_form.go.
|
||||
func (m Model) renderRequiresActionView() string {
|
||||
base := m.theme.TextLabel()
|
||||
accent := m.theme.TextHighlight()
|
||||
header := m.theme.TextAccent().Bold(true).Render("your bank wants to verify this charge")
|
||||
|
||||
if m.review.redirectURL == "" {
|
||||
return base.Render(" verifying with your bank...")
|
||||
}
|
||||
|
||||
qr, qrSize, err := qrfefe.Generate(m.review.redirectURL)
|
||||
if err != nil || qrSize > m.widthContent {
|
||||
body := header + "\n\n" +
|
||||
base.Render("open this url on your phone to authenticate:") + "\n\n" +
|
||||
accent.Render(m.review.redirectURL) + "\n\n" +
|
||||
base.Render("waiting for completion...")
|
||||
return lipgloss.Place(m.widthContainer, lipgloss.Height(body), lipgloss.Center, lipgloss.Center, body)
|
||||
}
|
||||
|
||||
// Same gotcha as payment_form.go: lipgloss layout helpers corrupt qrfefe ANSI.
|
||||
return qr +
|
||||
header + "\n" +
|
||||
base.Render("scan or visit to complete authentication") + "\n" +
|
||||
accent.Render(m.review.redirectURL) + "\n\n" +
|
||||
base.Render("waiting for completion...")
|
||||
}
|
||||
|
||||
func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case CheckoutResultMsg:
|
||||
m.CheckingOut = false
|
||||
if msg.Err != nil {
|
||||
m = m.SwitchPage(paymentPage)
|
||||
m.error = &VisibleError{message: fmt.Sprintf("checkout failed: %v", msg.Err)}
|
||||
var cmd tea.Cmd
|
||||
m, cmd = m.switchForCheckoutError(msg.Err)
|
||||
m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)}
|
||||
m.review.cardJustAdded = false
|
||||
m.payment.view = 0
|
||||
m.payment.form = nil
|
||||
return m, nil
|
||||
return m, cmd
|
||||
}
|
||||
if msg.RequiresAction {
|
||||
m.review.requiresAction = true
|
||||
m.review.redirectURL = msg.RedirectURL
|
||||
m.review.orderID = msg.OrderID
|
||||
return m, m.schedulePollOrderStatus(msg.OrderID)
|
||||
}
|
||||
m.Cart = make(map[uint]*models.CartItem)
|
||||
m.cart.cursor = 0
|
||||
@@ -205,10 +264,116 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
m.review.success = true
|
||||
return m, nil
|
||||
|
||||
case OrderStatusMsg:
|
||||
if !m.review.requiresAction || msg.OrderID != m.review.orderID {
|
||||
return m, nil
|
||||
}
|
||||
if msg.Err != nil {
|
||||
// Transient — keep polling.
|
||||
return m, m.schedulePollOrderStatus(msg.OrderID)
|
||||
}
|
||||
switch msg.Status {
|
||||
case "paid", "shipped", "delivered":
|
||||
m.Cart = make(map[uint]*models.CartItem)
|
||||
m.cart.cursor = 0
|
||||
m.ShippingInfo = nil
|
||||
m.OrdersLoaded = false
|
||||
m.review.requiresAction = false
|
||||
m.review.redirectURL = ""
|
||||
m.review.success = true
|
||||
return m, nil
|
||||
case "failed", "cancelled":
|
||||
m.review.requiresAction = false
|
||||
m.review.redirectURL = ""
|
||||
m.review.authFailed = true
|
||||
m.footer = []footerCommand{{key: "r", value: "retry"}, {key: "esc", value: "back"}}
|
||||
return m, nil
|
||||
default:
|
||||
return m, m.schedulePollOrderStatus(msg.OrderID)
|
||||
}
|
||||
|
||||
case RetryAuthResultMsg:
|
||||
if m.review.orderID != 0 && msg.OrderID != m.review.orderID {
|
||||
return m, nil
|
||||
}
|
||||
m.review.retrying = false
|
||||
if msg.Err != nil {
|
||||
s := msg.Err.Error()
|
||||
switch {
|
||||
case strings.Contains(s, "RETRY_LIMIT"):
|
||||
m.review.authFailed = false
|
||||
m.review.retryExhausted = true
|
||||
m.footer = []footerCommand{{key: "esc", value: "back"}}
|
||||
return m, nil
|
||||
case strings.Contains(s, "CARD_NO_LONGER_AVAILABLE"):
|
||||
m.review.authFailed = false
|
||||
m.review.requiresAction = false
|
||||
m.review.redirectURL = ""
|
||||
m.review.orderID = 0
|
||||
m = m.SwitchPage(paymentPage)
|
||||
m.payment.view = 0
|
||||
m.payment.form = nil
|
||||
m.error = &VisibleError{message: "saved card is no longer available. add it again to try."}
|
||||
return m, nil
|
||||
default:
|
||||
m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
m.review.authFailed = false
|
||||
m.review.retryExhausted = false
|
||||
m.review.requiresAction = true
|
||||
m.review.redirectURL = msg.RedirectURL
|
||||
m.review.orderID = msg.OrderID
|
||||
m.footer = []footerCommand{{key: "esc", value: "cancel"}}
|
||||
return m, m.schedulePollOrderStatus(msg.OrderID)
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.CheckingOut {
|
||||
return m, nil
|
||||
}
|
||||
if m.review.retrying {
|
||||
return m, nil
|
||||
}
|
||||
if m.review.authFailed {
|
||||
switch msg.String() {
|
||||
case "r":
|
||||
if m.review.orderID == 0 {
|
||||
return m, nil
|
||||
}
|
||||
m.review.retrying = true
|
||||
m.error = nil
|
||||
return m, m.retryAuthCmd(m.review.orderID)
|
||||
case "esc":
|
||||
m.review.authFailed = false
|
||||
m = m.SwitchPage(paymentPage)
|
||||
m.payment.view = 0
|
||||
m.payment.form = nil
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.review.retryExhausted {
|
||||
if msg.String() == "esc" {
|
||||
m.review.retryExhausted = false
|
||||
m.review.orderID = 0
|
||||
m = m.SwitchPage(paymentPage)
|
||||
m.payment.view = 0
|
||||
m.payment.form = nil
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.review.requiresAction {
|
||||
if msg.String() == "esc" {
|
||||
m.review.requiresAction = false
|
||||
m.review.redirectURL = ""
|
||||
m = m.SwitchPage(paymentPage)
|
||||
m.payment.view = 0
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.review.success {
|
||||
m.review.success = false
|
||||
m.review.cardJustAdded = false
|
||||
@@ -234,3 +399,40 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) switchForCheckoutError(err error) (Model, tea.Cmd) {
|
||||
switch {
|
||||
case strings.Contains(err.Error(), "CART_OVER_LIMIT"):
|
||||
return m.CartSwitch()
|
||||
default:
|
||||
m = m.SwitchPage(paymentPage)
|
||||
m.payment.view = 0
|
||||
m.payment.form = nil
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func friendlyCheckoutError(raw error) string {
|
||||
s := raw.Error()
|
||||
switch {
|
||||
case strings.Contains(s, "CART_OVER_LIMIT"):
|
||||
return "order total is over the per-order limit. remove items and try again."
|
||||
case strings.Contains(s, "AUTHENTICATION_REQUIRED"):
|
||||
return "your bank wants to verify this charge, authentication step coming soon."
|
||||
case strings.Contains(s, "AUTHENTICATION_NOT_SUPPORTED"):
|
||||
return "this card doesn't support online authentication. Try another card"
|
||||
case strings.Contains(s, "CARD_STORAGE_EXPIRED"):
|
||||
return "saved card expired. Add it again to verify the card is still yours"
|
||||
case strings.Contains(s, "CARD_EXPIRED"):
|
||||
return "card expired. Add a new card and try again"
|
||||
case strings.Contains(s, "CARD_CVC_FAILED"):
|
||||
return "card cvc incorrect. Check the back of the card and try again"
|
||||
case strings.Contains(s, "CARD_INSUFFICIENT_FUNDS"):
|
||||
return "insufficient funds on this card"
|
||||
case strings.Contains(s, "CARD_PROCESSING_ERROR"):
|
||||
return "card processing error. Try again in a moment"
|
||||
case strings.Contains(s, "CARD_DECLINED"):
|
||||
return "card declined"
|
||||
}
|
||||
return "checkout failed, try again."
|
||||
}
|
||||
|
||||
394
pkg/tui/confirmation_retry_test.go
Normal file
394
pkg/tui/confirmation_retry_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
24
pkg/tui/confirmation_test.go
Normal file
24
pkg/tui/confirmation_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
3
pkg/tui/doc.go
Normal file
3
pkg/tui/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package tui is the terminal UI you see over SSH: the shop, the cart, your
|
||||
// account, and the checkout flow, all drawn with Bubbletea.
|
||||
package tui
|
||||
@@ -3,23 +3,29 @@ package tui
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"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)
|
||||
}
|
||||
|
||||
@@ -2,57 +2,72 @@ package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/lipgloss/table"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
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(m.renderer.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)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func (m Model) BuildHelpView() string {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"fmt"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func (m Model) BuildMenuView() string {
|
||||
|
||||
145
pkg/tui/model.go
145
pkg/tui/model.go
@@ -7,10 +7,9 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
stripetoken "github.com/stripe/stripe-go/v78/token"
|
||||
|
||||
@@ -65,13 +64,17 @@ type VisibleError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
// VisibleNotice is a typed success message displayed to the user as a banner.
|
||||
type VisibleNotice struct {
|
||||
message string
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
// User authentication
|
||||
User *models.User
|
||||
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
|
||||
@@ -111,11 +114,11 @@ type Model struct {
|
||||
|
||||
Loading bool // true when fetching data from API
|
||||
error *VisibleError
|
||||
notice *VisibleNotice
|
||||
APIClient *api.Client
|
||||
|
||||
// Rendering
|
||||
renderer *lipgloss.Renderer
|
||||
theme theme.Theme
|
||||
theme theme.Theme
|
||||
|
||||
// Checkout flow state (spans multiple pages)
|
||||
CheckingOut bool
|
||||
@@ -130,6 +133,7 @@ type Model struct {
|
||||
shipping shippingState
|
||||
payment paymentState
|
||||
account accountState
|
||||
refund refundState
|
||||
review reviewState
|
||||
confirm confirmState
|
||||
|
||||
@@ -138,10 +142,7 @@ type Model struct {
|
||||
}
|
||||
|
||||
type shopState struct {
|
||||
selected int
|
||||
menuViewport viewport.Model
|
||||
detailViewport viewport.Model
|
||||
viewportsReady bool
|
||||
selected int
|
||||
}
|
||||
|
||||
type splashState struct {
|
||||
@@ -181,6 +182,7 @@ type accountState struct {
|
||||
cursor int
|
||||
orderViewState int
|
||||
orderCursor int
|
||||
orderWindowStart int // first fully-visible card in the windowed order list
|
||||
orderYOffset int // saved scroll pos when entering order detail
|
||||
faqFocused bool
|
||||
addressListFocused bool
|
||||
@@ -197,6 +199,17 @@ type reviewState struct {
|
||||
cardJustAdded bool
|
||||
cardWasDuplicate bool
|
||||
success bool
|
||||
|
||||
// 3DS / SCA flow: when the backend returns 202 requires_action,
|
||||
// requiresAction is true and redirectURL is the Stripe-hosted challenge
|
||||
// page. The TUI shows a QR and polls GetOrderStatus(orderID) until the
|
||||
// order flips to paid or failed.
|
||||
requiresAction bool
|
||||
redirectURL string
|
||||
orderID uint
|
||||
authFailed bool
|
||||
retrying bool
|
||||
retryExhausted bool
|
||||
}
|
||||
|
||||
type confirmState struct {
|
||||
@@ -219,12 +232,12 @@ func (m Model) SwitchPage(p page) Model {
|
||||
m.currentPage = p
|
||||
m.switched = true
|
||||
m.error = nil
|
||||
m.notice = nil
|
||||
return m
|
||||
}
|
||||
|
||||
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"},
|
||||
@@ -240,7 +253,6 @@ func (m Model) CartSwitch() (Model, tea.Cmd) {
|
||||
m = m.SwitchPage(cartPage)
|
||||
m = m.updateCartViewport()
|
||||
if m.IsCartEmpty() {
|
||||
|
||||
m.footer = []footerCommand{
|
||||
{key: "j/k", value: "items"},
|
||||
{key: "+/-", value: "qty"},
|
||||
@@ -250,13 +262,12 @@ func (m Model) CartSwitch() (Model, tea.Cmd) {
|
||||
{key: "q", value: "quit"},
|
||||
}
|
||||
} else {
|
||||
// Keep this list short enough to fit on one footer line.
|
||||
m.footer = []footerCommand{
|
||||
{key: "j/k", value: "items"},
|
||||
{key: "+/-", value: "qty"},
|
||||
{key: "p/enter", value: "checkout"},
|
||||
{key: "enter", value: "checkout"},
|
||||
{key: "s", value: "shop"},
|
||||
{key: "a", value: "account"},
|
||||
{key: "pgup/pgdn", value: "scroll"},
|
||||
{key: "?", value: "help"},
|
||||
{key: "q", value: "quit"},
|
||||
}
|
||||
@@ -413,11 +424,33 @@ func (m Model) Init() tea.Cmd {
|
||||
}
|
||||
|
||||
type CheckoutResultMsg struct {
|
||||
OrderID uint
|
||||
Total int
|
||||
RequiresAction bool
|
||||
RedirectURL string
|
||||
Err error
|
||||
}
|
||||
|
||||
// OrderStatusMsg carries the result of a poll against /orders/{id}/status.
|
||||
// Emitted on the 2s tick while waiting for 3DS authentication to complete.
|
||||
type OrderStatusMsg struct {
|
||||
OrderID uint
|
||||
Total int
|
||||
Status string
|
||||
Err error
|
||||
}
|
||||
|
||||
// RetryAuthResultMsg carries the outcome of POST /orders/{id}/retry-auth.
|
||||
// Status "requires_action" -> RedirectURL is a fresh challenge URL; resume
|
||||
// the QR + poll loop. Status "succeeded" -> frictionless retry approved;
|
||||
// server's APIError string ("RETRY_LIMIT" / "CARD_NO_LONGER_AVAILABLE" /
|
||||
// transient) - the Update branch matches on substring.
|
||||
type RetryAuthResultMsg struct {
|
||||
OrderID uint
|
||||
Status string
|
||||
RedirectURL string
|
||||
Err error
|
||||
}
|
||||
|
||||
// CartSyncedMsg is recieved when the server responds to a cart item update
|
||||
type CartSyncedMsg struct {
|
||||
UpdateID int64
|
||||
@@ -626,12 +659,52 @@ func (m Model) checkoutWithSavedCard() tea.Cmd {
|
||||
}
|
||||
|
||||
// 3. Convert the cart to an order
|
||||
order, err := m.APIClient.ConvertCart()
|
||||
outcome, err := m.APIClient.ConvertCart()
|
||||
if err != nil {
|
||||
return CheckoutResultMsg{Err: err}
|
||||
}
|
||||
|
||||
return CheckoutResultMsg{OrderID: order.ID, Total: order.Total}
|
||||
if outcome.RequiresAction {
|
||||
return CheckoutResultMsg{
|
||||
OrderID: outcome.OrderID,
|
||||
RequiresAction: true,
|
||||
RedirectURL: outcome.RedirectURL,
|
||||
}
|
||||
}
|
||||
|
||||
return CheckoutResultMsg{OrderID: outcome.Order.ID, Total: outcome.Order.Total}
|
||||
}
|
||||
}
|
||||
|
||||
// schedulePollOrderStatus sleeps 2s then polls /orders/{id}/status. Used while
|
||||
// waiting for the customer to complete 3DS authentication.
|
||||
func (m Model) schedulePollOrderStatus(orderID uint) tea.Cmd {
|
||||
return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
|
||||
if m.APIClient == nil {
|
||||
return OrderStatusMsg{OrderID: orderID, Err: fmt.Errorf("api client unavailable")}
|
||||
}
|
||||
status, err := m.APIClient.GetOrderStatus(orderID)
|
||||
if err != nil {
|
||||
return OrderStatusMsg{OrderID: orderID, Err: err}
|
||||
}
|
||||
return OrderStatusMsg{OrderID: orderID, Status: status}
|
||||
})
|
||||
}
|
||||
|
||||
func (m Model) retryAuthCmd(orderID uint) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if m.APIClient == nil {
|
||||
return RetryAuthResultMsg{OrderID: orderID, Err: fmt.Errorf("api client unavailable")}
|
||||
}
|
||||
outcome, err := m.APIClient.RetryAuth(orderID)
|
||||
if err != nil {
|
||||
return RetryAuthResultMsg{OrderID: orderID, Err: err}
|
||||
}
|
||||
return RetryAuthResultMsg{
|
||||
OrderID: orderID,
|
||||
Status: outcome.Status,
|
||||
RedirectURL: outcome.RedirectURL,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,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),
|
||||
@@ -755,22 +827,17 @@ func (m Model) SavedCardsIsEmpty() bool {
|
||||
return len(m.SavedCards) == 0
|
||||
}
|
||||
|
||||
// NewModel creates a new model using the default renderer.
|
||||
// NewModel creates a new model.
|
||||
func NewModel(username string) Model {
|
||||
return newModelWithRenderer(username, lipgloss.DefaultRenderer())
|
||||
}
|
||||
|
||||
// newModelWithRenderer creates a new model with the given renderer.
|
||||
func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
|
||||
m := Model{
|
||||
Username: username,
|
||||
currentPage: splashPage,
|
||||
renderer: renderer,
|
||||
theme: theme.BasicTheme(renderer),
|
||||
theme: theme.BasicTheme(),
|
||||
Coffees: []models.Coffee{
|
||||
{
|
||||
Name: "Espresso",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 5,
|
||||
Ounces: 2,
|
||||
BeanType: "Arabica",
|
||||
Price: 350, // $3.50
|
||||
@@ -780,6 +847,7 @@ func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
|
||||
{
|
||||
Name: "Latte",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 2,
|
||||
Ounces: 12,
|
||||
BeanType: "Arabica Blend",
|
||||
Price: 500, // $5.00
|
||||
@@ -789,6 +857,7 @@ func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
|
||||
{
|
||||
Name: "Cappuccino",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 3,
|
||||
Ounces: 8,
|
||||
BeanType: "Italian Blend",
|
||||
Price: 450, // $4.50
|
||||
@@ -798,6 +867,7 @@ func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
|
||||
{
|
||||
Name: "Americano",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 4,
|
||||
Ounces: 16,
|
||||
BeanType: "Arabica",
|
||||
Price: 400, // $4.00
|
||||
@@ -807,6 +877,7 @@ func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
|
||||
{
|
||||
Name: "Mocha",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 3,
|
||||
Ounces: 16,
|
||||
BeanType: "Colombian",
|
||||
Price: 550, // $5.50
|
||||
@@ -816,6 +887,7 @@ func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
|
||||
{
|
||||
Name: "Macchiato",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 4,
|
||||
Ounces: 3,
|
||||
BeanType: "Robusta Blend",
|
||||
Price: 425, // $4.25
|
||||
@@ -833,11 +905,12 @@ func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
|
||||
}
|
||||
|
||||
// NewModelWithAuth creates a new model with user authentication context.
|
||||
func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
|
||||
m := newModelWithRenderer("", renderer)
|
||||
// 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, "")
|
||||
@@ -859,10 +932,6 @@ func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr
|
||||
return m
|
||||
}
|
||||
|
||||
func newViewport(w, h int) viewport.Model {
|
||||
return viewport.New(w, h)
|
||||
}
|
||||
|
||||
func (m Model) collectCardCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if m.APIClient == nil {
|
||||
@@ -876,8 +945,10 @@ func (m Model) collectCardCmd() tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
const ordersPollInterval = 10 * time.Second
|
||||
const cardPollTimeout = 10 * time.Minute
|
||||
const (
|
||||
ordersPollInterval = 10 * time.Second
|
||||
cardPollTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
type OrdersPollTickMsg struct{}
|
||||
|
||||
|
||||
70
pkg/tui/palette.go
Normal file
70
pkg/tui/palette.go
Normal 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)
|
||||
}
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/tui/qrfefe"
|
||||
@@ -276,16 +276,14 @@ func (m Model) updatePaymentViewport() Model {
|
||||
breadH := lipgloss.Height(m.BuildBreadcrumbs())
|
||||
footerH := lipgloss.Height(m.BuildFooter())
|
||||
availH := m.heightContainer - headerH - footerH - breadH
|
||||
if availH < 1 {
|
||||
availH = 1
|
||||
}
|
||||
availH = max(availH, 1)
|
||||
if !m.payment.viewportReady {
|
||||
m.payment.viewport = viewport.New(m.widthContent, availH)
|
||||
m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||
m.payment.viewport.KeyMap = viewport.KeyMap{}
|
||||
m.payment.viewportReady = true
|
||||
} else {
|
||||
m.payment.viewport.Width = m.widthContent
|
||||
m.payment.viewport.Height = availH
|
||||
m.payment.viewport.SetWidth(m.widthContent)
|
||||
m.payment.viewport.SetHeight(availH)
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -301,20 +299,20 @@ func (m Model) PaymentPageView() string {
|
||||
// Bypass viewport — it strips the raw ANSI codes qrfefe emits.
|
||||
return lipgloss.Place(
|
||||
m.widthContainer,
|
||||
m.payment.viewport.Height,
|
||||
m.payment.viewport.Height(),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
m.renderPaymentHttpsView(),
|
||||
m.renderPaymentHTTPSView(),
|
||||
)
|
||||
} else if m.payment.view == 0 && m.payment.form == nil {
|
||||
content = m.RenderCardList()
|
||||
m.payment.viewport.SetContent(content)
|
||||
itemHeight := 3
|
||||
targetY := m.payment.cardCursor * itemHeight
|
||||
if targetY < m.payment.viewport.YOffset {
|
||||
if targetY < m.payment.viewport.YOffset() {
|
||||
m.payment.viewport.SetYOffset(targetY)
|
||||
}
|
||||
if targetY+itemHeight > m.payment.viewport.YOffset+m.payment.viewport.Height {
|
||||
m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height + itemHeight + 1)
|
||||
if targetY+itemHeight > m.payment.viewport.YOffset()+m.payment.viewport.Height() {
|
||||
m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height() + itemHeight + 1)
|
||||
}
|
||||
if m.payment.cardCursor == len(m.SavedCards) {
|
||||
m.payment.viewport.GotoBottom()
|
||||
@@ -355,11 +353,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)
|
||||
|
||||
@@ -369,7 +372,7 @@ func (m Model) RenderCardList() string {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||
}
|
||||
|
||||
func (m Model) renderPaymentHttpsView() string {
|
||||
func (m Model) renderPaymentHTTPSView() string {
|
||||
baseStyle := m.theme.TextLabel()
|
||||
accentStyle := m.theme.TextHighlight()
|
||||
|
||||
@@ -501,7 +504,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 +514,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 +540,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)
|
||||
}
|
||||
}
|
||||
|
||||
91
pkg/tui/payment_form_test.go
Normal file
91
pkg/tui/payment_form_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
3
pkg/tui/qrfefe/doc.go
Normal file
3
pkg/tui/qrfefe/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package qrfefe turns text into a QR code drawn with terminal block
|
||||
// characters, handy for showing links you can scan right off the screen.
|
||||
package qrfefe
|
||||
395
pkg/tui/refund.go
Normal file
395
pkg/tui/refund.go
Normal file
@@ -0,0 +1,395 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"terminalShop/pkg/api"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// Refund composer layout constants.
|
||||
const (
|
||||
refundMinTextareaRows = 5
|
||||
refundMaxTextareaRows = 10
|
||||
// chrome above the textarea inside the modal (title, label, padding, footer).
|
||||
refundModalChrome = 12
|
||||
refundModalMaxWidth = 58
|
||||
refundModalMinWidth = 24
|
||||
// horizontal chrome the modal subtracts from m.widthContent.
|
||||
refundModalHGutter = 6
|
||||
// inner padding around the textarea inside the modal body.
|
||||
refundTextareaHPad = 4
|
||||
)
|
||||
|
||||
// Nerd-font glyphs used in the refund composer. Requires a Nerd Font in the
|
||||
// SSH client terminal; renders as tofu otherwise.
|
||||
const (
|
||||
refundIconUndo = "\uf0e2" // nf-fa-undo
|
||||
refundIconTag = "\uf02b" // nf-oct-tag
|
||||
refundIconComment = "\uf41f" // nf-oct-comment
|
||||
refundIconKey = "\uf11c" // nf-fa-keyboard_o
|
||||
)
|
||||
|
||||
// refundFocus identifies which field in the refund composer has keyboard focus.
|
||||
type refundFocus int
|
||||
|
||||
const (
|
||||
refundFocusReason refundFocus = iota
|
||||
refundFocusMessage
|
||||
refundFocusCount
|
||||
)
|
||||
|
||||
type refundState struct {
|
||||
open bool
|
||||
orderID uint
|
||||
gen uint64 // bumped each open so late async results from a prior session are ignored
|
||||
reason string
|
||||
form *huh.Form
|
||||
textarea textarea.Model
|
||||
focusIdx refundFocus
|
||||
submitting bool
|
||||
err string
|
||||
reasonErr string
|
||||
messageErr string
|
||||
}
|
||||
|
||||
type refundRequestMsg struct {
|
||||
OrderID uint
|
||||
Gen uint64
|
||||
Err error
|
||||
}
|
||||
|
||||
// OpenRefundComposer opens the refund request composer for an order.
|
||||
func (m Model) OpenRefundComposer(orderID uint) (Model, tea.Cmd) {
|
||||
state := refundState{
|
||||
open: true,
|
||||
orderID: orderID,
|
||||
gen: m.refund.gen + 1,
|
||||
}
|
||||
state.textarea = m.newRefundTextarea()
|
||||
state.form = m.buildRefundForm(&state)
|
||||
m.refund = state
|
||||
return m, m.refund.form.Init()
|
||||
}
|
||||
|
||||
// RefundUpdate handles all input and async results while the refund composer is open.
|
||||
func (m Model) RefundUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
state := &m.refund
|
||||
if !state.open {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case refundRequestMsg:
|
||||
if msg.Gen != state.gen || msg.OrderID != state.orderID {
|
||||
return m, nil
|
||||
}
|
||||
state.submitting = false
|
||||
if msg.Err != nil {
|
||||
state.err = fmt.Sprintf("failed to send refund request: %v", msg.Err)
|
||||
return m, nil
|
||||
}
|
||||
gen := state.gen
|
||||
m.refund = refundState{gen: gen}
|
||||
m.notice = &VisibleNotice{message: "Refund request sent"}
|
||||
return m, nil
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
state.form = m.buildRefundForm(state)
|
||||
m.resizeRefundTextarea(state)
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
if state.submitting {
|
||||
return m, nil
|
||||
}
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
gen := state.gen
|
||||
m.refund = refundState{gen: gen}
|
||||
return m, nil
|
||||
case "ctrl+s":
|
||||
return m, m.submitRefundRequest(state)
|
||||
case "tab", "shift+tab":
|
||||
return m, m.setRefundFocus(state, (state.focusIdx+1)%refundFocusCount)
|
||||
}
|
||||
}
|
||||
|
||||
if state.focusIdx == refundFocusReason {
|
||||
state.err = ""
|
||||
state.reasonErr = ""
|
||||
form, cmd := state.form.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
state.form = f
|
||||
}
|
||||
if state.form.State == huh.StateCompleted {
|
||||
state.reason = state.form.GetString("reason")
|
||||
return m, m.setRefundFocus(state, refundFocusMessage)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
if _, ok := msg.(tea.KeyMsg); ok {
|
||||
state.err = ""
|
||||
state.messageErr = ""
|
||||
}
|
||||
ta, cmd := state.textarea.Update(msg)
|
||||
state.textarea = ta
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) buildRefundForm(state *refundState) *huh.Form {
|
||||
options := make([]huh.Option[string], 0, len(models.RefundRequestReasons))
|
||||
for _, reason := range models.RefundRequestReasons {
|
||||
options = append(options, huh.NewOption(reason, reason))
|
||||
}
|
||||
|
||||
return huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Reason").
|
||||
Key("reason").
|
||||
Options(options...).
|
||||
Value(&state.reason).
|
||||
Height(len(models.RefundRequestReasons) + 1),
|
||||
),
|
||||
).
|
||||
WithShowErrors(false).
|
||||
WithShowHelp(false).
|
||||
WithTheme(m.theme.Form()).
|
||||
WithLayout(huh.LayoutStack).
|
||||
WithWidth(m.refundContentWidth())
|
||||
}
|
||||
|
||||
func (m Model) newRefundTextarea() textarea.Model {
|
||||
ta := textarea.New()
|
||||
ta.Prompt = ""
|
||||
ta.Placeholder = "Describe the issue"
|
||||
ta.ShowLineNumbers = true
|
||||
ta.CharLimit = 65536
|
||||
ta.DynamicHeight = false
|
||||
ta.SetHeight(m.refundTextareaHeight())
|
||||
ta.SetWidth(m.refundTextareaWidth())
|
||||
|
||||
base := lipgloss.NewStyle()
|
||||
// Cursor-line uses a darker grey than theme.Border() so the active line
|
||||
// reads as a subtle wash, not a bright bar competing with the highlight.
|
||||
cursorLineBG := lipgloss.Color("236")
|
||||
ta.SetStyles(textarea.Styles{
|
||||
Focused: textarea.StyleState{
|
||||
Base: base,
|
||||
Text: base.Foreground(m.theme.Accent()),
|
||||
LineNumber: base.Foreground(m.theme.Dim()),
|
||||
CursorLine: base.Background(cursorLineBG).Foreground(m.theme.Accent()),
|
||||
CursorLineNumber: base.Foreground(m.theme.Body()),
|
||||
Placeholder: base.Foreground(m.theme.Dim()),
|
||||
EndOfBuffer: base.Foreground(m.theme.Dim()),
|
||||
},
|
||||
Blurred: textarea.StyleState{
|
||||
Base: base,
|
||||
Text: base.Foreground(m.theme.Body()),
|
||||
LineNumber: base.Foreground(m.theme.Dim()),
|
||||
CursorLine: base.Foreground(m.theme.Background()),
|
||||
CursorLineNumber: base.Foreground(m.theme.Dim()),
|
||||
Placeholder: base.Foreground(m.theme.Dim()),
|
||||
EndOfBuffer: base.Foreground(m.theme.Dim()),
|
||||
},
|
||||
Cursor: textarea.CursorStyle{
|
||||
Color: m.theme.Highlight(),
|
||||
Shape: tea.CursorBlock,
|
||||
Blink: true,
|
||||
},
|
||||
})
|
||||
return ta
|
||||
}
|
||||
|
||||
func (m Model) resizeRefundTextarea(state *refundState) {
|
||||
state.textarea.SetHeight(m.refundTextareaHeight())
|
||||
state.textarea.SetWidth(m.refundTextareaWidth())
|
||||
}
|
||||
|
||||
func (m Model) setRefundFocus(state *refundState, focusIdx refundFocus) tea.Cmd {
|
||||
state.focusIdx = focusIdx
|
||||
if focusIdx == refundFocusReason {
|
||||
state.textarea.Blur()
|
||||
state.form = m.buildRefundForm(state)
|
||||
return state.form.Init()
|
||||
}
|
||||
return state.textarea.Focus()
|
||||
}
|
||||
|
||||
func (m Model) submitRefundRequest(state *refundState) tea.Cmd {
|
||||
state.err = ""
|
||||
state.reasonErr = ""
|
||||
state.messageErr = ""
|
||||
|
||||
reason := strings.TrimSpace(state.reason)
|
||||
message := strings.TrimSpace(state.textarea.Value())
|
||||
|
||||
if reason == "" {
|
||||
state.reasonErr = "select a refund reason"
|
||||
return nil
|
||||
}
|
||||
|
||||
if reason == models.RefundRequestReasonOther && message == "" {
|
||||
state.messageErr = "message is required when reason is other"
|
||||
return nil
|
||||
}
|
||||
|
||||
state.submitting = true
|
||||
return m.createRefundRequestCmd(state.orderID, state.gen, reason, message)
|
||||
}
|
||||
|
||||
func (m Model) createRefundRequestCmd(orderID uint, gen uint64, reason string, message string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if m.APIClient == nil || m.User == nil {
|
||||
return refundRequestMsg{OrderID: orderID, Gen: gen, Err: fmt.Errorf("not authenticated")}
|
||||
}
|
||||
err := m.APIClient.CreateRefundRequest(orderID, api.RefundRequest{
|
||||
Reason: reason,
|
||||
Message: message,
|
||||
})
|
||||
return refundRequestMsg{OrderID: orderID, Gen: gen, Err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// RenderRefundOverlay renders the refund composer as a centered modal.
|
||||
// The modal has no border or background fill — only padding for breathing
|
||||
// room. lipgloss.Place handles centering against the container.
|
||||
func (m Model) RenderRefundOverlay() string {
|
||||
state := &m.refund
|
||||
contentWidth := m.refundContentWidth()
|
||||
|
||||
titleText := fmt.Sprintf("%s Refund request — Order #%d", refundIconUndo, state.orderID)
|
||||
title := m.theme.TextAccent().
|
||||
Bold(true).
|
||||
Width(contentWidth).
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(m.theme.Highlight()).
|
||||
Render(titleText)
|
||||
|
||||
body := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
title,
|
||||
"",
|
||||
m.renderRefundReason(state),
|
||||
"",
|
||||
m.renderRefundTextarea(state),
|
||||
"",
|
||||
m.renderRefundFooter(state),
|
||||
)
|
||||
|
||||
modal := lipgloss.NewStyle().
|
||||
Padding(1, 2).
|
||||
Render(body)
|
||||
|
||||
return lipgloss.Place(m.widthContainer, m.heightContainer, lipgloss.Center, lipgloss.Center, modal)
|
||||
}
|
||||
|
||||
func (m Model) renderRefundReason(state *refundState) string {
|
||||
if state.focusIdx == 0 && state.form != nil {
|
||||
out := state.form.View()
|
||||
if state.reasonErr != "" {
|
||||
out += "\n" + m.theme.TextError().Render(state.reasonErr)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
value := state.reason
|
||||
if value == "" {
|
||||
value = "Select a reason"
|
||||
}
|
||||
|
||||
label := m.theme.TextLabel().Render(refundIconTag + " Reason")
|
||||
selected := m.theme.TextAccent().
|
||||
Border(lipgloss.HiddenBorder()).
|
||||
PaddingLeft(1).
|
||||
Width(m.refundContentWidth()).
|
||||
Render(value)
|
||||
|
||||
out := lipgloss.JoinVertical(lipgloss.Left, label, selected)
|
||||
if state.reasonErr != "" {
|
||||
out += "\n" + m.theme.TextError().Render(state.reasonErr)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m Model) renderRefundTextarea(state *refundState) string {
|
||||
label := "Message (optional)"
|
||||
placeholder := "Describe the issue"
|
||||
if state.reason == models.RefundRequestReasonOther {
|
||||
label = "Message (required)"
|
||||
placeholder = "Describe the issue..."
|
||||
}
|
||||
state.textarea.Placeholder = placeholder
|
||||
|
||||
border := m.theme.Border()
|
||||
if state.focusIdx == refundFocusMessage {
|
||||
border = m.theme.Highlight()
|
||||
}
|
||||
|
||||
wrapper := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(border)
|
||||
|
||||
contentWidth := m.refundContentWidth()
|
||||
state.textarea.SetWidth(contentWidth - wrapper.GetHorizontalFrameSize())
|
||||
state.textarea.SetHeight(m.refundTextareaHeight())
|
||||
|
||||
textareaBox := wrapper.Render(state.textarea.View())
|
||||
|
||||
counter := m.theme.TextDim().
|
||||
Width(contentWidth).
|
||||
Align(lipgloss.Right).
|
||||
Render(fmt.Sprintf("%d chars", state.textarea.Length()))
|
||||
|
||||
out := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
m.theme.TextLabel().Render(refundIconComment+" "+label),
|
||||
textareaBox,
|
||||
counter,
|
||||
)
|
||||
if state.messageErr != "" {
|
||||
out += "\n" + m.theme.TextError().Render(state.messageErr)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m Model) renderRefundFooter(state *refundState) string {
|
||||
if state.submitting {
|
||||
return m.theme.TextAccent().Render("sending refund request...")
|
||||
}
|
||||
if state.err != "" {
|
||||
return m.theme.TextError().Render(state.err)
|
||||
}
|
||||
key := m.theme.TextHighlight().Bold(true)
|
||||
sep := m.theme.TextDim().Render(" · ")
|
||||
dim := m.theme.TextDim()
|
||||
return dim.Render(refundIconKey+" ") +
|
||||
key.Render("tab") + dim.Render(" next") + sep +
|
||||
key.Render("shift+tab") + dim.Render(" prev") + sep +
|
||||
key.Render("ctrl+s") + dim.Render(" submit") + sep +
|
||||
key.Render("esc") + dim.Render(" cancel")
|
||||
}
|
||||
|
||||
func (m Model) refundContentWidth() int {
|
||||
width := min(m.widthContent-refundModalHGutter, refundModalMaxWidth)
|
||||
if width < refundModalMinWidth {
|
||||
width = max(1, m.widthContent-refundTextareaHPad)
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
func (m Model) refundTextareaWidth() int {
|
||||
return max(1, m.refundContentWidth()-refundTextareaHPad)
|
||||
}
|
||||
|
||||
func (m Model) refundTextareaHeight() int {
|
||||
return max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome))
|
||||
}
|
||||
194
pkg/tui/render_smoke_test.go
Normal file
194
pkg/tui/render_smoke_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
var ansiRE = regexp.MustCompile("\x1b\\[[0-9;]*m")
|
||||
|
||||
// TestRenderShopSmoke prints the rendered shop frame so chrome alignment can
|
||||
// be inspected, and asserts that the header, body, and footer boxes share the
|
||||
// same left and right edges.
|
||||
func TestRenderShopSmoke(t *testing.T) {
|
||||
m := NewModel("smoke")
|
||||
m.Loading = false
|
||||
m.Cart[m.Coffees[0].ID] = &models.CartItem{CoffeeID: m.Coffees[0].ID, Coffee: m.Coffees[0], Quantity: 4}
|
||||
m = m.updateLayout(100, 40)
|
||||
m, _ = m.ShopSwitch()
|
||||
|
||||
out := m.renderString()
|
||||
plain := ansiRE.ReplaceAllString(out, "")
|
||||
t.Log("\n" + plain)
|
||||
|
||||
// Collect left/right border columns of every box-drawing row.
|
||||
var lefts, rights []int
|
||||
for _, line := range strings.Split(plain, "\n") {
|
||||
runes := []rune(line)
|
||||
first, last := -1, -1
|
||||
for i, r := range runes {
|
||||
if strings.ContainsRune("╭╮╰╯│", r) {
|
||||
if first == -1 {
|
||||
first = i
|
||||
}
|
||||
last = i
|
||||
}
|
||||
}
|
||||
if first != -1 {
|
||||
lefts = append(lefts, first)
|
||||
rights = append(rights, last)
|
||||
}
|
||||
}
|
||||
if len(lefts) == 0 {
|
||||
t.Fatal("no box-drawing rows found")
|
||||
}
|
||||
for i := range lefts {
|
||||
if lefts[i] != lefts[0] {
|
||||
t.Errorf("left edge misaligned: row %d at col %d, expected %d", i, lefts[i], lefts[0])
|
||||
break
|
||||
}
|
||||
if rights[i] != rights[0] {
|
||||
t.Errorf("right edge misaligned: row %d at col %d, expected %d", i, rights[i], rights[0])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderAccountSmoke verifies the account page boxes stay within the
|
||||
// header/footer chrome bounds. The detail area is unboxed, so inner boxes may
|
||||
// be narrower than the chrome but must never poke outside it.
|
||||
func TestRenderAccountSmoke(t *testing.T) {
|
||||
m := NewModel("smoke")
|
||||
m.Loading = false
|
||||
m = m.updateLayout(100, 40)
|
||||
m, _ = m.AccountSwitch()
|
||||
|
||||
plain := ansiRE.ReplaceAllString(m.renderString(), "")
|
||||
t.Log("\n" + plain)
|
||||
|
||||
var lefts, rights []int
|
||||
for _, line := range strings.Split(plain, "\n") {
|
||||
runes := []rune(line)
|
||||
first, last := -1, -1
|
||||
for i, r := range runes {
|
||||
if strings.ContainsRune("╭╮╰╯│", r) {
|
||||
if first == -1 {
|
||||
first = i
|
||||
}
|
||||
last = i
|
||||
}
|
||||
}
|
||||
if first != -1 {
|
||||
lefts = append(lefts, first)
|
||||
rights = append(rights, last)
|
||||
}
|
||||
}
|
||||
if len(lefts) == 0 {
|
||||
t.Fatal("no box-drawing rows found")
|
||||
}
|
||||
// Row 0 is the header box: it defines the chrome bounds.
|
||||
for i := range lefts {
|
||||
if lefts[i] < lefts[0] || rights[i] > rights[0] {
|
||||
t.Errorf("box outside chrome: row %d spans %d-%d, chrome is %d-%d",
|
||||
i, lefts[i], rights[i], lefts[0], rights[0])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderOrderWindowing verifies the windowed order list: whole cards
|
||||
// only, with ↑/↓ arrow rows marking hidden items.
|
||||
func TestRenderOrderWindowing(t *testing.T) {
|
||||
m := NewModel("smoke")
|
||||
m.Loading = false
|
||||
m.OrdersLoaded = true
|
||||
for i := 1; i <= 9; i++ {
|
||||
m.Orders = append(m.Orders, models.Order{
|
||||
ID: uint(9000 + i),
|
||||
Status: models.OrderStatusPaid,
|
||||
Total: 1000 + i*100,
|
||||
})
|
||||
}
|
||||
m = m.updateLayout(100, 40)
|
||||
m, _ = m.AccountSwitch()
|
||||
|
||||
// Preview mode: window starts at the top, arrow only below.
|
||||
plain := ansiRE.ReplaceAllString(m.renderString(), "")
|
||||
t.Log("\n" + plain)
|
||||
if !strings.Contains(plain, "↓ 4 more") {
|
||||
t.Errorf("preview mode: expected '↓ 4 more' indicator")
|
||||
}
|
||||
if strings.Contains(plain, "↑") {
|
||||
t.Errorf("preview mode: unexpected up arrow")
|
||||
}
|
||||
|
||||
// Browse mode scrolled to the middle: arrows on both ends.
|
||||
m.account.orderViewState = 1
|
||||
m.account.orderCursor = 6
|
||||
m = m.scrollToAccountDetailItem()
|
||||
plain = ansiRE.ReplaceAllString(m.renderString(), "")
|
||||
t.Log("\n" + plain)
|
||||
if !strings.Contains(plain, "↑ 2 more") || !strings.Contains(plain, "↓ 2 more") {
|
||||
t.Errorf("browse mode: expected '↑ 2 more' and '↓ 2 more' indicators")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderCartSmoke verifies the cart page cards line up with the chrome.
|
||||
func TestRenderCartSmoke(t *testing.T) {
|
||||
m := NewModel("smoke")
|
||||
m.Loading = false
|
||||
// Seed coffees carry no IDs, so assign them for distinct cart keys
|
||||
for i := range m.Coffees {
|
||||
m.Coffees[i].ID = uint(i + 1)
|
||||
}
|
||||
m.Cart[m.Coffees[0].ID] = &models.CartItem{CoffeeID: m.Coffees[0].ID, Coffee: m.Coffees[0], Quantity: 2}
|
||||
m.Cart[m.Coffees[1].ID] = &models.CartItem{CoffeeID: m.Coffees[1].ID, Coffee: m.Coffees[1], Quantity: 1}
|
||||
m = m.updateLayout(100, 40)
|
||||
m, _ = m.CartSwitch()
|
||||
|
||||
plain := ansiRE.ReplaceAllString(m.renderString(), "")
|
||||
t.Log("\n" + plain)
|
||||
|
||||
var lefts, rights []int
|
||||
for _, line := range strings.Split(plain, "\n") {
|
||||
runes := []rune(line)
|
||||
first, last := -1, -1
|
||||
for i, r := range runes {
|
||||
if strings.ContainsRune("╭╮╰╯│", r) {
|
||||
if first == -1 {
|
||||
first = i
|
||||
}
|
||||
last = i
|
||||
}
|
||||
}
|
||||
if first != -1 {
|
||||
lefts = append(lefts, first)
|
||||
rights = append(rights, last)
|
||||
}
|
||||
}
|
||||
if len(lefts) == 0 {
|
||||
t.Fatal("no box-drawing rows found")
|
||||
}
|
||||
// Every box row (header, cards, footer) must share the chrome edges.
|
||||
for i := range lefts {
|
||||
if lefts[i] != lefts[0] || rights[i] != rights[0] {
|
||||
t.Errorf("box edge misaligned: row %d spans %d-%d, expected %d-%d",
|
||||
i, lefts[i], rights[i], lefts[0], rights[0])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderShopSmokeStacked prints the stacked (medium/small) layouts.
|
||||
func TestRenderShopSmokeStacked(t *testing.T) {
|
||||
for _, size := range [][2]int{{60, 32}, {45, 30}} {
|
||||
m := NewModel("smoke")
|
||||
m.Loading = false
|
||||
m = m.updateLayout(size[0], size[1])
|
||||
m, _ = m.ShopSwitch()
|
||||
t.Logf("--- %dx%d ---\n%s", size[0], size[1], ansiRE.ReplaceAllString(m.renderString(), ""))
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// countries defines the available countries with their dial codes.
|
||||
@@ -262,12 +262,12 @@ func (m Model) updateShippingViewport() Model {
|
||||
availH = 1
|
||||
}
|
||||
if !m.shipping.viewportReady {
|
||||
m.shipping.viewport = viewport.New(m.widthContent, availH)
|
||||
m.shipping.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||
m.shipping.viewport.KeyMap = viewport.KeyMap{}
|
||||
m.shipping.viewportReady = true
|
||||
} else {
|
||||
m.shipping.viewport.Width = m.widthContent
|
||||
m.shipping.viewport.Height = availH
|
||||
m.shipping.viewport.SetWidth(m.widthContent)
|
||||
m.shipping.viewport.SetHeight(availH)
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -281,11 +281,11 @@ func (m Model) ShippingPageView() string {
|
||||
m.shipping.viewport.SetContent(content)
|
||||
itemHeight := 4
|
||||
targetY := m.shipping.addressCursor * itemHeight
|
||||
if targetY < m.shipping.viewport.YOffset {
|
||||
if targetY < m.shipping.viewport.YOffset() {
|
||||
m.shipping.viewport.SetYOffset(targetY)
|
||||
}
|
||||
if targetY+itemHeight > m.shipping.viewport.YOffset+m.shipping.viewport.Height {
|
||||
m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height + itemHeight + 1)
|
||||
if targetY+itemHeight > m.shipping.viewport.YOffset()+m.shipping.viewport.Height() {
|
||||
m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height() + itemHeight + 1)
|
||||
}
|
||||
if m.shipping.addressCursor == len(m.SavedAddresses) {
|
||||
m.shipping.viewport.GotoBottom()
|
||||
|
||||
255
pkg/tui/shop.go
255
pkg/tui/shop.go
@@ -4,163 +4,173 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
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
|
||||
contentW = min(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.
|
||||
// It is the fallback for products stored before the roast_level column existed.
|
||||
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.Width = menuWidth
|
||||
m.shop.menuViewport.Height = availableHeight
|
||||
m.shop.detailViewport.Width = detailWidth
|
||||
m.shop.detailViewport.Height = 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 {
|
||||
n = max(0, min(n, 5))
|
||||
on := lipgloss.NewStyle().Foreground(cGold).Render(strings.Repeat("● ", n))
|
||||
off := pDim.Render(strings.Repeat("○ ", 5-n))
|
||||
return strings.TrimRight(on+off, " ")
|
||||
}
|
||||
|
||||
func (m Model) shopMenuContent(w int) string {
|
||||
var b strings.Builder
|
||||
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))
|
||||
|
||||
level := coffee.RoastLevel
|
||||
if level < 1 || level > 5 {
|
||||
level = roastLevel(coffee.RoastType)
|
||||
}
|
||||
strength := pDim.Render("roast ") + roastMeter(level)
|
||||
|
||||
price := lipgloss.NewStyle().Foreground(lipgloss.Color(coffee.Color)).Bold(true).
|
||||
Render(fmt.Sprintf("$%d.%02d", coffee.Price/100, coffee.Price%100)) +
|
||||
pDim.Render(" usd")
|
||||
|
||||
// Stock is not modeled in the backend yet, so the indicator is static.
|
||||
stock := lipgloss.NewStyle().Foreground(cGreen).Render("● in stock")
|
||||
|
||||
desc := clampLines(pBody.Width(w).Render(coffee.Description), shopDescLines)
|
||||
|
||||
// stepper: git-diff coloring — red minus, green plus
|
||||
minus := lipgloss.NewStyle().Foreground(cRed).Bold(true).Render("−")
|
||||
plus := lipgloss.NewStyle().Foreground(cGreen).Bold(true).Render("+")
|
||||
count := lipgloss.NewStyle().Foreground(cWhite).Bold(true).
|
||||
Padding(0, 2).Render(fmt.Sprintf("%d", quantity))
|
||||
stepper := pDim.Render("qty ") + minus + count + plus
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
name,
|
||||
spec,
|
||||
strength,
|
||||
"",
|
||||
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 +207,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 {
|
||||
@@ -216,18 +217,8 @@ func (m Model) shopMoveSelected(previous bool) Model {
|
||||
} else {
|
||||
next++
|
||||
}
|
||||
if next < 0 {
|
||||
next = 0
|
||||
}
|
||||
if next >= len(m.Coffees) {
|
||||
next = len(m.Coffees) - 1
|
||||
}
|
||||
next = max(next, 0)
|
||||
next = min(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
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"terminalShop/pkg/api"
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
type DelayCompleteMsg struct{}
|
||||
|
||||
@@ -3,18 +3,18 @@ package tui
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string {
|
||||
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)
|
||||
}
|
||||
|
||||
3
pkg/tui/theme/doc.go
Normal file
3
pkg/tui/theme/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package theme keeps the colors and styles in one spot so the whole UI
|
||||
// looks like it was built by the same person.
|
||||
package theme
|
||||
@@ -1,51 +1,50 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// HuhTheme creates a huh form theme using this project's color palette.
|
||||
func HuhTheme(t Theme) *huh.Theme {
|
||||
var th huh.Theme
|
||||
func HuhTheme(t Theme) huh.Theme {
|
||||
var s huh.Styles
|
||||
|
||||
th.FieldSeparator = t.renderer.NewStyle().SetString("\n\n")
|
||||
s.FieldSeparator = lipgloss.NewStyle().SetString("\n\n")
|
||||
|
||||
f := &th.Focused
|
||||
f.Base = t.renderer.NewStyle().
|
||||
f := &s.Focused
|
||||
f.Base = lipgloss.NewStyle().
|
||||
PaddingLeft(1).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderLeft(true).
|
||||
BorderForeground(t.accent)
|
||||
f.Title = t.renderer.NewStyle().Foreground(t.body)
|
||||
f.Description = t.renderer.NewStyle().Foreground(t.body)
|
||||
f.TextInput.Cursor = t.renderer.NewStyle().Foreground(t.highlight)
|
||||
f.TextInput.Placeholder = t.renderer.NewStyle().Foreground(t.dim)
|
||||
f.TextInput.Prompt = t.renderer.NewStyle().Foreground(t.accent)
|
||||
f.TextInput.Text = t.renderer.NewStyle().Foreground(t.accent)
|
||||
f.ErrorIndicator = t.renderer.NewStyle().Foreground(t.error)
|
||||
f.ErrorMessage = t.renderer.NewStyle().Foreground(t.error)
|
||||
f.Title = lipgloss.NewStyle().Foreground(t.body)
|
||||
f.Description = lipgloss.NewStyle().Foreground(t.body)
|
||||
f.TextInput.Cursor = lipgloss.NewStyle().Foreground(t.highlight)
|
||||
f.TextInput.Placeholder = lipgloss.NewStyle().Foreground(t.dim)
|
||||
f.TextInput.Prompt = lipgloss.NewStyle().Foreground(t.accent)
|
||||
f.TextInput.Text = lipgloss.NewStyle().Foreground(t.accent)
|
||||
f.ErrorIndicator = lipgloss.NewStyle().Foreground(t.error)
|
||||
f.ErrorMessage = lipgloss.NewStyle().Foreground(t.error)
|
||||
|
||||
f.SelectSelector = t.renderer.NewStyle().Foreground(t.accent).SetString("> ")
|
||||
f.Option = t.renderer.NewStyle().Foreground(t.body)
|
||||
f.SelectedOption = t.renderer.NewStyle().Foreground(t.highlight).Bold(true)
|
||||
f.MultiSelectSelector = t.renderer.NewStyle().Foreground(t.accent).SetString("> ")
|
||||
f.SelectedPrefix = t.renderer.NewStyle().Foreground(t.highlight).SetString("[x] ")
|
||||
f.UnselectedPrefix = t.renderer.NewStyle().Foreground(t.dim).SetString("[ ] ")
|
||||
f.UnselectedOption = t.renderer.NewStyle().Foreground(t.body)
|
||||
f.SelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ")
|
||||
f.Option = lipgloss.NewStyle().Foreground(t.body)
|
||||
f.SelectedOption = lipgloss.NewStyle().Foreground(t.highlight).Bold(true)
|
||||
f.MultiSelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ")
|
||||
f.SelectedPrefix = lipgloss.NewStyle().Foreground(t.highlight).SetString("[x] ")
|
||||
f.UnselectedPrefix = lipgloss.NewStyle().Foreground(t.dim).SetString("[ ] ")
|
||||
f.UnselectedOption = lipgloss.NewStyle().Foreground(t.body)
|
||||
|
||||
th.Help = help.New().Styles
|
||||
s.Help = help.New().Styles
|
||||
|
||||
th.Blurred = copyFieldStyles(*f)
|
||||
th.Blurred.Base = th.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
|
||||
th.Blurred.Title = th.Blurred.Title.Foreground(t.body)
|
||||
th.Blurred.SelectSelector = t.renderer.NewStyle().SetString(" ")
|
||||
th.Blurred.SelectedOption = t.renderer.NewStyle().Foreground(t.success).Bold(true)
|
||||
th.Blurred.Option = t.renderer.NewStyle().Foreground(t.dim)
|
||||
s.Blurred = copyFieldStyles(*f)
|
||||
s.Blurred.Base = s.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
|
||||
s.Blurred.Title = s.Blurred.Title.Foreground(t.body)
|
||||
s.Blurred.SelectSelector = lipgloss.NewStyle().SetString(" ")
|
||||
s.Blurred.SelectedOption = lipgloss.NewStyle().Foreground(t.success).Bold(true)
|
||||
s.Blurred.Option = lipgloss.NewStyle().Foreground(t.dim)
|
||||
|
||||
return &th
|
||||
return huh.ThemeFunc(func(bool) *huh.Styles { return &s })
|
||||
}
|
||||
|
||||
// copyFieldStyles returns a copy of FieldStyles (value type, assignment is a copy).
|
||||
func copyFieldStyles(f huh.FieldStyles) huh.FieldStyles { return f }
|
||||
|
||||
@@ -1,56 +1,55 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"image/color"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"charm.land/lipgloss/v2/compat"
|
||||
)
|
||||
|
||||
// Theme holds all colors and styles for the terminal coffee shop TUI.
|
||||
// Theme holds all colors and styles for the TUI
|
||||
type Theme struct {
|
||||
renderer *lipgloss.Renderer
|
||||
|
||||
background lipgloss.TerminalColor
|
||||
border lipgloss.TerminalColor
|
||||
body lipgloss.TerminalColor
|
||||
dim lipgloss.TerminalColor
|
||||
accent lipgloss.TerminalColor
|
||||
highlight lipgloss.TerminalColor
|
||||
brand lipgloss.TerminalColor
|
||||
label lipgloss.TerminalColor
|
||||
success lipgloss.TerminalColor
|
||||
error lipgloss.TerminalColor
|
||||
background color.Color
|
||||
border color.Color
|
||||
body color.Color
|
||||
dim color.Color
|
||||
accent color.Color
|
||||
highlight color.Color
|
||||
brand color.Color
|
||||
label color.Color
|
||||
success color.Color
|
||||
error color.Color
|
||||
|
||||
base lipgloss.Style
|
||||
form *huh.Theme
|
||||
form huh.Theme
|
||||
}
|
||||
|
||||
// BasicTheme constructs the default theme for terminal coffee shop.
|
||||
func BasicTheme(renderer *lipgloss.Renderer) Theme {
|
||||
t := Theme{renderer: renderer}
|
||||
func BasicTheme() Theme {
|
||||
t := Theme{}
|
||||
|
||||
t.background = lipgloss.AdaptiveColor{Dark: "#000000", Light: "#FBFCFD"}
|
||||
t.border = lipgloss.AdaptiveColor{Dark: "#666666", Light: "#D7DBDF"}
|
||||
t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#000000"), Light: lipgloss.Color("#FBFCFD")}
|
||||
t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#666666"), Light: lipgloss.Color("#D7DBDF")}
|
||||
t.body = lipgloss.Color("#AAAAAA")
|
||||
t.dim = lipgloss.Color("#666666")
|
||||
t.accent = lipgloss.AdaptiveColor{Dark: "#FFFFFF", Light: "#11181C"}
|
||||
t.accent = compat.AdaptiveColor{Dark: lipgloss.Color("#FFFFFF"), Light: lipgloss.Color("#11181C")}
|
||||
t.highlight = lipgloss.Color("#4682B4")
|
||||
t.brand = lipgloss.Color("#4682B4")
|
||||
t.label = lipgloss.Color("#999999")
|
||||
t.success = lipgloss.Color("#00FF00")
|
||||
t.error = lipgloss.Color("196")
|
||||
|
||||
t.base = renderer.NewStyle().Foreground(t.body)
|
||||
t.base = lipgloss.NewStyle().Foreground(t.body)
|
||||
t.form = HuhTheme(t)
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// this should resemble something like catppuccin
|
||||
func Catppuccin(renderer *lipgloss.Renderer) Theme {
|
||||
t := Theme{renderer: renderer}
|
||||
func Catppuccin() Theme {
|
||||
t := Theme{}
|
||||
|
||||
t.background = lipgloss.AdaptiveColor{Dark: "#1e1e2e", Light: "#eff1f5"}
|
||||
t.border = lipgloss.AdaptiveColor{Dark: "#45475A", Light: "#BCC0CC"}
|
||||
t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#1e1e2e"), Light: lipgloss.Color("#eff1f5")}
|
||||
t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#45475A"), Light: lipgloss.Color("#BCC0CC")}
|
||||
t.body = lipgloss.Color("#AAAAAA")
|
||||
t.dim = lipgloss.Color("#6C7086")
|
||||
t.accent = lipgloss.Color("#CBA6F7")
|
||||
@@ -60,25 +59,25 @@ func Catppuccin(renderer *lipgloss.Renderer) Theme {
|
||||
t.success = lipgloss.Color("#A6E3A1")
|
||||
t.error = lipgloss.Color("#F38BA8")
|
||||
|
||||
t.base = renderer.NewStyle().Foreground(t.body)
|
||||
t.base = lipgloss.NewStyle().Foreground(t.body)
|
||||
t.form = HuhTheme(t)
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// Color accessors — use in .Foreground() / .Background() calls.
|
||||
func (t Theme) Background() lipgloss.TerminalColor { return t.background }
|
||||
func (t Theme) Border() lipgloss.TerminalColor { return t.border }
|
||||
func (t Theme) Body() lipgloss.TerminalColor { return t.body }
|
||||
func (t Theme) Dim() lipgloss.TerminalColor { return t.dim }
|
||||
func (t Theme) Accent() lipgloss.TerminalColor { return t.accent }
|
||||
func (t Theme) Highlight() lipgloss.TerminalColor { return t.highlight }
|
||||
func (t Theme) Brand() lipgloss.TerminalColor { return t.brand }
|
||||
func (t Theme) Label() lipgloss.TerminalColor { return t.label }
|
||||
func (t Theme) Success() lipgloss.TerminalColor { return t.success }
|
||||
func (t Theme) Error() lipgloss.TerminalColor { return t.error }
|
||||
// Color accessors.
|
||||
func (t Theme) Background() color.Color { return t.background }
|
||||
func (t Theme) Border() color.Color { return t.border }
|
||||
func (t Theme) Body() color.Color { return t.body }
|
||||
func (t Theme) Dim() color.Color { return t.dim }
|
||||
func (t Theme) Accent() color.Color { return t.accent }
|
||||
func (t Theme) Highlight() color.Color { return t.highlight }
|
||||
func (t Theme) Brand() color.Color { return t.brand }
|
||||
func (t Theme) Label() color.Color { return t.label }
|
||||
func (t Theme) Success() color.Color { return t.success }
|
||||
func (t Theme) Error() color.Color { return t.error }
|
||||
|
||||
// Style helpers — return a copy of base with the correct foreground.
|
||||
// Chain .Bold(), .Width(), .Padding() etc. on the returned style.
|
||||
// Style helpers.
|
||||
func (t Theme) Base() lipgloss.Style { return t.base }
|
||||
func (t Theme) TextBody() lipgloss.Style { return t.Base().Foreground(t.body) }
|
||||
func (t Theme) TextAccent() lipgloss.Style { return t.Base().Foreground(t.accent) }
|
||||
@@ -90,10 +89,8 @@ func (t Theme) TextSuccess() lipgloss.Style { return t.Base().Foreground(t.suc
|
||||
func (t Theme) TextError() lipgloss.Style { return t.Base().Foreground(t.error) }
|
||||
func (t Theme) TextLoading() lipgloss.Style { return t.Base().Foreground(lipgloss.Color("205")) }
|
||||
|
||||
// PanelError returns a style for the error banner: dark red bg, red fg.
|
||||
func (t Theme) PanelError() lipgloss.Style {
|
||||
return t.Base().Background(lipgloss.Color("52")).Foreground(lipgloss.Color("196"))
|
||||
}
|
||||
|
||||
// Form returns the huh form theme for shipping and payment forms.
|
||||
func (t Theme) Form() *huh.Theme { return t.form }
|
||||
func (t Theme) Form() huh.Theme { return t.form }
|
||||
|
||||
@@ -2,11 +2,12 @@ package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"terminalShop/pkg/api"
|
||||
"terminalShop/pkg/models"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"terminalShop/pkg/api"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -15,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:
|
||||
@@ -36,6 +35,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.payment.form != nil {
|
||||
m.payment.form.form = m.buildPaymentForm(m.payment.form)
|
||||
}
|
||||
if m.refund.open {
|
||||
m.refund.form = m.buildRefundForm(&m.refund)
|
||||
m.resizeRefundTextarea(&m.refund)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -51,9 +54,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case VisibleError:
|
||||
m.error = &msg
|
||||
m.notice = nil
|
||||
return m, nil
|
||||
case VisibleNotice:
|
||||
m.notice = &msg
|
||||
m.error = nil
|
||||
return m, nil
|
||||
case error:
|
||||
m.error = &VisibleError{message: msg.Error()}
|
||||
m.notice = nil
|
||||
if m.currentPage == shopPage || m.currentPage == cartPage {
|
||||
return m, m.fetchCartCmd
|
||||
}
|
||||
@@ -167,9 +176,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.shipping.form != nil || m.payment.form != nil {
|
||||
break
|
||||
}
|
||||
// Dismiss error banner with ESC
|
||||
if msg.String() == "esc" && m.error != nil {
|
||||
// Refund overlay owns input when open; dispatched below before page handlers.
|
||||
if m.refund.open {
|
||||
break
|
||||
}
|
||||
// Dismiss banners with ESC.
|
||||
if msg.String() == "esc" && (m.error != nil || m.notice != nil) {
|
||||
m.error = nil
|
||||
m.notice = nil
|
||||
return m, nil
|
||||
}
|
||||
// Global navigation
|
||||
@@ -192,6 +206,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
// non-globals. fall through to page dispatch below
|
||||
}
|
||||
|
||||
// Refund overlay intercepts all input after global handling.
|
||||
if m.refund.open {
|
||||
return m.RefundUpdate(msg)
|
||||
}
|
||||
|
||||
// Page-specifics
|
||||
switch m.currentPage {
|
||||
case shopPage:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// ResizeView renders a message when the terminal is too small.
|
||||
@@ -19,7 +20,13 @@ func (m Model) ResizeView() string {
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
func (m Model) View() tea.View {
|
||||
v := tea.NewView(m.renderString())
|
||||
v.AltScreen = true
|
||||
return v
|
||||
}
|
||||
|
||||
func (m Model) renderString() string {
|
||||
if m.viewportWidth == 0 {
|
||||
m.updateLayout(120, 30)
|
||||
}
|
||||
@@ -55,7 +62,8 @@ func (m Model) View() string {
|
||||
if breadcrumbs != "" {
|
||||
breadcrumbsHeight = lipgloss.Height(breadcrumbs)
|
||||
}
|
||||
footerHeight := 1
|
||||
footer := m.BuildFooter()
|
||||
footerHeight := lipgloss.Height(footer)
|
||||
marginTop := 1
|
||||
marginBottom := 1
|
||||
bufferSpace := 1
|
||||
@@ -75,14 +83,20 @@ func (m Model) View() string {
|
||||
content = loadingStyle.Render("Loading products from API...")
|
||||
} else if m.error != nil {
|
||||
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
|
||||
// soft-wrap long messages instead of clipping.
|
||||
if m.widthContent > 4 {
|
||||
errorStyle = errorStyle.Width(m.widthContent - 4)
|
||||
}
|
||||
errorBanner := errorStyle.Render(m.error.message)
|
||||
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
|
||||
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()
|
||||
} 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
|
||||
@@ -108,6 +122,10 @@ func (m Model) View() string {
|
||||
MaxHeight(m.heightContainer).
|
||||
Render(child)
|
||||
|
||||
if m.refund.open {
|
||||
constrained = m.RenderRefundOverlay()
|
||||
}
|
||||
|
||||
// Center the entire container in the terminal viewport
|
||||
return lipgloss.Place(
|
||||
m.viewportWidth,
|
||||
@@ -118,10 +136,10 @@ func (m Model) View() 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:
|
||||
|
||||
3
pkg/utils/doc.go
Normal file
3
pkg/utils/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package utils is the catch-all drawer for tiny HTTP helpers that didn't
|
||||
// earn a package of their own.
|
||||
package utils
|
||||
@@ -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)
|
||||
|
||||
|
||||
3
pkg/validate/doc.go
Normal file
3
pkg/validate/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package validate gives user input a once-over before the rest of the app
|
||||
// trusts it.
|
||||
package validate
|
||||
13
scripts/backups/01-log-tail.sh
Executable file
13
scripts/backups/01-log-tail.sh
Executable 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
14
scripts/backups/02-snapshots.sh
Executable 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
21
scripts/backups/03-check.sh
Executable 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
|
||||
57
scripts/backups/04-restore-verify.sh
Executable file
57
scripts/backups/04-restore-verify.sh
Executable 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
41
scripts/backups/05-freshness.sh
Executable 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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user