Compare commits
2 Commits
fix/temp-d
...
feat/refun
| Author | SHA1 | Date | |
|---|---|---|---|
| 74d6a9fdf2 | |||
| 3737eea7f6 |
33
.github/workflows/ci.yml
vendored
33
.github/workflows/ci.yml
vendored
@@ -8,9 +8,9 @@ jobs:
|
|||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
@@ -30,36 +30,9 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
if: ${{ !env.ACT }}
|
|
||||||
uses: golangci/golangci-lint-action@v7
|
uses: golangci/golangci-lint-action@v7
|
||||||
with:
|
with:
|
||||||
version: v2.4.0
|
version: v2.4.0
|
||||||
|
|
||||||
- name: Install tparse
|
|
||||||
run: go install github.com/mfridman/tparse@v0.18.0
|
|
||||||
|
|
||||||
- name: Test (race detector)
|
- name: Test (race detector)
|
||||||
run: |
|
run: go test -race ./...
|
||||||
set +e
|
|
||||||
go tool gotestsum \
|
|
||||||
--format pkgname \
|
|
||||||
--format-icons hivis \
|
|
||||||
--hide-summary=skipped,output,errors,failed \
|
|
||||||
--jsonfile test-output.json \
|
|
||||||
--junitfile test-report.xml \
|
|
||||||
--packages="./..." \
|
|
||||||
-- -count=1 -race -covermode=atomic -coverprofile=coverage.out
|
|
||||||
rc=$?
|
|
||||||
echo
|
|
||||||
tparse -all -notests -slow 10 -file=test-output.json || true
|
|
||||||
exit $rc
|
|
||||||
|
|
||||||
- name: Upload test report
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: test-report
|
|
||||||
path: |
|
|
||||||
test-output.json
|
|
||||||
test-report.xml
|
|
||||||
coverage.out
|
|
||||||
|
|||||||
6
.github/workflows/deploy.yml
vendored
6
.github/workflows/deploy.yml
vendored
@@ -8,10 +8,10 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version: '1.23'
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: go test ./...
|
run: go test ./...
|
||||||
- name: Deploy to server
|
- name: Deploy to server
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -5,9 +5,6 @@
|
|||||||
.env.example
|
.env.example
|
||||||
.env.prod
|
.env.prod
|
||||||
|
|
||||||
docs/sca-psd2-compliance.md
|
|
||||||
accept-a-payment/
|
|
||||||
|
|
||||||
|
|
||||||
FULFILLMENT.md
|
FULFILLMENT.md
|
||||||
.tmp-gocache/
|
.tmp-gocache/
|
||||||
@@ -33,8 +30,6 @@ api/api-server
|
|||||||
*.out
|
*.out
|
||||||
coverage.txt
|
coverage.txt
|
||||||
coverage.html
|
coverage.html
|
||||||
test-output.json
|
|
||||||
test-report.xml
|
|
||||||
|
|
||||||
# Dependency directories
|
# Dependency directories
|
||||||
vendor/
|
vendor/
|
||||||
@@ -116,6 +111,3 @@ ROADMAP.md
|
|||||||
|
|
||||||
*.bak
|
*.bak
|
||||||
terminalshop.prod.*
|
terminalshop.prod.*
|
||||||
|
|
||||||
# Backup verification config (real values; example file is committed)
|
|
||||||
scripts/backups/config.env
|
|
||||||
|
|||||||
55
Makefile
55
Makefile
@@ -1,55 +0,0 @@
|
|||||||
.PHONY: test test-pretty test-watch test-ci test-summary cover
|
|
||||||
|
|
||||||
PKGS ?= ./...
|
|
||||||
JSON_OUT ?= test-output.json
|
|
||||||
JUNIT ?= test-report.xml
|
|
||||||
COVER ?= coverage.out
|
|
||||||
|
|
||||||
# Plain go test. Fastest, no extra deps loaded
|
|
||||||
test:
|
|
||||||
go test $(PKGS)
|
|
||||||
|
|
||||||
# Live pretty output. Groups by test name, reruns flakes once.
|
|
||||||
# Hides gotestsum's plain-text summary; tparse renders failures instead.
|
|
||||||
test-pretty:
|
|
||||||
@set +e; \
|
|
||||||
go tool gotestsum \
|
|
||||||
--format testname \
|
|
||||||
--format-icons hivis \
|
|
||||||
--hide-summary=skipped,output,errors,failed \
|
|
||||||
--rerun-fails=1 \
|
|
||||||
--jsonfile $(JSON_OUT) \
|
|
||||||
--packages="$(PKGS)" \
|
|
||||||
-- -count=1; \
|
|
||||||
rc=$$?; \
|
|
||||||
echo; \
|
|
||||||
tparse -all -notests -file=$(JSON_OUT) || true; \
|
|
||||||
exit $$rc
|
|
||||||
|
|
||||||
# TDD loop: re-runs affected packages on file save.
|
|
||||||
test-watch:
|
|
||||||
go tool gotestsum --watch --format testname --packages="$(PKGS)"
|
|
||||||
|
|
||||||
# CI / pre-push: race + coverage + JSON + JUnit + tparse summary table
|
|
||||||
test-ci:
|
|
||||||
@set +e; \
|
|
||||||
go tool gotestsum \
|
|
||||||
--format pkgname \
|
|
||||||
--format-icons hivis \
|
|
||||||
--hide-summary=skipped,output,errors,failed \
|
|
||||||
--jsonfile $(JSON_OUT) \
|
|
||||||
--junitfile $(JUNIT) \
|
|
||||||
--packages="$(PKGS)" \
|
|
||||||
-- -count=1 -race -covermode=atomic -coverprofile=$(COVER); \
|
|
||||||
rc=$$?; \
|
|
||||||
echo; \
|
|
||||||
tparse -all -notests -slow 10 -file=$(JSON_OUT) || true; \
|
|
||||||
exit $$rc
|
|
||||||
|
|
||||||
# Replay last summary without re-running tests
|
|
||||||
test-summary:
|
|
||||||
tparse -all -notests -file=$(JSON_OUT)
|
|
||||||
|
|
||||||
# Open HTML coverage report from last `test-ci` run
|
|
||||||
cover:
|
|
||||||
go tool cover -html=$(COVER)
|
|
||||||
@@ -42,7 +42,7 @@ func (h *AddressHandler) GetAddresses(w http.ResponseWriter, r *http.Request) {
|
|||||||
var addresses []models.Address
|
var addresses []models.Address
|
||||||
db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&addresses)
|
db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&addresses)
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"addresses": addresses,
|
"addresses": addresses,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -154,7 +154,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusCreated, map[string]any{
|
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
|
||||||
"address": address,
|
"address": address,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -183,7 +183,7 @@ func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"message": "address successfully deleted",
|
"message": "address successfully deleted",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -218,7 +218,7 @@ func (h *AddressHandler) SetDefaultAddress(w http.ResponseWriter, r *http.Reques
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"address": address,
|
"address": address,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func TestGetAddressesEmpty(t *testing.T) {
|
|||||||
var resp struct {
|
var resp struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Data struct {
|
Data struct {
|
||||||
Addresses []any `json:"addresses"`
|
Addresses []interface{} `json:"addresses"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
middleware.RecordAuthAttempt("success")
|
middleware.RecordAuthAttempt("success")
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"access_token": token,
|
"access_token": token,
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"expires_in": 1800, // 30 minutes in seconds
|
"expires_in": 1800, // 30 minutes in seconds
|
||||||
@@ -146,7 +146,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(errors) > 0 {
|
if len(errors) > 0 {
|
||||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]any{
|
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]interface{}{
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -173,7 +173,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusCreated, map[string]any{
|
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
|
||||||
"user": user.ToPublic(),
|
"user": user.ToPublic(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -195,7 +195,7 @@ func (h *AuthHandler) GetUserBySSHKey(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"user": user.ToPublic(),
|
"user": user.ToPublic(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"terminalShop/pkg/database"
|
|
||||||
"terminalShop/pkg/models"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestGetCards_FiltersExpiredRows drives the SQL WHERE claude used by
|
|
||||||
// GetCards. a column-name typo (e.g used_id instead of user_id) surfaces
|
|
||||||
// here as a sqlite error, not in prod
|
|
||||||
func TestGetCards_FiltersExpiredRows(t *testing.T) {
|
|
||||||
testDB, user := setupCardsExpireTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
now := time.Now()
|
|
||||||
past := now.Add(-1 * time.Hour)
|
|
||||||
future := now.Add(24 * time.Hour)
|
|
||||||
|
|
||||||
expired := models.Card{
|
|
||||||
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
|
|
||||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
|
|
||||||
}
|
|
||||||
fresh := models.Card{
|
|
||||||
UserID: user.ID, StripePaymentID: "pm_fresh", Last4: "2222",
|
|
||||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &future,
|
|
||||||
}
|
|
||||||
legacy := models.Card{
|
|
||||||
UserID: user.ID, StripePaymentID: "pm_legacy", Last4: "3333",
|
|
||||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030,
|
|
||||||
}
|
|
||||||
for _, card := range []*models.Card{&expired, &fresh, &legacy} {
|
|
||||||
if err := db.Create(card).Error; err != nil {
|
|
||||||
t.Fatalf("seed card: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := NewCardHandler("")
|
|
||||||
req := authRequest("GET", "/api/v1/cards", nil, user.ID)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
handler.GetCards(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GetCards status %d, body %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Data struct {
|
|
||||||
Cards []models.Card `json:"cards"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v", err)
|
|
||||||
}
|
|
||||||
if len(resp.Data.Cards) != 2 {
|
|
||||||
t.Fatalf("want 2 visible cards (fresh + legacy NULL), got %d", len(resp.Data.Cards))
|
|
||||||
}
|
|
||||||
for _, card := range resp.Data.Cards {
|
|
||||||
if card.ID == expired.ID {
|
|
||||||
t.Fatalf("expired card leaked into GetCards response")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSetDefaultCard_ExpiredReturns410 verifies the sync-expire branch when
|
|
||||||
// the user acts on a card whose retention deadline already elapsed
|
|
||||||
func TestSetDefaultCard_ExpiredReturns410(t *testing.T) {
|
|
||||||
testDB, user := setupCardsExpireTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
past := time.Now().Add(-1 * time.Hour)
|
|
||||||
card := models.Card{
|
|
||||||
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
|
|
||||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
|
|
||||||
}
|
|
||||||
if err := db.Create(&card).Error; err != nil {
|
|
||||||
t.Fatalf("seed card: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := NewCardHandler("")
|
|
||||||
idStr := strconv.FormatUint(uint64(card.ID), 10)
|
|
||||||
req := authRequest("PUT", "/api/v1/cards/"+idStr+"/default", nil, user.ID)
|
|
||||||
routeCtx := chi.NewRouteContext()
|
|
||||||
routeCtx.URLParams.Add("id", idStr)
|
|
||||||
req = req.WithContext(contextWithRoute(req.Context(), routeCtx))
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
handler.SetDefaultCard(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusGone {
|
|
||||||
t.Fatalf("want 410 gone, got %d, body %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var resp struct {
|
|
||||||
Error struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
} `json:"error"`
|
|
||||||
}
|
|
||||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
|
||||||
if resp.Error.Code != "CARD_STORAGE_EXPIRED" {
|
|
||||||
t.Fatalf("want CARD_STORAGE_EXPIRED code, got %q", resp.Error.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var soft models.Card
|
|
||||||
if err := db.Unscoped().First(&soft, card.ID).Error; err != nil {
|
|
||||||
t.Fatalf("reload card unscoped: %v", err)
|
|
||||||
}
|
|
||||||
if !soft.DeletedAt.Valid {
|
|
||||||
t.Fatalf("expired card was not soft-deleted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRefreshCardStorageTTL_BumpLastUsedAndExpiry exercises the SQL Updates
|
|
||||||
// map used by both the payment_intent.succeede webhook and the duplicate-card
|
|
||||||
// add path. Column-name typos in the map surface here
|
|
||||||
func TestRefreshCardStorageTTL_BumpLastUsedAndExpiry(t *testing.T) {
|
|
||||||
testDB, user := setupCardsExpireTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
old := time.Now().Add(-30 * 24 * time.Hour)
|
|
||||||
card := models.Card{
|
|
||||||
UserID: user.ID, StripePaymentID: "pm_refresh", Last4: "4242",
|
|
||||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &old,
|
|
||||||
}
|
|
||||||
if err := db.Create(&card).Error; err != nil {
|
|
||||||
t.Fatalf("seed card: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
if err := refreshCardStorageTTL(db, card.ID, now); err != nil {
|
|
||||||
t.Fatalf("refreshCardStorageTTL: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var reloaded models.Card
|
|
||||||
if err := db.First(&reloaded, card.ID).Error; err != nil {
|
|
||||||
t.Fatalf("reload card: %v", err)
|
|
||||||
}
|
|
||||||
if reloaded.LastUsedAt == nil || !reloaded.StorageExpiresAt.After(now.Add(179*24*time.Hour)) {
|
|
||||||
t.Fatalf("storage_expires_at not extended, got %v", reloaded.StorageExpiresAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,11 +5,9 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/stripe/stripe-go/v78"
|
"github.com/stripe/stripe-go/v78"
|
||||||
@@ -26,75 +24,62 @@ import (
|
|||||||
stripeSession "github.com/stripe/stripe-go/v78/checkout/session"
|
stripeSession "github.com/stripe/stripe-go/v78/checkout/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
const RedirectTTL = 10 * time.Minute
|
// payRedirects maps short tokens to full Stripe checkout URLs.
|
||||||
|
var (
|
||||||
|
payRedirects = map[string]string{}
|
||||||
|
payRedirectsMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
func storeRedirect(stripeURL, purpose string) (string, error) {
|
func storeRedirect(stripeURL string) (string, error) {
|
||||||
b := make([]byte, 16)
|
b := make([]byte, 16)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
token := hex.EncodeToString(b)
|
token := hex.EncodeToString(b)
|
||||||
now := time.Now()
|
payRedirectsMu.Lock()
|
||||||
rec := models.PayRedirect{
|
payRedirects[token] = stripeURL
|
||||||
Token: token,
|
payRedirectsMu.Unlock()
|
||||||
URL: stripeURL,
|
|
||||||
Purpose: purpose,
|
|
||||||
CreatedAt: now,
|
|
||||||
ExpiresAt: now.Add(RedirectTTL),
|
|
||||||
}
|
|
||||||
if err := database.GetDB().Create(&rec).Error; err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PayRedirect resolves a short token and redirects to the Stripe checkout URL.
|
// PayRedirect resolves a short token and redirects to the Stripe checkout URL.
|
||||||
func PayRedirect(w http.ResponseWriter, r *http.Request) {
|
func PayRedirect(w http.ResponseWriter, r *http.Request) {
|
||||||
token := chi.URLParam(r, "token")
|
token := chi.URLParam(r, "token")
|
||||||
db := database.GetDB().WithContext(r.Context())
|
payRedirectsMu.Lock()
|
||||||
var rec models.PayRedirect
|
url, ok := payRedirects[token]
|
||||||
if err := db.Where("token = ?", token).First(&rec).Error; err != nil {
|
payRedirectsMu.Unlock()
|
||||||
|
if !ok {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if time.Now().After(rec.ExpiresAt) {
|
http.Redirect(w, r, url, http.StatusFound)
|
||||||
db.Where("token = ?", rec.Token).Delete(&models.PayRedirect{})
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, rec.URL, http.StatusFound)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CardHandler handles saved payment method CRUD.
|
// CardHandler handles saved payment method CRUD.
|
||||||
type CardHandler struct {
|
type CardHandler struct {
|
||||||
appURL string
|
stripeKey string
|
||||||
|
appURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCardHandler creates a new card handler. Stripe credentials are wired
|
// NewCardHandler creates a new card handler with the Stripe secret key.
|
||||||
// once at startup via stripe.Key in api/main.go.
|
func NewCardHandler(stripeSecretKey string, appURL string) *CardHandler {
|
||||||
func NewCardHandler(appURL string) *CardHandler {
|
return &CardHandler{stripeKey: stripeSecretKey, appURL: appURL}
|
||||||
return &CardHandler{appURL: appURL}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCards returns all saved cards for the authenticated user.
|
// GetCards returns all saved cards for the authenticated user.
|
||||||
// Cards past their retention deadline are filtered out at read time;
|
|
||||||
// physical deletion runs in handlers.ReconcileExpiredCards.
|
|
||||||
func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) {
|
func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) {
|
||||||
db := database.GetDB().WithContext(r.Context())
|
db := database.GetDB().WithContext(r.Context())
|
||||||
userID := middleware.UserIDFromContext(r.Context())
|
userID := middleware.UserIDFromContext(r.Context())
|
||||||
|
|
||||||
var cards []models.Card
|
var cards []models.Card
|
||||||
db.Where(
|
db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&cards)
|
||||||
"user_id = ? AND (storage_expires_at IS NULL OR storage_expires_at > ?)",
|
|
||||||
userID, time.Now(),
|
|
||||||
).Order("is_default DESC, created_at DESC").Find(&cards)
|
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"cards": cards,
|
"cards": cards,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]any)
|
// func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]interface{})
|
||||||
|
|
||||||
// GetCard returns a single saved card by ID.
|
// GetCard returns a single saved card by ID.
|
||||||
func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
|
func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -114,7 +99,7 @@ func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"card": card,
|
"card": card,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -136,15 +121,6 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if card.IsStorageExpired(time.Now()) {
|
|
||||||
if err := expireStoredCard(db, &card); err != nil {
|
|
||||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondCardStorageExpired(w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Model(&models.Card{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil {
|
if err := db.Model(&models.Card{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil {
|
||||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update card", nil)
|
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update card", nil)
|
||||||
return
|
return
|
||||||
@@ -156,7 +132,7 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"card": card,
|
"card": card,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -171,6 +147,8 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stripe.Key = h.stripeKey
|
||||||
|
|
||||||
if err := ensureStripeCustomer(db, &user); err != nil {
|
if err := ensureStripeCustomer(db, &user); err != nil {
|
||||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
|
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
|
||||||
return
|
return
|
||||||
@@ -190,12 +168,12 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := storeRedirect(sess.URL, models.RedirectPurposeAddCard)
|
token, err := storeRedirect(sess.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
|
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
|
"url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -232,6 +210,8 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stripe.Key = h.stripeKey
|
||||||
|
|
||||||
// Ensure the user has a Stripe customer, creating one if needed.
|
// Ensure the user has a Stripe customer, creating one if needed.
|
||||||
if err := ensureStripeCustomer(db, &user); err != nil {
|
if err := ensureStripeCustomer(db, &user); err != nil {
|
||||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
|
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
|
||||||
@@ -298,7 +278,7 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
audit.CardAdded(userID, card.ID, card.Brand, card.Last4)
|
audit.CardAdded(userID, card.ID, card.Brand, card.Last4)
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusCreated, map[string]any{
|
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
|
||||||
"card": card,
|
"card": card,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -331,6 +311,7 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Detach from Stripe (best-effort; don't block deletion on Stripe errors).
|
// Detach from Stripe (best-effort; don't block deletion on Stripe errors).
|
||||||
if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" {
|
if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" {
|
||||||
|
stripe.Key = h.stripeKey
|
||||||
_, _ = paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{})
|
_, _ = paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,57 +323,11 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
audit.CardDeleted(userID, card.ID)
|
audit.CardDeleted(userID, card.ID)
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"message": "card deleted",
|
"message": "card deleted",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// expireStoredCard removes a card whose retention deadline elapsed.
|
|
||||||
// Local state (cart references + card row) is committed atomically;
|
|
||||||
// Stripe detach and audit run only after the local commit so retries
|
|
||||||
// cannot double-fire the audit event. Stripe detach is gated on
|
|
||||||
// stripe.Key being non-empty so tests (which never init the SDK key)
|
|
||||||
// skip the real network call without an explicit flag.
|
|
||||||
func expireStoredCard(db *gorm.DB, card *models.Card) error {
|
|
||||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", card.ID, card.UserID).Update("card_id", nil).Error; err != nil {
|
|
||||||
return fmt.Errorf("failed to clear expired card from cart: %w", err)
|
|
||||||
}
|
|
||||||
if err := tx.Delete(card).Error; err != nil {
|
|
||||||
return fmt.Errorf("failed to delete expired card: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if stripe.Key != "" && strings.HasPrefix(card.StripePaymentID, "pm_") {
|
|
||||||
if _, err := paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{}); err != nil {
|
|
||||||
slog.Warn("failed to detach expired card", "card_id", card.ID, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
audit.CardExpired(card.UserID, card.ID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshCardStorageTTL(db *gorm.DB, cardID uint, now time.Time) error {
|
|
||||||
return db.Model(&models.Card{}).Where("id = ?", cardID).Updates(map[string]any{
|
|
||||||
"last_used_at": now,
|
|
||||||
"storage_expires_at": models.CardStorageExpiresAt(now),
|
|
||||||
}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func respondCardStorageExpired(w http.ResponseWriter) {
|
|
||||||
utils.RespondError(
|
|
||||||
w,
|
|
||||||
http.StatusGone,
|
|
||||||
"CARD_STORAGE_EXPIRED",
|
|
||||||
"saved card expired, add it again",
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureStripeCustomer creates a Stripe Customer for the user if they don't
|
// ensureStripeCustomer creates a Stripe Customer for the user if they don't
|
||||||
// already have one and persists the customer ID. Safe to call concurrently —
|
// already have one and persists the customer ID. Safe to call concurrently —
|
||||||
// a duplicate Stripe customer is harmless; the local record reflects the first
|
// a duplicate Stripe customer is harmless; the local record reflects the first
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"terminalShop/pkg/database"
|
|
||||||
"terminalShop/pkg/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestReconcileExpiredCards_RemovesPastDeadlineOnly drives the real SQL path
|
|
||||||
// so a column-name typo in the WHERE clause surfaces here, not in production.
|
|
||||||
func TestReconcileExpiredCards_RemovesPastDeadlineOnly(t *testing.T) {
|
|
||||||
testDB, user := setupCardsExpireTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
now := time.Now()
|
|
||||||
past := now.Add(-1 * time.Hour)
|
|
||||||
future := now.Add(24 * time.Hour)
|
|
||||||
|
|
||||||
expired := models.Card{
|
|
||||||
UserID: user.ID,
|
|
||||||
StripePaymentID: "tok_expired",
|
|
||||||
Last4: "1111",
|
|
||||||
Brand: "Visa",
|
|
||||||
ExpMonth: 1,
|
|
||||||
ExpYear: 2030,
|
|
||||||
StorageExpiresAt: &past,
|
|
||||||
}
|
|
||||||
fresh := models.Card{
|
|
||||||
UserID: user.ID,
|
|
||||||
StripePaymentID: "tok_fresh",
|
|
||||||
Last4: "2222",
|
|
||||||
Brand: "Visa",
|
|
||||||
ExpMonth: 1,
|
|
||||||
ExpYear: 2030,
|
|
||||||
StorageExpiresAt: &future,
|
|
||||||
}
|
|
||||||
noTTL := models.Card{
|
|
||||||
UserID: user.ID,
|
|
||||||
StripePaymentID: "tok_legacy",
|
|
||||||
Last4: "3333",
|
|
||||||
Brand: "Visa",
|
|
||||||
ExpMonth: 1,
|
|
||||||
ExpYear: 2030,
|
|
||||||
}
|
|
||||||
for _, c := range []*models.Card{&expired, &fresh, &noTTL} {
|
|
||||||
if err := db.Create(c).Error; err != nil {
|
|
||||||
t.Fatalf("seed card: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cart := models.Cart{UserID: user.ID, CardID: &expired.ID}
|
|
||||||
if err := db.Create(&cart).Error; err != nil {
|
|
||||||
t.Fatalf("seed cart: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ReconcileExpiredCards(context.Background())
|
|
||||||
|
|
||||||
var remaining []models.Card
|
|
||||||
if err := db.Where("user_id = ?", user.ID).Find(&remaining).Error; err != nil {
|
|
||||||
t.Fatalf("list cards: %v", err)
|
|
||||||
}
|
|
||||||
if len(remaining) != 2 {
|
|
||||||
t.Fatalf("want 2 cards remaining, got %d", len(remaining))
|
|
||||||
}
|
|
||||||
for _, c := range remaining {
|
|
||||||
if c.ID == expired.ID {
|
|
||||||
t.Fatalf("expired card was not deleted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var refreshed models.Cart
|
|
||||||
if err := db.First(&refreshed, cart.ID).Error; err != nil {
|
|
||||||
t.Fatalf("reload cart: %v", err)
|
|
||||||
}
|
|
||||||
if refreshed.CardID != nil {
|
|
||||||
t.Fatalf("cart still references expired card: %v", refreshed.CardID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReconcileExpiredCards_SweepsAcrossUsers verifies the sweeper is not
|
|
||||||
// scoped to one user; it must collect every past-deadline row in one pass.
|
|
||||||
func TestReconcileExpiredCards_SweepsAcrossUsers(t *testing.T) {
|
|
||||||
testDB, user := setupCardsExpireTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
past := time.Now().Add(-1 * time.Hour)
|
|
||||||
|
|
||||||
other := models.User{
|
|
||||||
SSHKeyFingerprint: "SHA256:other",
|
|
||||||
SSHPublicKey: "ssh-ed25519 AAAA other",
|
|
||||||
Name: "Other",
|
|
||||||
Email: "other@example.com",
|
|
||||||
}
|
|
||||||
if err := db.Create(&other).Error; err != nil {
|
|
||||||
t.Fatalf("seed other user: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, uid := range []uint{user.ID, other.ID} {
|
|
||||||
c := models.Card{
|
|
||||||
UserID: uid,
|
|
||||||
StripePaymentID: "tok_expired",
|
|
||||||
Last4: "9999",
|
|
||||||
Brand: "Visa",
|
|
||||||
ExpMonth: 1,
|
|
||||||
ExpYear: 2030,
|
|
||||||
StorageExpiresAt: &past,
|
|
||||||
}
|
|
||||||
if err := db.Create(&c).Error; err != nil {
|
|
||||||
t.Fatalf("seed card: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ReconcileExpiredCards(context.Background())
|
|
||||||
|
|
||||||
var count int64
|
|
||||||
if err := db.Model(&models.Card{}).Count(&count).Error; err != nil {
|
|
||||||
t.Fatalf("count cards: %v", err)
|
|
||||||
}
|
|
||||||
if count != 0 {
|
|
||||||
t.Fatalf("want 0 cards after sweep, got %d", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupCardsExpireTestDB(t *testing.T) (string, models.User) {
|
|
||||||
t.Helper()
|
|
||||||
testDB := "test_cards_expire.db"
|
|
||||||
_ = os.Remove(testDB)
|
|
||||||
database.ResetForTesting()
|
|
||||||
|
|
||||||
db, err := database.Connect(testDB)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("connect: %v", err)
|
|
||||||
}
|
|
||||||
if err := database.Migrate(db); err != nil {
|
|
||||||
t.Fatalf("migrate: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
user := models.User{
|
|
||||||
SSHKeyFingerprint: "SHA256:cards-expire",
|
|
||||||
SSHPublicKey: "ssh-ed25519 AAAA cardsexpire",
|
|
||||||
Name: "Test User",
|
|
||||||
Email: "cards-expire@example.com",
|
|
||||||
}
|
|
||||||
if err := db.Create(&user).Error; err != nil {
|
|
||||||
t.Fatalf("seed user: %v", err)
|
|
||||||
}
|
|
||||||
return testDB, user
|
|
||||||
}
|
|
||||||
@@ -5,10 +5,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/stripe/stripe-go/v78"
|
"github.com/stripe/stripe-go/v78"
|
||||||
"github.com/stripe/stripe-go/v78/paymentintent"
|
"github.com/stripe/stripe-go/v78/paymentintent"
|
||||||
"github.com/stripe/stripe-go/v78/paymentmethod"
|
"github.com/stripe/stripe-go/v78/paymentmethod"
|
||||||
@@ -25,20 +23,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type CartHandler struct {
|
type CartHandler struct {
|
||||||
appURL string
|
stripeKey string
|
||||||
maxOrderCents int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxAuthIssuances caps how many times a single ordes's PaymentIntent can be
|
// NewCartHandler creates a new cart handler with the Stripe secret key
|
||||||
// pushed into requires_action; one initial challende plus up to three retries
|
func NewCartHandler(stripeSecretKey string) *CartHandler {
|
||||||
// before we tell the customer to use a different card. Counted via existing
|
return &CartHandler{stripeKey: stripeSecretKey}
|
||||||
// audit rows (audit.EventOrderRequiresAction) so the cap stays migration-free.
|
|
||||||
const maxAuthIssuances = 4
|
|
||||||
|
|
||||||
// NewCartHandler creates a new cart handler. Stripe credentials are wired
|
|
||||||
// once at startup via stripe.Key in api/main.go.
|
|
||||||
func NewCartHandler(appURL string, maxOrderCents int) *CartHandler {
|
|
||||||
return &CartHandler{appURL: appURL, maxOrderCents: maxOrderCents}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getOrCreateCart finds the users cart or creates one.
|
// getOrCreateCart finds the users cart or creates one.
|
||||||
@@ -55,18 +45,18 @@ func getOrCreateCart(ctx context.Context, userID uint) (*models.Cart, error) {
|
|||||||
return &cart, nil
|
return &cart, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cartResponse(ctx context.Context, cart *models.Cart) map[string]any {
|
func cartResponse(ctx context.Context, cart *models.Cart) map[string]interface{} {
|
||||||
db := database.GetDB().WithContext(ctx)
|
db := database.GetDB().WithContext(ctx)
|
||||||
|
|
||||||
var items []models.CartItem
|
var items []models.CartItem
|
||||||
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
|
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
|
||||||
|
|
||||||
subtotal := 0
|
subtotal := 0
|
||||||
itemsData := make([]map[string]any, 0, len(items))
|
itemsData := make([]map[string]interface{}, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
lineTotal := item.Coffee.Price * item.Quantity
|
lineTotal := item.Coffee.Price * item.Quantity
|
||||||
subtotal += lineTotal
|
subtotal += lineTotal
|
||||||
itemsData = append(itemsData, map[string]any{
|
itemsData = append(itemsData, map[string]interface{}{
|
||||||
"id": item.ID,
|
"id": item.ID,
|
||||||
"coffee_id": item.CoffeeID,
|
"coffee_id": item.CoffeeID,
|
||||||
"quantity": item.Quantity,
|
"quantity": item.Quantity,
|
||||||
@@ -75,7 +65,7 @@ func cartResponse(ctx context.Context, cart *models.Cart) map[string]any {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return map[string]any{
|
return map[string]interface{}{
|
||||||
"items": itemsData,
|
"items": itemsData,
|
||||||
"subtotal": subtotal,
|
"subtotal": subtotal,
|
||||||
"address_id": cart.AddressID,
|
"address_id": cart.AddressID,
|
||||||
@@ -94,7 +84,7 @@ func (h *CartHandler) GetCart(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"cart": cartResponse(r.Context(), cart),
|
"cart": cartResponse(r.Context(), cart),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -157,7 +147,7 @@ func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"cart": cartResponse(r.Context(), cart),
|
"cart": cartResponse(r.Context(), cart),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -193,7 +183,7 @@ func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"message": "address set",
|
"message": "address set",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -218,15 +208,6 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if card.IsStorageExpired(time.Now()) {
|
|
||||||
if err := expireStoredCard(db, &card); err != nil {
|
|
||||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondCardStorageExpired(w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cart, err := getOrCreateCart(r.Context(), userID)
|
cart, err := getOrCreateCart(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||||
@@ -238,7 +219,7 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"message": "card set",
|
"message": "card set",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -258,7 +239,7 @@ func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"message": "cart cleared",
|
"message": "cart cleared",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -313,16 +294,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if card.IsStorageExpired(time.Now()) {
|
|
||||||
middleware.RecordCartConversion("validation_card_expired")
|
|
||||||
if err := expireStoredCard(db, &card); err != nil {
|
|
||||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondCardStorageExpired(w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var user models.User
|
var user models.User
|
||||||
if err := db.First(&user, userID).Error; err != nil {
|
if err := db.First(&user, userID).Error; err != nil {
|
||||||
middleware.RecordCartConversion("user_not_found")
|
middleware.RecordCartConversion("user_not_found")
|
||||||
@@ -330,6 +301,8 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stripe.Key = h.stripeKey
|
||||||
|
|
||||||
// Ensure the user has a Stripe customer (shared helper with cards handler).
|
// Ensure the user has a Stripe customer (shared helper with cards handler).
|
||||||
if err := ensureStripeCustomer(db, &user); err != nil {
|
if err := ensureStripeCustomer(db, &user); err != nil {
|
||||||
middleware.RecordCartConversion("stripe_customer_failed")
|
middleware.RecordCartConversion("stripe_customer_failed")
|
||||||
@@ -380,18 +353,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
total := subtotal + cart.ShippingCost
|
total := subtotal + cart.ShippingCost
|
||||||
|
|
||||||
if h.maxOrderCents > 0 && total > h.maxOrderCents {
|
|
||||||
middleware.RecordCartConversion("validation_over_limit")
|
|
||||||
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 {
|
if total < 50 {
|
||||||
middleware.RecordCartConversion("validation_min_amount")
|
middleware.RecordCartConversion("validation_min_amount")
|
||||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil)
|
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil)
|
||||||
@@ -430,15 +391,16 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Charge the card. The idempotency key is tied to the order ID so retries
|
// Charge the card. The idempotency key is tied to the order ID so retries
|
||||||
// never produce a double charge.
|
// never produce a double charge.
|
||||||
piParams := &stripe.PaymentIntentParams{
|
piParams := &stripe.PaymentIntentParams{
|
||||||
Amount: stripe.Int64(int64(total)),
|
Amount: stripe.Int64(int64(total)),
|
||||||
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
||||||
Customer: stripe.String(user.StripeCustomerID),
|
Customer: stripe.String(user.StripeCustomerID),
|
||||||
PaymentMethod: stripe.String(paymentMethodID),
|
PaymentMethod: stripe.String(paymentMethodID),
|
||||||
OffSession: stripe.Bool(true),
|
AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{
|
||||||
PaymentMethodTypes: stripe.StringSlice([]string{"card"}),
|
Enabled: stripe.Bool(true),
|
||||||
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
|
AllowRedirects: stripe.String("never"),
|
||||||
Confirm: stripe.Bool(true),
|
},
|
||||||
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
|
Confirm: stripe.Bool(true),
|
||||||
|
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
|
||||||
Shipping: &stripe.ShippingDetailsParams{
|
Shipping: &stripe.ShippingDetailsParams{
|
||||||
Name: stripe.String(address.Name),
|
Name: stripe.String(address.Name),
|
||||||
Address: &stripe.AddressParams{
|
Address: &stripe.AddressParams{
|
||||||
@@ -461,17 +423,13 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
|||||||
stripeDur := time.Since(stripeStart).Seconds()
|
stripeDur := time.Since(stripeStart).Seconds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur)
|
middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur)
|
||||||
|
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||||
if stripeErr, ok := err.(*stripe.Error); ok {
|
if stripeErr, ok := err.(*stripe.Error); ok {
|
||||||
if stripeErr.Code == stripe.ErrorCodeAuthenticationRequired && stripeErr.PaymentIntent != nil {
|
|
||||||
h.respondRequiresAction(w, &order, paymentMethodID, stripeErr.PaymentIntent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
|
||||||
switch stripeErr.Type {
|
switch stripeErr.Type {
|
||||||
case stripe.ErrorTypeCard:
|
case stripe.ErrorTypeCard:
|
||||||
middleware.RecordCartConversion("card_declined")
|
middleware.RecordCartConversion("card_declined")
|
||||||
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
|
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
|
||||||
utils.RespondError(w, http.StatusPaymentRequired, mapStripeCardErrCode(stripeErr.Code), stripeErr.Msg, nil)
|
utils.RespondError(w, http.StatusPaymentRequired, "CARD_DECLINED", stripeErr.Msg, nil)
|
||||||
case stripe.ErrorTypeInvalidRequest:
|
case stripe.ErrorTypeInvalidRequest:
|
||||||
middleware.RecordCartConversion("stripe_invalid_request")
|
middleware.RecordCartConversion("stripe_invalid_request")
|
||||||
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
|
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
|
||||||
@@ -483,7 +441,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
|
||||||
middleware.RecordCartConversion("stripe_error")
|
middleware.RecordCartConversion("stripe_error")
|
||||||
audit.OrderFailed(userID, order.ID, total, err.Error())
|
audit.OrderFailed(userID, order.ID, total, err.Error())
|
||||||
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
|
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
|
||||||
@@ -491,11 +448,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
middleware.ObserveStripeRequest("payment_intent_create", "success", stripeDur)
|
middleware.ObserveStripeRequest("payment_intent_create", "success", stripeDur)
|
||||||
|
|
||||||
if pi.Status == stripe.PaymentIntentStatusRequiresAction {
|
|
||||||
h.respondRequiresAction(w, &order, paymentMethodID, pi)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commit payment ID and clear cart in a single transaction.
|
// Commit payment ID and clear cart in a single transaction.
|
||||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||||
order.StripePaymentID = pi.ID
|
order.StripePaymentID = pi.ID
|
||||||
@@ -506,7 +458,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
|
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Model(cart).Updates(map[string]any{
|
return tx.Model(cart).Updates(map[string]interface{}{
|
||||||
"address_id": nil,
|
"address_id": nil,
|
||||||
"card_id": nil,
|
"card_id": nil,
|
||||||
}).Error
|
}).Error
|
||||||
@@ -533,248 +485,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
go notify.SlackOrderPaid(&order)
|
go notify.SlackOrderPaid(&order)
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"order": order,
|
"order": order,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// respondRequiresAction handles a PaymentIntent that needs 3DS auth. The order
|
|
||||||
// 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,19 +3,14 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/stripe/stripe-go/v78"
|
|
||||||
|
|
||||||
"terminalShop/api/middleware"
|
"terminalShop/api/middleware"
|
||||||
"terminalShop/pkg/audit"
|
|
||||||
"terminalShop/pkg/database"
|
"terminalShop/pkg/database"
|
||||||
"terminalShop/pkg/models"
|
"terminalShop/pkg/models"
|
||||||
)
|
)
|
||||||
@@ -70,7 +65,7 @@ func TestGetCartEmpty(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", 0)
|
handler := NewCartHandler("")
|
||||||
|
|
||||||
req := authRequest("GET", "/api/v1/cart", nil, user.ID)
|
req := authRequest("GET", "/api/v1/cart", nil, user.ID)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -85,8 +80,8 @@ func TestGetCartEmpty(t *testing.T) {
|
|||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Data struct {
|
Data struct {
|
||||||
Cart struct {
|
Cart struct {
|
||||||
Items []any `json:"items"`
|
Items []interface{} `json:"items"`
|
||||||
Subtotal int `json:"subtotal"`
|
Subtotal int `json:"subtotal"`
|
||||||
} `json:"cart"`
|
} `json:"cart"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
@@ -109,10 +104,10 @@ func TestSetItemAndGetCart(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", 0)
|
handler := NewCartHandler("")
|
||||||
|
|
||||||
// Add item (CoffeeID=1, Quantity=2)
|
// Add item (CoffeeID=1, Quantity=2)
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
"coffee_id": 1,
|
"coffee_id": 1,
|
||||||
"quantity": 2,
|
"quantity": 2,
|
||||||
})
|
})
|
||||||
@@ -165,16 +160,16 @@ func TestSetItemUpdateQuantity(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", 0)
|
handler := NewCartHandler("")
|
||||||
|
|
||||||
// Add item
|
// Add item
|
||||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 2})
|
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2})
|
||||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
handler.SetItem(w, req)
|
handler.SetItem(w, req)
|
||||||
|
|
||||||
// Update quantity
|
// Update quantity
|
||||||
body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5})
|
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5})
|
||||||
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
handler.SetItem(w, req)
|
handler.SetItem(w, req)
|
||||||
@@ -210,16 +205,16 @@ func TestSetItemRemove(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", 0)
|
handler := NewCartHandler("")
|
||||||
|
|
||||||
// Add item
|
// Add item
|
||||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 3})
|
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3})
|
||||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
handler.SetItem(w, req)
|
handler.SetItem(w, req)
|
||||||
|
|
||||||
// Remove item (quantity 0)
|
// Remove item (quantity 0)
|
||||||
body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 0})
|
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 0})
|
||||||
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
handler.SetItem(w, req)
|
handler.SetItem(w, req)
|
||||||
@@ -233,7 +228,7 @@ func TestSetItemRemove(t *testing.T) {
|
|||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Data struct {
|
Data struct {
|
||||||
Cart struct {
|
Cart struct {
|
||||||
Items []any `json:"items"`
|
Items []interface{} `json:"items"`
|
||||||
} `json:"cart"`
|
} `json:"cart"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
@@ -249,9 +244,9 @@ func TestSetItemInvalidProduct(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", 0)
|
handler := NewCartHandler("")
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]any{"coffee_id": 999, "quantity": 1})
|
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1})
|
||||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
handler.SetItem(w, req)
|
handler.SetItem(w, req)
|
||||||
@@ -266,9 +261,9 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", 0)
|
handler := NewCartHandler("")
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]any{"quantity": 1})
|
body, _ := json.Marshal(map[string]interface{}{"quantity": 1})
|
||||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
handler.SetItem(w, req)
|
handler.SetItem(w, req)
|
||||||
@@ -283,11 +278,11 @@ func TestClearCart(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", 0)
|
handler := NewCartHandler("")
|
||||||
|
|
||||||
// Add two items
|
// Add two items
|
||||||
for _, coffeeID := range []uint{1, 2} {
|
for _, coffeeID := range []uint{1, 2} {
|
||||||
body, _ := json.Marshal(map[string]any{"coffee_id": coffeeID, "quantity": 1})
|
body, _ := json.Marshal(map[string]interface{}{"coffee_id": coffeeID, "quantity": 1})
|
||||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
handler.SetItem(w, req)
|
handler.SetItem(w, req)
|
||||||
@@ -311,7 +306,7 @@ func TestClearCart(t *testing.T) {
|
|||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Data struct {
|
Data struct {
|
||||||
Cart struct {
|
Cart struct {
|
||||||
Items []any `json:"items"`
|
Items []interface{} `json:"items"`
|
||||||
} `json:"cart"`
|
} `json:"cart"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
@@ -339,9 +334,9 @@ func TestSetAddress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
db.Create(&addr)
|
db.Create(&addr)
|
||||||
|
|
||||||
handler := NewCartHandler("", 0)
|
handler := NewCartHandler("")
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]any{"address_id": addr.ID})
|
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||||
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
handler.SetAddress(w, req)
|
handler.SetAddress(w, req)
|
||||||
@@ -393,9 +388,9 @@ func TestSetAddressWrongUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
db.Create(&addr)
|
db.Create(&addr)
|
||||||
|
|
||||||
handler := NewCartHandler("", 0)
|
handler := NewCartHandler("")
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]any{"address_id": addr.ID})
|
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||||
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
handler.SetAddress(w, req)
|
handler.SetAddress(w, req)
|
||||||
@@ -421,9 +416,9 @@ func TestSetCard(t *testing.T) {
|
|||||||
}
|
}
|
||||||
db.Create(&card)
|
db.Create(&card)
|
||||||
|
|
||||||
handler := NewCartHandler("", 0)
|
handler := NewCartHandler("")
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
|
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
||||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
handler.SetCard(w, req)
|
handler.SetCard(w, req)
|
||||||
@@ -473,9 +468,9 @@ func TestSetCardWrongUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
db.Create(&card)
|
db.Create(&card)
|
||||||
|
|
||||||
handler := NewCartHandler("", 0)
|
handler := NewCartHandler("")
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
|
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
||||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
handler.SetCard(w, req)
|
handler.SetCard(w, req)
|
||||||
@@ -490,10 +485,10 @@ func TestConvertCartMissingAddress(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", 0)
|
handler := NewCartHandler("")
|
||||||
|
|
||||||
// Add item but no address or card
|
// Add item but no address or card
|
||||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
|
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
|
||||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
handler.SetItem(w, req)
|
handler.SetItem(w, req)
|
||||||
@@ -513,18 +508,18 @@ func TestConvertCartMissingCard(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", 0)
|
handler := NewCartHandler("")
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|
||||||
// Add item and address
|
// Add item and address
|
||||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
|
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
|
||||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
handler.SetItem(w, req)
|
handler.SetItem(w, req)
|
||||||
|
|
||||||
addr := models.Address{UserID: user.ID, Name: "Test", Street: "123 St", City: "PDX", State: "OR", Zip: "97201", Country: "US"}
|
addr := models.Address{UserID: user.ID, Name: "Test", Street: "123 St", City: "PDX", State: "OR", Zip: "97201", Country: "US"}
|
||||||
db.Create(&addr)
|
db.Create(&addr)
|
||||||
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
|
body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||||
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
handler.SetAddress(w, req)
|
handler.SetAddress(w, req)
|
||||||
@@ -544,7 +539,7 @@ func TestConvertCartEmpty(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", 0)
|
handler := NewCartHandler("")
|
||||||
|
|
||||||
// Empty cart convert
|
// Empty cart convert
|
||||||
req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
|
req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
|
||||||
@@ -561,11 +556,11 @@ func TestMultipleItems(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", 0)
|
handler := NewCartHandler("")
|
||||||
|
|
||||||
// Add three different items
|
// Add three different items
|
||||||
for i := uint(1); i <= 3; i++ {
|
for i := uint(1); i <= 3; i++ {
|
||||||
body, _ := json.Marshal(map[string]any{"coffee_id": i, "quantity": int(i)})
|
body, _ := json.Marshal(map[string]interface{}{"coffee_id": i, "quantity": int(i)})
|
||||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
handler.SetItem(w, req)
|
handler.SetItem(w, req)
|
||||||
@@ -583,8 +578,8 @@ func TestMultipleItems(t *testing.T) {
|
|||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Data struct {
|
Data struct {
|
||||||
Cart struct {
|
Cart struct {
|
||||||
Items []any `json:"items"`
|
Items []interface{} `json:"items"`
|
||||||
Subtotal int `json:"subtotal"`
|
Subtotal int `json:"subtotal"`
|
||||||
} `json:"cart"`
|
} `json:"cart"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
@@ -610,19 +605,19 @@ func TestCartIsolation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
db.Create(&user2)
|
db.Create(&user2)
|
||||||
|
|
||||||
handler := NewCartHandler("", 0)
|
handler := NewCartHandler("")
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Put("/cart/item", handler.SetItem)
|
r.Put("/cart/item", handler.SetItem)
|
||||||
r.Get("/cart", handler.GetCart)
|
r.Get("/cart", handler.GetCart)
|
||||||
|
|
||||||
// User 1 adds item
|
// User 1 adds item
|
||||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5})
|
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5})
|
||||||
req := authRequest("PUT", "/cart/item", body, user1.ID)
|
req := authRequest("PUT", "/cart/item", body, user1.ID)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
// User 2 adds different item
|
// User 2 adds different item
|
||||||
body, _ = json.Marshal(map[string]any{"coffee_id": 2, "quantity": 3})
|
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 2, "quantity": 3})
|
||||||
req = authRequest("PUT", "/cart/item", body, user2.ID)
|
req = authRequest("PUT", "/cart/item", body, user2.ID)
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
@@ -669,533 +664,3 @@ func TestCartIsolation(t *testing.T) {
|
|||||||
t.Errorf("User 2 expected coffee_id 2, got %d", resp.Data.Cart.Items[0].CoffeeID)
|
t.Errorf("User 2 expected coffee_id 2, got %d", resp.Data.Cart.Items[0].CoffeeID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConvertCartLimit(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
capCents int
|
|
||||||
quantity int
|
|
||||||
expectOverCap bool
|
|
||||||
}{
|
|
||||||
{"over cap rejects", 20000, 41, true}, // $205
|
|
||||||
{"at cap accepts", 20000, 40, false}, // $200 boundary
|
|
||||||
{"under cap accepts", 20000, 39, false}, // $195
|
|
||||||
{"zero cap disables", 0, 1000, false}, // explicit off-switch
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
testDB, user := setupCartTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
user.StripeCustomerID = "cus_test_overlimit"
|
|
||||||
if err := db.Save(&user).Error; err != nil {
|
|
||||||
t.Fatalf("saved user: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
addr := models.Address{
|
|
||||||
UserID: user.ID, Name: "Test", Street: "1 Main St",
|
|
||||||
City: "PDX", State: "OR", Zip: "97201", Country: "US",
|
|
||||||
}
|
|
||||||
if err := db.Create(&addr).Error; err != nil {
|
|
||||||
t.Fatalf("seed address: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
card := models.Card{
|
|
||||||
UserID: user.ID, StripePaymentID: "pm_test_overlimit",
|
|
||||||
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
|
|
||||||
}
|
|
||||||
if err := db.Create(&card).Error; err != nil {
|
|
||||||
t.Fatalf("seed card: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pin coffee_id=4's price so cart-cap math stays stable across
|
|
||||||
// seed-price changes. The cap test is about (price * quantity)
|
|
||||||
// vs cap, not which coffee or what the menu currently charges.
|
|
||||||
const pinnedPrice = 500
|
|
||||||
if err := db.Model(&models.Coffee{}).Where("id = ?", 4).Update("price", pinnedPrice).Error; err != nil {
|
|
||||||
t.Fatalf("pin coffee price: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := NewCartHandler("", tc.capCents)
|
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]any{"coffee_id": 4, "quantity": tc.quantity})
|
|
||||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
handler.SetItem(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("SetItem: %d %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// set address
|
|
||||||
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
|
|
||||||
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
handler.SetAddress(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("SetAddress: %d %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// set card
|
|
||||||
body, _ = json.Marshal(map[string]any{"card_id": card.ID})
|
|
||||||
req = authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
handler.SetCard(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("SetCard: %d %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type OrderHandler struct {
|
type OrderHandler struct {
|
||||||
|
stripeKey string
|
||||||
bringAPIUID string
|
bringAPIUID string
|
||||||
bringAPIKey string
|
bringAPIKey string
|
||||||
bringCustomerNumber string
|
bringCustomerNumber string
|
||||||
@@ -43,10 +44,9 @@ type refundRequestPayload struct {
|
|||||||
// repeated submissions from spamming the operator's Slack channel.
|
// repeated submissions from spamming the operator's Slack channel.
|
||||||
const refundRequestCooldown = 15 * time.Minute
|
const refundRequestCooldown = 15 * time.Minute
|
||||||
|
|
||||||
// NewOrderHandler creates an order handler. Stripe credentials are wired
|
func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
|
||||||
// once at startup via stripe.Key in api/main.go.
|
|
||||||
func NewOrderHandler(bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
|
|
||||||
return &OrderHandler{
|
return &OrderHandler{
|
||||||
|
stripeKey: stripeSecretKey,
|
||||||
bringAPIUID: bringAPIUID,
|
bringAPIUID: bringAPIUID,
|
||||||
bringAPIKey: bringAPIKey,
|
bringAPIKey: bringAPIKey,
|
||||||
bringCustomerNumber: bringCustomerNumber,
|
bringCustomerNumber: bringCustomerNumber,
|
||||||
@@ -114,7 +114,7 @@ func (h *OrderHandler) CreateRefundRequest(w http.ResponseWriter, r *http.Reques
|
|||||||
text += "\n\n" + quoted
|
text += "\n\n" + quoted
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := notify.SlackPostToOrderThreadBroadcast(order.ID, text); err != nil {
|
if err := notify.SlackPostToOrderThread(order.ID, text); err != nil {
|
||||||
utils.RespondError(w, http.StatusBadGateway, "SLACK_ERROR", "failed to send refund request", nil)
|
utils.RespondError(w, http.StatusBadGateway, "SLACK_ERROR", "failed to send refund request", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -145,29 +145,6 @@ func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *OrderHandler) GetOrderStatus(w http.ResponseWriter, r *http.Request) {
|
|
||||||
db := database.GetDB().WithContext(r.Context())
|
|
||||||
userID := middleware.UserIDFromContext(r.Context())
|
|
||||||
|
|
||||||
idStr := chi.URLParam(r, "id")
|
|
||||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var order models.Order
|
|
||||||
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil {
|
|
||||||
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
|
||||||
"id": order.ID,
|
|
||||||
"status": string(order.Status),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefundOrder issues a full refund for an order via Stripe and marks it as
|
// RefundOrder issues a full refund for an order via Stripe and marks it as
|
||||||
// refunded. Only orders in paid or shipped status can be refunded.
|
// refunded. Only orders in paid or shipped status can be refunded.
|
||||||
func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
|
func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -200,6 +177,8 @@ func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stripe.Key = h.stripeKey
|
||||||
|
|
||||||
rf, err := refund.New(&stripe.RefundParams{
|
rf, err := refund.New(&stripe.RefundParams{
|
||||||
PaymentIntent: stripe.String(order.StripePaymentID),
|
PaymentIntent: stripe.String(order.StripePaymentID),
|
||||||
})
|
})
|
||||||
@@ -219,7 +198,7 @@ func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
audit.OrderRefunded(userID, order.ID, order.Total, rf.ID)
|
audit.OrderRefunded(userID, order.ID, order.Total, rf.ID)
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"refund_id": rf.ID,
|
"refund_id": rf.ID,
|
||||||
"status": "refunded",
|
"status": "refunded",
|
||||||
})
|
})
|
||||||
@@ -285,7 +264,7 @@ func (h *OrderHandler) UpdateTracking(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
audit.OrderShipped(order.UserID, order.ID, req.Carrier, req.TrackingNumber)
|
audit.OrderShipped(order.UserID, order.ID, req.Carrier, req.TrackingNumber)
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"order_id": order.ID,
|
"order_id": order.ID,
|
||||||
"carrier": req.Carrier,
|
"carrier": req.Carrier,
|
||||||
"tracking_number": req.TrackingNumber,
|
"tracking_number": req.TrackingNumber,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func contextWithRoute(ctx context.Context, routeCtx *chi.Context) context.Contex
|
|||||||
func TestCreateRefundRequestRequiresMessageForOther(t *testing.T) {
|
func TestCreateRefundRequestRequiresMessageForOther(t *testing.T) {
|
||||||
user := setupOrderTestDB(t)
|
user := setupOrderTestDB(t)
|
||||||
|
|
||||||
handler := NewOrderHandler("", "", "")
|
handler := NewOrderHandler("", "", "", "")
|
||||||
body := []byte(`{"reason":"Other","message":" "}`)
|
body := []byte(`{"reason":"Other","message":" "}`)
|
||||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, "1")
|
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, "1")
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -100,7 +100,7 @@ func TestCreateRefundRequestRejectsInvalidOrderState(t *testing.T) {
|
|||||||
t.Fatalf("failed to create order: %v", err)
|
t.Fatalf("failed to create order: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOrderHandler("", "", "")
|
handler := NewOrderHandler("", "", "", "")
|
||||||
body := []byte(`{"reason":"Quality issue","message":""}`)
|
body := []byte(`{"reason":"Quality issue","message":""}`)
|
||||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
|
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -142,7 +142,7 @@ func TestCreateRefundRequestEnforcesCooldown(t *testing.T) {
|
|||||||
t.Fatalf("failed to create order: %v", err)
|
t.Fatalf("failed to create order: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOrderHandler("", "", "")
|
handler := NewOrderHandler("", "", "", "")
|
||||||
body := []byte(`{"reason":"Quality issue","message":""}`)
|
body := []byte(`{"reason":"Quality issue","message":""}`)
|
||||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
|
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func (h *ProductHandler) GetProducts(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"products": products,
|
"products": products,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"product": product,
|
"product": product,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"terminalShop/api/middleware"
|
|
||||||
"terminalShop/pkg/audit"
|
|
||||||
"terminalShop/pkg/database"
|
"terminalShop/pkg/database"
|
||||||
"terminalShop/pkg/models"
|
"terminalShop/pkg/models"
|
||||||
"terminalShop/pkg/notify"
|
"terminalShop/pkg/notify"
|
||||||
@@ -17,19 +14,15 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// paymentIntentGetFn lets tests stub the Stripe Get call without spinning
|
|
||||||
// up an httptest backend. Production callers go straight through to the
|
|
||||||
// real paymentintent.Get.
|
|
||||||
var paymentIntentGetFn = paymentintent.Get
|
|
||||||
|
|
||||||
func reconcileLog() *slog.Logger { return slog.With("component", "reconcile") }
|
func reconcileLog() *slog.Logger { return slog.With("component", "reconcile") }
|
||||||
|
|
||||||
// ReconcileOrders finds pending orders with no Stripe ID older than 10 minutes
|
// ReconcileOrders finds pending orders with no Stripe ID older than 10 minutes
|
||||||
// and checks whether Stripe has a succeeded PaymentIntent for them. This fixes
|
// and checks whether Stripe has a succeeded PaymentIntent for them. This fixes
|
||||||
// the case where the server crashed after charging but before the DB transaction
|
// the case where the server crashed after charging but before the DB transaction
|
||||||
// completed.
|
// completed.
|
||||||
func ReconcileOrders(ctx context.Context) {
|
func ReconcileOrders(stripeKey string) {
|
||||||
db := database.GetDB().WithContext(ctx)
|
stripe.Key = stripeKey
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
var orders []models.Order
|
var orders []models.Order
|
||||||
cutoff := time.Now().Add(-10 * time.Minute)
|
cutoff := time.Now().Add(-10 * time.Minute)
|
||||||
@@ -43,14 +36,9 @@ func ReconcileOrders(ctx context.Context) {
|
|||||||
reconcileLog().Info("checking pending orders", "count", len(orders))
|
reconcileLog().Info("checking pending orders", "count", len(orders))
|
||||||
|
|
||||||
for _, order := range orders {
|
for _, order := range orders {
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
reconcileLog().Info("reconcile orders cancelled mid-loop", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params := &stripe.PaymentIntentSearchParams{
|
params := &stripe.PaymentIntentSearchParams{
|
||||||
SearchParams: stripe.SearchParams{
|
SearchParams: stripe.SearchParams{
|
||||||
Context: ctx,
|
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
|
||||||
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +49,7 @@ func ReconcileOrders(ctx context.Context) {
|
|||||||
"order_id", order.ID, "pi", pi.ID)
|
"order_id", order.ID, "pi", pi.ID)
|
||||||
|
|
||||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Model(&order).Updates(map[string]any{
|
if err := tx.Model(&order).Updates(map[string]interface{}{
|
||||||
"status": models.OrderStatusPaid,
|
"status": models.OrderStatusPaid,
|
||||||
"stripe_payment_id": pi.ID,
|
"stripe_payment_id": pi.ID,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
@@ -74,7 +62,7 @@ func ReconcileOrders(ctx context.Context) {
|
|||||||
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
|
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Model(&cart).Updates(map[string]any{
|
return tx.Model(&cart).Updates(map[string]interface{}{
|
||||||
"address_id": nil,
|
"address_id": nil,
|
||||||
"card_id": nil,
|
"card_id": nil,
|
||||||
}).Error
|
}).Error
|
||||||
@@ -89,146 +77,10 @@ func ReconcileOrders(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReconcileStale3DSOrders flips requires_action orders that have been
|
// ReconcileUnshipped finds paid order orlder than 24h with no tracking
|
||||||
// awaiting the customer's 3DS challenge longer than threshold to failed,
|
// number and posts single Slack reminder. Pure read + notif
|
||||||
// after re-checking the PaymentIntent with Stripe so a late
|
func ReconcileUnshipped() {
|
||||||
// payment_intent.succeeded webhook is respected. Deliberately separate
|
db := database.GetDB()
|
||||||
// from ReconcileOrders: the empty-PI predicate there is load-bearing for
|
|
||||||
// the crash-mid-create case (see sca-psd2-compliance.md caveat #20).
|
|
||||||
// stripe.Key is wired once at startup in api/main.go.
|
|
||||||
func ReconcileStale3DSOrders(ctx context.Context, threshold time.Duration) {
|
|
||||||
db := database.GetDB().WithContext(ctx)
|
|
||||||
|
|
||||||
var orders []models.Order
|
|
||||||
// Predicate keys off updated_at, not created_at: respondRequiresAction
|
|
||||||
// and RetryAuth both bump updated_at via GORM auto-stamping, so every
|
|
||||||
// customer-driven retry extends the grace window. Intentional - sweeping
|
|
||||||
// a row the customer just re-armed would race the new challenge.
|
|
||||||
cutoff := time.Now().Add(-threshold)
|
|
||||||
if err := db.Where("status = ? AND updated_at < ?",
|
|
||||||
models.OrderStatusRequiresAction, cutoff).Find(&orders).Error; err != nil {
|
|
||||||
reconcileLog().Error("stale 3ds scan failed", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(orders) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reconcileLog().Info("checking stale requires_action orders", "count", len(orders))
|
|
||||||
|
|
||||||
piParams := &stripe.PaymentIntentParams{Params: stripe.Params{Context: ctx}}
|
|
||||||
for i := range orders {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
reconcileLog().Info("stale 3ds sweep cancelled mid-loop", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
order := &orders[i]
|
|
||||||
if order.StripePaymentID == "" {
|
|
||||||
// respondRequiresAction writes stripe_payment_id and status in
|
|
||||||
// one UPDATE so this should be unreachable; guard anyway so a
|
|
||||||
// future regression noisily logs instead of 4xx'ing Stripe.
|
|
||||||
reconcileLog().Warn("requires_action row missing PI id; skipping", "order_id", order.ID)
|
|
||||||
middleware.RecordAbandoned3DSSweep("missing_pi")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
actual, err := paymentIntentGetFn(order.StripePaymentID, piParams)
|
|
||||||
if err != nil {
|
|
||||||
reconcileLog().Error("stripe get failed",
|
|
||||||
"order_id", order.ID, "pi", order.StripePaymentID, "error", err)
|
|
||||||
middleware.RecordAbandoned3DSSweep("stripe_error")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch actual.Status {
|
|
||||||
case stripe.PaymentIntentStatusSucceeded:
|
|
||||||
// Stripe says paid but our row still says requires_action, the
|
|
||||||
// payment_intent.succeeded webhook has not landed yet (or was
|
|
||||||
// dropped). Defer to handlePaymentIntentsucceeded; it owns the
|
|
||||||
// paid transition + cart cleanup atomically. The next sweep
|
|
||||||
// tick re-checks and surfaces the dropped webhook if it persists
|
|
||||||
reconcileLog().Warn("stripe reports succeeded for requires_action row; deferring to webhook",
|
|
||||||
"order_id", order.ID, "pi", order.StripePaymentID)
|
|
||||||
middleware.RecordAbandoned3DSSweep("succeeded_skip")
|
|
||||||
case stripe.PaymentIntentStatusProcessing,
|
|
||||||
stripe.PaymentIntentStatusRequiresCapture:
|
|
||||||
reconcileLog().Info("payment intent still transitioning; skipping",
|
|
||||||
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", string(actual.Status))
|
|
||||||
middleware.RecordAbandoned3DSSweep("processing_skip")
|
|
||||||
case stripe.PaymentIntentStatusRequiresAction,
|
|
||||||
stripe.PaymentIntentStatusRequiresPaymentMethod,
|
|
||||||
stripe.PaymentIntentStatusRequiresConfirmation,
|
|
||||||
stripe.PaymentIntentStatusCanceled:
|
|
||||||
flipStale3DSToFailed(db, order, string(actual.Status))
|
|
||||||
default:
|
|
||||||
reconcileLog().Warn("unrecognized stripe status; skipping",
|
|
||||||
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", string(actual.Status))
|
|
||||||
middleware.RecordAbandoned3DSSweep("unrecognized_status")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// flipStale3DSToFailed transitions a single stale requires_action row
|
|
||||||
// to failed. The UPDATE is conditional on status = requires_action so a
|
|
||||||
// payment_intent.succeeded webhook that landed between the stripe get and
|
|
||||||
// this write (caveat #20 race) wins and we silently no-op
|
|
||||||
func flipStale3DSToFailed(db *gorm.DB, order *models.Order, stripeStatus string) {
|
|
||||||
// Map form so the single-field UPDATE is explicit at the call site.
|
|
||||||
// GORM still auto-stamps updated_at; the row exits the sweep predicate
|
|
||||||
// naturally on the next tick because status no longer matches anyway.
|
|
||||||
res := db.Model(&models.Order{}).
|
|
||||||
Where("id = ? AND status = ?", order.ID, models.OrderStatusRequiresAction).
|
|
||||||
Updates(map[string]any{"status": models.OrderStatusFailed})
|
|
||||||
if res.Error != nil {
|
|
||||||
reconcileLog().Error("flip stale 3ds failed", "order_id", order.ID, "error", res.Error)
|
|
||||||
middleware.RecordAbandoned3DSSweep("flip_failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if res.RowsAffected == 0 {
|
|
||||||
reconcileLog().Info("stale 3ds row already transitioned; sweep no-op",
|
|
||||||
"order_id", order.ID)
|
|
||||||
middleware.RecordAbandoned3DSSweep("flip_noop")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
audit.Order3DSAbandoned(order.UserID, order.ID, order.StripePaymentID)
|
|
||||||
reconcileLog().Info("3ds abandoned order flipped to failed",
|
|
||||||
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", stripeStatus)
|
|
||||||
middleware.RecordAbandoned3DSSweep("flipped")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReconcileExpiredCards removes saved cards whose retention deadline elapsed.
|
|
||||||
// Read paths filter out expired rows so users never see them; this job drives
|
|
||||||
// the Stripe detach + audit + physical row delete out of band so request
|
|
||||||
// handlers never block on Stripe API calls during a card sweep.
|
|
||||||
func ReconcileExpiredCards(ctx context.Context) {
|
|
||||||
db := database.GetDB().WithContext(ctx)
|
|
||||||
|
|
||||||
var cards []models.Card
|
|
||||||
if err := db.Where(
|
|
||||||
"storage_expires_at IS NOT NULL AND storage_expires_at <= ?",
|
|
||||||
time.Now(),
|
|
||||||
).Find(&cards).Error; err != nil {
|
|
||||||
reconcileLog().Error("expired card scan failed", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(cards) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reconcileLog().Info("expiring inactive saved cards", "count", len(cards))
|
|
||||||
for i := range cards {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
reconcileLog().Info("card sweep cancelled mid-loop", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := expireStoredCard(db, &cards[i]); err != nil {
|
|
||||||
reconcileLog().Error("expire card failed", "card_id", cards[i].ID, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReconcileUnshipped finds paid orders older than 24h with no tracking
|
|
||||||
// number and posts a single Slack reminder. Pure read + notify.
|
|
||||||
func ReconcileUnshipped(ctx context.Context) {
|
|
||||||
db := database.GetDB().WithContext(ctx)
|
|
||||||
|
|
||||||
var orders []models.Order
|
var orders []models.Order
|
||||||
cutoff := time.Now().Add(-24 * time.Hour)
|
cutoff := time.Now().Add(-24 * time.Hour)
|
||||||
|
|||||||
@@ -1,520 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"terminalShop/api/middleware"
|
|
||||||
"terminalShop/pkg/audit"
|
|
||||||
"terminalShop/pkg/database"
|
|
||||||
"terminalShop/pkg/models"
|
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
|
||||||
"github.com/stripe/stripe-go/v78"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestReconcileStale3DSOrders_FlipsAbandonedToFailed verifies the predicate
|
|
||||||
// and Stripe-Get branching: a row past the threshold whose PaymentIntent
|
|
||||||
// Stripe still reports as requires_payment_method (the customer never
|
|
||||||
// completed the challenge) transitions to failed and emits the new
|
|
||||||
// order_3ds_abandoned audit row.
|
|
||||||
func TestReconcileStale3DSOrders_FlipsAbandonedToFailed(t *testing.T) {
|
|
||||||
testDB, user := setupCartTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
audit.SetDB(db)
|
|
||||||
defer audit.SetDB(nil)
|
|
||||||
|
|
||||||
order := seedRequiresActionOrder(t, user.ID, "pi_stale_abandoned",
|
|
||||||
time.Now().Add(-1*time.Hour))
|
|
||||||
|
|
||||||
orig := paymentIntentGetFn
|
|
||||||
defer func() { paymentIntentGetFn = orig }()
|
|
||||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
|
||||||
return &stripe.PaymentIntent{
|
|
||||||
ID: id,
|
|
||||||
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
flipped := middleware.Abandoned3DSSweepCounter().WithLabelValues("flipped")
|
|
||||||
before := testutil.ToFloat64(flipped)
|
|
||||||
|
|
||||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
|
||||||
|
|
||||||
var reloaded models.Order
|
|
||||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
|
||||||
t.Fatalf("reload order: %v", err)
|
|
||||||
}
|
|
||||||
if reloaded.Status != models.OrderStatusFailed {
|
|
||||||
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
var events []models.OrderEvent
|
|
||||||
if err := db.Where("order_id = ? AND type = ?",
|
|
||||||
order.ID, audit.EventOrder3DSAbandoned).Find(&events).Error; err != nil {
|
|
||||||
t.Fatalf("list events: %v", err)
|
|
||||||
}
|
|
||||||
if len(events) != 1 {
|
|
||||||
t.Fatalf("want 1 order_3ds_abandoned audit row, got %d", len(events))
|
|
||||||
}
|
|
||||||
if got := testutil.ToFloat64(flipped) - before; got != 1 {
|
|
||||||
t.Fatalf("want 1 flipped sweep counter increment, got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReconcileStale3DSOrders_SkipsRecent verifies the threshold predicate
|
|
||||||
// excludes rows still within the grace window. A fresh requires_action row
|
|
||||||
// must not even reach the Stripe Get call.
|
|
||||||
func TestReconcileStale3DSOrders_SkipsRecent(t *testing.T) {
|
|
||||||
testDB, user := setupCartTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
audit.SetDB(db)
|
|
||||||
defer audit.SetDB(nil)
|
|
||||||
|
|
||||||
order := seedRequiresActionOrder(t, user.ID, "pi_fresh_3ds",
|
|
||||||
time.Now().Add(-5*time.Minute))
|
|
||||||
|
|
||||||
called := false
|
|
||||||
orig := paymentIntentGetFn
|
|
||||||
defer func() { paymentIntentGetFn = orig }()
|
|
||||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
|
||||||
called = true
|
|
||||||
return &stripe.PaymentIntent{
|
|
||||||
ID: id,
|
|
||||||
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
|
||||||
|
|
||||||
if called {
|
|
||||||
t.Fatalf("stripe Get called for in-window row")
|
|
||||||
}
|
|
||||||
var reloaded models.Order
|
|
||||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
|
||||||
t.Fatalf("reload: %v", err)
|
|
||||||
}
|
|
||||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
|
||||||
t.Fatalf("status changed unexpectedly: %q", reloaded.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded covers the
|
|
||||||
// webhook-race guard from caveat #20. If Stripe-side already says succeeded,
|
|
||||||
// the sweep must defer to handlePaymentIntentSucceeded rather than overwrite
|
|
||||||
// the authoritative end state.
|
|
||||||
func TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded(t *testing.T) {
|
|
||||||
testDB, user := setupCartTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
audit.SetDB(db)
|
|
||||||
defer audit.SetDB(nil)
|
|
||||||
|
|
||||||
order := seedRequiresActionOrder(t, user.ID, "pi_succeeded_late_webhook",
|
|
||||||
time.Now().Add(-1*time.Hour))
|
|
||||||
|
|
||||||
orig := paymentIntentGetFn
|
|
||||||
defer func() { paymentIntentGetFn = orig }()
|
|
||||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
|
||||||
return &stripe.PaymentIntent{
|
|
||||||
ID: id,
|
|
||||||
Status: stripe.PaymentIntentStatusSucceeded,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("succeeded_skip")
|
|
||||||
before := testutil.ToFloat64(skip)
|
|
||||||
|
|
||||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
|
||||||
|
|
||||||
var reloaded models.Order
|
|
||||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
|
||||||
t.Fatalf("reload: %v", err)
|
|
||||||
}
|
|
||||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
|
||||||
t.Fatalf("must defer to webhook on succeeded; got %q", reloaded.Status)
|
|
||||||
}
|
|
||||||
if got := testutil.ToFloat64(skip) - before; got != 1 {
|
|
||||||
t.Fatalf("want 1 succeeded_skip sweep counter increment, got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReconcileStale3DSOrders_GuardsAgainstWebhookRace covers the conditional
|
|
||||||
// UPDATE. If the row flipped to paid between the Stripe Get and the flip,
|
|
||||||
// the WHERE status = requires_action clause prevents clobber.
|
|
||||||
func TestReconcileStale3DSOrders_GuardsAgainstWebhookRace(t *testing.T) {
|
|
||||||
testDB, user := setupCartTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
audit.SetDB(db)
|
|
||||||
defer audit.SetDB(nil)
|
|
||||||
|
|
||||||
order := seedRequiresActionOrder(t, user.ID, "pi_race_paid",
|
|
||||||
time.Now().Add(-1*time.Hour))
|
|
||||||
|
|
||||||
orig := paymentIntentGetFn
|
|
||||||
defer func() { paymentIntentGetFn = orig }()
|
|
||||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
|
||||||
db.Model(&models.Order{}).Where("id = ?", order.ID).
|
|
||||||
Update("status", models.OrderStatusPaid)
|
|
||||||
return &stripe.PaymentIntent{
|
|
||||||
ID: id,
|
|
||||||
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
noop := middleware.Abandoned3DSSweepCounter().WithLabelValues("flip_noop")
|
|
||||||
before := testutil.ToFloat64(noop)
|
|
||||||
|
|
||||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
|
||||||
|
|
||||||
var reloaded models.Order
|
|
||||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
|
||||||
t.Fatalf("reload: %v", err)
|
|
||||||
}
|
|
||||||
if reloaded.Status != models.OrderStatusPaid {
|
|
||||||
t.Fatalf("conditional update must preserve paid; got %q", reloaded.Status)
|
|
||||||
}
|
|
||||||
if got := testutil.ToFloat64(noop) - before; got != 1 {
|
|
||||||
t.Fatalf("want 1 flip_noop sweep counter increment, got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReconcileStale3DSOrders_FlipsCanceledPI covers the canceled-PI branch:
|
|
||||||
// Stripe-terminal-canceled maps to OrderStatusFailed (matches RetryAuth's
|
|
||||||
// existing precedent — see api/handlers/cart.go RetryAuth canceled branch).
|
|
||||||
func TestReconcileStale3DSOrders_FlipsCanceledPI(t *testing.T) {
|
|
||||||
testDB, user := setupCartTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
audit.SetDB(db)
|
|
||||||
defer audit.SetDB(nil)
|
|
||||||
|
|
||||||
order := seedRequiresActionOrder(t, user.ID, "pi_canceled_stale",
|
|
||||||
time.Now().Add(-1*time.Hour))
|
|
||||||
|
|
||||||
orig := paymentIntentGetFn
|
|
||||||
defer func() { paymentIntentGetFn = orig }()
|
|
||||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
|
||||||
return &stripe.PaymentIntent{
|
|
||||||
ID: id,
|
|
||||||
Status: stripe.PaymentIntentStatusCanceled,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
|
||||||
|
|
||||||
var reloaded models.Order
|
|
||||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
|
||||||
t.Fatalf("reload: %v", err)
|
|
||||||
}
|
|
||||||
if reloaded.Status != models.OrderStatusFailed {
|
|
||||||
t.Fatalf("canceled PI must flip to failed; got %q", reloaded.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReconcileStale3DSOrders_DoesNotConsumeRetryCap regression-tests the
|
|
||||||
// caveat #17 decoupling: the new sweep emits EventOrder3DSAbandoned, never
|
|
||||||
// EventOrderRequiresAction, so a swept-then-stuck order does not silently
|
|
||||||
// shrink the retry ceiling for any future user-driven retry attempts.
|
|
||||||
func TestReconcileStale3DSOrders_DoesNotConsumeRetryCap(t *testing.T) {
|
|
||||||
testDB, user := setupCartTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
audit.SetDB(db)
|
|
||||||
defer audit.SetDB(nil)
|
|
||||||
|
|
||||||
order := seedRequiresActionOrder(t, user.ID, "pi_decoupled_cap",
|
|
||||||
time.Now().Add(-1*time.Hour))
|
|
||||||
|
|
||||||
orig := paymentIntentGetFn
|
|
||||||
defer func() { paymentIntentGetFn = orig }()
|
|
||||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
|
||||||
return &stripe.PaymentIntent{
|
|
||||||
ID: id,
|
|
||||||
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
|
||||||
|
|
||||||
var retryCount int64
|
|
||||||
if err := db.Model(&models.OrderEvent{}).
|
|
||||||
Where("order_id = ? AND type = ?", order.ID, audit.EventOrderRequiresAction).
|
|
||||||
Count(&retryCount).Error; err != nil {
|
|
||||||
t.Fatalf("count retry events: %v", err)
|
|
||||||
}
|
|
||||||
if retryCount != 0 {
|
|
||||||
t.Fatalf("sweep must not emit EventOrderRequiresAction; got %d", retryCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReconcileStale3DSOrders_SkipsTransitioningStatuses covers the
|
|
||||||
// processing and requires_capture branches: Stripe still owns the PI's
|
|
||||||
// next move, so the sweep must not flip even past the threshold.
|
|
||||||
func TestReconcileStale3DSOrders_SkipsTransitioningStatuses(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
status stripe.PaymentIntentStatus
|
|
||||||
}{
|
|
||||||
{"processing", stripe.PaymentIntentStatusProcessing},
|
|
||||||
{"requires_capture", stripe.PaymentIntentStatusRequiresCapture},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
testDB, user := setupCartTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
audit.SetDB(db)
|
|
||||||
defer audit.SetDB(nil)
|
|
||||||
|
|
||||||
order := seedRequiresActionOrder(t, user.ID,
|
|
||||||
"pi_transitioning_"+tc.name, time.Now().Add(-1*time.Hour))
|
|
||||||
|
|
||||||
orig := paymentIntentGetFn
|
|
||||||
defer func() { paymentIntentGetFn = orig }()
|
|
||||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
|
||||||
return &stripe.PaymentIntent{ID: id, Status: tc.status}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("processing_skip")
|
|
||||||
before := testutil.ToFloat64(skip)
|
|
||||||
|
|
||||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
|
||||||
|
|
||||||
var reloaded models.Order
|
|
||||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
|
||||||
t.Fatalf("reload: %v", err)
|
|
||||||
}
|
|
||||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
|
||||||
t.Fatalf("%s must stay requires_action; got %q", tc.name, reloaded.Status)
|
|
||||||
}
|
|
||||||
if got := testutil.ToFloat64(skip) - before; got != 1 {
|
|
||||||
t.Fatalf("want 1 processing_skip increment, got %v", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReconcileStale3DSOrders_SkipsUnknownStatus covers the switch's default
|
|
||||||
// arm: an unrecognized Stripe status logs and skips rather than guessing a
|
|
||||||
// transition. Defends against new PI statuses appearing in future SDKs.
|
|
||||||
func TestReconcileStale3DSOrders_SkipsUnknownStatus(t *testing.T) {
|
|
||||||
testDB, user := setupCartTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
audit.SetDB(db)
|
|
||||||
defer audit.SetDB(nil)
|
|
||||||
|
|
||||||
order := seedRequiresActionOrder(t, user.ID, "pi_unknown_status",
|
|
||||||
time.Now().Add(-1*time.Hour))
|
|
||||||
|
|
||||||
orig := paymentIntentGetFn
|
|
||||||
defer func() { paymentIntentGetFn = orig }()
|
|
||||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
|
||||||
return &stripe.PaymentIntent{
|
|
||||||
ID: id,
|
|
||||||
Status: stripe.PaymentIntentStatus("some_new_future_status"),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
unknown := middleware.Abandoned3DSSweepCounter().WithLabelValues("unrecognized_status")
|
|
||||||
before := testutil.ToFloat64(unknown)
|
|
||||||
|
|
||||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
|
||||||
|
|
||||||
var reloaded models.Order
|
|
||||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
|
||||||
t.Fatalf("reload: %v", err)
|
|
||||||
}
|
|
||||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
|
||||||
t.Fatalf("unknown status must not flip; got %q", reloaded.Status)
|
|
||||||
}
|
|
||||||
if got := testutil.ToFloat64(unknown) - before; got != 1 {
|
|
||||||
t.Fatalf("want 1 unrecognized_status increment, got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReconcileStale3DSOrders_SkipsOnStripeError verifies a Stripe Get
|
|
||||||
// failure logs and leaves the row alone. Transient network errors must not
|
|
||||||
// look like an abandoned 3DS.
|
|
||||||
func TestReconcileStale3DSOrders_SkipsOnStripeError(t *testing.T) {
|
|
||||||
testDB, user := setupCartTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
audit.SetDB(db)
|
|
||||||
defer audit.SetDB(nil)
|
|
||||||
|
|
||||||
order := seedRequiresActionOrder(t, user.ID, "pi_stripe_error",
|
|
||||||
time.Now().Add(-1*time.Hour))
|
|
||||||
|
|
||||||
orig := paymentIntentGetFn
|
|
||||||
defer func() { paymentIntentGetFn = orig }()
|
|
||||||
paymentIntentGetFn = func(_ string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
|
||||||
return nil, fmt.Errorf("simulated stripe outage")
|
|
||||||
}
|
|
||||||
|
|
||||||
stripeErr := middleware.Abandoned3DSSweepCounter().WithLabelValues("stripe_error")
|
|
||||||
before := testutil.ToFloat64(stripeErr)
|
|
||||||
|
|
||||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
|
||||||
|
|
||||||
var reloaded models.Order
|
|
||||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
|
||||||
t.Fatalf("reload: %v", err)
|
|
||||||
}
|
|
||||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
|
||||||
t.Fatalf("stripe error must not flip; got %q", reloaded.Status)
|
|
||||||
}
|
|
||||||
if got := testutil.ToFloat64(stripeErr) - before; got != 1 {
|
|
||||||
t.Fatalf("want 1 stripe_error sweep counter increment, got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID guards the
|
|
||||||
// defensive empty-PI short-circuit: even past the threshold, a row missing
|
|
||||||
// stripe_payment_id must not reach paymentintent.Get (which would 4xx).
|
|
||||||
func TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID(t *testing.T) {
|
|
||||||
testDB, user := setupCartTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
audit.SetDB(db)
|
|
||||||
defer audit.SetDB(nil)
|
|
||||||
|
|
||||||
order := seedRequiresActionOrder(t, user.ID, "pi_will_be_blanked",
|
|
||||||
time.Now().Add(-1*time.Hour))
|
|
||||||
if err := db.Model(&order).UpdateColumn("stripe_payment_id", "").Error; err != nil {
|
|
||||||
t.Fatalf("blank PI id: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
called := false
|
|
||||||
orig := paymentIntentGetFn
|
|
||||||
defer func() { paymentIntentGetFn = orig }()
|
|
||||||
paymentIntentGetFn = func(_ string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
|
||||||
called = true
|
|
||||||
return &stripe.PaymentIntent{Status: stripe.PaymentIntentStatusRequiresPaymentMethod}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
missing := middleware.Abandoned3DSSweepCounter().WithLabelValues("missing_pi")
|
|
||||||
before := testutil.ToFloat64(missing)
|
|
||||||
|
|
||||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
|
||||||
|
|
||||||
if called {
|
|
||||||
t.Fatalf("stripe Get called for empty-PI row")
|
|
||||||
}
|
|
||||||
var reloaded models.Order
|
|
||||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
|
||||||
t.Fatalf("reload: %v", err)
|
|
||||||
}
|
|
||||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
|
||||||
t.Fatalf("empty-PI guard must leave status alone; got %q", reloaded.Status)
|
|
||||||
}
|
|
||||||
if got := testutil.ToFloat64(missing) - before; got != 1 {
|
|
||||||
t.Fatalf("want 1 missing_pi increment, got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReconcileStale3DSOrders_BailsMidLoopOnCancel verifies the per-iteration
|
|
||||||
// ctx.Err() check stops the sweep once the supervising context cancels, so a
|
|
||||||
// SIGTERM during a large stale-3DS batch does not block the binary past the
|
|
||||||
// 30s graceful-shutdown window in api/main.go
|
|
||||||
func TestReconcileStale3DSOrders_BailsMidLoopOnCancel(t *testing.T) {
|
|
||||||
testDB, user := setupCartTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
audit.SetDB(db)
|
|
||||||
defer audit.SetDB(nil)
|
|
||||||
|
|
||||||
seedRequiresActionOrder(t, user.ID, "pi_first", time.Now().Add(-1*time.Hour))
|
|
||||||
seedRequiresActionOrder(t, user.ID, "pi_second", time.Now().Add(-1*time.Hour))
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
calls := 0
|
|
||||||
orig := paymentIntentGetFn
|
|
||||||
defer func() { paymentIntentGetFn = orig }()
|
|
||||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
|
||||||
calls++
|
|
||||||
if calls == 1 {
|
|
||||||
cancel()
|
|
||||||
}
|
|
||||||
return &stripe.PaymentIntent{
|
|
||||||
ID: id,
|
|
||||||
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
ReconcileStale3DSOrders(ctx, 30*time.Minute)
|
|
||||||
|
|
||||||
if calls != 1 {
|
|
||||||
t.Fatalf("want exactly 1 stripe Get call (loop bails before row 2); got %d", calls)
|
|
||||||
}
|
|
||||||
var stillPending int64
|
|
||||||
if err := db.Model(&models.Order{}).Where("status = ?", models.OrderStatusRequiresAction).Count(&stillPending).Error; err != nil {
|
|
||||||
t.Fatalf("count requires_action rows: %v", err)
|
|
||||||
}
|
|
||||||
if stillPending < 1 {
|
|
||||||
t.Fatalf("want at least 1 row left in requires_action after mid-loop cancel; got %d", stillPending)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedRequiresActionOrder inserts a card + order pair in the requires_action
|
|
||||||
// state. updatedAt is forced via UpdateColumn so GORM's auto-stamping of
|
|
||||||
// UpdatedAt on Create does not bump the row out of the sweep predicate.
|
|
||||||
func seedRequiresActionOrder(t *testing.T, userID uint, piID string, updatedAt time.Time) models.Order {
|
|
||||||
t.Helper()
|
|
||||||
db := database.GetDB()
|
|
||||||
card := models.Card{
|
|
||||||
UserID: userID,
|
|
||||||
StripePaymentID: "pm_test_" + piID,
|
|
||||||
Last4: "4242", Brand: "Visa",
|
|
||||||
ExpMonth: 12, ExpYear: 2030,
|
|
||||||
}
|
|
||||||
if err := db.Create(&card).Error; err != nil {
|
|
||||||
t.Fatalf("seed card: %v", err)
|
|
||||||
}
|
|
||||||
order := models.Order{
|
|
||||||
UserID: userID, CardID: card.ID,
|
|
||||||
StripePaymentID: piID,
|
|
||||||
Status: models.OrderStatusRequiresAction,
|
|
||||||
Subtotal: 500, Total: 500,
|
|
||||||
ShippingName: "Test", ShippingStreet: "1 Main St",
|
|
||||||
ShippingCity: "PDX", ShippingState: "OR",
|
|
||||||
ShippingZip: "97201", ShippingCountry: "US",
|
|
||||||
}
|
|
||||||
if err := db.Create(&order).Error; err != nil {
|
|
||||||
t.Fatalf("seed order: %v", err)
|
|
||||||
}
|
|
||||||
if err := db.Model(&order).UpdateColumn("updated_at", updatedAt).Error; err != nil {
|
|
||||||
t.Fatalf("backdate updated_at: %v", err)
|
|
||||||
}
|
|
||||||
return order
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"terminalShop/api/middleware"
|
"terminalShop/api/middleware"
|
||||||
"terminalShop/pkg/database"
|
"terminalShop/pkg/database"
|
||||||
@@ -12,7 +11,6 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ViewHandler handles bulk TUI bootstrap data
|
|
||||||
type ViewHandler struct{}
|
type ViewHandler struct{}
|
||||||
|
|
||||||
func NewViewHandler() *ViewHandler {
|
func NewViewHandler() *ViewHandler {
|
||||||
@@ -39,10 +37,7 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
|
|||||||
db.Where("user_id = ?", userID).Find(&addresses)
|
db.Where("user_id = ?", userID).Find(&addresses)
|
||||||
|
|
||||||
var cards []models.Card
|
var cards []models.Card
|
||||||
db.Where(
|
db.Where("user_id = ?", userID).Find(&cards)
|
||||||
"user_id = ? AND (storage_expires_at IS NULL OR storage_expires_at > ?)",
|
|
||||||
userID, time.Now(),
|
|
||||||
).Order("is_default DESC, created_at DESC").Find(&cards)
|
|
||||||
|
|
||||||
var orders []models.Order
|
var orders []models.Order
|
||||||
db.Where("user_id = ?", userID).
|
db.Where("user_id = ?", userID).
|
||||||
@@ -54,7 +49,7 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
|
|||||||
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems)
|
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"user": user.ToPublic(),
|
"user": user.ToPublic(),
|
||||||
"products": products,
|
"products": products,
|
||||||
"cart": cartItems,
|
"cart": cartItems,
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"terminalShop/pkg/database"
|
|
||||||
"terminalShop/pkg/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestGetViewInit_FiltersExpiredCardsAndOrders drives the WHERE + ORDER BY
|
|
||||||
// clauses in GetViewInit. a column-name typo in either clause (e.g.
|
|
||||||
// id_default instead of is_default) surfaces here as a sqlite error
|
|
||||||
func TestGetViewInit_FiltersExpiredCardsAndOrders(t *testing.T) {
|
|
||||||
testDB, user := setupCardsExpireTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
now := time.Now()
|
|
||||||
past := now.Add(-1 * time.Hour)
|
|
||||||
future := now.Add((24 * time.Hour))
|
|
||||||
|
|
||||||
expired := models.Card{
|
|
||||||
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
|
|
||||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
|
|
||||||
}
|
|
||||||
freshDefault := models.Card{
|
|
||||||
UserID: user.ID, StripePaymentID: "pm_fresh_d", Last4: "2222",
|
|
||||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &future,
|
|
||||||
IsDefault: true,
|
|
||||||
}
|
|
||||||
freshOther := models.Card{
|
|
||||||
UserID: user.ID, StripePaymentID: "pm_fresh_o", Last4: "3333",
|
|
||||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &future,
|
|
||||||
}
|
|
||||||
for _, card := range []*models.Card{&expired, &freshDefault, &freshOther} {
|
|
||||||
if err := db.Create(card).Error; err != nil {
|
|
||||||
t.Fatalf("seed card: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := NewViewHandler()
|
|
||||||
req := authRequest("GET", "/api/v1/view/init", nil, user.ID)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
handler.GetViewInit(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GetViewInit status %d, body: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp struct {
|
|
||||||
Data struct {
|
|
||||||
Cards []models.Card `json:"cards"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v", err)
|
|
||||||
}
|
|
||||||
if len(resp.Data.Cards) != 2 {
|
|
||||||
t.Fatalf("want 2 visible cards, got %d", len(resp.Data.Cards))
|
|
||||||
}
|
|
||||||
if resp.Data.Cards[0].ID != freshDefault.ID {
|
|
||||||
t.Fatalf("default card should sort first, got id=%d (want %d)", resp.Data.Cards[0].ID, freshDefault.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -28,13 +28,13 @@ func webhookLog() *slog.Logger { return slog.With("component", "webhook") }
|
|||||||
// WebhookHandler handles inbound Stripe webhook events.
|
// WebhookHandler handles inbound Stripe webhook events.
|
||||||
type WebhookHandler struct {
|
type WebhookHandler struct {
|
||||||
webhookSecret string
|
webhookSecret string
|
||||||
|
stripeKey string
|
||||||
shippoSecret string
|
shippoSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWebhookHandler creates a new webhook handler. Stripe credentials are
|
// NewWebhookHandler creates a new webhook handler.
|
||||||
// wired once at startup via stripe.Key in api/main.go.
|
func NewWebhookHandler(webhookSecret, stripeKey, shippoSecret string) *WebhookHandler {
|
||||||
func NewWebhookHandler(webhookSecret, shippoSecret string) *WebhookHandler {
|
return &WebhookHandler{webhookSecret: webhookSecret, stripeKey: stripeKey, shippoSecret: shippoSecret}
|
||||||
return &WebhookHandler{webhookSecret: webhookSecret, shippoSecret: shippoSecret}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleStripe validates the Stripe signature and routes the event.
|
// HandleStripe validates the Stripe signature and routes the event.
|
||||||
@@ -125,7 +125,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
|
|||||||
}
|
}
|
||||||
|
|
||||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Model(&order).Updates(map[string]any{
|
if err := tx.Model(&order).Updates(map[string]interface{}{
|
||||||
"status": models.OrderStatusPaid,
|
"status": models.OrderStatusPaid,
|
||||||
"stripe_payment_id": pi.ID,
|
"stripe_payment_id": pi.ID,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
@@ -139,7 +139,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
|
|||||||
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
|
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
|
||||||
return fmt.Errorf("failed to delete cart item: %w", err)
|
return fmt.Errorf("failed to delete cart item: %w", err)
|
||||||
}
|
}
|
||||||
return tx.Model(&cart).Updates(map[string]any{
|
return tx.Model(&cart).Updates(map[string]interface{}{
|
||||||
"address_id": nil,
|
"address_id": nil,
|
||||||
"card_id": nil,
|
"card_id": nil,
|
||||||
}).Error
|
}).Error
|
||||||
@@ -150,12 +150,6 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if order.CardID != 0 {
|
|
||||||
if err := refreshCardStorageTTL(db, order.CardID, time.Now()); err != nil {
|
|
||||||
webhookLog().Warn("failed to refresh card storage ttl", "card_id", order.CardID, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID)
|
audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID)
|
||||||
go notify.SlackOrderPaid(&order)
|
go notify.SlackOrderPaid(&order)
|
||||||
webhookLog().Info("order marked paid", "order_id", orderIDStr, "pi", pi.ID)
|
webhookLog().Info("order marked paid", "order_id", orderIDStr, "pi", pi.ID)
|
||||||
@@ -173,18 +167,8 @@ func (h *WebhookHandler) handlePaymentIntentFailed(ctx context.Context, event st
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// authentication_required is recoverable — the cart handler re-confirms the
|
|
||||||
// PI on-session and the customer completes 3DS via redirect. Marking the
|
|
||||||
// order failed here would race with the recovery path and surface a
|
|
||||||
// momentarily-broken state in the TUI.
|
|
||||||
if pi.LastPaymentError != nil && string(pi.LastPaymentError.Code) == "authentication_required" {
|
|
||||||
webhookLog().Info("ignoring authentication_required payment_failed; handled via 3ds redirect",
|
|
||||||
"order_id", orderIDStr, "pi", pi.ID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
db := database.GetDB().WithContext(ctx)
|
db := database.GetDB().WithContext(ctx)
|
||||||
if err := db.Model(&models.Order{}).Where("id = ? AND status IN (?, ?)", orderIDStr, models.OrderStatusPending, models.OrderStatusRequiresAction).
|
if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
|
||||||
Update("status", models.OrderStatusFailed).Error; err != nil {
|
Update("status", models.OrderStatusFailed).Error; err != nil {
|
||||||
webhookLog().Error("failed to mark order failed", "order_id", orderIDStr, "error", err)
|
webhookLog().Error("failed to mark order failed", "order_id", orderIDStr, "error", err)
|
||||||
}
|
}
|
||||||
@@ -212,6 +196,8 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, eve
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stripe.Key = h.stripeKey
|
||||||
|
|
||||||
si, err := setupintent.Get(sess.SetupIntent.ID, nil)
|
si, err := setupintent.Get(sess.SetupIntent.ID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
webhookLog().Error("failed to get setup intent", "setup_intent", sess.SetupIntent.ID, "error", err)
|
webhookLog().Error("failed to get setup intent", "setup_intent", sess.SetupIntent.ID, "error", err)
|
||||||
@@ -259,14 +245,8 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, eve
|
|||||||
webhookLog().Warn("failed to detach duplicate pm", "user_id", user.ID, "error", derr)
|
webhookLog().Warn("failed to detach duplicate pm", "user_id", user.ID, "error", derr)
|
||||||
}
|
}
|
||||||
// Bump UpdatedAt so the TUI poll signal can detect that the webhook
|
// Bump UpdatedAt so the TUI poll signal can detect that the webhook
|
||||||
// fired even though no new row was inserted. storage_expires_at is
|
// fired even though no new row was inserted.
|
||||||
// reset because the user just re-added the card; this is a card-add
|
if uerr := db.Model(&dup).Update("updated_at", time.Now()).Error; uerr != nil {
|
||||||
// path, not a payment-success refresh, so last_used_at stays as-is.
|
|
||||||
now := time.Now()
|
|
||||||
if uerr := db.Model(&dup).Updates(map[string]any{
|
|
||||||
"updated_at": now,
|
|
||||||
"storage_expires_at": models.CardStorageExpiresAt(now),
|
|
||||||
}).Error; uerr != nil {
|
|
||||||
webhookLog().Warn("failed to bump dup card updated_at", "card_id", dup.ID, "error", uerr)
|
webhookLog().Warn("failed to bump dup card updated_at", "card_id", dup.ID, "error", uerr)
|
||||||
}
|
}
|
||||||
webhookLog().Info("skipped duplicate card via local-db match", "user_id", user.ID, "existing_card_id", dup.ID)
|
webhookLog().Info("skipped duplicate card via local-db match", "user_id", user.ID, "existing_card_id", dup.ID)
|
||||||
@@ -282,7 +262,6 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, eve
|
|||||||
ExpMonth: int(pm.Card.ExpMonth),
|
ExpMonth: int(pm.Card.ExpMonth),
|
||||||
ExpYear: int(pm.Card.ExpYear),
|
ExpYear: int(pm.Card.ExpYear),
|
||||||
}
|
}
|
||||||
card.InitializeStorageTTL(time.Now())
|
|
||||||
|
|
||||||
if err := db.Create(&card).Error; err != nil {
|
if err := db.Create(&card).Error; err != nil {
|
||||||
webhookLog().Error("failed to save card", "user_id", user.ID, "error", err)
|
webhookLog().Error("failed to save card", "user_id", user.ID, "error", err)
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stripe/stripe-go/v78"
|
|
||||||
|
|
||||||
"terminalShop/pkg/database"
|
|
||||||
"terminalShop/pkg/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// buildPaymentFailedEvent constructs a stripe.Event whose Data.Raw decodes to
|
|
||||||
// a PaymentIntent referencing the given order via metadata. errCode is set on
|
|
||||||
// last_payment_error to drive the authentication_required short-circuit
|
|
||||||
// branch in handlePaymentIntentFailed (pass "" to omit the error block).
|
|
||||||
func buildPaymentFailedEvent(t *testing.T, piID string, orderID uint, errCode string) stripe.Event {
|
|
||||||
t.Helper()
|
|
||||||
pi := map[string]any{
|
|
||||||
"id": piID,
|
|
||||||
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", orderID)},
|
|
||||||
}
|
|
||||||
if errCode != "" {
|
|
||||||
pi["last_payment_error"] = map[string]any{"code": errCode}
|
|
||||||
}
|
|
||||||
raw, err := json.Marshal(pi)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal pi: %v", err)
|
|
||||||
}
|
|
||||||
return stripe.Event{
|
|
||||||
Type: "payment_intent.payment_failed",
|
|
||||||
Data: &stripe.EventData{Raw: raw},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func seedOrderForWebhook(t *testing.T, userID uint, status models.OrderStatus, piID string) models.Order {
|
|
||||||
t.Helper()
|
|
||||||
db := database.GetDB()
|
|
||||||
card := models.Card{
|
|
||||||
UserID: userID,
|
|
||||||
StripePaymentID: fmt.Sprintf("pm_test_%s", piID),
|
|
||||||
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
|
|
||||||
}
|
|
||||||
if err := db.Create(&card).Error; err != nil {
|
|
||||||
t.Fatalf("seed card: %v", err)
|
|
||||||
}
|
|
||||||
order := models.Order{
|
|
||||||
UserID: userID, CardID: card.ID,
|
|
||||||
StripePaymentID: piID,
|
|
||||||
Status: status,
|
|
||||||
Subtotal: 500, Total: 500,
|
|
||||||
ShippingName: "Test", ShippingStreet: "1 Main St",
|
|
||||||
ShippingCity: "PDX", ShippingState: "OR",
|
|
||||||
ShippingZip: "97201", ShippingCountry: "US",
|
|
||||||
}
|
|
||||||
if err := db.Create(&order).Error; err != nil {
|
|
||||||
t.Fatalf("seed order: %v", err)
|
|
||||||
}
|
|
||||||
return order
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandlePaymentIntentFailed_RequiresActionFlipsToFailed verifies the
|
|
||||||
// widened predicate (IN (pending, requires_action)) catches a row in the new
|
|
||||||
// requires_action state. Without this case, a card_declined webhook arriving
|
|
||||||
// after respondRequiresAction wrote requires_action would leave the row
|
|
||||||
// stuck forever.
|
|
||||||
func TestHandlePaymentIntentFailed_RequiresActionFlipsToFailed(t *testing.T) {
|
|
||||||
testDB, user := setupCartTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_failed")
|
|
||||||
|
|
||||||
h := NewWebhookHandler("", "")
|
|
||||||
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
|
|
||||||
h.handlePaymentIntentFailed(context.Background(), evt)
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
var reloaded models.Order
|
|
||||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
|
||||||
t.Fatalf("reload order: %v", err)
|
|
||||||
}
|
|
||||||
if reloaded.Status != models.OrderStatusFailed {
|
|
||||||
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandlePaymentIntentFailed_RequiresActionAuthRequiredShortCircuits
|
|
||||||
// verifies the existing short-circuit (note #3 in sca-psd2-compliance.md)
|
|
||||||
// still fires when the row is in requires_action and the error code is
|
|
||||||
// authentication_required. Without the short-circuit, the recovery flow
|
|
||||||
// would race the webhook and surface a momentarily-failed state to the TUI.
|
|
||||||
func TestHandlePaymentIntentFailed_RequiresActionAuthRequiredShortCircuits(t *testing.T) {
|
|
||||||
testDB, user := setupCartTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_authreq")
|
|
||||||
|
|
||||||
h := NewWebhookHandler("", "")
|
|
||||||
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "authentication_required")
|
|
||||||
h.handlePaymentIntentFailed(context.Background(), evt)
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
var reloaded models.Order
|
|
||||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
|
||||||
t.Fatalf("reload order: %v", err)
|
|
||||||
}
|
|
||||||
if reloaded.Status != models.OrderStatusRequiresAction {
|
|
||||||
t.Fatalf("auth_required must not flip requires_action; got %q", reloaded.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandlePaymentIntentFailed_PendingStillFlips guards the pre-existing
|
|
||||||
// pending -> failed transition against a regression where the widened
|
|
||||||
// predicate accidentally dropped pending from the source set.
|
|
||||||
func TestHandlePaymentIntentFailed_PendingStillFlips(t *testing.T) {
|
|
||||||
testDB, user := setupCartTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
order := seedOrderForWebhook(t, user.ID, models.OrderStatusPending, "pi_test_pending_failed")
|
|
||||||
|
|
||||||
h := NewWebhookHandler("", "")
|
|
||||||
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
|
|
||||||
h.handlePaymentIntentFailed(context.Background(), evt)
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
var reloaded models.Order
|
|
||||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
|
||||||
t.Fatalf("reload order: %v", err)
|
|
||||||
}
|
|
||||||
if reloaded.Status != models.OrderStatusFailed {
|
|
||||||
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandlePaymentIntentFailed_PaidNotTouched locks the upper boundary of
|
|
||||||
// the predicate: a paid row must NEVER be flipped to failed by a stale
|
|
||||||
// payment_failed webhook. Without this guard, a future maintainer could
|
|
||||||
// widen the IN clause to include paid (e.g. for some refund-retry flow)
|
|
||||||
// and silently break idempotency.
|
|
||||||
func TestHandlePaymentIntentFailed_PaidNotTouched(t *testing.T) {
|
|
||||||
testDB, user := setupCartTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
order := seedOrderForWebhook(t, user.ID, models.OrderStatusPaid, "pi_test_paid_untouched")
|
|
||||||
|
|
||||||
h := NewWebhookHandler("", "")
|
|
||||||
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
|
|
||||||
h.handlePaymentIntentFailed(context.Background(), evt)
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
var reloaded models.Order
|
|
||||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
|
||||||
t.Fatalf("reload order: %v", err)
|
|
||||||
}
|
|
||||||
if reloaded.Status != models.OrderStatusPaid {
|
|
||||||
t.Fatalf("paid order was modified by payment_failed webhook: got %q", reloaded.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandlePaymentIntentFailed_MissingMetadataNoOp covers the no-order
|
|
||||||
// short-circuit so the test file exercises every early-return branch in
|
|
||||||
// handlePaymentIntentFailed touched by the predicate work.
|
|
||||||
func TestHandlePaymentIntentFailed_MissingMetadataNoOp(t *testing.T) {
|
|
||||||
testDB, _ := setupCartTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
pi := map[string]any{"id": "pi_test_no_meta"}
|
|
||||||
raw, err := json.Marshal(pi)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal pi: %v", err)
|
|
||||||
}
|
|
||||||
evt := stripe.Event{
|
|
||||||
Type: "payment_intent.payment_failed",
|
|
||||||
Data: &stripe.EventData{Raw: raw},
|
|
||||||
}
|
|
||||||
|
|
||||||
h := NewWebhookHandler("", "")
|
|
||||||
h.handlePaymentIntentFailed(context.Background(), evt) // must not panic
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandlePaymentIntentSucceeded_RequiresActionFlipsToPaid(t *testing.T) {
|
|
||||||
testDB, user := setupCartTestDB(t)
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer database.ResetForTesting()
|
|
||||||
|
|
||||||
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_paid")
|
|
||||||
|
|
||||||
pi := map[string]any{
|
|
||||||
"id": order.StripePaymentID,
|
|
||||||
"amount": order.Total,
|
|
||||||
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
|
|
||||||
}
|
|
||||||
raw, err := json.Marshal(pi)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal pi: %v", err)
|
|
||||||
}
|
|
||||||
evt := stripe.Event{
|
|
||||||
Type: "payment_intent.succeeded",
|
|
||||||
Data: &stripe.EventData{Raw: raw},
|
|
||||||
}
|
|
||||||
|
|
||||||
h := NewWebhookHandler("", "")
|
|
||||||
h.handlePaymentIntentSucceeded(context.Background(), evt)
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
var reloaded models.Order
|
|
||||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
|
||||||
t.Fatalf("reload order: %v", err)
|
|
||||||
}
|
|
||||||
if reloaded.Status != models.OrderStatusPaid {
|
|
||||||
t.Fatalf("status: want %q, got %q", models.OrderStatusPaid, reloaded.Status)
|
|
||||||
}
|
|
||||||
if reloaded.StripePaymentID != order.StripePaymentID {
|
|
||||||
t.Fatalf("stripe_payment_id: want %q, got %q", order.StripePaymentID, reloaded.StripePaymentID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
58
api/main.go
58
api/main.go
@@ -19,8 +19,6 @@ import (
|
|||||||
"terminalShop/pkg/database"
|
"terminalShop/pkg/database"
|
||||||
"terminalShop/pkg/observability"
|
"terminalShop/pkg/observability"
|
||||||
"terminalShop/pkg/stripeclient"
|
"terminalShop/pkg/stripeclient"
|
||||||
|
|
||||||
"github.com/stripe/stripe-go/v78"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "v0.1.0"
|
const version = "v0.1.0"
|
||||||
@@ -51,12 +49,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
audit.SetDB(db)
|
audit.SetDB(db)
|
||||||
|
|
||||||
// wire the stripe SDK's package-level key once at startup so concurrent
|
|
||||||
// reconcilers and the request path don't race on stripe.Key writes.
|
|
||||||
// NOTE: assumes single-process — if you ever embed both the SSH and API
|
|
||||||
// binaries together, the last writer wins and one of them silently breaks.
|
|
||||||
stripe.Key = cfg.StripeSecretKey
|
|
||||||
|
|
||||||
// Seed database with initial data
|
// Seed database with initial data
|
||||||
if err := database.Seed(db); err != nil {
|
if err := database.Seed(db); err != nil {
|
||||||
log.Fatalf("Failed to seed database: %v", err)
|
log.Fatalf("Failed to seed database: %v", err)
|
||||||
@@ -79,7 +71,7 @@ func main() {
|
|||||||
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
|
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
|
||||||
|
|
||||||
// Setup routes
|
// Setup routes
|
||||||
router := routes.SetupRoutes(version, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL, cfg.SlackSigningSecret, cfg.AdminAPIKey, cfg.APIPort, cfg.MaxOrderCents)
|
router := routes.SetupRoutes(version, cfg.StripeSecretKey, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL, cfg.SlackSigningSecret, cfg.AdminAPIKey, cfg.APIPort)
|
||||||
|
|
||||||
// Create HTTP server
|
// Create HTTP server
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
@@ -114,7 +106,7 @@ func main() {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
handlers.ReconcileOrders(ctx)
|
handlers.ReconcileOrders(cfg.StripeSecretKey)
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -122,48 +114,14 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
select {
|
time.Sleep(30 * time.Second)
|
||||||
case <-time.After(30 * time.Second):
|
handlers.ReconcileUnshipped()
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handlers.ReconcileUnshipped(ctx)
|
|
||||||
ticker := time.NewTicker(24 * time.Hour)
|
ticker := time.NewTicker(24 * time.Hour)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
handlers.ReconcileUnshipped(ctx)
|
handlers.ReconcileUnshipped()
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Sweep saved cards whose retention deadline elapsed. Read paths filter
|
|
||||||
// expired rows from query results, so this sweeper only drives the Stripe
|
|
||||||
// detach + audit + physical row delete asynchronously.
|
|
||||||
go func() {
|
|
||||||
ticker := time.NewTicker(1 * time.Hour)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
handlers.ReconcileExpiredCards(ctx)
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
ticker := time.NewTicker(15 * time.Minute)
|
|
||||||
defer ticker.Stop()
|
|
||||||
threshold := time.Duration(cfg.Abandoned3DSThresholdMinutes) * time.Minute
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
handlers.ReconcileStale3DSOrders(ctx, threshold)
|
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -178,10 +136,10 @@ func main() {
|
|||||||
log.Println("Server is shutting down...")
|
log.Println("Server is shutting down...")
|
||||||
|
|
||||||
// Graceful shutdown with 30 second timeout
|
// Graceful shutdown with 30 second timeout
|
||||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer shutdownCancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
if err := server.Shutdown(ctx); err != nil {
|
||||||
log.Fatalf("Server forced to shutdown: %v", err)
|
log.Fatalf("Server forced to shutdown: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,14 +70,6 @@ var (
|
|||||||
[]string{"outcome"},
|
[]string{"outcome"},
|
||||||
)
|
)
|
||||||
|
|
||||||
abandoned3DSSweepTotal = promauto.NewCounterVec(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Name: "abandoned_3ds_sweep_total",
|
|
||||||
Help: "ReconcileStale3DSOrders outcomes per row processed (flipped, flip_noop, flip_failed, succeeded_skip, processing_skip, stripe_error, missing_pi, unrecognized_status)",
|
|
||||||
},
|
|
||||||
[]string{"outcome"},
|
|
||||||
)
|
|
||||||
|
|
||||||
orderValueDollars = promauto.NewHistogram(
|
orderValueDollars = promauto.NewHistogram(
|
||||||
prometheus.HistogramOpts{
|
prometheus.HistogramOpts{
|
||||||
Name: "order_value_dollars",
|
Name: "order_value_dollars",
|
||||||
@@ -181,19 +173,6 @@ func ObserveOrderValueCents(cents int) {
|
|||||||
orderValueDollars.Observe(float64(cents) / 100.0)
|
orderValueDollars.Observe(float64(cents) / 100.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordAbandoned3DSSweep records the outcome of one row processed by
|
|
||||||
// ReconcileStale3DSOrders. Bounded enumeration documented on the counter's
|
|
||||||
// Help string.
|
|
||||||
func RecordAbandoned3DSSweep(outcome string) {
|
|
||||||
abandoned3DSSweepTotal.WithLabelValues(outcome).Inc()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Abandoned3DSSweepCounter exposes the sweep counter so tests can snapshot
|
|
||||||
// per-label values via prometheus testutil. Not used in production code.
|
|
||||||
func Abandoned3DSSweepCounter() *prometheus.CounterVec {
|
|
||||||
return abandoned3DSSweepTotal
|
|
||||||
}
|
|
||||||
|
|
||||||
// ObserveStripeRequest records latency of a Stripe API call.
|
// ObserveStripeRequest records latency of a Stripe API call.
|
||||||
func ObserveStripeRequest(op, outcome string, durSec float64) {
|
func ObserveStripeRequest(op, outcome string, durSec float64) {
|
||||||
stripeRequestDuration.WithLabelValues(op, outcome).Observe(durSec)
|
stripeRequestDuration.WithLabelValues(op, outcome).Observe(durSec)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
// SetupRoutes configures all API routes
|
// SetupRoutes configures all API routes
|
||||||
func SetupRoutes(
|
func SetupRoutes(
|
||||||
version string,
|
version string,
|
||||||
|
stripeSecretKey string,
|
||||||
stripeWebhookSecret string,
|
stripeWebhookSecret string,
|
||||||
jwtManager *auth.JWTManager,
|
jwtManager *auth.JWTManager,
|
||||||
authFingerprintKey string,
|
authFingerprintKey string,
|
||||||
@@ -28,7 +29,6 @@ func SetupRoutes(
|
|||||||
slackSigningSecret string,
|
slackSigningSecret string,
|
||||||
adminAPIKey string,
|
adminAPIKey string,
|
||||||
apiPort string,
|
apiPort string,
|
||||||
maxOrderCents int,
|
|
||||||
) *chi.Mux {
|
) *chi.Mux {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
@@ -46,12 +46,12 @@ func SetupRoutes(
|
|||||||
healthHandler := handlers.NewHealthHandler(version)
|
healthHandler := handlers.NewHealthHandler(version)
|
||||||
productHandler := handlers.NewProductHandler()
|
productHandler := handlers.NewProductHandler()
|
||||||
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
|
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
|
||||||
cartHandler := handlers.NewCartHandler(appURL, maxOrderCents)
|
cartHandler := handlers.NewCartHandler(stripeSecretKey)
|
||||||
cardHandler := handlers.NewCardHandler(appURL)
|
cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
|
||||||
orderHandler := handlers.NewOrderHandler(bringAPIUID, bringAPIKey, bringCustomerNumber)
|
orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber)
|
||||||
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
|
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
|
||||||
viewHandler := handlers.NewViewHandler()
|
viewHandler := handlers.NewViewHandler()
|
||||||
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, shippoWebhookSecret)
|
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, stripeSecretKey, shippoWebhookSecret)
|
||||||
slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort, shippoWebhookSecret)
|
slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort, shippoWebhookSecret)
|
||||||
|
|
||||||
// Short payment redirect and success page — no auth required.
|
// Short payment redirect and success page — no auth required.
|
||||||
@@ -61,10 +61,6 @@ func SetupRoutes(
|
|||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
_, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`))
|
_, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`))
|
||||||
})
|
})
|
||||||
r.Get("/post-3ds", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
|
||||||
_, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Authentication complete.</h2><p>Return to your terminal.</p></body></html>`))
|
|
||||||
})
|
|
||||||
r.Handle("/metrics", middleware.MetricsHandler())
|
r.Handle("/metrics", middleware.MetricsHandler())
|
||||||
|
|
||||||
r.Route("/api/v1", func(r chi.Router) {
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
@@ -130,8 +126,6 @@ func SetupRoutes(
|
|||||||
|
|
||||||
// Orders
|
// Orders
|
||||||
r.Get("/orders", orderHandler.GetOrders)
|
r.Get("/orders", orderHandler.GetOrders)
|
||||||
r.Get("/orders/{id}/status", orderHandler.GetOrderStatus)
|
|
||||||
r.With(middleware.RateLimitByUser(10, time.Minute)).Post("/orders/{id}/retry-auth", cartHandler.RetryAuth)
|
|
||||||
r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest)
|
r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest)
|
||||||
r.Post("/orders/{id}/refund", orderHandler.RefundOrder)
|
r.Post("/orders/{id}/refund", orderHandler.RefundOrder)
|
||||||
|
|
||||||
|
|||||||
18
go.mod
18
go.mod
@@ -39,7 +39,6 @@ require (
|
|||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bitfield/gotestdox v0.2.2 // indirect
|
|
||||||
github.com/catppuccin/go v0.3.0 // indirect
|
github.com/catppuccin/go v0.3.0 // indirect
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
@@ -57,20 +56,15 @@ require (
|
|||||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/creack/pty v1.1.24 // indirect
|
github.com/creack/pty v1.1.24 // indirect
|
||||||
github.com/dnephin/pflag v1.0.7 // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/fatih/color v1.18.0 // indirect
|
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
|
||||||
github.com/go-faster/city v1.0.1 // indirect
|
github.com/go-faster/city v1.0.1 // indirect
|
||||||
github.com/go-faster/errors v0.7.1 // indirect
|
github.com/go-faster/errors v0.7.1 // indirect
|
||||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gotesttools/gotestfmt/v2 v2.5.0 // indirect
|
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||||
github.com/hashicorp/go-version v1.6.0 // indirect
|
github.com/hashicorp/go-version v1.6.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
@@ -81,10 +75,7 @@ require (
|
|||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||||
@@ -107,13 +98,10 @@ require (
|
|||||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/mod v0.34.0 // indirect
|
|
||||||
golang.org/x/net v0.52.0 // indirect
|
golang.org/x/net v0.52.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
golang.org/x/term v0.42.0 // indirect
|
|
||||||
golang.org/x/text v0.36.0 // indirect
|
golang.org/x/text v0.36.0 // indirect
|
||||||
golang.org/x/tools v0.43.0 // indirect
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||||
google.golang.org/grpc v1.80.0 // indirect
|
google.golang.org/grpc v1.80.0 // indirect
|
||||||
@@ -121,10 +109,4 @@ require (
|
|||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gorm.io/driver/clickhouse v0.7.0 // indirect
|
gorm.io/driver/clickhouse v0.7.0 // indirect
|
||||||
gorm.io/driver/mysql v1.5.7 // indirect
|
gorm.io/driver/mysql v1.5.7 // indirect
|
||||||
gotest.tools/gotestsum v1.13.0 // indirect
|
|
||||||
)
|
|
||||||
|
|
||||||
tool (
|
|
||||||
github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt
|
|
||||||
gotest.tools/gotestsum
|
|
||||||
)
|
)
|
||||||
|
|||||||
27
go.sum
27
go.sum
@@ -26,8 +26,6 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ
|
|||||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE=
|
|
||||||
github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY=
|
|
||||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||||
@@ -71,18 +69,12 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk=
|
|
||||||
github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE=
|
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
|
||||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ=
|
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ=
|
||||||
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c/go.mod h1:0ghtL4RhKBjyWMkHCWosp2aHSEXShQeQMLQ0qBCytfU=
|
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c/go.mod h1:0ghtL4RhKBjyWMkHCWosp2aHSEXShQeQMLQ0qBCytfU=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
|
||||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
@@ -113,12 +105,8 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
|||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
|
||||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gotesttools/gotestfmt/v2 v2.5.0 h1:fSU3MnR+E+fvuXdw1l8xbufKhDxY3Tfjsjx/I1WerB4=
|
|
||||||
github.com/gotesttools/gotestfmt/v2 v2.5.0/go.mod h1:oQJg2KZ2aGoqEbMC2PDaAeBYm0tOkocgixK9FzsCdp4=
|
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||||
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
|
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
|
||||||
@@ -155,11 +143,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
|||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||||
@@ -256,8 +239,6 @@ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2
|
|||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
|
||||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
@@ -278,8 +259,6 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
@@ -295,8 +274,6 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
|||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
|
||||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
@@ -333,9 +310,5 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
|||||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc=
|
gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc=
|
||||||
gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y=
|
gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y=
|
||||||
gotest.tools/gotestsum v1.13.0 h1:+Lh454O9mu9AMG1APV4o0y7oDYKyik/3kBOiCqiEpRo=
|
|
||||||
gotest.tools/gotestsum v1.13.0/go.mod h1:7f0NS5hFb0dWr4NtcsAsF0y1kzjEFfAil0HiBQJE03Q=
|
|
||||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
|
||||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
|
||||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||||
|
|||||||
15
main.go
15
main.go
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
@@ -15,7 +16,6 @@ import (
|
|||||||
"charm.land/wish/v2/bubbletea"
|
"charm.land/wish/v2/bubbletea"
|
||||||
"charm.land/wish/v2/logging"
|
"charm.land/wish/v2/logging"
|
||||||
"github.com/charmbracelet/ssh"
|
"github.com/charmbracelet/ssh"
|
||||||
"github.com/stripe/stripe-go/v78"
|
|
||||||
|
|
||||||
"terminalShop/pkg/auth"
|
"terminalShop/pkg/auth"
|
||||||
"terminalShop/pkg/config"
|
"terminalShop/pkg/config"
|
||||||
@@ -32,6 +32,7 @@ func listenHost() string {
|
|||||||
var (
|
var (
|
||||||
apiURL string
|
apiURL string
|
||||||
authFingerprintKey string
|
authFingerprintKey string
|
||||||
|
stripePublicKey string
|
||||||
)
|
)
|
||||||
|
|
||||||
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||||
@@ -43,7 +44,7 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
|||||||
pubKeyStr = auth.FormatSSHPublicKey(pubKey)
|
pubKeyStr = auth.FormatSSHPublicKey(pubKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey)
|
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,15 +58,7 @@ func main() {
|
|||||||
// Set API URL and auth key from config for TUI connections
|
// Set API URL and auth key from config for TUI connections
|
||||||
apiURL = cfg.APIURL
|
apiURL = cfg.APIURL
|
||||||
authFingerprintKey = cfg.AuthFingerprintKey
|
authFingerprintKey = cfg.AuthFingerprintKey
|
||||||
|
stripePublicKey = cfg.StripePublicKey
|
||||||
// Wire the publishable key into stripe-go's package-level global once.
|
|
||||||
// TUI uses the legacy Tokens API (stripetoken.New) which accepts the
|
|
||||||
// publishable key; per-session writes would race across concurrent SSH
|
|
||||||
// sessions even though they'd all write the same value.
|
|
||||||
// NOTE: assumes single-process — if you ever embed both the SSH and API
|
|
||||||
// binaries together, the last writer wins and one of them silently breaks.
|
|
||||||
stripe.Key = cfg.StripePublicKey
|
|
||||||
|
|
||||||
log.Printf("TUI will connect to API at: %s", apiURL)
|
log.Printf("TUI will connect to API at: %s", apiURL)
|
||||||
|
|
||||||
// Create SSH server
|
// Create SSH server
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ type TokenResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) {
|
func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) {
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL)
|
url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL)
|
||||||
|
|
||||||
reqBody := TokenRequest{
|
reqBody := TokenRequest{
|
||||||
@@ -104,16 +105,16 @@ func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error)
|
|||||||
|
|
||||||
// APIResponse represents the standard API response format
|
// APIResponse represents the standard API response format
|
||||||
type APIResponse struct {
|
type APIResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Data map[string]any `json:"data"`
|
Data map[string]interface{} `json:"data"`
|
||||||
Error *APIError `json:"error,omitempty"`
|
Error *APIError `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIError represents an error from the API
|
// APIError represents an error from the API
|
||||||
type APIError struct {
|
type APIError struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Details map[string]any `json:"details,omitempty"`
|
Details map[string]interface{} `json:"details,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProductsResponse represents the products API response
|
// ProductsResponse represents the products API response
|
||||||
@@ -280,31 +281,14 @@ type CardResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ConvertCartResponse represents the cart conversion (checkout) API response.
|
// ConvertCartResponse represents the cart conversion (checkout) API response.
|
||||||
// 200 carries Order populated; 202 (requires_action / 3DS) carries the
|
|
||||||
// redirect fields and OrderID instead.
|
|
||||||
type ConvertCartResponse struct {
|
type ConvertCartResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Data struct {
|
Data struct {
|
||||||
Order *models.Order `json:"order,omitempty"`
|
Order models.Order `json:"order"`
|
||||||
OrderID uint `json:"order_id,omitempty"`
|
|
||||||
PaymentIntentID string `json:"payment_intent_id,omitempty"`
|
|
||||||
Status string `json:"status,omitempty"`
|
|
||||||
RedirectToken string `json:"redirect_token,omitempty"`
|
|
||||||
RedirectURL string `json:"redirect_url,omitempty"`
|
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
Error *APIError `json:"error,omitempty"`
|
Error *APIError `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckoutOutcome is the unified result of ConvertCart. RequiresAction=true
|
|
||||||
// means the customer needs to complete 3DS at RedirectURL; poll
|
|
||||||
// GetOrderStatus(OrderID) until the webhook flips the order to paid or failed.
|
|
||||||
type CheckoutOutcome struct {
|
|
||||||
Order *models.Order
|
|
||||||
OrderID uint
|
|
||||||
RequiresAction bool
|
|
||||||
RedirectURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCart fetches the user's current cart.
|
// GetCart fetches the user's current cart.
|
||||||
func (c *Client) GetCart() (*CartData, error) {
|
func (c *Client) GetCart() (*CartData, error) {
|
||||||
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL)
|
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL)
|
||||||
@@ -339,7 +323,7 @@ func (c *Client) GetCart() (*CartData, error) {
|
|||||||
func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
|
func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
|
||||||
url := fmt.Sprintf("%s/api/v1/cart/item", c.BaseURL)
|
url := fmt.Sprintf("%s/api/v1/cart/item", c.BaseURL)
|
||||||
|
|
||||||
body := map[string]any{
|
body := map[string]interface{}{
|
||||||
"coffee_id": coffeeID,
|
"coffee_id": coffeeID,
|
||||||
"quantity": quantity,
|
"quantity": quantity,
|
||||||
}
|
}
|
||||||
@@ -379,7 +363,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
|
|||||||
func (c *Client) SetCartAddress(addressID uint) error {
|
func (c *Client) SetCartAddress(addressID uint) error {
|
||||||
url := fmt.Sprintf("%s/api/v1/cart/address", c.BaseURL)
|
url := fmt.Sprintf("%s/api/v1/cart/address", c.BaseURL)
|
||||||
|
|
||||||
body := map[string]any{
|
body := map[string]interface{}{
|
||||||
"address_id": addressID,
|
"address_id": addressID,
|
||||||
}
|
}
|
||||||
jsonData, err := json.Marshal(body)
|
jsonData, err := json.Marshal(body)
|
||||||
@@ -418,7 +402,7 @@ func (c *Client) SetCartAddress(addressID uint) error {
|
|||||||
func (c *Client) SetCartCard(cardID uint) error {
|
func (c *Client) SetCartCard(cardID uint) error {
|
||||||
url := fmt.Sprintf("%s/api/v1/cart/card", c.BaseURL)
|
url := fmt.Sprintf("%s/api/v1/cart/card", c.BaseURL)
|
||||||
|
|
||||||
body := map[string]any{
|
body := map[string]interface{}{
|
||||||
"card_id": cardID,
|
"card_id": cardID,
|
||||||
}
|
}
|
||||||
jsonData, err := json.Marshal(body)
|
jsonData, err := json.Marshal(body)
|
||||||
@@ -484,9 +468,7 @@ func (c *Client) ClearCart() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ConvertCart converts the cart into an order, charging the saved card.
|
// ConvertCart converts the cart into an order, charging the saved card.
|
||||||
// Returns a CheckoutOutcome that either carries the completed Order or
|
func (c *Client) ConvertCart() (*models.Order, error) {
|
||||||
// indicates the customer must complete 3DS authentication via RedirectURL.
|
|
||||||
func (c *Client) ConvertCart() (*CheckoutOutcome, error) {
|
|
||||||
url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL)
|
url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL)
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", url, nil)
|
req, err := http.NewRequest("POST", url, nil)
|
||||||
@@ -512,107 +494,7 @@ func (c *Client) ConvertCart() (*CheckoutOutcome, error) {
|
|||||||
return nil, fmt.Errorf("failed to convert cart")
|
return nil, fmt.Errorf("failed to convert cart")
|
||||||
}
|
}
|
||||||
|
|
||||||
if convertResp.Data.Status == "requires_action" {
|
return &convertResp.Data.Order, nil
|
||||||
return &CheckoutOutcome{
|
|
||||||
OrderID: convertResp.Data.OrderID,
|
|
||||||
RequiresAction: true,
|
|
||||||
RedirectURL: convertResp.Data.RedirectURL,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if convertResp.Data.Order == nil {
|
|
||||||
return nil, fmt.Errorf("malformed convert response: missing order")
|
|
||||||
}
|
|
||||||
return &CheckoutOutcome{
|
|
||||||
Order: convertResp.Data.Order,
|
|
||||||
OrderID: convertResp.Data.Order.ID,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type OrderStatusResponse struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Data struct {
|
|
||||||
ID uint `json:"id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
} `json:"data"`
|
|
||||||
Error *APIError `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) GetOrderStatus(id uint) (string, error) {
|
|
||||||
url := fmt.Sprintf("%s/api/v1/orders/%d/status", c.BaseURL, id)
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
resp, err := c.doRequest(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to fetch order status: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
var sResp OrderStatusResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&sResp); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to decode order status response: %w", err)
|
|
||||||
}
|
|
||||||
if !sResp.Success {
|
|
||||||
if sResp.Error != nil {
|
|
||||||
return "", fmt.Errorf("%s: %s", sResp.Error.Code, sResp.Error.Message)
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("failed to fetch order status")
|
|
||||||
}
|
|
||||||
return sResp.Data.Status, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RetryAuthOutcome is the result of POST /orders/{id}/retry-auth.
|
|
||||||
// status=="requires_action" -> RedirectURL is a fresh QR target; resume polling.
|
|
||||||
// Status=="succeeded" -> stripe approved frictionless; keep polling for the
|
|
||||||
// webhook to flip status=paid (RedirectURL will be empty)
|
|
||||||
type RetryAuthOutcome struct {
|
|
||||||
OrderID uint
|
|
||||||
Status string
|
|
||||||
RedirectURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
type retryAuthResponse struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Data struct {
|
|
||||||
OrderID uint `json:"order_id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
RedirectURL string `json:"redirect_url,omitempty"`
|
|
||||||
} `json:"data"`
|
|
||||||
Error *APIError `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RetryAuth asks the server to re-Confirm the existing PaymentIntent for the
|
|
||||||
// order, producing a new 3DS challenge URL. Used by the TUI's authFailed
|
|
||||||
// screen so the customer can recover without re-entering the checkout flow.
|
|
||||||
func (c *Client) RetryAuth(orderID uint) (*RetryAuthOutcome, error) {
|
|
||||||
url := fmt.Sprintf("%s/api/v1/orders/%d/retry-auth", c.BaseURL, orderID)
|
|
||||||
req, err := http.NewRequest("POST", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create retry request: %w", err)
|
|
||||||
}
|
|
||||||
resp, err := c.doRequest(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to send retry request: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
var rResp retryAuthResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&rResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode retry response: %w", err)
|
|
||||||
}
|
|
||||||
if !rResp.Success {
|
|
||||||
if rResp.Error != nil {
|
|
||||||
return nil, fmt.Errorf("%s: %s", rResp.Error.Code, rResp.Error.Message)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("failed to retry authentication")
|
|
||||||
}
|
|
||||||
return &RetryAuthOutcome{
|
|
||||||
OrderID: rResp.Data.OrderID,
|
|
||||||
Status: rResp.Data.Status,
|
|
||||||
RedirectURL: rResp.Data.RedirectURL,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCards fetches all saved cards for the user.
|
// GetCards fetches all saved cards for the user.
|
||||||
|
|||||||
@@ -7,42 +7,20 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"terminalShop/pkg/models"
|
"terminalShop/pkg/models"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EventOrderRequiresAction is the audit event-type string emitted whenever a
|
|
||||||
// PaymentIntent is pushed into requires_action. Exposed so cart-flow handler
|
|
||||||
// can count retry attempts without hardcoding the string literal.
|
|
||||||
const EventOrderRequiresAction = "order_requires_action"
|
|
||||||
|
|
||||||
// EventOrder3DSAbandoned is the audit event-type string emitted by the
|
|
||||||
// abandoned-3DS sweep when it flips a stale requires_action row to failed.
|
|
||||||
// Distinct from EventOrderRequiresAction (caveat #17) so the retry-cap count
|
|
||||||
// stays decoupled from sweep activity
|
|
||||||
const EventOrder3DSAbandoned = "order_3ds_abandoned"
|
|
||||||
|
|
||||||
// EventCartRejected is the audit event-type string emitted when the per-order
|
|
||||||
// spend cap blocks a checkout BEFORE an order row is created. The event lives
|
|
||||||
// in slog only, persist() no-ops on zero order id, which is the correct shape
|
|
||||||
// here (cap rejection is upstream of order state). See caveat #13.
|
|
||||||
const EventCartRejected = "cart_rejected"
|
|
||||||
|
|
||||||
type eventType string
|
type eventType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
eventCardAdded eventType = "card_added"
|
eventCardAdded eventType = "card_added"
|
||||||
eventCardDeleted eventType = "card_deleted"
|
eventCardDeleted eventType = "card_deleted"
|
||||||
eventCardExpired eventType = "card_expired"
|
|
||||||
eventOrderCreated eventType = "order_created"
|
eventOrderCreated eventType = "order_created"
|
||||||
eventOrderPaid eventType = "order_paid"
|
eventOrderPaid eventType = "order_paid"
|
||||||
eventOrderShipped eventType = "order_shipped"
|
eventOrderShipped eventType = "order_shipped"
|
||||||
eventOrderFailed eventType = "order_failed"
|
eventOrderFailed eventType = "order_failed"
|
||||||
eventOrderRequiresAction eventType = EventOrderRequiresAction
|
|
||||||
eventOrder3DSAbandoned eventType = EventOrder3DSAbandoned
|
|
||||||
eventCartRejected eventType = EventCartRejected
|
|
||||||
eventOrderRefunded eventType = "order_refunded"
|
eventOrderRefunded eventType = "order_refunded"
|
||||||
eventPaymentCritical eventType = "payment_critical"
|
eventPaymentCritical eventType = "payment_critical"
|
||||||
eventLabelPurchased eventType = "label_purchased"
|
eventLabelPurchased eventType = "label_purchased"
|
||||||
@@ -53,21 +31,19 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type event struct {
|
type event struct {
|
||||||
Event eventType
|
Event eventType
|
||||||
UserID uint
|
UserID uint
|
||||||
OrderID uint
|
OrderID uint
|
||||||
CardID uint
|
CardID uint
|
||||||
Amount int
|
Amount int
|
||||||
StripeID string
|
StripeID string
|
||||||
CardLast4 string
|
CardLast4 string
|
||||||
CardBrand string
|
CardBrand string
|
||||||
Carrier string
|
Carrier string
|
||||||
Tracking string
|
Tracking string
|
||||||
Status string
|
Status string
|
||||||
Error string
|
Error string
|
||||||
Actor string
|
Actor string
|
||||||
TotalCents int
|
|
||||||
CapCents int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e event) attrs() []any {
|
func (e event) attrs() []any {
|
||||||
@@ -108,12 +84,6 @@ func (e event) attrs() []any {
|
|||||||
if e.Actor != "" {
|
if e.Actor != "" {
|
||||||
a = append(a, "actor", e.Actor)
|
a = append(a, "actor", e.Actor)
|
||||||
}
|
}
|
||||||
if e.TotalCents != 0 {
|
|
||||||
a = append(a, "total_cents", e.TotalCents)
|
|
||||||
}
|
|
||||||
if e.CapCents != 0 {
|
|
||||||
a = append(a, "cap_cents", e.CapCents)
|
|
||||||
}
|
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,17 +111,6 @@ func CardDeleted(userID, cardID uint) {
|
|||||||
persist(0, eventCardDeleted, e)
|
persist(0, eventCardDeleted, e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CardExpired records that a saved payment method expired from inactivity
|
|
||||||
func CardExpired(userID, cardID uint) {
|
|
||||||
e := event{
|
|
||||||
Event: eventCardExpired,
|
|
||||||
UserID: userID,
|
|
||||||
CardID: cardID,
|
|
||||||
}
|
|
||||||
slog.Info("audit", e.attrs()...)
|
|
||||||
persist(0, eventCardExpired, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// OrderCreated records that an order record was created (before payment).
|
// OrderCreated records that an order record was created (before payment).
|
||||||
func OrderCreated(userID, orderID uint, amountCents int) {
|
func OrderCreated(userID, orderID uint, amountCents int) {
|
||||||
e := event{
|
e := event{
|
||||||
@@ -339,44 +298,3 @@ func attrsToMap(a []any) map[string]any {
|
|||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
|
|
||||||
e := event{
|
|
||||||
Event: eventOrderRequiresAction,
|
|
||||||
UserID: userID,
|
|
||||||
OrderID: orderID,
|
|
||||||
StripeID: paymentIntentID,
|
|
||||||
}
|
|
||||||
slog.Info("audit", e.attrs()...)
|
|
||||||
persist(orderID, eventOrderRequiresAction, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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()...)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
package audit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"log/slog"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCartRejected(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
prev := slog.Default()
|
|
||||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
|
|
||||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
|
||||||
|
|
||||||
CartRejected(42, 25000, 20000)
|
|
||||||
|
|
||||||
raw := strings.TrimSpace(buf.String())
|
|
||||||
if raw == "" {
|
|
||||||
t.Fatalf("CartRejected: no slog output captured")
|
|
||||||
}
|
|
||||||
|
|
||||||
var rec map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(raw), &rec); err != nil {
|
|
||||||
t.Fatalf("decode slog record: %v\nraw: %s", err, raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got, want := rec["event"], "cart_rejected"; got != want {
|
|
||||||
t.Errorf("event: want %q, got %v", want, got)
|
|
||||||
}
|
|
||||||
if n, _ := rec["user_id"].(float64); int(n) != 42 {
|
|
||||||
t.Errorf("user_id want 42, got %v", rec["user_id"])
|
|
||||||
}
|
|
||||||
if n, _ := rec["total_cents"].(float64); int(n) != 25000 {
|
|
||||||
t.Errorf("total_cents: want 25000, got %v", rec["total_cents"])
|
|
||||||
}
|
|
||||||
if n, _ := rec["cap_cents"].(float64); int(n) != 20000 {
|
|
||||||
t.Errorf("cap_cents: want 20000, got %v", rec["cap_cents"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -53,7 +53,7 @@ func (m *JWTManager) GenerateToken(userID uint, email, username string) (string,
|
|||||||
|
|
||||||
// ValidateToken validates a JWT token and returns the claims
|
// ValidateToken validates a JWT token and returns the claims
|
||||||
func (m *JWTManager) ValidateToken(tokenString string) (*Claims, error) {
|
func (m *JWTManager) ValidateToken(tokenString string) (*Claims, error) {
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
// Verify signing method
|
// Verify signing method
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
@@ -14,26 +12,24 @@ import (
|
|||||||
|
|
||||||
// Config holds all application configuration
|
// Config holds all application configuration
|
||||||
type Config struct {
|
type Config struct {
|
||||||
APIPort string
|
APIPort string
|
||||||
APIURL string
|
APIURL string
|
||||||
AppURL string
|
AppURL string
|
||||||
SSHPort string
|
SSHPort string
|
||||||
Environment string
|
Environment string
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
StripeSecretKey string
|
StripeSecretKey string
|
||||||
StripePublicKey string
|
StripePublicKey string
|
||||||
StripeWebhookSecret string
|
StripeWebhookSecret string
|
||||||
ShippoAPIKey string
|
ShippoAPIKey string
|
||||||
AuthFingerprintKey string
|
AuthFingerprintKey string
|
||||||
BringAPIUID string
|
BringAPIUID string
|
||||||
BringAPIKey string
|
BringAPIKey string
|
||||||
BringCustomerNumber string
|
BringCustomerNumber string
|
||||||
ShippoWebhookSecret string
|
ShippoWebhookSecret string
|
||||||
SlackSigningSecret string
|
SlackSigningSecret string
|
||||||
AdminAPIKey string
|
AdminAPIKey string
|
||||||
MaxOrderCents int
|
|
||||||
Abandoned3DSThresholdMinutes int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables and validates required secrets.
|
// Load reads configuration from environment variables and validates required secrets.
|
||||||
@@ -52,26 +48,24 @@ func Load() (*Config, error) {
|
|||||||
apiPort := getEnvOrDefault("API_PORT", "8080")
|
apiPort := getEnvOrDefault("API_PORT", "8080")
|
||||||
apiURL := getEnvOrDefault("API_URL", fmt.Sprintf("http://localhost:%s", apiPort))
|
apiURL := getEnvOrDefault("API_URL", fmt.Sprintf("http://localhost:%s", apiPort))
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
APIPort: apiPort,
|
APIPort: apiPort,
|
||||||
APIURL: apiURL,
|
APIURL: apiURL,
|
||||||
AppURL: getEnvOrDefault("APP_URL", apiURL),
|
AppURL: getEnvOrDefault("APP_URL", apiURL),
|
||||||
SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
|
SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
|
||||||
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
|
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
|
||||||
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
|
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
|
||||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||||
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||||
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
|
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
|
||||||
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||||
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
|
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
|
||||||
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
|
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
|
||||||
BringAPIUID: os.Getenv("BRING_API_UID"),
|
BringAPIUID: os.Getenv("BRING_API_UID"),
|
||||||
BringAPIKey: os.Getenv("BRING_API_KEY"),
|
BringAPIKey: os.Getenv("BRING_API_KEY"),
|
||||||
BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"),
|
BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"),
|
||||||
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
|
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
|
||||||
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
|
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
|
||||||
AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
|
AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
|
||||||
MaxOrderCents: loadMaxOrderCents(),
|
|
||||||
Abandoned3DSThresholdMinutes: loadAbandoned3DSThresholdMinutes(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cfg.validate(); err != nil {
|
if err := cfg.validate(); err != nil {
|
||||||
@@ -136,45 +130,3 @@ func getEnvOrDefault(key, defaultValue string) string {
|
|||||||
}
|
}
|
||||||
return defaultValue
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
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,8 +3,6 @@ package database
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
|
||||||
|
|
||||||
"terminalShop/pkg/models"
|
"terminalShop/pkg/models"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -26,23 +24,10 @@ func Migrate(db *gorm.DB) error {
|
|||||||
&models.OrderItem{}, // Line items within orders
|
&models.OrderItem{}, // Line items within orders
|
||||||
&models.Address{}, // Saved addresses
|
&models.Address{}, // Saved addresses
|
||||||
&models.OrderEvent{},
|
&models.OrderEvent{},
|
||||||
&models.PayRedirect{}, // short-token -> stripe url store (10 min TTL)
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("migration failed: %w", err)
|
return fmt.Errorf("migration failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := backfillCardStorageExpiration(db); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Database migrations completed successfully")
|
log.Println("Database migrations completed successfully")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func backfillCardStorageExpiration(db *gorm.DB) error {
|
|
||||||
expiresAt := models.CardStorageExpiresAt(time.Now())
|
|
||||||
if err := db.Model(&models.Card{}).Where("storage_expires_at IS NULL").Update("storage_expires_at", expiresAt).Error; err != nil {
|
|
||||||
return fmt.Errorf("card storage ttl backfill failed: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"terminalShop/pkg/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestBackfillCardStorageExpiration_FillsNullRowsOnly(t *testing.T) {
|
|
||||||
testDB := "test_backfill.db"
|
|
||||||
defer func() { _ = os.Remove(testDB) }()
|
|
||||||
defer ResetForTesting()
|
|
||||||
|
|
||||||
db, err := Connect(testDB)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("connect: %v", err)
|
|
||||||
}
|
|
||||||
if err := Migrate(db); err != nil {
|
|
||||||
t.Fatalf("migrate: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
user := models.User{
|
|
||||||
SSHKeyFingerprint: "SHA256:backfill",
|
|
||||||
SSHPublicKey: "ssh-ed25519 AAAA backfill",
|
|
||||||
Name: "Backfill",
|
|
||||||
Email: "backfill@example.com",
|
|
||||||
}
|
|
||||||
if err := db.Create(&user).Error; err != nil {
|
|
||||||
t.Fatalf("seed user: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
legacy := models.Card{
|
|
||||||
UserID: user.ID, StripePaymentID: "pm_legacy", Last4: "0000",
|
|
||||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030,
|
|
||||||
}
|
|
||||||
if err := db.Create(&legacy).Error; err != nil {
|
|
||||||
t.Fatalf("seed legacy: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fixed := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
||||||
preset := models.Card{
|
|
||||||
UserID: user.ID, StripePaymentID: "pm_preset", Last4: "9999",
|
|
||||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &fixed,
|
|
||||||
}
|
|
||||||
if err := db.Create(&preset).Error; err != nil {
|
|
||||||
t.Fatalf("ssed preset: %v", err)
|
|
||||||
}
|
|
||||||
if err := backfillCardStorageExpiration(db); err != nil {
|
|
||||||
t.Fatalf("seed preset: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var reloadedLegacy, reloadedPreset models.Card
|
|
||||||
if err := db.First(&reloadedLegacy, legacy.ID).Error; err != nil {
|
|
||||||
t.Fatalf("reload legacy: %v", err)
|
|
||||||
}
|
|
||||||
if err := db.First(&reloadedPreset, preset.ID).Error; err != nil {
|
|
||||||
t.Fatalf("reload preset: %v", err)
|
|
||||||
}
|
|
||||||
if reloadedLegacy.StorageExpiresAt == nil {
|
|
||||||
t.Fatalf("legacy card storage_expires_at still NULL after backfill")
|
|
||||||
}
|
|
||||||
if reloadedPreset.StorageExpiresAt == nil || !reloadedPreset.StorageExpiresAt.Equal(fixed) {
|
|
||||||
t.Fatalf("preset card storage_expires_at was overwritten: %v", reloadedPreset.StorageExpiresAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,9 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"terminalShop/pkg/models"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"terminalShop/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Seed populates the database with initial data
|
// Seed populates the database with initial data
|
||||||
@@ -28,7 +27,7 @@ func Seed(db *gorm.DB) error {
|
|||||||
RoastType: "Dark Roast",
|
RoastType: "Dark Roast",
|
||||||
Ounces: 2,
|
Ounces: 2,
|
||||||
BeanType: "Arabica",
|
BeanType: "Arabica",
|
||||||
Price: 1800, // $18.00
|
Price: 350, // $3.50
|
||||||
Color: "#8B4513",
|
Color: "#8B4513",
|
||||||
Description: "Bold and intense coffee with a rich crema",
|
Description: "Bold and intense coffee with a rich crema",
|
||||||
},
|
},
|
||||||
@@ -37,7 +36,7 @@ func Seed(db *gorm.DB) error {
|
|||||||
RoastType: "Medium Roast",
|
RoastType: "Medium Roast",
|
||||||
Ounces: 8,
|
Ounces: 8,
|
||||||
BeanType: "Arabica",
|
BeanType: "Arabica",
|
||||||
Price: 2200, // $22.00
|
Price: 400, // $4.00
|
||||||
Color: "#A0522D",
|
Color: "#A0522D",
|
||||||
Description: "Smooth and balanced espresso diluted with hot water",
|
Description: "Smooth and balanced espresso diluted with hot water",
|
||||||
},
|
},
|
||||||
@@ -46,7 +45,7 @@ func Seed(db *gorm.DB) error {
|
|||||||
RoastType: "Medium Roast",
|
RoastType: "Medium Roast",
|
||||||
Ounces: 6,
|
Ounces: 6,
|
||||||
BeanType: "Arabica",
|
BeanType: "Arabica",
|
||||||
Price: 2200, // $22.00
|
Price: 450, // $4.50
|
||||||
Color: "#C4A574",
|
Color: "#C4A574",
|
||||||
Description: "Espresso with steamed milk and a thick layer of foam",
|
Description: "Espresso with steamed milk and a thick layer of foam",
|
||||||
},
|
},
|
||||||
@@ -55,7 +54,7 @@ func Seed(db *gorm.DB) error {
|
|||||||
RoastType: "Medium Roast",
|
RoastType: "Medium Roast",
|
||||||
Ounces: 12,
|
Ounces: 12,
|
||||||
BeanType: "Arabica",
|
BeanType: "Arabica",
|
||||||
Price: 2500, // $25.00
|
Price: 500, // $5.00
|
||||||
Color: "#D2B48C",
|
Color: "#D2B48C",
|
||||||
Description: "Creamy espresso with plenty of steamed milk and a touch of foam",
|
Description: "Creamy espresso with plenty of steamed milk and a touch of foam",
|
||||||
},
|
},
|
||||||
@@ -64,7 +63,7 @@ func Seed(db *gorm.DB) error {
|
|||||||
RoastType: "Dark Roast",
|
RoastType: "Dark Roast",
|
||||||
Ounces: 12,
|
Ounces: 12,
|
||||||
BeanType: "Arabica",
|
BeanType: "Arabica",
|
||||||
Price: 3000, // $30.00
|
Price: 550, // $5.50
|
||||||
Color: "#8B4513",
|
Color: "#8B4513",
|
||||||
Description: "Chocolate and espresso combined with steamed milk. Sweet and indulgent.",
|
Description: "Chocolate and espresso combined with steamed milk. Sweet and indulgent.",
|
||||||
},
|
},
|
||||||
@@ -73,7 +72,7 @@ func Seed(db *gorm.DB) error {
|
|||||||
RoastType: "Dark Roast",
|
RoastType: "Dark Roast",
|
||||||
Ounces: 3,
|
Ounces: 3,
|
||||||
BeanType: "Robusta Blend",
|
BeanType: "Robusta Blend",
|
||||||
Price: 1800, // $18.00
|
Price: 425, // $4.25
|
||||||
Color: "#DAA520",
|
Color: "#DAA520",
|
||||||
Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.",
|
Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,25 +6,21 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const cardStorageTTL = 180 * 24 * time.Hour
|
|
||||||
|
|
||||||
// Card represents a saved payment method linked to a Stripe token.
|
// Card represents a saved payment method linked to a Stripe token.
|
||||||
// The actual card details live on Stripe's servers — we only store
|
// The actual card details live on Stripe's servers — we only store
|
||||||
// a reference and display info (last 4 digits, brand).
|
// a reference and display info (last 4 digits, brand).
|
||||||
type Card struct {
|
type Card struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||||
StripePaymentID string `gorm:"size:255;not null" json:"-"` // Stripe payment method or token 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
|
Last4 string `gorm:"size:4;not null" json:"last4"` // Last 4 digits for display
|
||||||
Brand string `gorm:"size:50" json:"brand"` // Visa, Mastercard, etc.
|
Brand string `gorm:"size:50" json:"brand"` // Visa, Mastercard, etc.
|
||||||
ExpMonth int `gorm:"not null" json:"exp_month"`
|
ExpMonth int `gorm:"not null" json:"exp_month"`
|
||||||
ExpYear int `gorm:"not null" json:"exp_year"`
|
ExpYear int `gorm:"not null" json:"exp_year"`
|
||||||
IsDefault bool `gorm:"default:false" json:"is_default"` // User's preferred card
|
IsDefault bool `gorm:"default:false" json:"is_default"` // User's preferred card
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
|
||||||
StorageExpiresAt *time.Time `gorm:"index" json:"storage_expires_at,omitempty"`
|
|
||||||
|
|
||||||
// Associations
|
// Associations
|
||||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||||
@@ -34,20 +30,3 @@ type Card struct {
|
|||||||
func (Card) TableName() string {
|
func (Card) TableName() string {
|
||||||
return "cards"
|
return "cards"
|
||||||
}
|
}
|
||||||
|
|
||||||
// CardStorageExpiresAt returns the inactivity expiration for a saved card
|
|
||||||
func CardStorageExpiresAt(now time.Time) *time.Time {
|
|
||||||
expiresAt := now.Add(cardStorageTTL)
|
|
||||||
return &expiresAt
|
|
||||||
}
|
|
||||||
|
|
||||||
// InitializeStorageTTL sets the initial retention deadline without marking
|
|
||||||
// the card as used for payment
|
|
||||||
func (c *Card) InitializeStorageTTL(now time.Time) {
|
|
||||||
c.StorageExpiresAt = CardStorageExpiresAt(now)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsStorageExpired reports whether the saved card retention deadline elapsed
|
|
||||||
func (c *Card) IsStorageExpired(now time.Time) bool {
|
|
||||||
return c.StorageExpiresAt != nil && !c.StorageExpiresAt.After(now)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCardStorageTTL(t *testing.T) {
|
|
||||||
now := time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
var card Card
|
|
||||||
card.InitializeStorageTTL(now)
|
|
||||||
|
|
||||||
if card.LastUsedAt != nil {
|
|
||||||
t.Fatalf("InitializeStorageTTL should not mark card as used")
|
|
||||||
}
|
|
||||||
if card.StorageExpiresAt == nil || !card.StorageExpiresAt.Equal(now.Add(cardStorageTTL)) {
|
|
||||||
t.Fatalf("unexpected initial expiration: %v", card.StorageExpiresAt)
|
|
||||||
}
|
|
||||||
if card.IsStorageExpired(now.Add(cardStorageTTL - time.Second)) {
|
|
||||||
t.Fatalf("card expired before ttl elapsed")
|
|
||||||
}
|
|
||||||
if !card.IsStorageExpired(now.Add(cardStorageTTL)) {
|
|
||||||
t.Fatalf("card should expire at ttl boundary")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,14 +10,13 @@ import (
|
|||||||
type OrderStatus string
|
type OrderStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
OrderStatusPending OrderStatus = "pending"
|
OrderStatusPending OrderStatus = "pending"
|
||||||
OrderStatusRequiresAction OrderStatus = "requires_action"
|
OrderStatusPaid OrderStatus = "paid"
|
||||||
OrderStatusPaid OrderStatus = "paid"
|
OrderStatusShipped OrderStatus = "shipped"
|
||||||
OrderStatusShipped OrderStatus = "shipped"
|
OrderStatusDelivered OrderStatus = "delivered"
|
||||||
OrderStatusDelivered OrderStatus = "delivered"
|
OrderStatusCancelled OrderStatus = "cancelled"
|
||||||
OrderStatusCancelled OrderStatus = "cancelled"
|
OrderStatusRefunded OrderStatus = "refunded"
|
||||||
OrderStatusRefunded OrderStatus = "refunded"
|
OrderStatusFailed OrderStatus = "failed"
|
||||||
OrderStatusFailed OrderStatus = "failed"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type TrackingStatus string
|
type TrackingStatus string
|
||||||
|
|||||||
@@ -11,32 +11,13 @@ const (
|
|||||||
DisplayKindBrand DisplayKind = "brand" // shipped / in transit
|
DisplayKindBrand DisplayKind = "brand" // shipped / in transit
|
||||||
DisplayKindSuccess DisplayKind = "success" // delivered
|
DisplayKindSuccess DisplayKind = "success" // delivered
|
||||||
DisplayKindError DisplayKind = "error" // cancelled, failed, refunded
|
DisplayKindError DisplayKind = "error" // cancelled, failed, refunded
|
||||||
DisplayKindWarning DisplayKind = "warning" // delivery issue, returned
|
DisplayKindWarning DisplayKind = "warning" // refund pending, delivery issue
|
||||||
DisplayKindRefund DisplayKind = "refund" // refund requested, awaiting review
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// refundPending reports whether the order has an open customer refund request
|
|
||||||
// that has not yet been resolved by a terminal status change.
|
|
||||||
func (order *Order) refundPending() bool {
|
|
||||||
if order.LastRefundRequestAt == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
switch order.Status {
|
|
||||||
case OrderStatusRefunded, OrderStatusCancelled, OrderStatusFailed:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (order *Order) DisplayState() string {
|
func (order *Order) DisplayState() string {
|
||||||
if order.refundPending() {
|
|
||||||
return "Refund Requested"
|
|
||||||
}
|
|
||||||
switch order.Status {
|
switch order.Status {
|
||||||
case OrderStatusPending:
|
case OrderStatusPending:
|
||||||
return "Awaiting payment"
|
return "Awaiting payment"
|
||||||
case OrderStatusRequiresAction:
|
|
||||||
return "Awaiting authentication"
|
|
||||||
case OrderStatusPaid:
|
case OrderStatusPaid:
|
||||||
if order.TrackingNumber == "" {
|
if order.TrackingNumber == "" {
|
||||||
return "Paid * awaiting label"
|
return "Paid * awaiting label"
|
||||||
@@ -73,14 +54,9 @@ func (order *Order) DisplayState() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (order *Order) DisplayKind() DisplayKind {
|
func (order *Order) DisplayKind() DisplayKind {
|
||||||
if order.refundPending() {
|
|
||||||
return DisplayKindRefund
|
|
||||||
}
|
|
||||||
switch order.Status {
|
switch order.Status {
|
||||||
case OrderStatusPending:
|
case OrderStatusPending:
|
||||||
return DisplayKindNeutral
|
return DisplayKindNeutral
|
||||||
case OrderStatusRequiresAction:
|
|
||||||
return DisplayKindNeutral
|
|
||||||
case OrderStatusPaid:
|
case OrderStatusPaid:
|
||||||
return DisplayKindAccent
|
return DisplayKindAccent
|
||||||
case OrderStatusShipped:
|
case OrderStatusShipped:
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import "testing"
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestOrderDisplayState(t *testing.T) {
|
func TestOrderDisplayState(t *testing.T) {
|
||||||
now := time.Now()
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
order Order
|
order Order
|
||||||
@@ -14,7 +10,6 @@ func TestOrderDisplayState(t *testing.T) {
|
|||||||
kind DisplayKind
|
kind DisplayKind
|
||||||
}{
|
}{
|
||||||
{"pending", Order{Status: OrderStatusPending}, "Awaiting payment", DisplayKindNeutral},
|
{"pending", Order{Status: OrderStatusPending}, "Awaiting payment", DisplayKindNeutral},
|
||||||
{"requires_action", Order{Status: OrderStatusRequiresAction}, "Awaiting authentication", DisplayKindNeutral},
|
|
||||||
{"paid no tracking", Order{Status: OrderStatusPaid}, "Paid * awaiting label", DisplayKindAccent},
|
{"paid no tracking", Order{Status: OrderStatusPaid}, "Paid * awaiting label", DisplayKindAccent},
|
||||||
{"paid with tracking, no event", Order{Status: OrderStatusPaid, TrackingNumber: "12999"}, "Paid", DisplayKindAccent},
|
{"paid with tracking, no event", Order{Status: OrderStatusPaid, TrackingNumber: "12999"}, "Paid", DisplayKindAccent},
|
||||||
{"Paid pre-transit", Order{Status: OrderStatusPaid, TrackingNumber: "12999", TrackingStatus: TrackingStatusPreTransit}, "Paid * ready to ship", DisplayKindAccent},
|
{"Paid pre-transit", Order{Status: OrderStatusPaid, TrackingNumber: "12999", TrackingStatus: TrackingStatusPreTransit}, "Paid * ready to ship", DisplayKindAccent},
|
||||||
@@ -28,10 +23,6 @@ func TestOrderDisplayState(t *testing.T) {
|
|||||||
{"cancelled", Order{Status: OrderStatusCancelled}, "Cancelled", DisplayKindError},
|
{"cancelled", Order{Status: OrderStatusCancelled}, "Cancelled", DisplayKindError},
|
||||||
{"refunded", Order{Status: OrderStatusRefunded}, "Refunded", DisplayKindError},
|
{"refunded", Order{Status: OrderStatusRefunded}, "Refunded", DisplayKindError},
|
||||||
{"failed", Order{Status: OrderStatusFailed}, "Payment failed", DisplayKindError},
|
{"failed", Order{Status: OrderStatusFailed}, "Payment failed", DisplayKindError},
|
||||||
{"paid with open refund request", Order{Status: OrderStatusPaid, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund},
|
|
||||||
{"delivered with open refund request", Order{Status: OrderStatusDelivered, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund},
|
|
||||||
{"refunded order ignores prior request", Order{Status: OrderStatusRefunded, LastRefundRequestAt: &now}, "Refunded", DisplayKindError},
|
|
||||||
{"cancelled order ignores prior request", Order{Status: OrderStatusCancelled, LastRefundRequestAt: &now}, "Cancelled", DisplayKindError},
|
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
@@ -191,17 +191,6 @@ func SlackUnpinOrder(orderID uint) {
|
|||||||
// It looks up the thread ts from the database and returns an error when the
|
// It looks up the thread ts from the database and returns an error when the
|
||||||
// reply cannot be posted.
|
// reply cannot be posted.
|
||||||
func SlackPostToOrderThread(orderID uint, text string) error {
|
func SlackPostToOrderThread(orderID uint, text string) error {
|
||||||
return slackPostToOrderThread(orderID, text, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SlackPostToOrderThreadBroadcast posts a thread reply that is also surfaced
|
|
||||||
// to the channel (reply_broadcast=true) so channel members get a normal
|
|
||||||
// notification. Use for messages that need immediate operator attention.
|
|
||||||
func SlackPostToOrderThreadBroadcast(orderID uint, text string) error {
|
|
||||||
return slackPostToOrderThread(orderID, text, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func slackPostToOrderThread(orderID uint, text string, broadcast bool) error {
|
|
||||||
token := os.Getenv("SLACK_BOT_TOKEN")
|
token := os.Getenv("SLACK_BOT_TOKEN")
|
||||||
channel := os.Getenv("SLACK_CHANNEL")
|
channel := os.Getenv("SLACK_CHANNEL")
|
||||||
if token == "" || channel == "" {
|
if token == "" || channel == "" {
|
||||||
@@ -217,16 +206,11 @@ func slackPostToOrderThread(orderID uint, text string, broadcast bool) error {
|
|||||||
return fmt.Errorf("order has no slack thread")
|
return fmt.Errorf("order has no slack thread")
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]any{
|
if _, ok := postSlackMessage(token, map[string]any{
|
||||||
"channel": channel,
|
"channel": channel,
|
||||||
"thread_ts": *order.SlackThreadTS,
|
"thread_ts": *order.SlackThreadTS,
|
||||||
"text": text,
|
"text": text,
|
||||||
}
|
}); !ok {
|
||||||
if broadcast {
|
|
||||||
payload["reply_broadcast"] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := postSlackMessage(token, payload); !ok {
|
|
||||||
slog.Warn("failed to post slack thread reply", "order_id", orderID)
|
slog.Warn("failed to post slack thread reply", "order_id", orderID)
|
||||||
return fmt.Errorf("failed to post slack thread reply")
|
return fmt.Errorf("failed to post slack thread reply")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,9 +278,10 @@ func (m Model) displayStyle(k models.DisplayKind) lipgloss.Style {
|
|||||||
case models.DisplayKindError:
|
case models.DisplayKindError:
|
||||||
return m.theme.TextError()
|
return m.theme.TextError()
|
||||||
case models.DisplayKindWarning:
|
case models.DisplayKindWarning:
|
||||||
|
// !TODO add refunds and change this
|
||||||
|
// No warning color in theme yet, reuse loading pink as visual flag
|
||||||
|
// for now
|
||||||
return m.theme.TextLoading()
|
return m.theme.TextLoading()
|
||||||
case models.DisplayKindRefund:
|
|
||||||
return m.theme.TextBrand()
|
|
||||||
}
|
}
|
||||||
return m.theme.TextHighlight()
|
return m.theme.TextHighlight()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"terminalShop/pkg/models"
|
"terminalShop/pkg/models"
|
||||||
"terminalShop/pkg/tui/qrfefe"
|
|
||||||
|
|
||||||
"charm.land/bubbles/v2/viewport"
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
@@ -170,30 +168,6 @@ func (m Model) ReviewView() string {
|
|||||||
if m.CheckingOut {
|
if m.CheckingOut {
|
||||||
return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...")
|
return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...")
|
||||||
}
|
}
|
||||||
if m.review.retrying {
|
|
||||||
return m.theme.TextAccent().Bold(true).Padding(1).Render(" retrying authentication...")
|
|
||||||
}
|
|
||||||
if m.review.retryExhausted {
|
|
||||||
content := m.theme.TextError().Bold(true).Padding(1).Render("too many authentication attempts") + "\n" + m.theme.TextLabel().Padding(0, 1).Render("press esc to start a new order")
|
|
||||||
return lipgloss.Place(
|
|
||||||
m.widthContainer,
|
|
||||||
lipgloss.Height(content),
|
|
||||||
lipgloss.Center, lipgloss.Center,
|
|
||||||
content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if m.review.authFailed {
|
|
||||||
content := m.theme.TextError().Bold(true).Padding(1).Render("authentication failed — press r to retry, esc to go back")
|
|
||||||
return lipgloss.Place(
|
|
||||||
m.widthContainer,
|
|
||||||
lipgloss.Height(content),
|
|
||||||
lipgloss.Center, lipgloss.Center,
|
|
||||||
content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if m.review.requiresAction {
|
|
||||||
return m.renderRequiresActionView()
|
|
||||||
}
|
|
||||||
if m.review.success {
|
if m.review.success {
|
||||||
content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!")
|
content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!")
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
@@ -212,50 +186,17 @@ func (m Model) ReviewView() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderRequiresActionView shows a QR for the 3DS challenge URL while the TUI
|
|
||||||
// polls the order status. Mirrors the add-card QR layout in payment_form.go.
|
|
||||||
func (m Model) renderRequiresActionView() string {
|
|
||||||
base := m.theme.TextLabel()
|
|
||||||
accent := m.theme.TextHighlight()
|
|
||||||
header := m.theme.TextAccent().Bold(true).Render("your bank wants to verify this charge")
|
|
||||||
|
|
||||||
if m.review.redirectURL == "" {
|
|
||||||
return base.Render(" verifying with your bank...")
|
|
||||||
}
|
|
||||||
|
|
||||||
qr, qrSize, err := qrfefe.Generate(m.review.redirectURL)
|
|
||||||
if err != nil || qrSize > m.widthContent {
|
|
||||||
body := header + "\n\n" +
|
|
||||||
base.Render("open this url on your phone to authenticate:") + "\n\n" +
|
|
||||||
accent.Render(m.review.redirectURL) + "\n\n" +
|
|
||||||
base.Render("waiting for completion...")
|
|
||||||
return lipgloss.Place(m.widthContainer, lipgloss.Height(body), lipgloss.Center, lipgloss.Center, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same gotcha as payment_form.go: lipgloss layout helpers corrupt qrfefe ANSI.
|
|
||||||
return qr +
|
|
||||||
header + "\n" +
|
|
||||||
base.Render("scan or visit to complete authentication") + "\n" +
|
|
||||||
accent.Render(m.review.redirectURL) + "\n\n" +
|
|
||||||
base.Render("waiting for completion...")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case CheckoutResultMsg:
|
case CheckoutResultMsg:
|
||||||
m.CheckingOut = false
|
m.CheckingOut = false
|
||||||
if msg.Err != nil {
|
if msg.Err != nil {
|
||||||
var cmd tea.Cmd
|
m = m.SwitchPage(paymentPage)
|
||||||
m, cmd = m.switchForCheckoutError(msg.Err)
|
m.error = &VisibleError{message: fmt.Sprintf("checkout failed: %v", msg.Err)}
|
||||||
m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)}
|
|
||||||
m.review.cardJustAdded = false
|
m.review.cardJustAdded = false
|
||||||
return m, cmd
|
m.payment.view = 0
|
||||||
}
|
m.payment.form = nil
|
||||||
if msg.RequiresAction {
|
return m, nil
|
||||||
m.review.requiresAction = true
|
|
||||||
m.review.redirectURL = msg.RedirectURL
|
|
||||||
m.review.orderID = msg.OrderID
|
|
||||||
return m, m.schedulePollOrderStatus(msg.OrderID)
|
|
||||||
}
|
}
|
||||||
m.Cart = make(map[uint]*models.CartItem)
|
m.Cart = make(map[uint]*models.CartItem)
|
||||||
m.cart.cursor = 0
|
m.cart.cursor = 0
|
||||||
@@ -264,116 +205,10 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
m.review.success = true
|
m.review.success = true
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case OrderStatusMsg:
|
|
||||||
if !m.review.requiresAction || msg.OrderID != m.review.orderID {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
if msg.Err != nil {
|
|
||||||
// Transient — keep polling.
|
|
||||||
return m, m.schedulePollOrderStatus(msg.OrderID)
|
|
||||||
}
|
|
||||||
switch msg.Status {
|
|
||||||
case "paid", "shipped", "delivered":
|
|
||||||
m.Cart = make(map[uint]*models.CartItem)
|
|
||||||
m.cart.cursor = 0
|
|
||||||
m.ShippingInfo = nil
|
|
||||||
m.OrdersLoaded = false
|
|
||||||
m.review.requiresAction = false
|
|
||||||
m.review.redirectURL = ""
|
|
||||||
m.review.success = true
|
|
||||||
return m, nil
|
|
||||||
case "failed", "cancelled":
|
|
||||||
m.review.requiresAction = false
|
|
||||||
m.review.redirectURL = ""
|
|
||||||
m.review.authFailed = true
|
|
||||||
m.footer = []footerCommand{{key: "r", value: "retry"}, {key: "esc", value: "back"}}
|
|
||||||
return m, nil
|
|
||||||
default:
|
|
||||||
return m, m.schedulePollOrderStatus(msg.OrderID)
|
|
||||||
}
|
|
||||||
|
|
||||||
case RetryAuthResultMsg:
|
|
||||||
if m.review.orderID != 0 && msg.OrderID != m.review.orderID {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.review.retrying = false
|
|
||||||
if msg.Err != nil {
|
|
||||||
s := msg.Err.Error()
|
|
||||||
switch {
|
|
||||||
case strings.Contains(s, "RETRY_LIMIT"):
|
|
||||||
m.review.authFailed = false
|
|
||||||
m.review.retryExhausted = true
|
|
||||||
m.footer = []footerCommand{{key: "esc", value: "back"}}
|
|
||||||
return m, nil
|
|
||||||
case strings.Contains(s, "CARD_NO_LONGER_AVAILABLE"):
|
|
||||||
m.review.authFailed = false
|
|
||||||
m.review.requiresAction = false
|
|
||||||
m.review.redirectURL = ""
|
|
||||||
m.review.orderID = 0
|
|
||||||
m = m.SwitchPage(paymentPage)
|
|
||||||
m.payment.view = 0
|
|
||||||
m.payment.form = nil
|
|
||||||
m.error = &VisibleError{message: "saved card is no longer available. add it again to try."}
|
|
||||||
return m, nil
|
|
||||||
default:
|
|
||||||
m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.review.authFailed = false
|
|
||||||
m.review.retryExhausted = false
|
|
||||||
m.review.requiresAction = true
|
|
||||||
m.review.redirectURL = msg.RedirectURL
|
|
||||||
m.review.orderID = msg.OrderID
|
|
||||||
m.footer = []footerCommand{{key: "esc", value: "cancel"}}
|
|
||||||
return m, m.schedulePollOrderStatus(msg.OrderID)
|
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
if m.CheckingOut {
|
if m.CheckingOut {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if m.review.retrying {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
if m.review.authFailed {
|
|
||||||
switch msg.String() {
|
|
||||||
case "r":
|
|
||||||
if m.review.orderID == 0 {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.review.retrying = true
|
|
||||||
m.error = nil
|
|
||||||
return m, m.retryAuthCmd(m.review.orderID)
|
|
||||||
case "esc":
|
|
||||||
m.review.authFailed = false
|
|
||||||
m = m.SwitchPage(paymentPage)
|
|
||||||
m.payment.view = 0
|
|
||||||
m.payment.form = nil
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
if m.review.retryExhausted {
|
|
||||||
if msg.String() == "esc" {
|
|
||||||
m.review.retryExhausted = false
|
|
||||||
m.review.orderID = 0
|
|
||||||
m = m.SwitchPage(paymentPage)
|
|
||||||
m.payment.view = 0
|
|
||||||
m.payment.form = nil
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
if m.review.requiresAction {
|
|
||||||
if msg.String() == "esc" {
|
|
||||||
m.review.requiresAction = false
|
|
||||||
m.review.redirectURL = ""
|
|
||||||
m = m.SwitchPage(paymentPage)
|
|
||||||
m.payment.view = 0
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
if m.review.success {
|
if m.review.success {
|
||||||
m.review.success = false
|
m.review.success = false
|
||||||
m.review.cardJustAdded = false
|
m.review.cardJustAdded = false
|
||||||
@@ -399,40 +234,3 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) switchForCheckoutError(err error) (Model, tea.Cmd) {
|
|
||||||
switch {
|
|
||||||
case strings.Contains(err.Error(), "CART_OVER_LIMIT"):
|
|
||||||
return m.CartSwitch()
|
|
||||||
default:
|
|
||||||
m = m.SwitchPage(paymentPage)
|
|
||||||
m.payment.view = 0
|
|
||||||
m.payment.form = nil
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func friendlyCheckoutError(raw error) string {
|
|
||||||
s := raw.Error()
|
|
||||||
switch {
|
|
||||||
case strings.Contains(s, "CART_OVER_LIMIT"):
|
|
||||||
return "order total is over the per-order limit. remove items and try again."
|
|
||||||
case strings.Contains(s, "AUTHENTICATION_REQUIRED"):
|
|
||||||
return "your bank wants to verify this charge, authentication step coming soon."
|
|
||||||
case strings.Contains(s, "AUTHENTICATION_NOT_SUPPORTED"):
|
|
||||||
return "this card doesn't support online authentication. Try another card"
|
|
||||||
case strings.Contains(s, "CARD_STORAGE_EXPIRED"):
|
|
||||||
return "saved card expired. Add it again to verify the card is still yours"
|
|
||||||
case strings.Contains(s, "CARD_EXPIRED"):
|
|
||||||
return "card expired. Add a new card and try again"
|
|
||||||
case strings.Contains(s, "CARD_CVC_FAILED"):
|
|
||||||
return "card cvc incorrect. Check the back of the card and try again"
|
|
||||||
case strings.Contains(s, "CARD_INSUFFICIENT_FUNDS"):
|
|
||||||
return "insufficient funds on this card"
|
|
||||||
case strings.Contains(s, "CARD_PROCESSING_ERROR"):
|
|
||||||
return "card processing error. Try again in a moment"
|
|
||||||
case strings.Contains(s, "CARD_DECLINED"):
|
|
||||||
return "card declined"
|
|
||||||
}
|
|
||||||
return "checkout failed, try again."
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,394 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newReviewModel returns a Model sitting on the review page with the default
|
|
||||||
// layout already applied. Use as the starting point for every retry-auth
|
|
||||||
// state machine test so transitions assert from a realistic baseline.
|
|
||||||
func newReviewModel(t *testing.T) Model {
|
|
||||||
t.Helper()
|
|
||||||
m := NewModel("test")
|
|
||||||
m.currentPage = reviewPage
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// keyR / keyEsc / keyJ build the bubbletea v2 key events Update expects.
|
|
||||||
// KeyMsg is an interface; KeyPressMsg satisfies it. Key.String() returns
|
|
||||||
// Text when non-empty, otherwise the keystroke (which is why esc relies on
|
|
||||||
// Code: tea.KeyEsc rather than Text).
|
|
||||||
func keyR() tea.KeyMsg { return tea.KeyPressMsg{Text: "r", Code: 'r'} }
|
|
||||||
func keyEsc() tea.KeyMsg { return tea.KeyPressMsg{Code: tea.KeyEsc} }
|
|
||||||
func keyJ() tea.KeyMsg { return tea.KeyPressMsg{Text: "j", Code: 'j'} }
|
|
||||||
|
|
||||||
func TestRetryAuthCmd_NilClient(t *testing.T) {
|
|
||||||
m := NewModel("test")
|
|
||||||
m.APIClient = nil
|
|
||||||
cmd := m.retryAuthCmd(42)
|
|
||||||
if cmd == nil {
|
|
||||||
t.Fatal("retryAuthCmd returned nil cmd")
|
|
||||||
}
|
|
||||||
msg := cmd()
|
|
||||||
rm, ok := msg.(RetryAuthResultMsg)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected RetryAuthResultMsg, got %T", msg)
|
|
||||||
}
|
|
||||||
if rm.OrderID != 42 {
|
|
||||||
t.Errorf("OrderID = %d, want 42", rm.OrderID)
|
|
||||||
}
|
|
||||||
if rm.Err == nil || !strings.Contains(rm.Err.Error(), "api client unavailable") {
|
|
||||||
t.Errorf("expected api-client-unavailable err, got %v", rm.Err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewView_RetryingShowsCopy(t *testing.T) {
|
|
||||||
m := newReviewModel(t)
|
|
||||||
m.review.retrying = true
|
|
||||||
out := m.ReviewView()
|
|
||||||
if !strings.Contains(out, "retrying authentication") {
|
|
||||||
t.Fatalf("missing retrying copy in: %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewView_RetryExhaustedShowsCopy(t *testing.T) {
|
|
||||||
m := newReviewModel(t)
|
|
||||||
m.review.retryExhausted = true
|
|
||||||
out := m.ReviewView()
|
|
||||||
if !strings.Contains(out, "too many authentication attempts") {
|
|
||||||
t.Fatalf("missing retry-exhausted copy in: %q", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "press esc to start a new order") {
|
|
||||||
t.Fatalf("missing recovery hint in: %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewView_AuthFailedAdvertisesRetry(t *testing.T) {
|
|
||||||
m := newReviewModel(t)
|
|
||||||
m.review.authFailed = true
|
|
||||||
out := m.ReviewView()
|
|
||||||
if !strings.Contains(out, "press r to retry") {
|
|
||||||
t.Fatalf("authFailed copy must advertise r: %q", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "esc to go back") {
|
|
||||||
t.Fatalf("authFailed copy must advertise esc: %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRenderRequiresActionView_FrictionlessShowsVerifying(t *testing.T) {
|
|
||||||
m := newReviewModel(t)
|
|
||||||
m.review.requiresAction = true
|
|
||||||
m.review.redirectURL = ""
|
|
||||||
out := m.renderRequiresActionView()
|
|
||||||
if !strings.Contains(out, "verifying with your bank") {
|
|
||||||
t.Fatalf("frictionless retry-success path must show verifying copy: %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewUpdate_AuthFailed_PressR_StartsRetry(t *testing.T) {
|
|
||||||
m := newReviewModel(t)
|
|
||||||
m.review.authFailed = true
|
|
||||||
m.review.orderID = 7
|
|
||||||
m.error = &VisibleError{message: "leftover banner"}
|
|
||||||
|
|
||||||
out, cmd := m.ReviewUpdate(keyR())
|
|
||||||
if !out.review.retrying {
|
|
||||||
t.Fatal("expected retrying=true after r press")
|
|
||||||
}
|
|
||||||
if out.error != nil {
|
|
||||||
t.Fatal("stale banner should be cleared when retry starts")
|
|
||||||
}
|
|
||||||
if cmd == nil {
|
|
||||||
t.Fatal("expected non-nil cmd to call retryAuth")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewUpdate_AuthFailed_PressR_ZeroOrderID_NoOp(t *testing.T) {
|
|
||||||
m := newReviewModel(t)
|
|
||||||
m.review.authFailed = true
|
|
||||||
m.review.orderID = 0
|
|
||||||
|
|
||||||
out, cmd := m.ReviewUpdate(keyR())
|
|
||||||
if out.review.retrying {
|
|
||||||
t.Fatal("retry must not start without an order id")
|
|
||||||
}
|
|
||||||
if cmd != nil {
|
|
||||||
t.Fatal("expected nil cmd when orderID=0")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewUpdate_AuthFailed_PressEsc_BouncesToPayment(t *testing.T) {
|
|
||||||
m := newReviewModel(t)
|
|
||||||
m.review.authFailed = true
|
|
||||||
m.review.orderID = 7
|
|
||||||
|
|
||||||
out, _ := m.ReviewUpdate(keyEsc())
|
|
||||||
if out.review.authFailed {
|
|
||||||
t.Fatal("authFailed must clear on esc")
|
|
||||||
}
|
|
||||||
if out.currentPage != paymentPage {
|
|
||||||
t.Fatalf("currentPage = %v, want paymentPage", out.currentPage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewUpdate_AuthFailed_OtherKey_NoOp(t *testing.T) {
|
|
||||||
m := newReviewModel(t)
|
|
||||||
m.review.authFailed = true
|
|
||||||
m.review.orderID = 7
|
|
||||||
|
|
||||||
out, cmd := m.ReviewUpdate(keyJ())
|
|
||||||
if !out.review.authFailed {
|
|
||||||
t.Fatal("unrelated key must not clear authFailed")
|
|
||||||
}
|
|
||||||
if out.review.retrying {
|
|
||||||
t.Fatal("unrelated key must not start retry")
|
|
||||||
}
|
|
||||||
if cmd != nil {
|
|
||||||
t.Fatal("unrelated key must not produce a cmd")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewUpdate_Retrying_BlocksKeys(t *testing.T) {
|
|
||||||
m := newReviewModel(t)
|
|
||||||
m.review.retrying = true
|
|
||||||
m.review.authFailed = true
|
|
||||||
m.review.orderID = 7
|
|
||||||
|
|
||||||
out, cmd := m.ReviewUpdate(keyR())
|
|
||||||
if !out.review.retrying {
|
|
||||||
t.Fatal("retrying flag should persist while a retry is in flight")
|
|
||||||
}
|
|
||||||
if cmd != nil {
|
|
||||||
t.Fatal("r press while retrying must not spawn a second cmd")
|
|
||||||
}
|
|
||||||
|
|
||||||
out, cmd = m.ReviewUpdate(keyEsc())
|
|
||||||
if out.currentPage != reviewPage {
|
|
||||||
t.Fatalf("currentPage changed while retrying: %v", out.currentPage)
|
|
||||||
}
|
|
||||||
if cmd != nil {
|
|
||||||
t.Fatal("esc while retrying must not navigate away")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewUpdate_RetryAuthResult_RequiresAction(t *testing.T) {
|
|
||||||
m := newReviewModel(t)
|
|
||||||
m.review.retrying = true
|
|
||||||
m.review.authFailed = true
|
|
||||||
m.review.orderID = 7
|
|
||||||
|
|
||||||
out, cmd := m.ReviewUpdate(RetryAuthResultMsg{
|
|
||||||
OrderID: 7,
|
|
||||||
Status: "requires_action",
|
|
||||||
RedirectURL: "https://stripe/auth/abc",
|
|
||||||
})
|
|
||||||
|
|
||||||
if out.review.retrying {
|
|
||||||
t.Fatal("retrying must clear when the result arrives")
|
|
||||||
}
|
|
||||||
if out.review.authFailed {
|
|
||||||
t.Fatal("authFailed must clear on retry success")
|
|
||||||
}
|
|
||||||
if !out.review.requiresAction {
|
|
||||||
t.Fatal("expected requiresAction=true so the QR view renders")
|
|
||||||
}
|
|
||||||
if out.review.redirectURL != "https://stripe/auth/abc" {
|
|
||||||
t.Fatalf("redirectURL = %q", out.review.redirectURL)
|
|
||||||
}
|
|
||||||
if cmd == nil {
|
|
||||||
t.Fatal("expected schedulePollOrderStatus cmd")
|
|
||||||
}
|
|
||||||
if len(out.footer) != 1 || out.footer[0].key != "esc" || out.footer[0].value != "cancel" {
|
|
||||||
t.Fatalf("footer = %+v, want only esc=cancel", out.footer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewUpdate_RetryAuthResult_FrictionlessSuccess(t *testing.T) {
|
|
||||||
m := newReviewModel(t)
|
|
||||||
m.review.retrying = true
|
|
||||||
m.review.authFailed = true
|
|
||||||
m.review.orderID = 9
|
|
||||||
|
|
||||||
out, cmd := m.ReviewUpdate(RetryAuthResultMsg{
|
|
||||||
OrderID: 9,
|
|
||||||
Status: "succeeded",
|
|
||||||
})
|
|
||||||
|
|
||||||
if !out.review.requiresAction {
|
|
||||||
t.Fatal("frictionless retry must set requiresAction=true to render the verifying view")
|
|
||||||
}
|
|
||||||
if out.review.redirectURL != "" {
|
|
||||||
t.Fatalf("redirectURL must stay empty for frictionless retry, got %q", out.review.redirectURL)
|
|
||||||
}
|
|
||||||
if cmd == nil {
|
|
||||||
t.Fatal("expected poll cmd to wait for the webhook")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewUpdate_RetryAuthResult_RetryLimit(t *testing.T) {
|
|
||||||
m := newReviewModel(t)
|
|
||||||
m.review.retrying = true
|
|
||||||
m.review.authFailed = true
|
|
||||||
m.review.orderID = 7
|
|
||||||
|
|
||||||
out, cmd := m.ReviewUpdate(RetryAuthResultMsg{
|
|
||||||
OrderID: 7,
|
|
||||||
Err: errors.New("RETRY_LIMIT: too many authentication attempts, please use a different card"),
|
|
||||||
})
|
|
||||||
|
|
||||||
if out.review.retrying {
|
|
||||||
t.Fatal("retrying must clear after a result")
|
|
||||||
}
|
|
||||||
if !out.review.retryExhausted {
|
|
||||||
t.Fatal("expected retryExhausted=true on RETRY_LIMIT")
|
|
||||||
}
|
|
||||||
if out.review.authFailed {
|
|
||||||
t.Fatal("authFailed must clear when retryExhausted takes over")
|
|
||||||
}
|
|
||||||
if cmd != nil {
|
|
||||||
t.Fatal("RETRY_LIMIT is terminal — no follow-up cmd")
|
|
||||||
}
|
|
||||||
if len(out.footer) != 1 || out.footer[0].key != "esc" {
|
|
||||||
t.Fatalf("footer = %+v, want only esc=back", out.footer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewUpdate_RetryAuthResult_CardGone(t *testing.T) {
|
|
||||||
m := newReviewModel(t)
|
|
||||||
m.review.retrying = true
|
|
||||||
m.review.authFailed = true
|
|
||||||
m.review.orderID = 7
|
|
||||||
|
|
||||||
out, _ := m.ReviewUpdate(RetryAuthResultMsg{
|
|
||||||
OrderID: 7,
|
|
||||||
Err: errors.New("CARD_NO_LONGER_AVAILABLE: saved card is no longer available, add it again to retry"),
|
|
||||||
})
|
|
||||||
|
|
||||||
if out.currentPage != paymentPage {
|
|
||||||
t.Fatalf("currentPage = %v, want paymentPage", out.currentPage)
|
|
||||||
}
|
|
||||||
if out.review.authFailed || out.review.requiresAction {
|
|
||||||
t.Fatal("auth state must reset when bouncing to payment")
|
|
||||||
}
|
|
||||||
if out.review.orderID != 0 {
|
|
||||||
t.Fatalf("orderID should reset to 0, got %d", out.review.orderID)
|
|
||||||
}
|
|
||||||
if out.error == nil || !strings.Contains(out.error.message, "saved card is no longer available") {
|
|
||||||
t.Fatalf("error banner missing or wrong copy: %+v", out.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewUpdate_RetryAuthResult_Transient(t *testing.T) {
|
|
||||||
m := newReviewModel(t)
|
|
||||||
m.review.retrying = true
|
|
||||||
m.review.authFailed = true
|
|
||||||
m.review.orderID = 7
|
|
||||||
|
|
||||||
out, cmd := m.ReviewUpdate(RetryAuthResultMsg{
|
|
||||||
OrderID: 7,
|
|
||||||
Err: errors.New("CARD_DECLINED: card declined"),
|
|
||||||
})
|
|
||||||
|
|
||||||
if out.review.retrying {
|
|
||||||
t.Fatal("retrying must clear after a result")
|
|
||||||
}
|
|
||||||
if !out.review.authFailed {
|
|
||||||
t.Fatal("authFailed must persist so the user can retry again")
|
|
||||||
}
|
|
||||||
if out.review.retryExhausted {
|
|
||||||
t.Fatal("transient errors must not flip retryExhausted")
|
|
||||||
}
|
|
||||||
if out.error == nil {
|
|
||||||
t.Fatal("expected friendly banner for transient error")
|
|
||||||
}
|
|
||||||
if cmd != nil {
|
|
||||||
t.Fatal("transient error must not start a poll")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewUpdate_RetryAuthResult_OrderIDMismatch(t *testing.T) {
|
|
||||||
m := newReviewModel(t)
|
|
||||||
m.review.retrying = true
|
|
||||||
m.review.authFailed = true
|
|
||||||
m.review.orderID = 7
|
|
||||||
|
|
||||||
out, cmd := m.ReviewUpdate(RetryAuthResultMsg{
|
|
||||||
OrderID: 99,
|
|
||||||
Status: "requires_action",
|
|
||||||
RedirectURL: "https://wrong",
|
|
||||||
})
|
|
||||||
|
|
||||||
if !out.review.retrying {
|
|
||||||
t.Fatal("mismatched orderID must leave retrying unchanged")
|
|
||||||
}
|
|
||||||
if out.review.requiresAction {
|
|
||||||
t.Fatal("mismatched orderID must not flip requiresAction")
|
|
||||||
}
|
|
||||||
if out.review.redirectURL == "https://wrong" {
|
|
||||||
t.Fatal("mismatched orderID must not adopt the foreign URL")
|
|
||||||
}
|
|
||||||
if cmd != nil {
|
|
||||||
t.Fatal("mismatched orderID must not start a poll")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewUpdate_RetryExhausted_PressEsc_BouncesToPayment(t *testing.T) {
|
|
||||||
m := newReviewModel(t)
|
|
||||||
m.review.retryExhausted = true
|
|
||||||
m.review.orderID = 7
|
|
||||||
|
|
||||||
out, _ := m.ReviewUpdate(keyEsc())
|
|
||||||
if out.review.retryExhausted {
|
|
||||||
t.Fatal("retryExhausted must clear on esc")
|
|
||||||
}
|
|
||||||
if out.review.orderID != 0 {
|
|
||||||
t.Fatalf("orderID should reset to 0, got %d", out.review.orderID)
|
|
||||||
}
|
|
||||||
if out.currentPage != paymentPage {
|
|
||||||
t.Fatalf("currentPage = %v, want paymentPage", out.currentPage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewUpdate_RetryExhausted_OtherKey_NoOp(t *testing.T) {
|
|
||||||
m := newReviewModel(t)
|
|
||||||
m.review.retryExhausted = true
|
|
||||||
|
|
||||||
out, cmd := m.ReviewUpdate(keyR())
|
|
||||||
if !out.review.retryExhausted {
|
|
||||||
t.Fatal("unrelated key must not clear retryExhausted")
|
|
||||||
}
|
|
||||||
if cmd != nil {
|
|
||||||
t.Fatal("unrelated key must not produce a cmd")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewUpdate_OrderStatusFailed_SetsRetryFooter(t *testing.T) {
|
|
||||||
m := newReviewModel(t)
|
|
||||||
m.review.requiresAction = true
|
|
||||||
m.review.orderID = 7
|
|
||||||
|
|
||||||
out, _ := m.ReviewUpdate(OrderStatusMsg{OrderID: 7, Status: "failed"})
|
|
||||||
|
|
||||||
if !out.review.authFailed {
|
|
||||||
t.Fatal("expected authFailed=true after failed status")
|
|
||||||
}
|
|
||||||
if out.review.requiresAction {
|
|
||||||
t.Fatal("requiresAction must clear when poll resolves to failed")
|
|
||||||
}
|
|
||||||
hasR, hasEsc := false, false
|
|
||||||
for _, c := range out.footer {
|
|
||||||
if c.key == "r" && c.value == "retry" {
|
|
||||||
hasR = true
|
|
||||||
}
|
|
||||||
if c.key == "esc" && c.value == "back" {
|
|
||||||
hasEsc = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasR || !hasEsc {
|
|
||||||
t.Fatalf("footer missing r/esc retry hints: %+v", out.footer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestFriendlyCheckoutError_CardStorageExpired(t *testing.T) {
|
|
||||||
got := friendlyCheckoutError(errors.New("CARD_STORAGE_EXPIRED: saved card epired, add it again"))
|
|
||||||
if !strings.Contains(got, "saved card expired") {
|
|
||||||
t.Fatalf("want saved-card-expired copy, got %q", got)
|
|
||||||
}
|
|
||||||
if strings.Contains(got, "card expired. Add a new card") {
|
|
||||||
t.Fatalf("CARD_STORAGE_EXPIRED was matched by the CARD_EXPIRED branch: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFriendlyCheckoutError_CardExpiredStillReachable(t *testing.T) {
|
|
||||||
got := friendlyCheckoutError(errors.New("CARD_EXPIRED: card expired"))
|
|
||||||
if !strings.Contains(got, "card expired. Add a new card") {
|
|
||||||
t.Fatalf("want plain card-expired copy, got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -75,6 +75,7 @@ type Model struct {
|
|||||||
Fingerprint string
|
Fingerprint string
|
||||||
AccessToken string
|
AccessToken string
|
||||||
AuthFingerprintKey string // shared secret for /auth/token refresh
|
AuthFingerprintKey string // shared secret for /auth/token refresh
|
||||||
|
StripePublicKey string
|
||||||
|
|
||||||
// One-shop auth command - capture pubKeyStr in closure, not stored on Model
|
// One-shop auth command - capture pubKeyStr in closure, not stored on Model
|
||||||
authCmd tea.Cmd
|
authCmd tea.Cmd
|
||||||
@@ -201,17 +202,6 @@ type reviewState struct {
|
|||||||
cardJustAdded bool
|
cardJustAdded bool
|
||||||
cardWasDuplicate bool
|
cardWasDuplicate bool
|
||||||
success bool
|
success bool
|
||||||
|
|
||||||
// 3DS / SCA flow: when the backend returns 202 requires_action,
|
|
||||||
// requiresAction is true and redirectURL is the Stripe-hosted challenge
|
|
||||||
// page. The TUI shows a QR and polls GetOrderStatus(orderID) until the
|
|
||||||
// order flips to paid or failed.
|
|
||||||
requiresAction bool
|
|
||||||
redirectURL string
|
|
||||||
orderID uint
|
|
||||||
authFailed bool
|
|
||||||
retrying bool
|
|
||||||
retryExhausted bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type confirmState struct {
|
type confirmState struct {
|
||||||
@@ -428,33 +418,11 @@ func (m Model) Init() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CheckoutResultMsg struct {
|
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
|
OrderID uint
|
||||||
Status string
|
Total int
|
||||||
Err error
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
// RetryAuthResultMsg carries the outcome of POST /orders/{id}/retry-auth.
|
|
||||||
// Status "requires_action" -> RedirectURL is a fresh challenge URL; resume
|
|
||||||
// the QR + poll loop. Status "succeeded" -> frictionless retry approved;
|
|
||||||
// server's APIError string ("RETRY_LIMIT" / "CARD_NO_LONGER_AVAILABLE" /
|
|
||||||
// transient) - the Update branch matches on substring.
|
|
||||||
type RetryAuthResultMsg struct {
|
|
||||||
OrderID uint
|
|
||||||
Status string
|
|
||||||
RedirectURL string
|
|
||||||
Err error
|
|
||||||
}
|
|
||||||
|
|
||||||
// CartSyncedMsg is recieved when the server responds to a cart item update
|
// CartSyncedMsg is recieved when the server responds to a cart item update
|
||||||
type CartSyncedMsg struct {
|
type CartSyncedMsg struct {
|
||||||
UpdateID int64
|
UpdateID int64
|
||||||
@@ -663,52 +631,12 @@ func (m Model) checkoutWithSavedCard() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Convert the cart to an order
|
// 3. Convert the cart to an order
|
||||||
outcome, err := m.APIClient.ConvertCart()
|
order, err := m.APIClient.ConvertCart()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CheckoutResultMsg{Err: err}
|
return CheckoutResultMsg{Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
if outcome.RequiresAction {
|
return CheckoutResultMsg{OrderID: order.ID, Total: order.Total}
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,6 +663,7 @@ func (m Model) saveCardOnlyCmd(form PaymentFormCompleteMsg) tea.Cmd {
|
|||||||
if m.APIClient == nil {
|
if m.APIClient == nil {
|
||||||
return CardSavedForReviewMsg{Err: fmt.Errorf("API client not available")}
|
return CardSavedForReviewMsg{Err: fmt.Errorf("API client not available")}
|
||||||
}
|
}
|
||||||
|
stripe.Key = m.StripePublicKey
|
||||||
tok, err := stripetoken.New(&stripe.TokenParams{
|
tok, err := stripetoken.New(&stripe.TokenParams{
|
||||||
Card: &stripe.CardParams{
|
Card: &stripe.CardParams{
|
||||||
Name: stripe.String(form.CardName),
|
Name: stripe.String(form.CardName),
|
||||||
@@ -903,12 +832,11 @@ func NewModel(username string) Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewModelWithAuth creates a new model with user authentication context.
|
// NewModelWithAuth creates a new model with user authentication context.
|
||||||
// The publishable Stripe key is wired into stripe.Key once at startup in
|
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
|
||||||
// the SSH binary's main, so it is not threaded through here.
|
|
||||||
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string) Model {
|
|
||||||
m := NewModel("")
|
m := NewModel("")
|
||||||
m.Fingerprint = fingerprint
|
m.Fingerprint = fingerprint
|
||||||
m.AuthFingerprintKey = clientSecret
|
m.AuthFingerprintKey = clientSecret
|
||||||
|
m.StripePublicKey = stripePublicKey
|
||||||
m.currentPage = splashPage
|
m.currentPage = splashPage
|
||||||
if apiURL != "" {
|
if apiURL != "" {
|
||||||
m.APIClient = api.NewClient(apiURL, "")
|
m.APIClient = api.NewClient(apiURL, "")
|
||||||
|
|||||||
@@ -355,16 +355,11 @@ func (m Model) RenderCardList() string {
|
|||||||
boxes = append(boxes, box)
|
boxes = append(boxes, box)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSH inline card-add is dormant until i have a norwegian org
|
sshFocused := m.payment.cardCursor == len(m.SavedCards)
|
||||||
// number wired to stripe, something i dont have since its $300+. Keep the row visible so the option is
|
sshBox := m.CreateBox(m.formatListItem("+ add card via ssh", sshFocused), sshFocused)
|
||||||
// discoverable, but render it dimmed and exclude it from cursor space
|
|
||||||
// (handled in PaymentUpdate below) so it can not be picked
|
|
||||||
dim := m.theme.TextDim().Render
|
|
||||||
sshLabel := dim("+ add card via ssh") + " " + dim("(WIP)")
|
|
||||||
sshBox := m.CreateBox(m.formatListItem(sshLabel, false), false)
|
|
||||||
boxes = append(boxes, sshBox)
|
boxes = append(boxes, sshBox)
|
||||||
|
|
||||||
httpsFocused := m.payment.cardCursor == len(m.SavedCards)
|
httpsFocused := m.payment.cardCursor == len(m.SavedCards)+1
|
||||||
httpsBox := m.CreateBox(m.formatListItem("+ add payment via browser", httpsFocused), httpsFocused)
|
httpsBox := m.CreateBox(m.formatListItem("+ add payment via browser", httpsFocused), httpsFocused)
|
||||||
boxes = append(boxes, httpsBox)
|
boxes = append(boxes, httpsBox)
|
||||||
|
|
||||||
@@ -506,7 +501,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
m.payment.cardCursor--
|
m.payment.cardCursor--
|
||||||
}
|
}
|
||||||
case "down", "j":
|
case "down", "j":
|
||||||
if m.payment.cardCursor < len(m.SavedCards) {
|
if m.payment.cardCursor < len(m.SavedCards)+1 {
|
||||||
m.payment.cardCursor++
|
m.payment.cardCursor++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,8 +511,17 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
m.SelectedCard = &selected
|
m.SelectedCard = &selected
|
||||||
return m.ReviewSwitch()
|
return m.ReviewSwitch()
|
||||||
}
|
}
|
||||||
// Browser flow, the only reachable add-card path while the SSH
|
if m.payment.cardCursor == len(m.SavedCards) {
|
||||||
// inline form is dormant (see RenderCardList)
|
m.payment.view = 1
|
||||||
|
m.payment.form = m.InitPaymentForm()
|
||||||
|
m.footer = []footerCommand{
|
||||||
|
{key: "tab", value: "next"},
|
||||||
|
{key: "enter", value: "submit"},
|
||||||
|
{key: "esc", value: "back"},
|
||||||
|
}
|
||||||
|
return m, m.payment.form.form.Init()
|
||||||
|
}
|
||||||
|
// Browser payment
|
||||||
m.payment.view = 2
|
m.payment.view = 2
|
||||||
m.payment.collectURL = nil
|
m.payment.collectURL = nil
|
||||||
m.payment.collectTimeOut = false
|
m.payment.collectTimeOut = false
|
||||||
@@ -542,6 +546,16 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 {
|
if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 {
|
||||||
m.payment.cardCursor--
|
m.payment.cardCursor--
|
||||||
}
|
}
|
||||||
|
if len(m.SavedCards) == 0 {
|
||||||
|
m.payment.view = 1
|
||||||
|
m.payment.form = m.InitPaymentForm()
|
||||||
|
m.footer = []footerCommand{
|
||||||
|
{key: "tab", value: "next"},
|
||||||
|
{key: "enter", value: "submit"},
|
||||||
|
{key: "esc", value: "back"},
|
||||||
|
}
|
||||||
|
return m, tea.Batch(m.payment.form.form.Init(), m.deleteCardCmd(card.ID))
|
||||||
|
}
|
||||||
return m, m.deleteCardCmd(card.ID)
|
return m, m.deleteCardCmd(card.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"terminalShop/pkg/models"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newPaymentCardListModel parks a Model on the payment page card-list view
|
|
||||||
// with n synthetic saved cards. Baseline for assertint that the dormant
|
|
||||||
// "+ add card via ssh" row is dimmed and unreachable by cursor navigation.
|
|
||||||
func newPaymentCardListModel(t *testing.T, n int) Model {
|
|
||||||
t.Helper()
|
|
||||||
m := NewModel("test")
|
|
||||||
m = m.updateLayout(120, 40)
|
|
||||||
m.currentPage = paymentPage
|
|
||||||
m.payment.view = 0
|
|
||||||
m.payment.form = nil
|
|
||||||
m.payment.cardCursor = 0
|
|
||||||
for i := range n {
|
|
||||||
m.SavedCards = append(m.SavedCards, models.Card{
|
|
||||||
ID: uint(i + 1),
|
|
||||||
Brand: "visa",
|
|
||||||
Last4: "4242",
|
|
||||||
ExpMonth: 12,
|
|
||||||
ExpYear: 2030,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func keyEnter() tea.KeyMsg { return tea.KeyPressMsg{Code: tea.KeyEnter} }
|
|
||||||
|
|
||||||
func TestRenderCardList_SshOptionComingSoon(t *testing.T) {
|
|
||||||
m := newPaymentCardListModel(t, 1)
|
|
||||||
out := m.RenderCardList()
|
|
||||||
if !strings.Contains(out, "add card via ssh") {
|
|
||||||
t.Fatalf("ssh option missing from card list output:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "WIP") {
|
|
||||||
t.Fatalf("want 'WIP' marked on ssh option:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPaymentUpdate_DownNavigationStopsAtBrowserSlot(t *testing.T) {
|
|
||||||
m := newPaymentCardListModel(t, 2)
|
|
||||||
for range 10 {
|
|
||||||
var cmd tea.Cmd
|
|
||||||
m, cmd = m.PaymentUpdate(keyJ())
|
|
||||||
_ = cmd
|
|
||||||
}
|
|
||||||
want := len(m.SavedCards) // browser slot; ssh slot in unreachable
|
|
||||||
if m.payment.cardCursor != want {
|
|
||||||
t.Fatalf("cursor stopped at %d, want %d (browser slot)", m.payment.cardCursor, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPaymentUpdate_EnterOnBrowserSlotLaunchesBrowserFlow(t *testing.T) {
|
|
||||||
m := newPaymentCardListModel(t, 1)
|
|
||||||
m.payment.cardCursor = len(m.SavedCards) // browser slot
|
|
||||||
newM, cmd := m.PaymentUpdate(keyEnter())
|
|
||||||
if newM.payment.view != 2 {
|
|
||||||
t.Fatalf("payment.view = %d, want 2 (browser flow)", newM.payment.view)
|
|
||||||
}
|
|
||||||
if newM.payment.form != nil {
|
|
||||||
t.Fatalf("payment.form must be nil after enter; ssh inline form is dormant")
|
|
||||||
}
|
|
||||||
if cmd == nil {
|
|
||||||
t.Fatalf("expected collectCardCmd, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPaymentUpdate_EnterWithZeroCardsLaunchesBrowserFlow(t *testing.T) {
|
|
||||||
m := newPaymentCardListModel(t, 0)
|
|
||||||
if m.payment.cardCursor != 0 {
|
|
||||||
t.Fatalf("default cursor = %d, want 0 ", m.payment.cardCursor)
|
|
||||||
}
|
|
||||||
newM, cmd := m.PaymentUpdate(keyEnter())
|
|
||||||
if newM.payment.view != 2 {
|
|
||||||
t.Fatalf("payment.view = %d, want 2 (browser flow)", newM.payment.view)
|
|
||||||
}
|
|
||||||
if newM.payment.form != nil {
|
|
||||||
t.Fatalf("payment.form must be nil; ssh inline form is dormant")
|
|
||||||
}
|
|
||||||
if cmd == nil {
|
|
||||||
t.Fatalf("expected collectCardCmd, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,15 +27,6 @@ const (
|
|||||||
refundTextareaHPad = 4
|
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.
|
// refundFocus identifies which field in the refund composer has keyboard focus.
|
||||||
type refundFocus int
|
type refundFocus int
|
||||||
|
|
||||||
@@ -129,7 +120,7 @@ func (m Model) RefundUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
state.form = f
|
state.form = f
|
||||||
}
|
}
|
||||||
if state.form.State == huh.StateCompleted {
|
if state.form.State == huh.StateCompleted {
|
||||||
state.reason = state.form.GetString("reason")
|
state.form = m.buildRefundForm(state)
|
||||||
return m, m.setRefundFocus(state, refundFocusMessage)
|
return m, m.setRefundFocus(state, refundFocusMessage)
|
||||||
}
|
}
|
||||||
return m, cmd
|
return m, cmd
|
||||||
@@ -170,23 +161,22 @@ func (m Model) buildRefundForm(state *refundState) *huh.Form {
|
|||||||
func (m Model) newRefundTextarea() textarea.Model {
|
func (m Model) newRefundTextarea() textarea.Model {
|
||||||
ta := textarea.New()
|
ta := textarea.New()
|
||||||
ta.Prompt = ""
|
ta.Prompt = ""
|
||||||
ta.Placeholder = "Describe the issue"
|
ta.Placeholder = "Describe the issue (optional)..."
|
||||||
ta.ShowLineNumbers = true
|
ta.ShowLineNumbers = true
|
||||||
ta.CharLimit = 65536
|
ta.CharLimit = 65536
|
||||||
ta.DynamicHeight = false
|
ta.DynamicHeight = true
|
||||||
ta.SetHeight(m.refundTextareaHeight())
|
ta.MinHeight = refundMinTextareaRows
|
||||||
|
ta.MaxHeight = max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome))
|
||||||
|
ta.SetHeight(refundMinTextareaRows)
|
||||||
ta.SetWidth(m.refundTextareaWidth())
|
ta.SetWidth(m.refundTextareaWidth())
|
||||||
|
|
||||||
base := lipgloss.NewStyle()
|
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{
|
ta.SetStyles(textarea.Styles{
|
||||||
Focused: textarea.StyleState{
|
Focused: textarea.StyleState{
|
||||||
Base: base,
|
Base: base,
|
||||||
Text: base.Foreground(m.theme.Accent()),
|
Text: base.Foreground(m.theme.Accent()),
|
||||||
LineNumber: base.Foreground(m.theme.Dim()),
|
LineNumber: base.Foreground(m.theme.Dim()),
|
||||||
CursorLine: base.Background(cursorLineBG).Foreground(m.theme.Accent()),
|
CursorLine: base.Background(m.theme.Border()).Foreground(m.theme.Accent()),
|
||||||
CursorLineNumber: base.Foreground(m.theme.Body()),
|
CursorLineNumber: base.Foreground(m.theme.Body()),
|
||||||
Placeholder: base.Foreground(m.theme.Dim()),
|
Placeholder: base.Foreground(m.theme.Dim()),
|
||||||
EndOfBuffer: base.Foreground(m.theme.Dim()),
|
EndOfBuffer: base.Foreground(m.theme.Dim()),
|
||||||
@@ -210,7 +200,8 @@ func (m Model) newRefundTextarea() textarea.Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) resizeRefundTextarea(state *refundState) {
|
func (m Model) resizeRefundTextarea(state *refundState) {
|
||||||
state.textarea.SetHeight(m.refundTextareaHeight())
|
state.textarea.MinHeight = refundMinTextareaRows
|
||||||
|
state.textarea.MaxHeight = max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome))
|
||||||
state.textarea.SetWidth(m.refundTextareaWidth())
|
state.textarea.SetWidth(m.refundTextareaWidth())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,19 +251,14 @@ func (m Model) createRefundRequestCmd(orderID uint, gen uint64, reason string, m
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RenderRefundOverlay renders the refund composer as a centered modal.
|
// 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 {
|
func (m Model) RenderRefundOverlay() string {
|
||||||
state := &m.refund
|
state := &m.refund
|
||||||
contentWidth := m.refundContentWidth()
|
contentWidth := m.refundContentWidth()
|
||||||
|
|
||||||
titleText := fmt.Sprintf("%s Refund request — Order #%d", refundIconUndo, state.orderID)
|
|
||||||
title := m.theme.TextAccent().
|
title := m.theme.TextAccent().
|
||||||
Bold(true).
|
Bold(true).
|
||||||
Width(contentWidth).
|
Width(contentWidth).
|
||||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
Render(fmt.Sprintf("Refund request - Order #%d", state.orderID))
|
||||||
BorderForeground(m.theme.Highlight()).
|
|
||||||
Render(titleText)
|
|
||||||
|
|
||||||
body := lipgloss.JoinVertical(
|
body := lipgloss.JoinVertical(
|
||||||
lipgloss.Left,
|
lipgloss.Left,
|
||||||
@@ -286,6 +272,9 @@ func (m Model) RenderRefundOverlay() string {
|
|||||||
)
|
)
|
||||||
|
|
||||||
modal := lipgloss.NewStyle().
|
modal := lipgloss.NewStyle().
|
||||||
|
Width(contentWidth).
|
||||||
|
Border(lipgloss.NormalBorder()).
|
||||||
|
BorderForeground(m.theme.Border()).
|
||||||
Padding(1, 2).
|
Padding(1, 2).
|
||||||
Render(body)
|
Render(body)
|
||||||
|
|
||||||
@@ -306,7 +295,7 @@ func (m Model) renderRefundReason(state *refundState) string {
|
|||||||
value = "Select a reason"
|
value = "Select a reason"
|
||||||
}
|
}
|
||||||
|
|
||||||
label := m.theme.TextLabel().Render(refundIconTag + " Reason")
|
label := m.theme.TextLabel().Render("Reason")
|
||||||
selected := m.theme.TextAccent().
|
selected := m.theme.TextAccent().
|
||||||
Border(lipgloss.HiddenBorder()).
|
Border(lipgloss.HiddenBorder()).
|
||||||
PaddingLeft(1).
|
PaddingLeft(1).
|
||||||
@@ -322,7 +311,7 @@ func (m Model) renderRefundReason(state *refundState) string {
|
|||||||
|
|
||||||
func (m Model) renderRefundTextarea(state *refundState) string {
|
func (m Model) renderRefundTextarea(state *refundState) string {
|
||||||
label := "Message (optional)"
|
label := "Message (optional)"
|
||||||
placeholder := "Describe the issue"
|
placeholder := "Describe the issue (optional)..."
|
||||||
if state.reason == models.RefundRequestReasonOther {
|
if state.reason == models.RefundRequestReasonOther {
|
||||||
label = "Message (required)"
|
label = "Message (required)"
|
||||||
placeholder = "Describe the issue..."
|
placeholder = "Describe the issue..."
|
||||||
@@ -334,26 +323,22 @@ func (m Model) renderRefundTextarea(state *refundState) string {
|
|||||||
border = m.theme.Highlight()
|
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().
|
counter := m.theme.TextDim().
|
||||||
Width(contentWidth).
|
Width(m.refundTextareaWidth()).
|
||||||
Align(lipgloss.Right).
|
Align(lipgloss.Right).
|
||||||
Render(fmt.Sprintf("%d chars", state.textarea.Length()))
|
Render(fmt.Sprintf("%d chars", state.textarea.Length()))
|
||||||
|
|
||||||
|
box := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.NormalBorder()).
|
||||||
|
BorderForeground(border).
|
||||||
|
Padding(0, 1).
|
||||||
|
Width(m.refundContentWidth()).
|
||||||
|
Render(state.textarea.View() + "\n" + counter)
|
||||||
|
|
||||||
out := lipgloss.JoinVertical(
|
out := lipgloss.JoinVertical(
|
||||||
lipgloss.Left,
|
lipgloss.Left,
|
||||||
m.theme.TextLabel().Render(refundIconComment+" "+label),
|
m.theme.TextLabel().Render(label),
|
||||||
textareaBox,
|
box,
|
||||||
counter,
|
|
||||||
)
|
)
|
||||||
if state.messageErr != "" {
|
if state.messageErr != "" {
|
||||||
out += "\n" + m.theme.TextError().Render(state.messageErr)
|
out += "\n" + m.theme.TextError().Render(state.messageErr)
|
||||||
@@ -368,14 +353,7 @@ func (m Model) renderRefundFooter(state *refundState) string {
|
|||||||
if state.err != "" {
|
if state.err != "" {
|
||||||
return m.theme.TextError().Render(state.err)
|
return m.theme.TextError().Render(state.err)
|
||||||
}
|
}
|
||||||
key := m.theme.TextHighlight().Bold(true)
|
return m.theme.TextDim().Render("tab next field | shift+tab prev | ctrl+s submit | esc cancel")
|
||||||
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 {
|
func (m Model) refundContentWidth() int {
|
||||||
@@ -389,7 +367,3 @@ func (m Model) refundContentWidth() int {
|
|||||||
func (m Model) refundTextareaWidth() int {
|
func (m Model) refundTextareaWidth() int {
|
||||||
return max(1, m.refundContentWidth()-refundTextareaHPad)
|
return max(1, m.refundContentWidth()-refundTextareaHPad)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) refundTextareaHeight() int {
|
|
||||||
return max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -82,10 +82,6 @@ func (m Model) renderString() string {
|
|||||||
content = loadingStyle.Render("Loading products from API...")
|
content = loadingStyle.Render("Loading products from API...")
|
||||||
} else if m.error != nil {
|
} else if m.error != nil {
|
||||||
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
|
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
|
||||||
// soft-wrap long messages instead of clipping.
|
|
||||||
if m.widthContent > 4 {
|
|
||||||
errorStyle = errorStyle.Width(m.widthContent - 4)
|
|
||||||
}
|
|
||||||
errorBanner := errorStyle.Render(m.error.message)
|
errorBanner := errorStyle.Render(m.error.message)
|
||||||
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
|
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
|
||||||
} else if m.notice != nil {
|
} else if m.notice != nil {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
|
|
||||||
// SuccessResponse represents a successful API response
|
// SuccessResponse represents a successful API response
|
||||||
type SuccessResponse struct {
|
type SuccessResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Data any `json:"data"`
|
Data interface{} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrorResponse represents an error API response
|
// ErrorResponse represents an error API response
|
||||||
@@ -20,13 +20,13 @@ type ErrorResponse struct {
|
|||||||
|
|
||||||
// Error contains error details
|
// Error contains error details
|
||||||
type Error struct {
|
type Error struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Details map[string]any `json:"details,omitempty"`
|
Details map[string]interface{} `json:"details,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RespondSuccess sends a successful JSON response
|
// RespondSuccess sends a successful JSON response
|
||||||
func RespondSuccess(w http.ResponseWriter, statusCode int, data any) {
|
func RespondSuccess(w http.ResponseWriter, statusCode int, data interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(statusCode)
|
w.WriteHeader(statusCode)
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ func RespondSuccess(w http.ResponseWriter, statusCode int, data any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RespondError sends an error JSON response
|
// RespondError sends an error JSON response
|
||||||
func RespondError(w http.ResponseWriter, statusCode int, code, message string, details map[string]any) {
|
func RespondError(w http.ResponseWriter, statusCode int, code, message string, details map[string]interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(statusCode)
|
w.WriteHeader(statusCode)
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Independent filesystem check on lfs-remote. No restic, no password needed.
|
|
||||||
# Proves the repo dir exists, files are present, recent uploads landed,
|
|
||||||
# and the backup partition has room.
|
|
||||||
set -euo pipefail
|
|
||||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
|
||||||
source "$SCRIPT_DIR/lib.sh"
|
|
||||||
|
|
||||||
print_header "lfs-remote filesystem-level repo check"
|
|
||||||
spinner_start "inspecting $LFS_HOST filesystem..."
|
|
||||||
|
|
||||||
ssh "$LFS_HOST" "
|
|
||||||
set -e
|
|
||||||
REPO='$LFS_REPO_PATH'
|
|
||||||
WARN_PCT=$LFS_DISK_WARN_PCT
|
|
||||||
|
|
||||||
if [ ! -d \"\$REPO\" ]; then
|
|
||||||
echo 'ERROR: repo dir missing on lfs' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo '--- snapshot pointer count (one file per restic snapshot) ---'
|
|
||||||
COUNT=\$(find \"\$REPO/snapshots\" -type f 2>/dev/null | wc -l)
|
|
||||||
echo \"snapshots/: \$COUNT files\"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo '--- repo size on disk ---'
|
|
||||||
du -sh \"\$REPO\"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo '--- recent uploads (files newer than 48h) ---'
|
|
||||||
RECENT=\$(find \"\$REPO\" -type f -mtime -2 2>/dev/null | wc -l)
|
|
||||||
echo \"files added in last 48h: \$RECENT\"
|
|
||||||
if [ \"\$RECENT\" -eq 0 ]; then
|
|
||||||
echo 'WARN: no new files in last 48h' >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo '--- most recent snapshot pointer ---'
|
|
||||||
LATEST=\$(ls -t \"\$REPO/snapshots\"/* 2>/dev/null | head -1)
|
|
||||||
if [ -n \"\$LATEST\" ]; then
|
|
||||||
stat -c '%y %n' \"\$LATEST\"
|
|
||||||
else
|
|
||||||
echo 'WARN: no snapshot files found' >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo '--- disk usage ---'
|
|
||||||
df -h \"\$REPO\"
|
|
||||||
USED=\$(df --output=pcent \"\$REPO\" 2>/dev/null | tail -1 | tr -dc 0-9)
|
|
||||||
if [ -n \"\$USED\" ] && [ \"\$USED\" -gt \"\$WARN_PCT\" ]; then
|
|
||||||
echo \"FAIL: backup partition \${USED}% full (threshold \${WARN_PCT}%)\" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo 'OK'
|
|
||||||
"
|
|
||||||
spinner_stop
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Independent restic check from the lfs-remote side. Reads repo via a
|
|
||||||
# LOCAL filesystem path (not SFTP), so it proves the data is parseable
|
|
||||||
# without any involvement from the prod box at restic level.
|
|
||||||
#
|
|
||||||
# Needs:
|
|
||||||
# - restic installed on lfs-remote (see scratchpkg port at /usr/ports/main/restic)
|
|
||||||
# - SSH access to both prod (to fetch the repo password) and lfs
|
|
||||||
set -euo pipefail
|
|
||||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
|
||||||
source "$SCRIPT_DIR/lib.sh"
|
|
||||||
|
|
||||||
print_header "lfs-remote restic check (independent vantage point)"
|
|
||||||
|
|
||||||
# Precondition: restic must be on lfs.
|
|
||||||
if ! ssh "$LFS_HOST" 'command -v restic >/dev/null 2>&1'; then
|
|
||||||
echo 'ERROR: restic not in PATH on lfs-remote' >&2
|
|
||||||
echo "Install via scratchpkg, then re-run." >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Pull restic password from prod (needs sudo on prod).
|
|
||||||
spinner_start "fetching restic password from $SSH_HOST..."
|
|
||||||
PW=$(ssh "$SSH_HOST" "sudo cat '$RESTIC_PASSWORD_FILE'")
|
|
||||||
spinner_stop
|
|
||||||
if [ -z "$PW" ]; then
|
|
||||||
echo 'ERROR: could not read restic password from prod' >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Run restic against the LOCAL repo path on lfs. Password fed via stdin
|
|
||||||
# so it never lands in argv or a file on lfs.
|
|
||||||
spinner_start "running restic check from $LFS_HOST..."
|
|
||||||
ssh "$LFS_HOST" "restic -r '$LFS_REPO_PATH' --password-file=/dev/stdin check" <<<"$PW"
|
|
||||||
spinner_stop
|
|
||||||
|
|
||||||
echo
|
|
||||||
print_ok "lfs-side restic check passed"
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Master runner. Executes every backup check in sequence.
|
|
||||||
# ./check-all.sh stop on first failure; interactive menu when TTY
|
|
||||||
# ./check-all.sh --keep-going keep running after failures (no menu)
|
|
||||||
# ./check-all.sh --deep use --deep on 03-check.sh
|
|
||||||
#
|
|
||||||
# When stdin and stdout are TTYs, a failed step opens a drill-down menu:
|
|
||||||
# [r]etry [l]og [u]nlock [s]kip [q]uit
|
|
||||||
# Non-TTY (cron / piped) skips the menu and behaves like the old runner.
|
|
||||||
set -uo pipefail
|
|
||||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
|
||||||
|
|
||||||
KEEP_GOING=0
|
|
||||||
DEEP=0
|
|
||||||
for arg in "$@"; do
|
|
||||||
case "$arg" in
|
|
||||||
--keep-going) KEEP_GOING=1 ;;
|
|
||||||
--deep) DEEP=1 ;;
|
|
||||||
-h|--help)
|
|
||||||
echo "Usage: $0 [--keep-going] [--deep]"
|
|
||||||
echo " --keep-going don't stop on first failure, suppress interactive menu"
|
|
||||||
echo " --deep pass --deep to 03-check.sh (10% data re-read)"
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "unknown flag: $arg" >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# Pull in config + color helpers (also gives us SSH_HOST + restic env for unlock).
|
|
||||||
source "$SCRIPT_DIR/lib.sh"
|
|
||||||
|
|
||||||
declare -a STEP_NAMES=()
|
|
||||||
declare -a STEP_STATUS=() # OK | FAIL
|
|
||||||
FAIL=0
|
|
||||||
|
|
||||||
prompt_after_fail() {
|
|
||||||
# returns 0=skip, 1=quit, 2=retry
|
|
||||||
local script="$1"
|
|
||||||
while :; do
|
|
||||||
printf '\n%sNext?%s [r]etry [l]og [u]nlock [s]kip [q]uit > ' "$C_BOLD" "$C_RESET"
|
|
||||||
local choice
|
|
||||||
read -r choice
|
|
||||||
case "$choice" in
|
|
||||||
r|R) return 2 ;;
|
|
||||||
l|L) "$SCRIPT_DIR/01-log-tail.sh"; echo ;;
|
|
||||||
u|U)
|
|
||||||
echo
|
|
||||||
ssh "$SSH_HOST" "
|
|
||||||
export RESTIC_PASSWORD_FILE='$RESTIC_PASSWORD_FILE'
|
|
||||||
export RESTIC_REPOSITORY='$RESTIC_REPO'
|
|
||||||
sudo -E restic -o sftp.args='-i $RESTIC_SSH_KEY' unlock
|
|
||||||
"
|
|
||||||
echo
|
|
||||||
;;
|
|
||||||
s|S) return 0 ;;
|
|
||||||
q|Q) return 1 ;;
|
|
||||||
"") ;;
|
|
||||||
*) printf 'unknown: %s\n' "$choice" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
run_step() {
|
|
||||||
local script="$1"; shift
|
|
||||||
echo
|
|
||||||
printf '%s########## %s %s ##########%s\n' "$C_CYAN$C_BOLD" "$script" "$*" "$C_RESET"
|
|
||||||
|
|
||||||
local attempts=0
|
|
||||||
while :; do
|
|
||||||
attempts=$((attempts + 1))
|
|
||||||
if "$SCRIPT_DIR/$script" "$@"; then
|
|
||||||
STEP_NAMES+=("$script")
|
|
||||||
STEP_STATUS+=("OK")
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%sFAILED:%s %s\n' "$C_RED$C_BOLD" "$C_RESET" "$script" >&2
|
|
||||||
|
|
||||||
if [ -t 0 ] && [ -t 1 ] && [ "$KEEP_GOING" -eq 0 ]; then
|
|
||||||
prompt_after_fail "$script"
|
|
||||||
case "$?" in
|
|
||||||
0) STEP_NAMES+=("$script"); STEP_STATUS+=("FAIL"); FAIL=$((FAIL + 1)); return 0 ;;
|
|
||||||
1) STEP_NAMES+=("$script"); STEP_STATUS+=("FAIL"); FAIL=$((FAIL + 1)); print_summary; exit 1 ;;
|
|
||||||
2) echo
|
|
||||||
printf '%s>>>>>> retry %s (attempt %d) <<<<<<%s\n' "$C_CYAN$C_BOLD" "$script" $((attempts + 1)) "$C_RESET"
|
|
||||||
continue ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
# non-interactive path
|
|
||||||
STEP_NAMES+=("$script")
|
|
||||||
STEP_STATUS+=("FAIL")
|
|
||||||
FAIL=$((FAIL + 1))
|
|
||||||
if [ "$KEEP_GOING" -eq 0 ]; then
|
|
||||||
print_summary
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
return 0
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
print_summary() {
|
|
||||||
echo
|
|
||||||
printf '%s===============================================%s\n' "$C_CYAN$C_BOLD" "$C_RESET"
|
|
||||||
printf ' %-28s %s\n' "Script" "Result"
|
|
||||||
printf ' %-28s %s\n' "----------------------------" "--------"
|
|
||||||
local i
|
|
||||||
for i in "${!STEP_NAMES[@]}"; do
|
|
||||||
local name="${STEP_NAMES[$i]}"
|
|
||||||
local status="${STEP_STATUS[$i]}"
|
|
||||||
local color sym
|
|
||||||
case "$status" in
|
|
||||||
OK) color="$C_GREEN"; sym="✓ OK" ;;
|
|
||||||
FAIL) color="$C_RED" ; sym="✗ FAIL" ;;
|
|
||||||
esac
|
|
||||||
printf ' %-28s %s%s%s\n' "$name" "$color" "$sym" "$C_RESET"
|
|
||||||
done
|
|
||||||
printf '%s===============================================%s\n' "$C_CYAN$C_BOLD" "$C_RESET"
|
|
||||||
if [ "$FAIL" -eq 0 ]; then
|
|
||||||
printf '%sAll checks passed.%s\n' "$C_GREEN$C_BOLD" "$C_RESET"
|
|
||||||
else
|
|
||||||
printf '%s%d check(s) failed.%s\n' "$C_RED$C_BOLD" "$FAIL" "$C_RESET"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
run_step 01-log-tail.sh
|
|
||||||
run_step 02-snapshots.sh
|
|
||||||
if [ "$DEEP" -eq 1 ]; then run_step 03-check.sh --deep; else run_step 03-check.sh; fi
|
|
||||||
run_step 04-restore-verify.sh
|
|
||||||
run_step 05-freshness.sh
|
|
||||||
run_step 06-lfs-direct.sh
|
|
||||||
run_step 07-lfs-restic-check.sh
|
|
||||||
|
|
||||||
print_summary
|
|
||||||
[ "$FAIL" -eq 0 ] || exit 1
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Backup verification config.
|
|
||||||
# Copy to config.env (gitignored) and fill in your real values.
|
|
||||||
#
|
|
||||||
# cp config.env.example config.env
|
|
||||||
# $EDITOR config.env
|
|
||||||
|
|
||||||
# SSH alias for the prod box (resolved via ~/.ssh/config).
|
|
||||||
SSH_HOST="ubuntu-helsinki"
|
|
||||||
|
|
||||||
# Restic repository URL. Format: sftp:HOST:PATH
|
|
||||||
# HOST is the Tailscale name of the backup target box.
|
|
||||||
RESTIC_REPO="sftp:YOUR_TAILNET_HOST.ts.net:/root/backups/tshop"
|
|
||||||
|
|
||||||
# Paths on the REMOTE prod box (not your laptop).
|
|
||||||
RESTIC_PASSWORD_FILE="/root/.restic-password"
|
|
||||||
RESTIC_SSH_KEY="/root/.ssh/tshop-backup"
|
|
||||||
BACKUP_LOG="/var/log/backup-tshop.log"
|
|
||||||
LIVE_DB="/root/terminalShop/data/terminalshop.db"
|
|
||||||
|
|
||||||
# Freshness alert threshold (hours). 24h cron + 4h slop = 28.
|
|
||||||
STALE_HOURS="28"
|
|
||||||
|
|
||||||
# lfs-remote = the box that RECEIVES the backups.
|
|
||||||
# Used by 06-lfs-direct.sh and 07-lfs-restic-check.sh for out-of-band checks.
|
|
||||||
LFS_HOST="lfs-remote"
|
|
||||||
LFS_REPO_PATH="/root/backups/tshop"
|
|
||||||
# 06 fails if the backup partition is more full than this percent.
|
|
||||||
LFS_DISK_WARN_PCT="80"
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Shared loader for the backup verification scripts.
|
|
||||||
# Each script should: source "$SCRIPT_DIR/lib.sh"
|
|
||||||
|
|
||||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
|
||||||
|
|
||||||
if [ ! -f "$SCRIPT_DIR/config.env" ]; then
|
|
||||||
echo "ERROR: $SCRIPT_DIR/config.env missing." >&2
|
|
||||||
echo "Run: cp $SCRIPT_DIR/config.env.example $SCRIPT_DIR/config.env" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
source "$SCRIPT_DIR/config.env"
|
|
||||||
|
|
||||||
# ANSI colors. Emit only when stdout is a TTY and NO_COLOR is unset.
|
|
||||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
|
|
||||||
C_RED=$'\033[1;31m'
|
|
||||||
C_GREEN=$'\033[1;32m'
|
|
||||||
C_YELLOW=$'\033[1;33m'
|
|
||||||
C_BLUE=$'\033[1;34m'
|
|
||||||
C_CYAN=$'\033[1;36m'
|
|
||||||
C_BOLD=$'\033[1m'
|
|
||||||
C_RESET=$'\033[0m'
|
|
||||||
else
|
|
||||||
C_RED=""; C_GREEN=""; C_YELLOW=""; C_BLUE=""; C_CYAN=""; C_BOLD=""; C_RESET=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
print_header() { printf '\n%s=== %s ===%s\n' "$C_CYAN" "$1" "$C_RESET"; }
|
|
||||||
print_ok() { printf '%sOK%s%s\n' "$C_GREEN" "$C_RESET" "${1:+ — $1}"; }
|
|
||||||
print_fail() { printf '%sFAIL%s%s\n' "$C_RED" "$C_RESET" "${1:+ — $1}" >&2; }
|
|
||||||
print_warn() { printf '%sWARN%s%s\n' "$C_YELLOW" "$C_RESET" "${1:+ — $1}" >&2; }
|
|
||||||
print_info() { printf '%s%s%s\n' "$C_BLUE" "$1" "$C_RESET"; }
|
|
||||||
|
|
||||||
# Spinner: shows a rotating braille char while a foreground command runs.
|
|
||||||
# Use as a pair around the silent/blocking call:
|
|
||||||
# spinner_start "fetching log..."
|
|
||||||
# ssh ...
|
|
||||||
# spinner_stop
|
|
||||||
# Auto-skips when stdout isn't a TTY (cron / piped). Real output from the
|
|
||||||
# wrapped command will visually overwrite the spinner — by design.
|
|
||||||
SPINNER_PID=""
|
|
||||||
spinner_start() {
|
|
||||||
[ -t 1 ] && [ -z "${NO_COLOR:-}" ] || return 0
|
|
||||||
local msg="${1:-working}"
|
|
||||||
(
|
|
||||||
trap 'exit 0' TERM
|
|
||||||
local chars=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
|
|
||||||
local i=0
|
|
||||||
while :; do
|
|
||||||
i=$(( (i + 1) % ${#chars[@]} ))
|
|
||||||
printf '\r%s%s%s %s' "$C_CYAN" "${chars[$i]}" "$C_RESET" "$msg"
|
|
||||||
sleep 0.08
|
|
||||||
done
|
|
||||||
) &
|
|
||||||
SPINNER_PID=$!
|
|
||||||
disown "$SPINNER_PID" 2>/dev/null || true
|
|
||||||
}
|
|
||||||
spinner_stop() {
|
|
||||||
[ -n "$SPINNER_PID" ] || return 0
|
|
||||||
kill "$SPINNER_PID" 2>/dev/null || true
|
|
||||||
wait "$SPINNER_PID" 2>/dev/null || true
|
|
||||||
printf '\r\033[K'
|
|
||||||
SPINNER_PID=""
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user