Compare commits

..

1 Commits

Author SHA1 Message Date
06b57300a0 fix: fail deploy loudly on stale pull or HEAD mismatch 2026-06-09 19:16:11 +02:00
66 changed files with 474 additions and 3347 deletions

1
.gitignore vendored
View File

@@ -7,7 +7,6 @@
docs/sca-psd2-compliance.md
accept-a-payment/
codemap/
FULFILLMENT.md

View File

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

View File

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

View File

@@ -1,161 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
)
// AccountHandler serves JWT-authed, customer-facing account-settings endpoints.
// Unlike AdminUserHandler (X-Admin-Key gated), every route here edits the
// logged-in user's own row, identified by the JWT user id in context.
type AccountHandler struct {
// maxOrderCents is the global spend cap, needed to bound a self-limit: a
// customer may only TIGHTEN their effective ceiling, never raise it.
maxOrderCents int
}
// NewAccountHandler constructs an AccountHandler. maxOrderCents is the global
// MAX_ORDER_CENTS, wired from cfg in routes.SetupRoutes (same value the cart
// handler receives).
func NewAccountHandler(maxOrderCents int) *AccountHandler {
return &AccountHandler{maxOrderCents: maxOrderCents}
}
// SetSpendLimit sets or clears the logged-in user's self-service spend limit
// (User.SelfLimitCents). Body: {"self_limit_cents": <int|null>}.
// - null -> clears the self-limit; the user reverts to the admin/global cap
// - 0 -> a real "block everything" ceiling (NOT an off-switch; that is the
// whole point of a customer-set limit — it can only tighten)
// - >0 -> custom ceiling, must be <= the effective admin/global cap
//
// Security: this endpoint is JWT-authed and edits the caller's OWN row, so a
// self-limit is LOWER-ONLY. A value above the user's effective admin/global cap
// is rejected here (HTTP 400) so a compromised session cannot lift its own
// ceiling; the min() fold in cart.go:ConvertCart is the authoritative second
// guard. Negative values are rejected at the write boundary (the ConvertCart
// read path also ignores a stray negative from a direct DB write).
//
// Mirrors AdminUserHandler.SetUserOrderCap but reads the user id from the JWT
// context instead of a {id} URL param, uses a distinct body key + audit event,
// and gates the value against the cap.
func (h *AccountHandler) SetSpendLimit(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
var raw map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "invalid request body", nil)
return
}
rawLimit, ok := raw["self_limit_cents"]
if !ok {
utils.RespondError(w, http.StatusBadRequest, "MISSING_FIELD", "self_limit_cents is required (use null to clear the limit)", nil)
return
}
var limitCents *int
if string(rawLimit) != "null" {
var n int
if err := json.Unmarshal(rawLimit, &n); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "self_limit_cents must be an integer or null", nil)
return
}
if n < 0 {
utils.RespondError(w, http.StatusBadRequest, "INVALID_LIMIT", "self_limit_cents must be >= 0 (use null to clear the limit)", nil)
return
}
limitCents = &n
}
var user models.User
if err := db.First(&user, userID).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
return
}
// A self-limit may only tighten the effective cap, never raise it. Compute
// the user's effective admin/global ceiling exactly as cart.go:ConvertCart
// does (per-user override wins when present and non-negative; an active cap
// is > 0). Reject any self-limit above an ACTIVE admin ceiling. When no admin
// ceiling is active (global disabled and no override) there is nothing to
// raise, so any non-negative self-limit is allowed — it can only ADD a cap.
if limitCents != nil {
adminCap := h.maxOrderCents
if user.MaxOrderCents != nil && *user.MaxOrderCents >= 0 {
adminCap = *user.MaxOrderCents
}
if adminCap > 0 && *limitCents > adminCap {
utils.RespondError(w, http.StatusBadRequest, "SELF_LIMIT_ABOVE_CAP", "self_limit_cents may not exceed your account spend cap", map[string]any{"self_limit_cents": *limitCents, "cap_cents": adminCap})
return
}
}
// Map form so a nil pointer persists SQL NULL; a struct Updates would skip it.
if err := db.Model(&user).Updates(map[string]any{"self_limit_cents": limitCents}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to update spend limit", nil)
return
}
audit.UserSelfLimitSet(user.ID, limitCents)
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"user_id": user.ID,
"self_limit_cents": limitCents,
})
}
// SetPrivacyMode sets the logged-in user's account-level privacy default
// (User.PrivacyMode). Body: {"privacy_mode": <bool>}.
//
// PrivacyMode is a SOFT default, not a server-enforced guarantee: it only
// pre-selects "don't keep" at checkout in the TUI. The checkout still sends an
// explicit per-order intent which the backend honors (cart.go:ConvertCart) —
// that is what lets a customer override the default with "save anyway". A
// stricter server-enforced "never save" would be a separate mode.
//
// Mirrors SetSpendLimit: JWT-authed, edits the caller's OWN row.
func (h *AccountHandler) SetPrivacyMode(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
var raw map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "invalid request body", nil)
return
}
rawMode, ok := raw["privacy_mode"]
if !ok {
utils.RespondError(w, http.StatusBadRequest, "MISSING_FIELD", "privacy_mode is required", nil)
return
}
var enabled bool
if err := json.Unmarshal(rawMode, &enabled); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "privacy_mode must be a boolean", nil)
return
}
var user models.User
if err := db.First(&user, userID).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
return
}
// Map form so an explicit false persists (a struct Updates skips a zero bool)
if err := db.Model(&user).Updates(map[string]any{"privacy_mode": enabled}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to update privacy mode", nil)
return
}
audit.UserPrivacyModeSet(user.ID, enabled)
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"user_id": user.ID,
"privacy_mode": enabled,
})
}

View File

@@ -1,61 +0,0 @@
package handlers
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// TestSetPrivacyMode covers the account privacy toggle: enable, disable (a real
// true->false transition, which the map-form Updates must persist), and the two
// 400 paths (missing field, wrong type).
func TestSetPrivacyMode(t *testing.T) {
cases := []struct {
name string
body string
wantStatus int
wantValue bool
}{
{"enable", `{"privacy_mode": true}`, http.StatusOK, true},
{"disable", `{"privacy_mode": false}`, http.StatusOK, false},
{"missing field", `{}`, http.StatusBadRequest, false},
{"wrong type", `{"privacy_mode": "yes"}`, http.StatusBadRequest, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
// Seed the opposite so an OK case proves a real transition (and the
// disable case actually exercises persisting an explicit false).
if tc.wantStatus == http.StatusOK {
if err := database.GetDB().Model(&user).Update("privacy_mode", !tc.wantValue).Error; err != nil {
t.Fatalf("seed privacy_mode: %v", err)
}
}
w := httptest.NewRecorder()
NewAccountHandler(0).SetPrivacyMode(
w, authRequest("PUT", "/api/v1/account/privacy-mode", []byte(tc.body), user.ID))
if w.Code != tc.wantStatus {
t.Fatalf("status: want %d, got %d body=%s", tc.wantStatus, w.Code, w.Body.String())
}
if tc.wantStatus != http.StatusOK {
return
}
var reloaded models.User
if err := database.GetDB().First(&reloaded, user.ID).Error; err != nil {
t.Fatalf("reload user: %v", err)
}
if reloaded.PrivacyMode != tc.wantValue {
t.Errorf("privacy_mode: want %v, got %v", tc.wantValue, reloaded.PrivacyMode)
}
})
}
}

View File

@@ -1,118 +0,0 @@
package handlers
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"terminalShop/api/middleware"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
func TestSetSpendLimit(t *testing.T) {
const globalCap = 5000
cases := []struct {
name string
seedSelf *int // starting SelfLimitCents on the row
seedAdmin *int // per-user MaxOrderCents override (effective admin cap)
body string
wantStatus int
wantCode string // error code; "" means success
wantLimit *int // expected SelfLimitCents after a successful update
}{
{"set positive under cap", nil, nil, `{"self_limit_cents":2000}`, http.StatusOK, "", intPtr(2000)},
{"set zero blocks all", nil, nil, `{"self_limit_cents":0}`, http.StatusOK, "", intPtr(0)},
{"set equal to cap", nil, nil, `{"self_limit_cents":5000}`, http.StatusOK, "", intPtr(5000)},
{"clear with null", intPtr(2000), nil, `{"self_limit_cents":null}`, http.StatusOK, "", nil},
{"overwrite existing", intPtr(2000), nil, `{"self_limit_cents":1000}`, http.StatusOK, "", intPtr(1000)},
{"above global cap rejected", nil, nil, `{"self_limit_cents":6000}`, http.StatusBadRequest, "SELF_LIMIT_ABOVE_CAP", nil},
{"under per-user override accepted", nil, intPtr(10000), `{"self_limit_cents":8000}`, http.StatusOK, "", intPtr(8000)},
{"above per-user override rejected", nil, intPtr(10000), `{"self_limit_cents":12000}`, http.StatusBadRequest, "SELF_LIMIT_ABOVE_CAP", nil},
{"negative rejected", nil, nil, `{"self_limit_cents":-1}`, http.StatusBadRequest, "INVALID_LIMIT", nil},
{"missing field rejected", nil, nil, `{}`, http.StatusBadRequest, "MISSING_FIELD", nil},
{"non-integer rejected", nil, nil, `{"self_limit_cents":"lots"}`, http.StatusBadRequest, "INVALID_BODY", nil},
{"malformed json rejected", nil, nil, `{`, http.StatusBadRequest, "INVALID_BODY", nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
user := setupOrderTestDB(t)
db := database.GetDB()
if tc.seedSelf != nil {
if err := db.Model(&user).Update("self_limit_cents", *tc.seedSelf).Error; err != nil {
t.Fatalf("seed self limit: %v", err)
}
}
if tc.seedAdmin != nil {
if err := db.Model(&user).Update("max_order_cents", *tc.seedAdmin).Error; err != nil {
t.Fatalf("seed admin cap: %v", err)
}
}
handler := NewAccountHandler(globalCap)
req := authRequest("PUT", "/api/v1/account/spend-limit", []byte(tc.body), user.ID)
w := httptest.NewRecorder()
handler.SetSpendLimit(w, req)
if w.Code != tc.wantStatus {
t.Fatalf("status: want %d got %d (%s)", tc.wantStatus, w.Code, w.Body.String())
}
assertErrCode(t, w, tc.wantCode)
var got models.User
if err := db.First(&got, user.ID).Error; err != nil {
t.Fatalf("reload user: %v", err)
}
if tc.wantStatus == http.StatusOK {
if !eqIntPtr(got.SelfLimitCents, tc.wantLimit) {
t.Errorf("db SelfLimitCents: want %s got %s", fmtIntPtr(tc.wantLimit), fmtIntPtr(got.SelfLimitCents))
}
} else if !eqIntPtr(got.SelfLimitCents, tc.seedSelf) {
// A rejected request must not mutate the column.
t.Errorf("rejected request changed db limit: want %s got %s", fmtIntPtr(tc.seedSelf), fmtIntPtr(got.SelfLimitCents))
}
})
}
}
// TestSetSpendLimit_CapDisabledAllowsAnyLimit: with no active admin ceiling
// (global 0, no override) any non-negative self-limit is accepted — a self-limit
// can only ADD a ceiling, so there is nothing to raise.
func TestSetSpendLimit_CapDisabledAllowsAnyLimit(t *testing.T) {
user := setupOrderTestDB(t)
handler := NewAccountHandler(0) // global cap disabled
req := authRequest("PUT", "/api/v1/account/spend-limit", []byte(`{"self_limit_cents":999999}`), user.ID)
w := httptest.NewRecorder()
handler.SetSpendLimit(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: want 200 got %d (%s)", w.Code, w.Body.String())
}
var got models.User
if err := database.GetDB().First(&got, user.ID).Error; err != nil {
t.Fatalf("reload user: %v", err)
}
if !eqIntPtr(got.SelfLimitCents, intPtr(999999)) {
t.Errorf("db SelfLimitCents: want 999999 got %s", fmtIntPtr(got.SelfLimitCents))
}
}
// TestSetSpendLimit_RequireAuthGate exercises the JWT gate through a real
// router: no user context => 401, the handler is never reached.
func TestSetSpendLimit_RequireAuthGate(t *testing.T) {
setupOrderTestDB(t)
router := chi.NewRouter()
router.With(middleware.RequireAuth).
Put("/api/v1/account/spend-limit", NewAccountHandler(5000).SetSpendLimit)
req := httptest.NewRequest("PUT", "/api/v1/account/spend-limit", strings.NewReader(`{"self_limit_cents":1000}`))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status: want 401 got %d", w.Code)
}
assertErrCode(t, w, "UNAUTHORIZED")
}

View File

@@ -1,97 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
)
// AdminUserHandler serves admin-gated user-administration endpoints. Routes that
// use it are mounted behind middleware.RequireAdmin (X-Admin-Key), not the JWT
// auth used by customer-facing endpoints.
type AdminUserHandler struct{}
// NewAdminUserHandler constructs an AdminUserHandler.
func NewAdminUserHandler() *AdminUserHandler {
return &AdminUserHandler{}
}
// SetUserOrderCap sets or clears a user's per-user spend-cap override
// (User.MaxOrderCents). Body: {"max_order_cents": <int|null>}.
// - null -> clears the override; the user reverts to the global MAX_ORDER_CENTS
// - 0 -> per-user off-switch (disables the cap for this user)
// - >0 -> custom ceiling
//
// Negative values are rejected here at the write boundary (HTTP 400) to keep bad
// data out of the column. The read path in cart.go:ConvertCart independently
// defends against a negative that arrives via a direct DB write (falls back to
// the global cap), so the two layers cover each other.
//
// Adapted from the admin OrderHandler.UpdateTracking pattern in
// api/handlers/orders.go (URL id param -> body decode -> row lookup -> update ->
// audit -> respond).
func (h *AdminUserHandler) SetUserOrderCap(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(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 user id", nil)
return
}
// Decode raw so an explicit null (clear) is distinguishable from an absent key.
var raw map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "invalid request body", nil)
return
}
rawCap, ok := raw["max_order_cents"]
if !ok {
utils.RespondError(w, http.StatusBadRequest, "MISSING_FIELD",
"max_order_cents is required (use null to clear the override)", nil)
return
}
var capCents *int
if string(rawCap) != "null" {
var n int
if err := json.Unmarshal(rawCap, &n); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY",
"max_order_cents must be an integer or null", nil)
return
}
if n < 0 {
utils.RespondError(w, http.StatusBadRequest, "INVALID_CAP",
"max_order_cents must be >= 0 (use null to clear the override)", nil)
return
}
capCents = &n
}
var user models.User
if err := db.First(&user, id).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
return
}
// Map form so a nil pointer persists SQL NULL; a struct Updates would skip it.
if err := db.Model(&user).Updates(map[string]any{"max_order_cents": capCents}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to update order cap", nil)
return
}
audit.UserOrderCapSet("admin", user.ID, capCents)
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"user_id": user.ID,
"max_order_cents": capCents,
})
}

View File

@@ -1,194 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"terminalShop/api/middleware"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// adminCapRequest builds a PATCH request carrying the {id} chi URL param, as the
// admin order-cap route would. No JWT — the route is gated by RequireAdmin.
func adminCapRequest(idParam, body string) *http.Request {
req := httptest.NewRequest("PATCH", "/api/v1/users/"+idParam+"/order-cap", strings.NewReader(body))
rc := chi.NewRouteContext()
rc.URLParams.Add("id", idParam)
return req.WithContext(contextWithRoute(req.Context(), rc))
}
func eqIntPtr(a, b *int) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
func fmtIntPtr(p *int) string {
if p == nil {
return "<nil>"
}
return strconv.Itoa(*p)
}
func TestSetUserOrderCap(t *testing.T) {
cases := []struct {
name string
seed *int // starting MaxOrderCents on the user row
body string
wantStatus int
wantCode string // error code; "" means success
wantCap *int // expected DB value after a successful update
}{
{"set positive", nil, `{"max_order_cents":7500}`, http.StatusOK, "", intPtr(7500)},
{"set zero off-switch", nil, `{"max_order_cents":0}`, http.StatusOK, "", intPtr(0)},
{"clear with null reverts to global", intPtr(500), `{"max_order_cents":null}`, http.StatusOK, "", nil},
{"overwrite existing value", intPtr(500), `{"max_order_cents":12000}`, http.StatusOK, "", intPtr(12000)},
{"negative rejected", nil, `{"max_order_cents":-1}`, http.StatusBadRequest, "INVALID_CAP", nil},
{"missing field rejected", nil, `{}`, http.StatusBadRequest, "MISSING_FIELD", nil},
{"non-integer rejected", nil, `{"max_order_cents":"lots"}`, http.StatusBadRequest, "INVALID_BODY", nil},
{"malformed json rejected", nil, `{`, http.StatusBadRequest, "INVALID_BODY", nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
user := setupOrderTestDB(t)
db := database.GetDB()
if tc.seed != nil {
if err := db.Model(&user).Update("max_order_cents", *tc.seed).Error; err != nil {
t.Fatalf("seed cap: %v", err)
}
}
handler := NewAdminUserHandler()
req := adminCapRequest(strconv.FormatUint(uint64(user.ID), 10), tc.body)
w := httptest.NewRecorder()
handler.SetUserOrderCap(w, req)
if w.Code != tc.wantStatus {
t.Fatalf("status: want %d got %d (%s)", tc.wantStatus, w.Code, w.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Error.Code != tc.wantCode {
t.Errorf("error code: want %q got %q", tc.wantCode, resp.Error.Code)
}
var got models.User
if err := db.First(&got, user.ID).Error; err != nil {
t.Fatalf("reload user: %v", err)
}
if tc.wantStatus == http.StatusOK {
if !eqIntPtr(got.MaxOrderCents, tc.wantCap) {
t.Errorf("db MaxOrderCents: want %s got %s", fmtIntPtr(tc.wantCap), fmtIntPtr(got.MaxOrderCents))
}
} else if !eqIntPtr(got.MaxOrderCents, tc.seed) {
// A rejected request must not mutate the column.
t.Errorf("rejected request changed db cap: want %s got %s", fmtIntPtr(tc.seed), fmtIntPtr(got.MaxOrderCents))
}
})
}
}
func TestSetUserOrderCap_InvalidID(t *testing.T) {
setupOrderTestDB(t)
w := httptest.NewRecorder()
NewAdminUserHandler().SetUserOrderCap(w, adminCapRequest("abc", `{"max_order_cents":100}`))
if w.Code != http.StatusBadRequest {
t.Fatalf("status: want 400 got %d", w.Code)
}
assertErrCode(t, w, "INVALID_ID")
}
func TestSetUserOrderCap_UserNotFound(t *testing.T) {
setupOrderTestDB(t)
w := httptest.NewRecorder()
NewAdminUserHandler().SetUserOrderCap(w, adminCapRequest("99999", `{"max_order_cents":100}`))
if w.Code != http.StatusNotFound {
t.Fatalf("status: want 404 got %d", w.Code)
}
assertErrCode(t, w, "USER_NOT_FOUND")
}
// TestSetUserOrderCap_AdminGate exercises the RequireAdmin middleware through a
// real router (the handler-direct tests above bypass middleware).
func TestSetUserOrderCap_AdminGate(t *testing.T) {
user := setupOrderTestDB(t)
idStr := strconv.FormatUint(uint64(user.ID), 10)
router := func() http.Handler {
r := chi.NewRouter()
r.With(middleware.RequireAdmin).
Patch("/api/v1/users/{id}/order-cap", NewAdminUserHandler().SetUserOrderCap)
return r
}
newReq := func() *http.Request {
return httptest.NewRequest("PATCH", "/api/v1/users/"+idStr+"/order-cap",
strings.NewReader(`{"max_order_cents":100}`))
}
t.Run("disabled when ADMIN_API_KEY unset", func(t *testing.T) {
t.Setenv("ADMIN_API_KEY", "")
w := httptest.NewRecorder()
router().ServeHTTP(w, newReq())
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status: want 503 got %d", w.Code)
}
assertErrCode(t, w, "ADMIN_DISABLED")
})
t.Run("rejects wrong key", func(t *testing.T) {
t.Setenv("ADMIN_API_KEY", "secret")
req := newReq()
req.Header.Set("X-Admin-Key", "nope")
w := httptest.NewRecorder()
router().ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status: want 401 got %d", w.Code)
}
assertErrCode(t, w, "UNAUTHORIZED")
})
t.Run("accepts correct key", func(t *testing.T) {
t.Setenv("ADMIN_API_KEY", "secret")
req := newReq()
req.Header.Set("X-Admin-Key", "secret")
w := httptest.NewRecorder()
router().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: want 200 got %d (%s)", w.Code, w.Body.String())
}
var got models.User
if err := database.GetDB().First(&got, user.ID).Error; err != nil {
t.Fatalf("reload user: %v", err)
}
if !eqIntPtr(got.MaxOrderCents, intPtr(100)) {
t.Errorf("db MaxOrderCents: want 100 got %s", fmtIntPtr(got.MaxOrderCents))
}
})
}
func assertErrCode(t *testing.T, w *httptest.ResponseRecorder, want string) {
t.Helper()
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Error.Code != want {
t.Errorf("error code: want %q got %q", want, resp.Error.Code)
}
}

View File

@@ -4,8 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"time"
@@ -37,16 +35,6 @@ type CartHandler struct {
// audit rows (audit.EventOrderRequiresAction) so the cap stays migration-free.
const maxAuthIssuances = 4
// paymentIntentNewFn lets tests stub the Stripe create call without spinning
// up an httptest backend. Production callers go straight through to the real
// paymentintent.New. Mirrors paymentIntentGetFn in reconcile.go.
var paymentIntentNewFn = paymentintent.New
var (
paymentMethodNewFn = paymentmethod.New
paymentMethodDetachFn = paymentmethod.Detach
)
// 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 {
@@ -275,16 +263,6 @@ func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
})
}
// ConvertCartRequest carries the optional privacy-checkout controls. The body
// is optional: a saved.card checkout sends nothing and both field stay zero.
// Ephemeral opts a single order into privacy mode (ont-time card, no Card row,
// PM detached after settle); CardToken supplies that one-time card. A user with
// PrivacyMode set is ephemeral regardless of the flag.
type ConvertCartRequest struct {
Ephemeral bool `json:"ephemeral"`
CardToken string `json:"card_token"`
}
func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
@@ -293,12 +271,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
defer span.End()
r = r.WithContext(ctx)
var req ConvertCartRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
return
}
cart, err := getOrCreateCart(r.Context(), userID)
if err != nil {
middleware.RecordCartConversion("cart_lookup_failed")
@@ -321,6 +293,12 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
return
}
if cart.CardID == nil {
middleware.RecordCartConversion("validation_missing_card")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "payment card is required", nil)
return
}
var address models.Address
if err := db.First(&address, *cart.AddressID).Error; err != nil {
middleware.RecordCartConversion("validation_invalid_address")
@@ -328,6 +306,23 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
return
}
var card models.Card
if err := db.First(&card, *cart.CardID).Error; err != nil {
middleware.RecordCartConversion("validation_invalid_card")
utils.RespondError(w, http.StatusBadRequest, "INVALID_CARD", "payment card not found", nil)
return
}
if card.IsStorageExpired(time.Now()) {
middleware.RecordCartConversion("validation_card_expired")
if err := expireStoredCard(db, &card); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
var user models.User
if err := db.First(&user, userID).Error; err != nil {
middleware.RecordCartConversion("user_not_found")
@@ -335,90 +330,39 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
return
}
// Per-order intent drives the charge SHAPE: req.Ephemeral picks the one-time
// token path (no saved card). Privacy mode adds a second guarantee on the
// saved-card path — the live QR add-card flow has no "save anyway" prompt, so
// a privacy user who selects a (QR-collected) card still must not have it
// retained. forgetCard charges that card normally, then detaches the
// PaymentMethod and hard-deletes the local Card row once the order settles.
// It never applies to the token path, which has no local row.
ephemeral := req.Ephemeral
forgetCard := user.PrivacyMode && !ephemeral
// Ensure the user has a Stripe customer (shared helper with cards handler).
if err := ensureStripeCustomer(db, &user); err != nil {
middleware.RecordCartConversion("stripe_customer_failed")
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
return
}
var card models.Card
var paymentMethodID string
if ephemeral {
if req.CardToken == "" {
middleware.RecordCartConversion("validation_missing_card")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "card token is required for private checkout", nil)
return
}
pm, pmerr := paymentMethodNewFn(&stripe.PaymentMethodParams{
// All cards are now saved as pm_ PaymentMethods. This branch handles any
// legacy tok_ records that predate the server-side tokenization refactor.
paymentMethodID := card.StripePaymentID
if len(paymentMethodID) >= 3 && paymentMethodID[:3] == "tok" {
pm, pmErr := paymentmethod.New(&stripe.PaymentMethodParams{
Type: stripe.String("card"),
Card: &stripe.PaymentMethodCardParams{
Token: stripe.String(req.CardToken),
Token: stripe.String(paymentMethodID),
},
})
if pmerr != nil {
middleware.RecordCartConversion("stripe_error")
utils.RespondError(w, http.StatusBadRequest, "STRIPE_ERROR", "failed to create payment method", nil)
if pmErr != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create payment method", nil)
return
}
if _, attachErr := paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{
Customer: stripe.String(user.StripeCustomerID),
}); attachErr != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to attach payment method", nil)
return
}
paymentMethodID = pm.ID
} else {
if cart.CardID == nil {
middleware.RecordCartConversion("validation_missing_card")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "payment card is required", nil)
card.StripePaymentID = pm.ID
if err := db.Save(&card).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save card", nil)
return
}
if err := db.First(&card, *cart.CardID).Error; err != nil {
middleware.RecordCartConversion("validation_invalid_card")
utils.RespondError(w, http.StatusBadRequest, "INVALID_CARD", "payment card not found", nil)
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
}
if err := ensureStripeCustomer(db, &user); err != nil {
middleware.RecordCartConversion("stripe_customer_failed")
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
return
}
paymentMethodID = card.StripePaymentID
if len(paymentMethodID) >= 3 && paymentMethodID[:3] == "tok" {
pm, pmErr := paymentmethod.New(&stripe.PaymentMethodParams{
Type: stripe.String("card"),
Card: &stripe.PaymentMethodCardParams{
Token: stripe.String(paymentMethodID),
},
})
if pmErr != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create payment method", nil)
return
}
if _, attachErr := paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{
Customer: stripe.String(user.StripeCustomerID),
}); attachErr != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to attach payment method", nil)
return
}
paymentMethodID = pm.ID
card.StripePaymentID = pm.ID
if err := db.Save(&card).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save card", nil)
return
}
}
}
var subtotal int
@@ -436,46 +380,14 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
total := subtotal + cart.ShippingCost
// Per-user overridable spend cap. The override may raise OR lower the global
// cap — operators set it in both directions deliberately. A negative override
// is nonsense: ignore it and fall back to the global so an erroneous DB write
// cannot silently disable a fraud control (mirrors loadMaxOrderCents in
// pkg/config/config.go, which defaults a negative MAX_ORDER_CENTS). An explicit
// 0 is a valid per-user off-switch (same semantics as the global).
capCents := h.maxOrderCents
if user.MaxOrderCents != nil {
if *user.MaxOrderCents < 0 {
slog.Warn("ignoring negative per-user order cap, using global",
"user_id", user.ID,
"user_cap_cents", *user.MaxOrderCents,
"global_cap_cents", h.maxOrderCents)
} else {
capCents = *user.MaxOrderCents
}
}
// A user-set self-limit may only TIGHTEN the effective cap, never raise it.
// a compromised account must not be able to lift its own ceiling to drain a
// stolen card. Unlike the admin/global cap (where 0 is an off-switch), a
// present self-limit always activates a ceiling, a self-limit of 0 means
// "block everything", not "disable", so it folds in via min() and can only
// lower capCents. A negative value is ignored (mirrors the maxOrderCents
// guard above); this min() is the authoritative lower-only guard.
capActive := capCents > 0
if user.SelfLimitCents != nil && *user.SelfLimitCents >= 0 {
if self := *user.SelfLimitCents; !capActive || self < capCents {
capCents = self
}
capActive = true
}
if capActive && total > capCents {
if h.maxOrderCents > 0 && total > h.maxOrderCents {
middleware.RecordCartConversion("validation_over_limit")
audit.CartRejected(userID, total, capCents)
audit.CartRejected(userID, total, h.maxOrderCents)
utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT",
fmt.Sprintf("order total must be at most $%.2f", float64(capCents)/100),
fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100),
map[string]any{
"total_cents": total,
"limit_cents": capCents,
"limit_cents": h.maxOrderCents,
})
return
}
@@ -492,7 +404,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
UserID: user.ID,
CardID: card.ID,
StripePaymentID: "",
Ephemeral: ephemeral || forgetCard,
Status: models.OrderStatusPending,
Subtotal: subtotal,
ShippingCost: cart.ShippingCost,
@@ -516,23 +427,14 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
audit.OrderCreated(userID, order.ID, total)
// Charge the card. This is a Customer-Initiated Transaction (CIT): the
// customer is present in the TUI at checkout. We deliberately do NOT set
// OffSession — that flag marks a Merchant-Initiated Transaction (MIT),
// which under PSD2/SCA may only claim an exemption when a stored mandate
// authorises the merchant to charge while the customer is away. We capture
// no mandate and run no recurring billing, so MIT would misrepresent the
// transaction type. As a CIT, an SCA challenge surfaces as
// pi.Status == requires_action (handled below), not as an
// authentication_required error. See docs/sca-psd2-compliance.md "Gap 1".
// Reference: stripe-samples accept-a-payment custom-payment-flow —
// php/public/google-pay.php drives 3DS off status==requires_action and sets
// no off_session on a customer-present confirm.
// The idempotency key is tied to the order ID so retries never double charge.
// Charge the card. The idempotency key is tied to the order ID so retries
// never produce a double charge.
piParams := &stripe.PaymentIntentParams{
Amount: stripe.Int64(int64(total)),
Currency: stripe.String(string(stripe.CurrencyUSD)),
Customer: stripe.String(user.StripeCustomerID),
PaymentMethod: stripe.String(paymentMethodID),
OffSession: stripe.Bool(true),
PaymentMethodTypes: stripe.StringSlice([]string{"card"}),
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
Confirm: stripe.Bool(true),
@@ -554,21 +456,12 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
"order_id": fmt.Sprintf("%d", order.ID),
}
if !ephemeral {
piParams.Customer = stripe.String(user.StripeCustomerID)
}
stripeStart := time.Now()
pi, err := paymentIntentNewFn(piParams)
pi, err := paymentintent.New(piParams)
stripeDur := time.Since(stripeStart).Seconds()
if err != nil {
middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur)
if stripeErr, ok := err.(*stripe.Error); ok {
// Defensive fallback: as CIT (no off_session) Stripe normally
// signals 3DS via status=requires_action below, not via this error.
// authentication_required can still appear if the saved card needs
// auth that could not run inline; respondRequiresAction re-Confirms
// on-session to recover. (Pre-CIT migration this was the main path.)
if stripeErr.Code == stripe.ErrorCodeAuthenticationRequired && stripeErr.PaymentIntent != nil {
h.respondRequiresAction(w, &order, paymentMethodID, stripeErr.PaymentIntent)
return
@@ -629,26 +522,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
return
}
if ephemeral || forgetCard {
if _, derr := paymentMethodDetachFn(paymentMethodID, nil); derr != nil {
slog.Warn("failed to detach ephemeral payment method", "order_id", order.ID, "error", derr)
}
}
if forgetCard {
// Privacy mode: the card was persisted only long enough to select and
// charge it. Null the order's reference and hard-delete the local row so
// no card data (last4/brand/token) survives the checkout. Unscoped because
// Card uses gorm soft-delete; a plain delete would leave the data on disk.
// Mirrors upstream Card.remove (terminal-shop-source core/src/card/index.ts):
// detach the PM, drop the FK, delete the row.
if err := db.Model(&order).Update("card_id", 0).Error; err != nil {
slog.Warn("failed to null forgotten card reference", "order_id", order.ID, "error", err)
}
if err := db.Unscoped().Delete(&models.Card{}, card.ID).Error; err != nil {
slog.Warn("failed to delete forgotten card", "order_id", order.ID, "card_id", card.ID, "error", err)
}
}
audit.OrderPaid(userID, order.ID, total, pi.ID)
middleware.RecordOrderCreated(string(order.Status))
middleware.RecordCartConversion("success")

View File

@@ -670,67 +670,27 @@ func TestCartIsolation(t *testing.T) {
}
}
// intPtr returns a pointer to i, for seeding the nullable User.MaxOrderCents
// per-user cap override in table tests.
func intPtr(i int) *int { return &i }
func TestConvertCartLimit(t *testing.T) {
cases := []struct {
name string
capCents int
quantity int
expectOverCap bool
userCap *int // per-user admin override; nil = inherit the global capCents
selfLimit *int // user self-limit; nil = none. Lower-only: tightens, never raises.
}{
{"over cap rejects", 20000, 41, true, nil, nil}, // $205 vs global $200
{"at cap accepts", 20000, 40, false, nil, nil}, // $200 boundary
{"under cap accepts", 20000, 39, false, nil, nil}, // $195
{"zero cap disables", 0, 1000, false, nil, nil}, // explicit global off-switch
// Per-user admin override (User.MaxOrderCents) takes precedence over the global.
{"user override below global rejects", 20000, 12, true, intPtr(5000), nil}, // $60 > $50 user cap (global $200 would allow)
{"user override above global accepts", 20000, 41, false, intPtr(50000), nil}, // $205 < $500 user cap (global $200 would reject)
{"user override zero disables per user", 20000, 1000, false, intPtr(0), nil}, // per-user off-switch over a live global
{"nil override falls back to global", 20000, 41, true, nil, nil}, // explicit nil == inherit global $200
// A negative override is nonsense and must NOT disable the cap; it falls
// back to the global so a bad DB write can't void a fraud control. Here
// $205 > global $200 still rejects, at the GLOBAL cap (not the -1).
{"user override negative falls back to global", 20000, 41, true, intPtr(-1), nil},
// Self-limit (User.SelfLimitCents) is LOWER-ONLY: it can tighten the effective
// cap below the admin/global value but must never raise it.
{"self limit below global rejects", 20000, 12, true, nil, intPtr(5000)}, // $60 > $50 self-limit (global $200 would allow)
{"self limit under accepts", 20000, 8, false, nil, intPtr(5000)}, // $40 < $50 self-limit
{"self limit cannot raise above global", 20000, 50, true, nil, intPtr(30000)}, // $250: min($200 global,$300 self)=$200 still rejects
{"self limit tightens disabled global", 0, 12, true, nil, intPtr(5000)}, // global off-switch, self-limit $50 still caps; $60 rejects
{"self limit zero blocks everything", 20000, 1, true, nil, intPtr(0)}, // $5 > $0: self-limit 0 is "block all", NOT an off-switch
{"self limit negative ignored", 20000, 41, true, nil, intPtr(-1)}, // self ignored; rejects at global $200
{"self limit tightens below user override", 10000, 12, true, intPtr(10000), intPtr(5000)}, // $60 > $50 self (under the $100 admin override)
{"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) {
// Effective cap the handler should enforce, mirroring ConvertCart:
// a non-negative admin override wins over the global; then a present,
// non-negative self-limit folds in via min() (lower-only — 0 means
// "block all", a negative value is ignored).
effectiveCap := tc.capCents
if tc.userCap != nil && *tc.userCap >= 0 {
effectiveCap = *tc.userCap
}
if tc.selfLimit != nil && *tc.selfLimit >= 0 {
if effectiveCap <= 0 || *tc.selfLimit < effectiveCap {
effectiveCap = *tc.selfLimit
}
}
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
user.StripeCustomerID = "cus_test_overlimit"
user.MaxOrderCents = tc.userCap
user.SelfLimitCents = tc.selfLimit
if err := db.Save(&user).Error; err != nil {
t.Fatalf("saved user: %v", err)
}
@@ -820,8 +780,8 @@ func TestConvertCartLimit(t *testing.T) {
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) != effectiveCap {
t.Errorf("limit_cents in details: want %d, got %v", effectiveCap, resp.Error.Details["limit_cents"])
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"])
@@ -831,22 +791,12 @@ func TestConvertCartLimit(t *testing.T) {
t.Fatalf("audit: expected cart_rejected slog line, got nothing")
}
// The buffer may hold more than one JSON line (e.g. the negative-
// override warning precedes the audit record), so scan for the
// cart_rejected event rather than assuming it's the only line.
var rec map[string]any
for line := range bytes.SplitSeq(bytes.TrimSpace(auditBuf.Bytes()), []byte("\n")) {
var m map[string]any
if err := json.Unmarshal(line, &m); err != nil {
continue
}
if m["event"] == "cart_rejected" {
rec = m
break
}
if err := json.Unmarshal(auditBuf.Bytes(), &rec); err != nil {
t.Fatalf("audit: decode slog record: %v\nraw: %s", err, auditBuf.String())
}
if rec == nil {
t.Fatalf("audit: no cart_rejected slog record found\nraw: %s", 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"])
@@ -854,179 +804,14 @@ func TestConvertCartLimit(t *testing.T) {
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) != effectiveCap {
t.Errorf("audit cap_cents: want %d, got %v", effectiveCap, rec["cap_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"])
}
}
})
}
}
func seedConvertReadyCart(t *testing.T, user models.User) {
t.Helper()
db := database.GetDB()
user.StripeCustomerID = "cus_test_cit"
if err := db.Save(&user).Error; err != nil {
t.Fatalf("save 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_cit",
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
if err := db.Model(&models.Coffee{}).Where("id = ?", 4).Update("price", 500).Error; err != nil {
t.Fatalf("pin coffee price: %v", err)
}
h := NewCartHandler("", 0)
body, _ := json.Marshal(map[string]any{"coffee_id": 4, "quantity": 2})
w := httptest.NewRecorder()
h.SetItem(w, authRequest("PUT", "/api/v1/cart/item", body, user.ID))
if w.Code != http.StatusOK {
t.Fatalf("SetItem: %d %s", w.Code, w.Body.String())
}
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
w = httptest.NewRecorder()
h.SetAddress(w, authRequest("PUT", "/api/v1/cart/address", body, user.ID))
if w.Code != http.StatusOK {
t.Fatalf("SetAddress: %d %s", w.Code, w.Body.String())
}
body, _ = json.Marshal(map[string]any{"card_id": card.ID})
w = httptest.NewRecorder()
h.SetCard(w, authRequest("PUT", "/api/v1/cart/card", body, user.ID))
if w.Code != http.StatusOK {
t.Fatalf("SetCard: %d %s", w.Code, w.Body.String())
}
}
func TestConvertCartIsCustomerInitiated(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
var gotParams *stripe.PaymentIntentParams
orig := paymentIntentNewFn
defer func() { paymentIntentNewFn = orig }()
paymentIntentNewFn = func(p *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
gotParams = p
return &stripe.PaymentIntent{ID: "pi_cit", Status: stripe.PaymentIntentStatusSucceeded}, nil
}
seedConvertReadyCart(t, user)
handler := NewCartHandler("http://localhost", 0)
w := httptest.NewRecorder()
handler.ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", nil, user.ID))
if w.Code != http.StatusOK {
t.Fatalf("convert: want 200, got %d body=%s", w.Code, w.Body.String())
}
if gotParams == nil {
t.Fatalf("paymentIntentNewFn was never called")
}
if gotParams.OffSession != nil {
t.Errorf("CIT charge must not set off_session; got %v", *gotParams.OffSession)
}
if gotParams.Confirm == nil || !*gotParams.Confirm {
t.Error("expected Confirm=true on the charge")
}
if gotParams.ReturnURL == nil || *gotParams.ReturnURL == "" {
t.Error("expected ReturnURL set so redirect-based 3DS can populate next_action")
}
}
// TestConvertCartRequiresAction asserts that when the bank requires SCA, the
// CIT charge returns status=requires_action (the customer-initiated shape per
// the Stripe sample) and the handler responds 202 + redirect URL, persists the
// order in requires_action, and records the audit event the retry cap counts.
// next_action.redirect_to_url is pre-populated so respondRequiresAction skips
// its re-confirm and never touches the live Stripe API (hermetic).
func TestConvertCartRequiresAction(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
// Wire audit persistence so the order_requires_action row actually lands
// (audit.db is package-global and nil unless set — see api/main.go:71).
// Reset to nil after so it does not leak into other tests in this package.
audit.SetDB(database.GetDB())
defer audit.SetDB(nil)
orig := paymentIntentNewFn
defer func() { paymentIntentNewFn = orig }()
paymentIntentNewFn = func(_ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: "pi_sca",
Status: stripe.PaymentIntentStatusRequiresAction,
NextAction: &stripe.PaymentIntentNextAction{
RedirectToURL: &stripe.PaymentIntentNextActionRedirectToURL{
URL: "https://hooks.stripe.com/3ds/test",
},
},
}, nil
}
seedConvertReadyCart(t, user)
handler := NewCartHandler("http://localhost", 0)
w := httptest.NewRecorder()
handler.ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", nil, user.ID))
if w.Code != http.StatusAccepted {
t.Fatalf("convert: want 202, got %d body=%s", w.Code, w.Body.String())
}
var resp struct {
Success bool `json:"success"`
Data struct {
OrderID uint `json:"order_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: %v", err)
}
if resp.Data.Status != "requires_action" {
t.Errorf("status: want requires_action, got %q", resp.Data.Status)
}
if resp.Data.RedirectURL == "" {
t.Error("expected redirect_url in 202 response")
}
db := database.GetDB()
var order models.Order
if err := db.First(&order, resp.Data.OrderID).Error; err != nil {
t.Fatalf("load order: %v", err)
}
if order.Status != models.OrderStatusRequiresAction {
t.Errorf("order status: want requires_action, got %q", order.Status)
}
if order.StripePaymentID != "pi_sca" {
t.Errorf("order stripe_payment_id: want pi_sca, got %q", order.StripePaymentID)
}
var events int64
db.Model(&models.OrderEvent{}).
Where("order_id = ? AND type = ?", order.ID, audit.EventOrderRequiresAction).
Count(&events)
if events != 1 {
t.Errorf("expected 1 order_requires_action audit event, got %d", events)
}
}
// 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

View File

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

View File

@@ -1,421 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/stripe/stripe-go/v78"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// seedEphemeralCart seeds an item + address on the user's cart but deliberately
// NO card — the ephemeral (privacy) path supplies a one-time token at checkout
// instead of selecting a saved card.
func seedEphemeralCart(t *testing.T, user models.User) {
t.Helper()
db := database.GetDB()
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)
}
if err := db.Model(&models.Coffee{}).Where("id = ?", 4).Update("price", 500).Error; err != nil {
t.Fatalf("pin coffee price: %v", err)
}
h := NewCartHandler("", 0)
body, _ := json.Marshal(map[string]any{"coffee_id": 4, "quantity": 2})
w := httptest.NewRecorder()
h.SetItem(w, authRequest("PUT", "/api/v1/cart/item", body, user.ID))
if w.Code != http.StatusOK {
t.Fatalf("SetItem: %d %s", w.Code, w.Body.String())
}
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
w = httptest.NewRecorder()
h.SetAddress(w, authRequest("PUT", "/api/v1/cart/address", body, user.ID))
if w.Code != http.StatusOK {
t.Fatalf("SetAddress: %d %s", w.Code, w.Body.String())
}
}
// TestConvertCart_Ephemeral_InlineSuccess proves the privacy charge path: a
// one-time PaymentMethod is created from the token, the PaymentIntent attaches
// NO Customer, the PM is detached after settle, no Card row is written, and the
// order is paid + marked Ephemeral + keeps the PaymentIntent id (so refunds
// still work). The table covers both drivers: the per-request flag and the
// account-level PrivacyMode switch.
func TestConvertCart_Ephemeral_InlineSuccess(t *testing.T) {
cases := []struct {
name string
reqEphem bool // request "ephemeral"
}{
{"per-request flag", true},
}
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()
seedEphemeralCart(t, user)
var pmNewToken string
var piParams *stripe.PaymentIntentParams
var detached string
origNew, origPI, origDetach := paymentMethodNewFn, paymentIntentNewFn, paymentMethodDetachFn
defer func() {
paymentMethodNewFn, paymentIntentNewFn, paymentMethodDetachFn = origNew, origPI, origDetach
}()
paymentMethodNewFn = func(p *stripe.PaymentMethodParams) (*stripe.PaymentMethod, error) {
if p.Card != nil && p.Card.Token != nil {
pmNewToken = *p.Card.Token
}
return &stripe.PaymentMethod{ID: "pm_once"}, nil
}
paymentIntentNewFn = func(p *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
piParams = p
return &stripe.PaymentIntent{ID: "pi_eph", Status: stripe.PaymentIntentStatusSucceeded}, nil
}
paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
detached = id
return &stripe.PaymentMethod{ID: id}, nil
}
body, _ := json.Marshal(map[string]any{"ephemeral": tc.reqEphem, "card_token": "tok_visa"})
w := httptest.NewRecorder()
NewCartHandler("http://localhost", 0).ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", body, user.ID))
if w.Code != http.StatusOK {
t.Fatalf("convert: want 200, got %d body=%s", w.Code, w.Body.String())
}
if pmNewToken != "tok_visa" {
t.Errorf("one-time PM token: want tok_visa, got %q", pmNewToken)
}
if piParams == nil {
t.Fatal("paymentIntentNewFn was never called")
}
if piParams.Customer != nil {
t.Errorf("ephemeral charge must NOT attach a Customer; got %q", *piParams.Customer)
}
if piParams.PaymentMethod == nil || *piParams.PaymentMethod != "pm_once" {
t.Error("PI must charge the one-time PM pm_once")
}
if detached != "pm_once" {
t.Errorf("PM must be detached after settle; detached=%q", detached)
}
var cardCount int64
db.Model(&models.Card{}).Where("user_id = ?", user.ID).Count(&cardCount)
if cardCount != 0 {
t.Errorf("ephemeral checkout must not save a Card row; found %d", cardCount)
}
var order models.Order
if err := db.Where("user_id = ?", user.ID).First(&order).Error; err != nil {
t.Fatalf("load order: %v", err)
}
if !order.Ephemeral {
t.Error("order.Ephemeral must be true")
}
if order.CardID != 0 {
t.Errorf("ephemeral order must have CardID 0, got %d", order.CardID)
}
if order.Status != models.OrderStatusPaid {
t.Errorf("status: want paid, got %q", order.Status)
}
if order.StripePaymentID != "pi_eph" {
t.Errorf("order must keep the PaymentIntent id for refunds, got %q", order.StripePaymentID)
}
})
}
}
// TestConvertCart_Ephemeral_MissingTokenRejected: a privacy checkout with no
// card token is a 400, and we never reach Stripe.
func TestConvertCart_Ephemeral_MissingTokenRejected(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
seedEphemeralCart(t, user)
piCalled := false
origPI := paymentIntentNewFn
defer func() { paymentIntentNewFn = origPI }()
paymentIntentNewFn = func(_ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
piCalled = true
return &stripe.PaymentIntent{ID: "pi_x", Status: stripe.PaymentIntentStatusSucceeded}, nil
}
body, _ := json.Marshal(map[string]any{"ephemeral": true}) // no card_token
w := httptest.NewRecorder()
NewCartHandler("http://localhost", 0).ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", body, user.ID))
if w.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d body=%s", w.Code, w.Body.String())
}
if piCalled {
t.Error("must not create a PaymentIntent when the card token is missing")
}
}
// TestConvertCart_Ephemeral_RequiresActionDefersDetach: on a 3DS challenge the
// charge has not settled, so the PM must stay attached (the webhook detaches it
// later). The order persists Ephemeral so the webhook knows to detach.
func TestConvertCart_Ephemeral_RequiresActionDefersDetach(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
seedEphemeralCart(t, user)
detachCalled := false
origNew, origPI, origDetach := paymentMethodNewFn, paymentIntentNewFn, paymentMethodDetachFn
defer func() {
paymentMethodNewFn, paymentIntentNewFn, paymentMethodDetachFn = origNew, origPI, origDetach
}()
paymentMethodNewFn = func(_ *stripe.PaymentMethodParams) (*stripe.PaymentMethod, error) {
return &stripe.PaymentMethod{ID: "pm_once_3ds"}, nil
}
paymentIntentNewFn = func(_ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: "pi_eph_3ds",
Status: stripe.PaymentIntentStatusRequiresAction,
NextAction: &stripe.PaymentIntentNextAction{
RedirectToURL: &stripe.PaymentIntentNextActionRedirectToURL{URL: "https://hooks.stripe.com/3ds/test"},
},
}, nil
}
paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
detachCalled = true
return &stripe.PaymentMethod{ID: id}, nil
}
body, _ := json.Marshal(map[string]any{"ephemeral": true, "card_token": "tok_3ds"})
w := httptest.NewRecorder()
NewCartHandler("http://localhost", 0).ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", body, user.ID))
if w.Code != http.StatusAccepted {
t.Fatalf("want 202, got %d body=%s", w.Code, w.Body.String())
}
if detachCalled {
t.Error("must NOT detach the PM while 3DS is pending; the webhook owns the deferred detach")
}
var order models.Order
if err := database.GetDB().Where("user_id = ?", user.ID).First(&order).Error; err != nil {
t.Fatalf("load order: %v", err)
}
if !order.Ephemeral {
t.Error("order.Ephemeral must persist so the webhook can detach later")
}
if order.Status != models.OrderStatusRequiresAction {
t.Errorf("status: want requires_action, got %q", order.Status)
}
}
// TestHandlePaymentIntentSucceeded_EphemeralDetach exercises the deferred (3DS)
// detach: an Ephemeral order finishing via webhook detaches its PM; a
// non-ephemeral one does not.
func TestHandlePaymentIntentSucceeded_EphemeralDetach(t *testing.T) {
cases := []struct {
name string
ephemeral bool
wantDetach string // expected detached PM id, "" if none
}{
{"ephemeral order detaches PM", true, "pm_eph_once"},
{"non-ephemeral order keeps PM", false, ""},
}
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()
order := models.Order{
UserID: user.ID, CardID: 0, Ephemeral: tc.ephemeral,
StripePaymentID: "pi_eph_webhook", 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)
}
detached := ""
origDetach := paymentMethodDetachFn
defer func() { paymentMethodDetachFn = origDetach }()
paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
detached = id
return &stripe.PaymentMethod{ID: id}, nil
}
pi := map[string]any{
"id": order.StripePaymentID,
"amount": order.Total,
"currency": "usd",
"payment_method": "pm_eph_once",
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
}
raw, _ := json.Marshal(pi)
evt := stripe.Event{Type: "payment_intent.succeeded", Data: &stripe.EventData{Raw: raw}}
NewWebhookHandler("", "").handlePaymentIntentSucceeded(context.Background(), evt)
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 paid, got %q", reloaded.Status)
}
if detached != tc.wantDetach {
t.Errorf("detach: want %q, got %q", tc.wantDetach, detached)
}
})
}
}
// TestConvertCart_PrivacyModeForgetsCard proves the privacy guarantee on the
// saved-card path: a privacy_mode user who checks out a saved (QR-collected) card
// is charged normally (Customer attached — the PM is customer-attached, so the
// charge requires it), but once the charge settles the PaymentMethod is detached
// and the local Card row is hard-deleted, and the order is marked Ephemeral. The
// live QR add-card flow has no "save anyway" prompt, so privacy mode is the
// authoritative signal here.
func TestConvertCart_PrivacyModeForgetsCard(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
if err := db.Model(&user).Update("privacy_mode", true).Error; err != nil {
t.Fatalf("set privacy_mode: %v", err)
}
seedConvertReadyCart(t, user) // seeds a SAVED card + address + item
var piParams *stripe.PaymentIntentParams
detached := ""
origPI, origDetach := paymentIntentNewFn, paymentMethodDetachFn
defer func() { paymentIntentNewFn, paymentMethodDetachFn = origPI, origDetach }()
paymentIntentNewFn = func(p *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
piParams = p
return &stripe.PaymentIntent{ID: "pi_forget", Status: stripe.PaymentIntentStatusSucceeded}, nil
}
paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
detached = id
return &stripe.PaymentMethod{ID: id}, nil
}
body, _ := json.Marshal(map[string]any{"ephemeral": false})
w := httptest.NewRecorder()
NewCartHandler("http://localhost", 0).ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", body, user.ID))
if w.Code != http.StatusOK {
t.Fatalf("convert: want 200, got %d body=%s", w.Code, w.Body.String())
}
// The PM is attached to the Stripe customer, so the charge must still set
// Customer — you cannot charge an attached PM without it.
if piParams == nil || piParams.Customer == nil {
t.Error("saved-card charge must attach a Customer")
}
// ...but privacy mode forgets it afterward: detach + hard-delete.
if detached != "pm_test_cit" {
t.Errorf("forgotten card PM must be detached after settle; detached=%q", detached)
}
var cardCount int64
db.Unscoped().Model(&models.Card{}).Where("user_id = ?", user.ID).Count(&cardCount)
if cardCount != 0 {
t.Errorf("privacy checkout must HARD-delete the local card row; found %d", cardCount)
}
var order models.Order
if err := db.Where("user_id = ?", user.ID).First(&order).Error; err != nil {
t.Fatalf("load order: %v", err)
}
if !order.Ephemeral {
t.Error("forgotten-card order must be marked Ephemeral")
}
if order.CardID != 0 {
t.Errorf("forgotten-card order must null its CardID, got %d", order.CardID)
}
if order.Status != models.OrderStatusPaid {
t.Errorf("status: want paid, got %q", order.Status)
}
}
// TestHandlePaymentIntentSucceeded_ForgetsCard covers the deferred (3DS) twin of
// the inline forget-card cleanup: an Ephemeral order that still carries a local
// Card row (CardID != 0, the privacy + saved/QR-card case) has that row
// hard-deleted and its CardID nulled when the webhook marks it paid.
func TestHandlePaymentIntentSucceeded_ForgetsCard(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_forget_webhook",
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, Ephemeral: true,
StripePaymentID: "pi_forget_webhook", 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)
}
origDetach := paymentMethodDetachFn
defer func() { paymentMethodDetachFn = origDetach }()
paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
return &stripe.PaymentMethod{ID: id}, nil
}
pi := map[string]any{
"id": order.StripePaymentID,
"amount": order.Total,
"currency": "usd",
"payment_method": "pm_forget_webhook",
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
}
raw, _ := json.Marshal(pi)
evt := stripe.Event{Type: "payment_intent.succeeded", Data: &stripe.EventData{Raw: raw}}
NewWebhookHandler("", "").handlePaymentIntentSucceeded(context.Background(), evt)
var cardCount int64
db.Unscoped().Model(&models.Card{}).Where("id = ?", card.ID).Count(&cardCount)
if cardCount != 0 {
t.Errorf("webhook must hard-delete the forgotten card; found %d", cardCount)
}
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.Errorf("status: want paid, got %q", reloaded.Status)
}
if reloaded.CardID != 0 {
t.Errorf("order CardID must be nulled, got %d", reloaded.CardID)
}
}

View File

@@ -225,23 +225,6 @@ func ReconcileExpiredCards(ctx context.Context) {
}
}
// ReconcilePayRedirects deletes pay_redirects rows whose ExpiresAt had passed.
// Lazy deletion on read (PayRedirect) is the primary path, this sweep only
// reclaims tokens nobody ever followed so the table stays bounded.
func ReconcilePayRedirects(ctx context.Context) {
db := database.GetDB().WithContext(ctx)
res := db.Where("expires_at < ?", time.Now()).Delete(&models.PayRedirect{})
if res.Error != nil {
reconcileLog().Error("pay_redirects sweep failed", "error", res.Error)
return
}
if res.RowsAffected == 0 {
return
}
reconcileLog().Info("swept expired pay_redirects", "count", res.RowsAffected)
}
// 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) {

View File

@@ -518,81 +518,3 @@ func seedRequiresActionOrder(t *testing.T, userID uint, piID string, updatedAt t
}
return order
}
// TestReconcilePayRedirects_DeleteExpiredKeepsLive verifies the predicate
// boundary: a row past ExpiresAt is hard-deleted while a row whose ExpiresAt
// is still in the future survives. Seeding both sides through GORM keeps the
// stored timestamp format identical to the one the sweep binds for time.Now(),
// so the comparison is exercised exactly as it runs in production.
func TestReconcilePayRedirects_DeleteExpiredKeepsLive(t *testing.T) {
testDB := setupTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
now := time.Now()
expired := models.PayRedirect{
Token: "tok_expired",
URL: "https://example.test/expired",
Purpose: models.RedirectPurposeCheckout3DS,
CreatedAt: now.Add(-20 * time.Minute),
ExpiresAt: now.Add(-10 * time.Minute),
}
live := models.PayRedirect{
Token: "tok_live",
URL: "https://example.test/live",
Purpose: models.RedirectPurposeAddCard,
CreatedAt: now,
ExpiresAt: now.Add(10 * time.Minute),
}
if err := db.Create(&expired).Error; err != nil {
t.Fatalf("seed expired: %v", err)
}
if err := db.Create(&live).Error; err != nil {
t.Fatalf("seed live: %v", err)
}
ReconcilePayRedirects(context.Background())
var remaining []models.PayRedirect
if err := db.Find(&remaining).Error; err != nil {
t.Fatalf("list remaining: %v", err)
}
if len(remaining) != 1 {
t.Fatalf("want 1 row remaining, got %d", len(remaining))
}
if remaining[0].Token != "tok_live" {
t.Fatalf("wrong row survived: want tok_live, got %q", remaining[0].Token)
}
}
func TestReconcilePayRedirects_NoExpiredIsNoOp(t *testing.T) {
testDB := setupTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
live := models.PayRedirect{
Token: "tok_live_only",
URL: "https://example.test/live",
Purpose: models.RedirectPurposeCheckout3DS,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(10 * time.Minute),
}
if err := db.Create(&live).Error; err != nil {
t.Fatalf("seed live: %v", err)
}
ReconcilePayRedirects(context.Background())
var count int64
if err := db.Model(&models.PayRedirect{}).Count(&count).Error; err != nil {
t.Fatalf("count: %v", err)
}
if count != 1 {
t.Fatalf("want 1 row preserved, got %d", count)
}
}

View File

@@ -7,7 +7,6 @@ import (
"io"
"log/slog"
"net/http"
"os"
"strings"
"time"
@@ -53,17 +52,8 @@ func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
// Verify the Stripe signature. Reject anything that doesn't match.
if h.webhookSecret == "" {
// Fail closed in production. config.validate() requires STRIPE_WEBHOOK_SECRET
// in production, so the server should never boot into this state; this is
// defense in depth against a misconfigured deploy. Mirrors the Shippo and
// Slack handlers, which already refuse to run without their secret.
if os.Getenv("ENVIRONMENT") == "production" {
webhookLog().Error("stripe webhook secret not set in production; refusing to process event")
utils.RespondError(w, http.StatusServiceUnavailable, "WEBHOOK_NOT_CONFIGURED", "webhook verification not configured", nil)
return
}
// Development only; warn loudly but still process so local testing works.
webhookLog().Warn("stripe webhook secret not set; skipping signature verification (development only)")
// No secret configured — log a loud warning but still process in dev.
webhookLog().Warn("stripe webhook secret not set; skipping signature verification")
} else {
sig := r.Header.Get("Stripe-Signature")
if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{
@@ -134,20 +124,6 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
return
}
// Defense in depth: even behind a valid signature, refuse to mark an order
// paid unless the PaymentIntent's amount and currency match what we charged.
// Under ConvertCart these always match (Amount == order.Total at creation), so
// a mismatch means a malformed or unexpected event we should not trust.
if pi.Amount != int64(order.Total) {
webhookLog().Error("payment intent amount mismatch; refusing to mark paid", "order_id", orderIDStr, "pi", pi.ID, "pi_amount", pi.Amount, "order_total", order.Total)
return
}
if pi.Currency != "" && pi.Currency != stripe.CurrencyUSD {
webhookLog().Error("payment intent currency mismatch; refusing to mark paid",
"order_id", orderIDStr, "pi", pi.ID, "currency", string(pi.Currency))
return
}
txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]any{
"status": models.OrderStatusPaid,
@@ -174,33 +150,12 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
return
}
// A retained (non-ephemeral) card gets its storage TTL refreshed on use.
// Ephemeral orders are about to lose their card entirely, so skip the refresh.
if order.CardID != 0 && !order.Ephemeral {
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)
}
}
if order.Ephemeral && pi.PaymentMethod != nil && pi.PaymentMethod.ID != "" {
if _, derr := paymentMethodDetachFn(pi.PaymentMethod.ID, nil); derr != nil {
webhookLog().Warn("failed to detach ephemeral payment method",
"order_id", order.ID, "pi", pi.ID, "error", derr)
}
}
// Deferred (3DS) twin of the inline forget-card cleanup in ConvertCart: a
// privacy-mode order that selected a saved/QR card carries CardID != 0. Now
// that it is paid, hard-delete the local row (Unscoped: Card soft-deletes by
// default). Token-ephemeral orders have CardID == 0 and skip this.
if order.Ephemeral && order.CardID != 0 {
if err := db.Unscoped().Delete(&models.Card{}, order.CardID).Error; err != nil {
webhookLog().Warn("failed to delete forgotten card", "order_id", order.ID, "card_id", order.CardID, "error", err)
} else if err := db.Model(&order).Update("card_id", 0).Error; err != nil {
webhookLog().Warn("failed to null forgotten card reference", "order_id", order.ID, "error", err)
}
}
audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID)
go notify.SlackOrderPaid(&order)
webhookLog().Info("order marked paid", "order_id", orderIDStr, "pi", pi.ID)

View File

@@ -222,38 +222,3 @@ func TestHandlePaymentIntentSucceeded_RequiresActionFlipsToPaid(t *testing.T) {
t.Fatalf("stripe_payment_id: want %q, got %q", order.StripePaymentID, reloaded.StripePaymentID)
}
}
func TestHandlePaymentIntentSucceeded_AmountMismatchDoesNotPay(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_amount_mismatch")
// PI claims 1 cent; seed order.Total is 500.
pi := map[string]any{
"id": order.StripePaymentID,
"amount": 1,
"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("reloaded order: %v", err)
}
if reloaded.Status == models.OrderStatusPaid {
t.Fatalf("order marked paid despite amount mismatch")
}
}

View File

@@ -25,25 +25,6 @@ import (
const version = "v0.1.0"
// everyTick runs fn on its own goroutine every d until ctx is cancelled.
// Shared by the reconciler sweeps so the ticker + shutdown plumbing lives in
// one place. ReconcileUnshipped intentionally does not use it, it needs a
// staggered warm-up run before its first tick.
func everyTick(ctx context.Context, d time.Duration, fn func(context.Context)) {
go func() {
ticker := time.NewTicker(d)
defer ticker.Stop()
for {
select {
case <-ticker.C:
fn(ctx)
case <-ctx.Done():
return
}
}
}()
}
func main() {
observability.InitLogger()
middleware.SetBuildInfo(version)
@@ -125,8 +106,20 @@ func main() {
}
}()
// Catch orders charged but not recorded due to a crash. Every 5 minutes
everyTick(ctx, 5*time.Minute, handlers.ReconcileOrders)
// Start reconciliation job. runs every 5 minutes to catch any orders
// that were charged but not recorded due to a crash.
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
handlers.ReconcileOrders(ctx)
case <-ctx.Done():
return
}
}
}()
go func() {
select {
@@ -149,18 +142,33 @@ func main() {
// Sweep saved cards whose retention deadline elapsed. Read paths filter
// expired rows from query results, so this sweeper only drives the Stripe
// detach + audit + physical row delete asynchronously. Hourly.
everyTick(ctx, 1*time.Hour, handlers.ReconcileExpiredCards)
// 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
}
}
}()
// Flip abandoned 3DS orders to failed. Every 15 minutes.
threshold := time.Duration(cfg.Abandoned3DSThresholdMinutes) * time.Minute
everyTick(ctx, 15*time.Minute, func(ctx context.Context) {
handlers.ReconcileStale3DSOrders(ctx, threshold)
})
// Sweep expired pay_redirects rows so the table stays bounded. Lazy delete
// on read is the primary path; this only reclaims tokens nobody follows.
everyTick(ctx, 10*time.Minute, handlers.ReconcilePayRedirects)
go func() {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
threshold := time.Duration(cfg.Abandoned3DSThresholdMinutes) * time.Minute
for {
select {
case <-ticker.C:
handlers.ReconcileStale3DSOrders(ctx, threshold)
case <-ctx.Done():
return
}
}
}()
log.Println("API server started. Press Ctrl+C to shutdown.")

View File

@@ -1,92 +0,0 @@
package main
import (
"context"
"testing"
"time"
)
// TestEveryTick_CallsFnOnTick verifies the helper invokes fn repeatedly on its
// interval. A non-blocking send keeps the spawned goroutine from ever blocking
// on a full channel, and the 1s timeout per tick is generous vs the 1ms
// interval so the test is not timing-flaky.
func TestEveryTick_CallsFnOnTick(t *testing.T) {
ctx := t.Context()
fired := make(chan struct{}, 1)
everyTick(ctx, time.Millisecond, func(context.Context) {
select {
case fired <- struct{}{}:
default:
}
})
for i := range 3 {
select {
case <-fired:
case <-time.After(time.Second):
t.Fatalf("tick %d did not fire within 1s", i+1)
}
}
}
// TestEveryTick_StopsOnContextCancel verifies cancelling ctx stops the loop.
// After the first tick we cancel inside the inter-tick gap, so everyTick's
// select sees only ctx.Done ready (ticker.C is cold) and returns
// deterministically — there is no random pick between two ready cases. We then
// confirm no further tick arrives within a full interval, which a live loop
// could not satisfy. This is the shutdown guarantee the reconcilers depend on.
func TestEveryTick_StopsOnContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
const interval = 50 * time.Millisecond
ticks := make(chan struct{}) // unbuffered: fn blocks until the test receives
everyTick(ctx, interval, func(context.Context) {
ticks <- struct{}{}
})
// First tick proves the loop is running and leaves the goroutine back in
// select with ticker.C cold for the next ~interval.
select {
case <-ticks:
case <-time.After(time.Second):
t.Fatal("everyTick never called fn")
}
cancel()
// Cancel landed in the cold gap, so the loop must have taken ctx.Done and
// returned. A live loop would tick again at ~interval; a stopped one never
// will.
select {
case <-ticks:
t.Fatal("everyTick ticked again after cancel")
case <-time.After(interval + 50*time.Millisecond):
// good: silent past a full interval — the loop returned
}
}
func TestEveryTick_PassesContextToFn(t *testing.T) {
type ctxKey string
const marker ctxKey = "marker"
ctx, cancel := context.WithCancel(context.WithValue(context.Background(), marker, "present"))
defer cancel()
got := make(chan string, 1)
everyTick(ctx, time.Millisecond, func(c context.Context) {
v, _ := c.Value(marker).(string)
select {
case got <- v:
default:
}
})
select {
case v := <-got:
if v != "present" {
t.Fatalf("fn received wrong context: value = %q, want %q", v, "present")
}
case <-time.After(time.Second):
t.Fatalf("fn never ran")
}
}

View File

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

View File

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

View File

@@ -53,8 +53,6 @@ func SetupRoutes(
viewHandler := handlers.NewViewHandler()
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, shippoWebhookSecret)
slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort, shippoWebhookSecret)
adminUserHandler := handlers.NewAdminUserHandler()
accountHandler := handlers.NewAccountHandler(maxOrderCents)
// Short payment redirect and success page — no auth required.
r.Get("/pay/{token}", handlers.PayRedirect)
@@ -106,12 +104,6 @@ func SetupRoutes(
r.Get("/view/init", viewHandler.GetViewInit)
// Account self-service settings, JWT-authed, edits the callers
// own row (user id from context, never a URL param). Distinct from
// the admin /users/{id}/order-cap setter in the RequireAdmin group.
r.Put("/account/spend-limit", accountHandler.SetSpendLimit)
r.Put("/account/privacy-mode", accountHandler.SetPrivacyMode)
// Cart
r.Route("/cart", func(r chi.Router) {
r.Get("/", cartHandler.GetCart)
@@ -157,8 +149,6 @@ func SetupRoutes(
r.Use(middleware.RequireAdmin)
r.Patch("/orders/{id}/tracking", orderHandler.UpdateTracking)
r.Post("/orders/{id}/label", orderHandler.PurchaseLabel)
// Set or clear a user's per-user order spend-cap override.
r.Patch("/users/{id}/order-cap", adminUserHandler.SetUserOrderCap)
})
})

View File

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

View File

@@ -453,77 +453,6 @@ func (c *Client) SetCartCard(cardID uint) error {
return nil
}
func (c *Client) SetSpendLimit(cents *int) error {
url := fmt.Sprintf("%s/api/v1/account/spend-limit", c.BaseURL)
body := map[string]any{
"self_limit_cents": cents,
}
jsonData, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("PUT", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.doRequest(req)
if err != nil {
return fmt.Errorf("failed to set spend limit: %w", err)
}
defer func() { _ = resp.Body.Close() }()
var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
if !result.Success {
if result.Error != nil {
return fmt.Errorf("%s: %s", result.Error.Code, result.Error.Message)
}
return fmt.Errorf("failed to set spend limit")
}
return nil
}
// SetPrivacyMode sets the users account-level privacy default ("keep as little
// as possible"). A soft default the checkout can override per order.
func (c *Client) SetPrivacyMode(on bool) error {
url := fmt.Sprintf("%s/api/v1/account/privacy-mode", c.BaseURL)
jsonData, err := json.Marshal(map[string]any{"privacy_mode": on})
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("PUT", url, bytes.NewReader(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.doRequest(req)
if err != nil {
return fmt.Errorf("failed to set privacy mode: %w", err)
}
defer func() { _ = resp.Body.Close() }()
var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
if !result.Success {
if result.Error != nil {
return fmt.Errorf("%s: %s", result.Error.Code, result.Error.Message)
}
}
return nil
}
// ClearCart removes all items from the user's cart.
func (c *Client) ClearCart() error {
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL)
@@ -558,38 +487,9 @@ func (c *Client) ClearCart() error {
// Returns a CheckoutOutcome that either carries the completed Order or
// indicates the customer must complete 3DS authentication via RedirectURL.
func (c *Client) ConvertCart() (*CheckoutOutcome, error) {
return c.convertCart(nil)
}
// ConvertCartEphemeral converts the cart in privacy mode: it charges a one-time
// card (cardToken) that is never saved and is detached after the charge settles.
// Used when the customer leaves "save this card" unchecked at checkout.
func (c *Client) ConvertCartEphemeral(cardToken string) (*CheckoutOutcome, error) {
jsonData, err := json.Marshal(map[string]any{
"ephemeral": true,
"card_token": cardToken,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
return c.convertCart(jsonData)
}
// convertCart POSTs to /cart/convert with an optional JSON body (nil = saved-card
// flow) parses the shared response into a CheckoutOutcome.
func (c *Client) convertCart(jsonData []byte) (*CheckoutOutcome, error) {
url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL)
var req *http.Request
var err error
if jsonData != nil {
req, err = http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err == nil {
req.Header.Set("Content-Type", "application/json")
}
} else {
req, err = http.NewRequest("POST", url, nil)
}
req, err := http.NewRequest("POST", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

View File

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

View File

@@ -50,9 +50,6 @@ const (
eventTrackingUpdated eventType = "tracking_updated"
eventOrderDelivered eventType = "order_delivered"
eventTrackingMarkedManually eventType = "tracking_marked_manually"
eventUserOrderCapSet eventType = "user_order_cap_set"
eventUserSelfLimitSet eventType = "user_self_limit_set"
eventUserPrivacyModeSet eventType = "user_privacy_mode_set"
)
type event struct {
@@ -383,64 +380,3 @@ func CartRejected(userID uint, totalCents, capCents int) {
}
slog.Info("audit", e.attrs()...)
}
// UserOrderCapSet records an operator changing a user's per-user order-spend
// cap override (User.MaxOrderCents), an admin-gated action. A nil capCents means
// the override was cleared and the user reverts to the global MAX_ORDER_CENTS;
// Status distinguishes "set" from "cleared" so a cap set to 0 (off-switch, which
// attrs() omits because cap_cents == 0) is still legible in the log.
func UserOrderCapSet(actor string, userID uint, capCents *int) {
e := event{
Event: eventUserOrderCapSet,
Actor: actor,
UserID: userID,
}
if capCents == nil {
e.Status = "cleared"
} else {
e.Status = "set"
e.CapCents = *capCents
}
slog.Info("audit", e.attrs()...)
}
// UserSelfLimitSet records a customer changing their own self-service spend
// limit (User.SelfLimitCents) via the JWT-authed account endpoint. Kept
// deliberately distinct from UserOrderCapSet (the operator's admin-gated
// override) so the customer action and the operator action are never conflated
// in the audit trail. A nil capCents means the self-limit was cleared (the user
// reverts to the admin/global cap). Unlike the admin cap, an explicit 0 here is
// a real "block everything" ceiling, not an off-switch, so Status distinguishes
// "set" from "cleared" and a cap of 0 (which attrs() omits) stays legible.
func UserSelfLimitSet(userID uint, capCents *int) {
e := event{
Event: eventUserSelfLimitSet,
Actor: "self",
UserID: userID,
}
if capCents == nil {
e.Status = "cleared"
} else {
e.Status = "set"
e.CapCents = *capCents
}
slog.Info("audit", e.attrs()...)
}
// UserPrivacyModeSet records a customer toggling their account-level privacy
// ("keep as little as possible") default via the JWT-authed account endpoint.
// Actor "self" mirrors UserSelfLimitSet; Status carries the new state so the
// trail reads cleanly without a dedicated column.
func UserPrivacyModeSet(userID uint, enabled bool) {
e := event{
Event: eventUserPrivacyModeSet,
Actor: "self",
UserID: userID,
}
if enabled {
e.Status = "enabled"
} else {
e.Status = "disabled"
}
slog.Info("audit", e.attrs()...)
}

View File

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

View File

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

View File

@@ -92,7 +92,6 @@ func (c *Config) validate() error {
{"JWT_SECRET", c.JWTSecret},
{"STRIPE_SECRET_KEY", c.StripeSecretKey},
{"AUTH_FINGERPRINT_KEY", c.AuthFingerprintKey},
{"STRIPE_WEBHOOK_SECRET", c.StripeWebhookSecret},
}
var missing []string
@@ -138,14 +137,11 @@ func getEnvOrDefault(key, defaultValue string) string {
return defaultValue
}
// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $500 — roughly tracks the
// PSD2 TRA exemption ceiling of €500 (≈ $540, FX-dependent), above which Stripe
// always requests SCA, so the cap is only meaningful below it. We charge in USD
// and the regulatory ceiling is in EUR, so this is an approximate anchor, not an
// exact equivalence — $500 trips slightly below the true ceiling, which is the
// safe direction. 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.
// 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")

View File

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

View File

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

View File

@@ -26,7 +26,6 @@ func Seed(db *gorm.DB) error {
{
Name: "Espresso",
RoastType: "Dark Roast",
RoastLevel: 5,
Ounces: 2,
BeanType: "Arabica",
Price: 1800, // $18.00
@@ -36,7 +35,6 @@ func Seed(db *gorm.DB) error {
{
Name: "Americano",
RoastType: "Medium Roast",
RoastLevel: 3,
Ounces: 8,
BeanType: "Arabica",
Price: 2200, // $22.00
@@ -46,7 +44,6 @@ func Seed(db *gorm.DB) error {
{
Name: "Cappuccino",
RoastType: "Medium Roast",
RoastLevel: 3,
Ounces: 6,
BeanType: "Arabica",
Price: 2200, // $22.00
@@ -56,7 +53,6 @@ func Seed(db *gorm.DB) error {
{
Name: "Latte",
RoastType: "Medium Roast",
RoastLevel: 2,
Ounces: 12,
BeanType: "Arabica",
Price: 2500, // $25.00
@@ -66,7 +62,6 @@ func Seed(db *gorm.DB) error {
{
Name: "Mocha",
RoastType: "Dark Roast",
RoastLevel: 4,
Ounces: 12,
BeanType: "Arabica",
Price: 3000, // $30.00
@@ -76,7 +71,6 @@ func Seed(db *gorm.DB) error {
{
Name: "Macchiato",
RoastType: "Dark Roast",
RoastLevel: 4,
Ounces: 3,
BeanType: "Robusta Blend",
Price: 1800, // $18.00

View File

@@ -11,7 +11,6 @@ type Coffee struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:255;not null" json:"name"`
RoastType string `gorm:"size:100" json:"roast_type"`
RoastLevel int `json:"roast_level"` // 1-5 roast-intensity
Ounces int `json:"ounces"`
BeanType string `gorm:"size:100" json:"bean_type"`
Price int `gorm:"not null" json:"price"`
@@ -33,8 +32,6 @@ var AccountMenuItems = []string{
"order history",
"addresses",
"cards",
"spend limit",
"privacy",
"faq",
"about",
}

View File

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

View File

@@ -37,7 +37,6 @@ type Order struct {
UserID uint `gorm:"not null;index" json:"user_id"`
CardID uint `gorm:"not null" json:"card_id"`
StripePaymentID string `gorm:"size:255" json:"-"` // Stripe PaymentIntent ID
Ephemeral bool `gorm:"default:false" json:"ephemeral"`
Status OrderStatus `gorm:"size:50;not null;default:'pending'" json:"status"`
Subtotal int `gorm:"not null" json:"subtotal"` // Amount in cents
ShippingCost int `gorm:"default:0" json:"shipping_cost"` // Amount in cents

View File

@@ -9,26 +9,16 @@ import (
// User represents a user authenticated via SSH public key
// No passwords - SSH key is the ONLY authentication method (matches terminal.shop exactly)
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
SSHKeyFingerprint string `gorm:"uniqueIndex;size:100;not null" json:"ssh_key_fingerprint"` // SHA256 fingerprint - PRIMARY identifier
SSHPublicKey string `gorm:"uniqueIndex;type:text;not null" json:"-"` // Full SSH public key
Name string `gorm:"size:255" json:"name,omitempty"` // Real name (for shipping labels)
Email string `gorm:"size:255" json:"email,omitempty"` // Email (for receipts)
Anonymous bool `gorm:"default:false" json:"anonymous"` // True if connected without SSH key
StripeCustomerID string `gorm:"size:255" json:"-"`
PrivacyMode bool `gorm:"default:false" json:"privacy_mode"`
// MaxOrderCents is an optional per-user override of the global MAX_ORDER_CENTS
// spend cap. It may raise OR lower the global cap — both directions are
// intentional (e.g. a vetted corporate buyer gets a higher ceiling; a flagged
// account gets a lower one). States: nil = inherit the global; explicit 0 =
// per-user off-switch (same semantics as the global); >0 = custom ceiling. A
// negative value is treated as "no override" (falls back to global) with a
// warning — see ConvertCart. Operator-set out-of-band; kept out of API payloads.
MaxOrderCents *int `json:"-"`
SelfLimitCents *int `json:"self_limit_cents,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
ID uint `gorm:"primaryKey" json:"id"`
SSHKeyFingerprint string `gorm:"uniqueIndex;size:100;not null" json:"ssh_key_fingerprint"` // SHA256 fingerprint - PRIMARY identifier
SSHPublicKey string `gorm:"uniqueIndex;type:text;not null" json:"-"` // Full SSH public key
Name string `gorm:"size:255" json:"name,omitempty"` // Real name (for shipping labels)
Email string `gorm:"size:255" json:"email,omitempty"` // Email (for receipts)
Anonymous bool `gorm:"default:false" json:"anonymous"` // True if connected without SSH key
StripeCustomerID string `gorm:"size:255" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// TableName specifies the table name for the User model
@@ -42,8 +32,6 @@ type PublicUser struct {
SSHKeyFingerprint string `json:"ssh_key_fingerprint"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
SelfLimitCents *int `json:"self_limit_cents,omitempty"`
PrivacyMode bool `json:"privacy_mode"`
CreatedAt time.Time `json:"created_at"`
}
@@ -54,8 +42,6 @@ func (u *User) ToPublic() PublicUser {
SSHKeyFingerprint: u.SSHKeyFingerprint,
Name: u.Name,
Email: u.Email,
SelfLimitCents: u.SelfLimitCents,
PrivacyMode: u.PrivacyMode,
CreatedAt: u.CreatedAt,
}
}

View File

@@ -1,15 +0,0 @@
package models
import "testing"
// TestToPublic_IncludesPrivacyMode guards the converter: PublicUser must carry
// the user's PrivacyMode so the TUI can reflect the account toggle's real state.
// (Regression: ToPublic originally omitted this field and always reported false.)
func TestToPublic_IncludesPrivacyMode(t *testing.T) {
for _, want := range []bool{true, false} {
u := User{ID: 1, PrivacyMode: want}
if got := u.ToPublic().PrivacyMode; got != want {
t.Errorf("ToPublic().PrivacyMode = %v, want %v", got, want)
}
}
}

View File

@@ -253,30 +253,25 @@ func SlackPostLabelPurchased(orderID uint, labelURL string, costCents int) {
headLine := fmt.Sprintf(":white_check_mark: Labeled - %s `%s` (%s). <%s|Download label PDF>", order.Carrier, order.TrackingNumber, dollars(costCents), labelURL)
blocks := []map[string]any{
{
"type": "section",
"text": map[string]any{"type": "mrkdwn", "text": headLine},
},
}
if strings.EqualFold(order.Carrier, "BRING") {
blocks = append(blocks, markStatusActionBlock(order.ID))
}
_, _ = postSlackMessage(token, map[string]any{
"channel": channel,
"thread_ts": *order.SlackThreadTS,
"text": headLine,
"blocks": labelPurchaseBlocks(&order, headLine),
"blocks": blocks,
})
}
// labelPurchaseBlocks assembles the Block Kit payload for the post-label thread
// reply: a headline section plus the manual mark-status buttons for any labeled
// order. The buttons are gtated on a present tracking number (carrier-agnostic)
func labelPurchaseBlocks(order *models.Order, headline string) []map[string]any {
blocks := []map[string]any{
{
"type": "section",
"text": map[string]any{"type": "mrkdwn", "text": headline},
},
}
if order.TrackingNumber != "" {
blocks = append(blocks, markStatusActionBlock(order.ID))
}
return blocks
}
func markStatusActionBlock(orderID uint) map[string]any {
id := fmt.Sprintf("%d", orderID)
return map[string]any{

View File

@@ -1,41 +0,0 @@
package notify
import (
"testing"
"terminalShop/pkg/models"
)
// hadActionsBlock reports whether the Block Kit slice contains an "actions"
// block, the row that carries the mark-status buttons.
func hasActionsBlock(blocks []map[string]any) bool {
for _, b := range blocks {
if b["type"] == "actions" {
return true
}
}
return false
}
func TestLabelPurchasedBlocksButtonGating(t *testing.T) {
cases := []struct {
name string
order models.Order
wantButtons bool
}{
{"shippo us order with tracking shows buttons", models.Order{ID: 7, Carrier: "USPS", TrackingNumber: "9400111899223817"}, true},
{"bring orders with tracking shows buttons", models.Order{ID: 8, Carrier: "BRING", TrackingNumber: "CONS123456"}, true},
{"order without tracking shows no buttons", models.Order{ID: 9, Carrier: "USPS", TrackingNumber: ""}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
blocks := labelPurchaseBlocks(&tc.order, "headline")
if got := hasActionsBlock(blocks); got != tc.wantButtons {
t.Fatalf("hasActionsBlock = %v, want %v", got, tc.wantButtons)
}
if len(blocks) == 0 || blocks[0]["type"] != "section" {
t.Fatalf("expected first block to be the headline section, got %v", blocks)
}
})
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,6 @@
package tui
import (
"fmt"
"strconv"
"strings"
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
@@ -13,52 +8,36 @@ import (
"terminalShop/pkg/models"
)
// accountBodyHeight returns the inner content height of the account body
// panels. The body is quantized to whole order cards so the menu panel's
// bottom border lines up exactly with the last visible card; the two gap
// rows around the body host the up/down scroll arrows.
func (m Model) accountBodyHeight() int {
// updateAccountViewport initializes or resizes the detail viewport for the account page.
// Follows the reference pattern: each page owns its own viewport(s).
func (m Model) updateAccountViewport() Model {
// Match the reference: only subtract header + footer, no extra margins
// (account is a viewport page — views.go renders it without margin padding)
headerHeight := lipgloss.Height(m.BuildHeader())
footerHeight := lipgloss.Height(m.BuildFooter())
budget := m.heightContainer - headerHeight - footerHeight - 2 - 1
cards := budget / orderCardHeight
cards = max(cards, 1)
return cards*orderCardHeight - 2
}
verticalMarginHeight := headerHeight + footerHeight
// accountDetailWidth returns the width of the unboxed detail area: the chrome
// width minus the menu panel and the gap. The detail side has no outer panel
// (its content draws its own boxes), so it reclaims the border + padding.
func (m Model) accountDetailWidth() int {
contentW, menuW, _, _ := m.shopLayout()
availableHeight := m.heightContainer - verticalMarginHeight
leftWidth := 18
rightWidth := m.widthContent - leftWidth - 2
detailWidth := rightWidth
if m.size < large {
return contentW
detailWidth = m.widthContent
// In stacked mode, reduce available height by the left panel
leftPanelHeight := len(models.AccountMenuItems) + 1
availableHeight -= leftPanelHeight
}
return contentW - (menuW + 4) - 1
}
// updateAccountViewport initializes or resizes the detail viewport for the account page.
// The viewport spans the unboxed detail area to the right of the menu panel.
func (m Model) updateAccountViewport() Model {
detailW := m.accountDetailWidth()
// +2: no panel on the detail side, so the viewport spans the same rows
// as the menu panel including its border rows — tops and bottoms align.
availableHeight := m.accountBodyHeight() + 2
if m.size < large {
// Stacked: the menu panel sits above the detail area
menuPanelHeight := len(models.AccountMenuItems) + 2
availableHeight -= menuPanelHeight
if availableHeight < 3 {
availableHeight = 3
}
if availableHeight < 3 {
availableHeight = 3
}
if !m.account.viewportReady {
m.account.detailViewport = viewport.New(viewport.WithWidth(detailW), viewport.WithHeight(availableHeight))
m.account.detailViewport = viewport.New(viewport.WithWidth(detailWidth), viewport.WithHeight(availableHeight))
m.account.detailViewport.KeyMap = modifiedKeyMap
m.account.viewportReady = true
} else {
m.account.detailViewport.SetWidth(detailW)
m.account.detailViewport.SetWidth(detailWidth)
m.account.detailViewport.SetHeight(availableHeight)
}
@@ -67,7 +46,12 @@ func (m Model) updateAccountViewport() Model {
// getAccountDetailContent generates the content string for the current detail panel.
func (m Model) getAccountDetailContent() string {
detailContentWidth := m.accountDetailWidth()
leftWidth := 18
rightWidth := m.widthContent - leftWidth - 2
detailContentWidth := rightWidth - 2
if m.size < large {
detailContentWidth = m.widthContent - 2
}
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
return ""
@@ -83,11 +67,8 @@ func (m Model) getAccountDetailContent() string {
return m.AddressesView(detailContentWidth)
case "cards":
return m.CardsView(detailContentWidth)
case "spend limit":
return m.SpendLimitView(detailContentWidth)
case "privacy":
return m.PrivacyView(detailContentWidth)
case "faq":
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
questionStyle := m.theme.TextHighlight().Bold(true)
contentStyle := m.theme.TextBody().Width(detailContentWidth)
faqContent := ""
@@ -98,7 +79,7 @@ func (m Model) getAccountDetailContent() string {
faqContent += "\n\n"
}
}
detailView := faqContent
detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent
if !m.account.faqFocused {
hintStyle := m.theme.TextDim()
detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
@@ -122,25 +103,19 @@ func (m Model) scrollToAccountDetailItem() Model {
switch {
case m.account.orderViewState == 1:
// Order list uses whole-card windowing instead of viewport scroll:
// shift the window just enough to keep the cursor's card visible.
page := m.orderListPageSize()
if m.account.orderCursor < m.account.orderWindowStart {
m.account.orderWindowStart = m.account.orderCursor
}
if m.account.orderCursor >= m.account.orderWindowStart+page {
m.account.orderWindowStart = m.account.orderCursor - page + 1
}
m.account.detailViewport.GotoTop()
return m
case m.account.addressListFocused:
// Titles were removed from the detail views, lists start at the top
// No title in list mode, cards joined with JoinVertical (no gaps)
// Card: border(2) + content(2) + PaddingLeft only = 4 lines
headerOffset = 0
itemHeight = 4
itemCount = len(m.currentOrderList())
selectedIndex = m.account.orderCursor
case m.account.addressListFocused:
headerOffset = 3
itemHeight = 5
itemCount = len(m.SavedAddresses)
selectedIndex = m.account.addressCursor
case m.account.cardListFocused:
headerOffset = 0
headerOffset = 3
itemHeight = 4
itemCount = len(m.SavedCards)
selectedIndex = m.account.cardCursor
@@ -168,30 +143,23 @@ func (m Model) scrollToAccountDetailItem() Model {
return m
}
func (m Model) BuildAccountView() string {
_, menuW, _, _ := m.shopLayout()
func (m Model) BuildAccountView(availableHeight int) string {
leftWidth := 18
// Left panel: menu items. The highlight is blue while the user navigates
// the menu, and falls back to the gray pill when focus has moved into the
// detail area (browsing orders, managing addresses/cards, scrolling faq).
focusedInside := m.account.orderViewState > 0 || m.account.addressListFocused ||
m.account.cardListFocused || m.account.faqFocused || m.account.spendLimitFocused
pillBg := m.theme.Highlight()
if focusedInside {
pillBg = cPill
}
var menu []string
// Build the left panel (menu items)
leftPanel := ""
for i, item := range models.AccountMenuItems {
if m.account.cursor == i {
menu = append(menu, lipgloss.NewStyle().Width(menuW).
Background(pillBg).Foreground(cWhite).Bold(true).
Render(" "+item))
style := m.theme.Base().Background(m.theme.Highlight()).Foreground(m.theme.Accent()).Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
leftPanel += style.Render(item) + "\n"
} else {
menu = append(menu, lipgloss.NewStyle().Width(menuW).
Render(" "+pBody.Render(item)))
style := m.theme.TextBody().Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
leftPanel += style.Render(item) + "\n"
}
}
menuContent := lipgloss.JoinVertical(lipgloss.Left, menu...)
leftContainer := m.theme.Base().
Width(leftWidth)
// Generate detail content and set it on the viewport
detailContent := m.getAccountDetailContent()
@@ -200,43 +168,25 @@ func (m Model) BuildAccountView() string {
// Viewport handles scrolling/clipping — no manual line-slicing needed
detailRendered := m.account.detailViewport.View()
// Responsive layout: menu panel beside the unboxed detail area on large,
// stacked on small/medium. The detail side draws its own item boxes.
var body string
// Responsive layout: side-by-side on large, stacked on small/medium
if m.size < large {
body = lipgloss.JoinVertical(
return lipgloss.JoinVertical(
lipgloss.Left,
panel(menuW, 0, menuContent),
detailRendered,
)
} else {
bodyH := m.accountBodyHeight()
body = lipgloss.JoinHorizontal(
lipgloss.Top,
panel(menuW, bodyH, menuContent),
" ",
leftContainer.Render(leftPanel),
detailRendered,
)
}
// Blank rows separate the body from the header and footer boxes. When the
// windowed order list has hidden items, the arrows occupy those gap rows,
// centered over the card column, so the cards themselves always span
// exactly the menu panel's rows — top and bottom borders aligned.
topGap := ""
if above := m.orderHiddenAbove(); above > 0 && m.size >= large {
topGap = strings.Repeat(" ", menuW+5) +
orderScrollIndicator("↑", above, m.accountDetailWidth())
}
bottomGap := ""
if below := m.orderHiddenBelow(); below > 0 && m.size >= large {
bottomGap = strings.Repeat(" ", menuW+5) +
orderScrollIndicator("↓", below, m.accountDetailWidth())
}
return topGap + "\n" + body + "\n" + bottomGap
return lipgloss.JoinHorizontal(
lipgloss.Top,
leftContainer.Render(leftPanel),
" ",
detailRendered,
)
}
func (m Model) AboutView(width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
// USE this if wanting to add a corp name later
// accentStyle := m.theme.TextHighlight().Bold(true)
@@ -258,47 +208,10 @@ func (m Model) AboutView(width int) string {
width,
)) + "\n"
return aboutContent
return titleStyle.Render("About") + "\n\n" + aboutContent
}
func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
// Async result of a spend-limit save (delivered here because the account
// page is active when the command resolves).
if saved, ok := msg.(spendLimitSavedMsg); ok {
m.account.spendLimitSaving = false
if saved.Err != nil {
m.account.spendLimitErr = friendlySpendLimitError(saved.Err)
return m, nil
}
if m.User != nil {
m.User.SelfLimitCents = saved.Cents
}
m.account.spendLimitFocused = false
m.account.spendLimitInput.Blur()
m.account.spendLimitErr = ""
m.footer = defaultAccountFooter()
m.notice = &VisibleNotice{message: "Spend limit updated"}
return m, nil
}
if saved, ok := msg.(privacyModeSavedMsg); ok {
m.account.privacyModeSaving = false
if saved.Err != nil {
m.account.privacyModeErr = saved.Err.Error()
return m, nil
}
if m.User != nil {
m.User.PrivacyMode = saved.Enabled
}
m.account.privacyModeErr = ""
state := "off"
if saved.Enabled {
state = "on"
}
m.notice = &VisibleNotice{message: "Privacy mode " + state}
return m, nil
}
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
// Pass non-key messages to viewport (e.g. window resize)
@@ -307,24 +220,6 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
return m, cmd
}
// While the spend-limit input is focused it owns the keyboard: enter saves,
// esc cancels, everything else edits the field.
if m.account.spendLimitFocused {
switch keyMsg.String() {
case "esc":
m.account.spendLimitFocused = false
m.account.spendLimitInput.Blur()
m.account.spendLimitErr = ""
m.footer = defaultAccountFooter()
return m, nil
case "enter":
return m.submitSpendLimit()
}
var cmd tea.Cmd
m.account.spendLimitInput, cmd = m.account.spendLimitInput.Update(msg)
return m, cmd
}
selectedItem := ""
if m.account.cursor < len(models.AccountMenuItems) {
selectedItem = models.AccountMenuItems[m.account.cursor]
@@ -440,7 +335,6 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
case 0:
m.account.orderViewState = 1
m.account.orderCursor = 0
m.account.orderWindowStart = 0
m.account.detailViewport.GotoTop()
m.footer = []footerCommand{
{key: "j/k", value: "orders"},
@@ -483,28 +377,6 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
{key: "esc", value: "back"},
}
}
case "spend limit":
if !m.account.spendLimitFocused {
ti := textinput.New()
ti.Prompt = "$"
ti.Placeholder = "dollars (blank = no limit)"
ti.CharLimit = 12
// Prefill with the current limit so the user edits rather than retypes.
if m.User != nil && m.User.SelfLimitCents != nil {
ti.SetValue(formatCentsDollars(*m.User.SelfLimitCents))
}
cmd := ti.Focus()
m.account.spendLimitInput = ti
m.account.spendLimitFocused = true
m.account.spendLimitErr = ""
m.footer = []footerCommand{
{key: "enter", value: "save"},
{key: "esc", value: "cancel"},
}
return m, cmd
}
case "privacy":
return m.togglePrivacyMode()
case "faq":
if !m.account.faqFocused {
m.account.faqFocused = true
@@ -520,7 +392,14 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
}
case "esc":
accountDefaultFooter := defaultAccountFooter()
accountDefaultFooter := []footerCommand{
{key: "j/k", value: "navigate"},
{key: "enter", value: "select"},
{key: "s", value: "shop"},
{key: "c", value: "cart"},
{key: "?", value: "help"},
{key: "q", value: "quit"},
}
if m.account.addressDeleting != nil {
m.account.addressDeleting = nil
} else if m.account.addressListFocused {
@@ -560,240 +439,3 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
}
return m, nil
}
// spendLimitSavedMsg carries the result of an async SetSpendLimit call back to
// AccountUpdate. Cents is the value that was sent (echoed so the view can update
// without a refetch); Err is non-nil when the save failed.
type spendLimitSavedMsg struct {
Cents *int
Err error
}
// SpendLimitView renders the self-service spend-limit panel: the current limit,
// and — when focused — the editable input. Layout follows the label/box idiom
// used by the refund composer (pkg/tui/refund.go).
func (m Model) SpendLimitView(width int) string {
labelStyle := m.theme.TextLabel()
bodyStyle := m.theme.TextBody().Width(width)
hintStyle := m.theme.TextDim()
current := "no limit set"
if m.User != nil && m.User.SelfLimitCents != nil {
current = "$" + formatCentsDollars(*m.User.SelfLimitCents)
}
out := bodyStyle.Render(wordWrap(
"Set your own spending limit. It can only lower the cap on your account, never raise it. Leave blank to remove your limit; 0 blocks all orders.",
width,
)) + "\n\n"
out += labelStyle.Render("Current limit") + "\n"
out += m.theme.TextAccent().Render(current) + "\n\n"
if !m.account.spendLimitFocused {
return out + hintStyle.Render("enter: edit limit")
}
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(m.theme.Highlight()).
Width(max(1, width-2))
m.account.spendLimitInput.SetWidth(max(1, width-3))
out += labelStyle.Render("New limit (dollars)") + "\n"
out += box.Render(m.account.spendLimitInput.View())
switch {
case m.account.spendLimitSaving:
out += "\n" + m.theme.TextAccent().Render("saving...")
case m.account.spendLimitErr != "":
out += "\n" + m.theme.TextError().Render(m.account.spendLimitErr)
}
out += "\n" + hintStyle.Render("enter: save · esc: cancel")
return out
}
// submitSpendLimit validates the input and, if valid, fires the save command.
// Validation mirrors the server's write-boundary rules so the user gets instant
// feedback for the negative/non-numeric cases before any round-trip.
func (m Model) submitSpendLimit() (Model, tea.Cmd) {
cents, err := parseSpendLimitInput(m.account.spendLimitInput.Value())
if err != nil {
m.account.spendLimitErr = err.Error()
return m, nil
}
m.account.spendLimitErr = ""
m.account.spendLimitSaving = true
return m, m.setSpendLimitCmd(cents)
}
// setSpendLimitCmd calls the API off the UI thread and reports back via
// spendLimitSavedMsg. Mirrors the command shape of createRefundRequestCmd.
func (m Model) setSpendLimitCmd(cents *int) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil || m.User == nil {
return spendLimitSavedMsg{Err: fmt.Errorf("not authenticated")}
}
err := m.APIClient.SetSpendLimit(cents)
return spendLimitSavedMsg{Cents: cents, Err: err}
}
}
// privacyModeSavedMsg carries the result of an async SetPrivacyMode call back to
// AccountUpdate. Enabled echoes the value sent so the view updates without a
// refetch; Err is non-nil when the save failed.
type privacyModeSavedMsg struct {
Enabled bool
Err error
}
// PrivacyView renders the account-level privacy toggle ("keep as little as
// possible"). A soft default: the checkout can still override it per order.
func (m Model) PrivacyView(width int) string {
labelStyle := m.theme.TextLabel()
bodyStyle := m.theme.TextBody().Width(width)
hintStyle := m.theme.TextDim()
state := "off"
if m.User != nil && m.User.PrivacyMode {
state = "on"
}
// Privacy copy is split into short labelled sections instead of one wall of
// text, reusing the same labelStyle/bodyStyle pairing as the rest of the
// account view so it reads as scannable blocks.
sections := []struct{ title, body string }{
{
"Philosophy",
"Regardless of privacy mode, I never keep your full card number. Full stop.",
},
{
"Privacy disabled",
"Only the last 4 digits, card brand and a payment token is stored",
},
{
"Privacy enabled",
"With privacy mode ENABLED checkout uses a one-time card. All of the data is wiped the millisecond you pay",
},
{
"Your address",
"This one I have to keep. The law makes me, sometimes for years. So I store it offline, on my own network, where only I can reach it. Sorry about that.",
},
}
out := ""
for _, s := range sections {
out += labelStyle.Render(s.title) + "\n"
out += bodyStyle.Render(wordWrap(s.body, width)) + "\n\n"
}
out += labelStyle.Render("Privacy mode") + "\n"
out += m.theme.TextAccent().Render(state) + "\n\n"
switch {
case m.account.privacyModeSaving:
out += m.theme.TextAccent().Render("saving...") + "\n"
case m.account.privacyModeErr != "":
out += m.theme.TextError().Render(m.account.privacyModeErr) + "\n"
}
return out + hintStyle.Render("enter: toggle")
}
// togglePrivacyMode flips the account privacy default and fires the save command.
func (m Model) togglePrivacyMode() (Model, tea.Cmd) {
if m.account.privacyModeSaving {
return m, nil
}
current := m.User != nil && m.User.PrivacyMode
m.account.privacyModeErr = ""
m.account.privacyModeSaving = true
return m, m.setPrivacyModeCmd(!current)
}
// setPrivacyModeCmd calls the API off the UI thread and reports back via
// privacyModeSavedMsg. Mirrors setSpendLimitCmd.
func (m Model) setPrivacyModeCmd(on bool) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil || m.User == nil {
return privacyModeSavedMsg{Err: fmt.Errorf("not authenticated")}
}
err := m.APIClient.SetPrivacyMode(on)
return privacyModeSavedMsg{Enabled: on, Err: err}
}
}
// parseSpendLimitInput maps the raw dollar input to a *int of cents. Blank
// clears the limit (nil -> revert to admin/global). "0" is a real "block
// everything" ceiling, NOT a clear. Negative, non-numeric, or more-than-2-
// decimal input is rejected. Parsed as integer cents (dollars*100 + cents) with
// no float multiply, so an amount like 50.01 never loses a cent to binary
// rounding. The server still stores cents (api/handlers/account.go).
func parseSpendLimitInput(s string) (*int, error) {
s = strings.TrimSpace(s)
s = strings.TrimSpace(strings.TrimPrefix(s, "$"))
if s == "" {
return nil, nil
}
if strings.HasPrefix(s, "-") {
return nil, fmt.Errorf("limit cannot be negative")
}
whole, frac, hasDot := strings.Cut(s, ".")
if whole == "" {
whole = "0" // allow ".50"
}
dollars, err := strconv.Atoi(whole)
if err != nil {
return nil, fmt.Errorf("enter a dollar amount, e.g 25 or 25.50, or blank to clear")
}
cents := 0
if hasDot {
switch len(frac) {
case 0:
// trailing dot, e.g "25." -> treat as .00
case 1:
frac += "0" // "5" -> 50 cents
fallthrough
case 2:
cents, err = strconv.Atoi(frac)
if err != nil {
return nil, fmt.Errorf("enter a dollar amount, e.g 25 or 25.00, or blank to clear")
}
default:
return nil, fmt.Errorf("use at most two decimal places, e.g 25.00")
}
}
total := dollars*100 + cents
return &total, nil
}
// formatCentsDollars renders an integer cents value as a plain dollar string
// (no leading "$"), e.g. 2500 -> "25.00", 50 -> "0.50". Used for the input
// prefill and the current-limit display.
func formatCentsDollars(cents int) string {
return fmt.Sprintf("%d.%02d", cents/100, cents%100)
}
// defaultAccountFooter is the account-menu footer shown when no sub-view owns
// the keyboard. Extracted so the spend-limit save/cancel paths can restore it
// without duplicating the slice.
func defaultAccountFooter() []footerCommand {
return []footerCommand{
{key: "j/k", value: "navigate"},
{key: "enter", value: "select"},
{key: "s", value: "shop"},
{key: "c", value: "cart"},
{key: "?", value: "help"},
{key: "q", value: "quit"},
}
}
// friendlySpendLimitError turns a raw API error into one line of TUI copy.
// SELF_LIMIT_ABOVE_CAP is the one case worth calling out specifically.
func friendlySpendLimitError(err error) string {
if err == nil {
return ""
}
if strings.Contains(err.Error(), "SELF_LIMIT_ABOVE_CAP") {
return "limit can't exceed your account cap"
}
return "failed to save limit"
}

View File

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

View File

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

View File

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

View File

@@ -1,119 +0,0 @@
package tui
import (
"testing"
tea "charm.land/bubbletea/v2"
"terminalShop/pkg/models"
)
func TestParseSpendLimitInput(t *testing.T) {
cases := []struct {
in string
want int
wantNil bool
wantErr bool
}{
{"", 0, true, false}, // blank clears
{"0", 0, false, false}, // $0 is block-all, NOT a clear
{"25", 2500, false, false}, // whole dollars
{"25.50", 2550, false, false}, // dollars + cents
{"25.5", 2550, false, false}, // one decimal => tenths
{"0.05", 5, false, false}, // sub-dollar
{" $25.00 ", 2500, false, false}, // strips $ and whitespace
{".50", 50, false, false}, // leading dot
{"-5", 0, false, true}, // negative rejected
{"abc", 0, false, true}, // non-numeric rejected
{"25.999", 0, false, true}, // too many decimals rejected
}
for _, tc := range cases {
got, err := parseSpendLimitInput(tc.in)
if tc.wantErr {
if err == nil {
t.Errorf("%q: want error, got value %v", tc.in, got)
}
continue
}
if err != nil {
t.Errorf("%q: unexpected error: %v", tc.in, err)
continue
}
if tc.wantNil {
if got != nil {
t.Errorf("%q: want nil, got %d", tc.in, *got)
}
continue
}
if got == nil || *got != tc.want {
t.Errorf("%q: want %d, got %v", tc.in, tc.want, got)
}
}
}
func TestSetSpendLimitCmd_NilClient(t *testing.T) {
m := NewModel("test")
m.APIClient = nil
cmd := m.setSpendLimitCmd(nil)
if cmd == nil {
t.Fatal("setSpendLimitCmd returned nil cmd")
}
saved, ok := cmd().(spendLimitSavedMsg)
if !ok {
t.Fatalf("expected spendLimitSavedMsg, got %T", cmd())
}
if saved.Err == nil {
t.Error("expected error when API client is nil")
}
}
// TestSpendLimitFocusFlow: enter focuses the input and prefills from the user's
// current limit; esc cancels without mutating the stored limit.
func TestSpendLimitFocusFlow(t *testing.T) {
m := NewModel("test")
m = m.updateLayout(100, 40)
m, _ = m.AccountSwitch()
m.User = &models.User{ID: 1, SelfLimitCents: intp(2500)}
for i, item := range models.AccountMenuItems {
if item == "spend limit" {
m.account.cursor = i
}
}
m, _ = m.AccountUpdate(tea.KeyPressMsg{Code: tea.KeyEnter})
if !m.account.spendLimitFocused {
t.Fatal("enter did not focus the spend-limit input")
}
if got := m.account.spendLimitInput.Value(); got != "25.00" {
t.Errorf("prefill: want \"25.00\", got %q", got)
}
m, _ = m.AccountUpdate(tea.KeyPressMsg{Code: tea.KeyEsc})
if m.account.spendLimitFocused {
t.Error("esc did not blur the spend-limit input")
}
if m.User.SelfLimitCents == nil || *m.User.SelfLimitCents != 2500 {
t.Error("esc must not change the stored limit")
}
}
// TestSpendLimitSavedMsg: the async-result handler updates the local user and
// clears focus on success.
func TestSpendLimitSavedMsg(t *testing.T) {
m := NewModel("test")
m = m.updateLayout(100, 40)
m, _ = m.AccountSwitch()
m.User = &models.User{ID: 1}
m.account.spendLimitFocused = true
m.account.spendLimitSaving = true
m, _ = m.AccountUpdate(spendLimitSavedMsg{Cents: intp(750)})
if m.account.spendLimitFocused {
t.Error("successful save should blur the input")
}
if m.User.SelfLimitCents == nil || *m.User.SelfLimitCents != 750 {
t.Errorf("save should update local user limit to 750, got %v", m.User.SelfLimitCents)
}
}
func intp(i int) *int { return &i }

View File

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

View File

@@ -70,7 +70,7 @@ func (m Model) updateConfirmViewport() Model {
headerH := lipgloss.Height(m.BuildHeader())
breadH := lipgloss.Height(m.BuildBreadcrumbs())
footerH := lipgloss.Height(m.BuildFooter())
availH := m.heightContainer - headerH - footerH - breadH - 1
availH := m.heightContainer - headerH - footerH - breadH
if availH < 1 {
availH = 1
}

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ import (
"time"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"github.com/stripe/stripe-go/v78"
@@ -143,7 +142,10 @@ type Model struct {
}
type shopState struct {
selected int
selected int
menuViewport viewport.Model
detailViewport viewport.Model
viewportsReady bool
}
type splashState struct {
@@ -183,7 +185,6 @@ type accountState struct {
cursor int
orderViewState int
orderCursor int
orderWindowStart int // first fully-visible card in the windowed order list
orderYOffset int // saved scroll pos when entering order detail
faqFocused bool
addressListFocused bool
@@ -192,12 +193,6 @@ type accountState struct {
cardCursor int
addressDeleting *int
cardDeleting *int
spendLimitFocused bool
spendLimitInput textinput.Model
spendLimitSaving bool
spendLimitErr string
privacyModeSaving bool
privacyModeErr string
detailViewport viewport.Model
viewportReady bool
}
@@ -245,6 +240,7 @@ func (m Model) SwitchPage(p page) Model {
func (m Model) ShopSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(shopPage)
m = m.updateShopViewports()
m.footer = []footerCommand{
{key: "j/k", value: "products"},
{key: "+/-", value: "qty"},
@@ -269,12 +265,13 @@ func (m Model) CartSwitch() (Model, tea.Cmd) {
{key: "q", value: "quit"},
}
} else {
// Keep this list short enough to fit on one footer line.
m.footer = []footerCommand{
{key: "j/k", value: "items"},
{key: "+/-", value: "qty"},
{key: "enter", value: "checkout"},
{key: "p/enter", value: "checkout"},
{key: "s", value: "shop"},
{key: "a", value: "account"},
{key: "pgup/pgdn", value: "scroll"},
{key: "?", value: "help"},
{key: "q", value: "quit"},
}
@@ -683,51 +680,6 @@ func (m Model) checkoutWithSavedCard() tea.Cmd {
}
}
// checkoutEphemeralCmd tokenizes the entered card and charges it as a one-time,
// never-saved card (privacy checkout). Mirrors checkoutWithSavedCard but routes
// to ConvertCartEphemeral instead of saving the card + SetCartCard. The token is
// produced the same way as saveCardOnlyCmd (stripetoken.New) and handed straight
// to the charge — no Card row, no SelectedCard, and the backend detaches the
// PaymentMethod once the charge settles.
func (m Model) checkoutEphemeralCmd(form PaymentFormCompleteMsg) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil {
return CheckoutResultMsg{Err: fmt.Errorf("API client not available")}
}
if err := m.resolveCartAddress(); err != nil {
return CheckoutResultMsg{Err: err}
}
tok, err := stripetoken.New(&stripe.TokenParams{
Card: &stripe.CardParams{
Name: stripe.String(form.CardName),
Number: stripe.String(form.CardNumber),
ExpMonth: stripe.String(form.ExpiryMonth),
ExpYear: stripe.String(form.ExpiryYear),
CVC: stripe.String(form.CVC),
AddressZip: stripe.String(form.BillingZip),
},
})
if err != nil {
if stripeErr, ok := err.(*stripe.Error); ok {
return CheckoutResultMsg{Err: fmt.Errorf("%s", stripeErr.Msg)}
}
return CheckoutResultMsg{Err: fmt.Errorf("failed to tokenize card: %w", err)}
}
outcome, err := m.APIClient.ConvertCartEphemeral(tok.ID)
if err != nil {
return CheckoutResultMsg{Err: err}
}
if outcome.RequiresAction {
return CheckoutResultMsg{
OrderID: outcome.OrderID,
RequiresAction: true,
RedirectURL: outcome.RedirectURL,
}
}
return CheckoutResultMsg{OrderID: outcome.Order.ID, Total: outcome.Order.Total}
}
}
// schedulePollOrderStatus sleeps 2s then polls /orders/{id}/status. Used while
// waiting for the customer to complete 3DS authentication.
func (m Model) schedulePollOrderStatus(orderID uint) tea.Cmd {
@@ -889,7 +841,6 @@ func NewModel(username string) Model {
{
Name: "Espresso",
RoastType: "Dark Roast",
RoastLevel: 5,
Ounces: 2,
BeanType: "Arabica",
Price: 350, // $3.50
@@ -899,7 +850,6 @@ func NewModel(username string) Model {
{
Name: "Latte",
RoastType: "Medium Roast",
RoastLevel: 2,
Ounces: 12,
BeanType: "Arabica Blend",
Price: 500, // $5.00
@@ -909,7 +859,6 @@ func NewModel(username string) Model {
{
Name: "Cappuccino",
RoastType: "Medium Roast",
RoastLevel: 3,
Ounces: 8,
BeanType: "Italian Blend",
Price: 450, // $4.50
@@ -919,7 +868,6 @@ func NewModel(username string) Model {
{
Name: "Americano",
RoastType: "Dark Roast",
RoastLevel: 4,
Ounces: 16,
BeanType: "Arabica",
Price: 400, // $4.00
@@ -929,7 +877,6 @@ func NewModel(username string) Model {
{
Name: "Mocha",
RoastType: "Medium Roast",
RoastLevel: 3,
Ounces: 16,
BeanType: "Colombian",
Price: 550, // $5.50
@@ -939,7 +886,6 @@ func NewModel(username string) Model {
{
Name: "Macchiato",
RoastType: "Dark Roast",
RoastLevel: 4,
Ounces: 3,
BeanType: "Robusta Blend",
Price: 425, // $4.25
@@ -984,6 +930,10 @@ func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clien
return m
}
func newViewport(w, h int) viewport.Model {
return viewport.New(viewport.WithWidth(w), viewport.WithHeight(h))
}
func (m Model) collectCardCmd() tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil {

View File

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

View File

@@ -26,7 +26,6 @@ type PaymentFormState struct {
ExpiryYear string
CVC string
BillingZip string
SaveCard bool
submitting bool
}
@@ -39,7 +38,6 @@ type PaymentFormCompleteMsg struct {
ExpiryYear string
CVC string
BillingZip string
SaveCard bool
}
// CardSavedForReviewMsg is sent after a new card is tokenized and saved
@@ -79,13 +77,6 @@ type PollPaymentTimeoutMsg struct{}
// buildPaymentForm creates the huh form bound to state's fields.
// No field-level validators so Tab/Enter move freely.
func (m Model) buildPaymentForm(state *PaymentFormState) *huh.Form {
// The save prompt doubles as the privacy "save anyway?" warning: when the
// account privacy default is on, the affirmative choice opposes it, so the
// title says so. The user can still pick Save (the per-order override).
savePrompt := "Save this card for next time?"
if m.User != nil && m.User.PrivacyMode {
savePrompt = "Privacy mode is on — saving keeps this card. Save anyway?"
}
f := huh.NewForm(
huh.NewGroup(
huh.NewInput().
@@ -118,12 +109,6 @@ func (m Model) buildPaymentForm(state *PaymentFormState) *huh.Form {
Title("Billing ZIP").
Key("billing_zip").
Value(&state.BillingZip),
huh.NewConfirm().
Key("save_card").
Title(savePrompt).
Affirmative("Save").
Negative("Don't save").
Value(&state.SaveCard),
),
).
WithShowErrors(false).
@@ -143,11 +128,6 @@ func (m Model) buildPaymentForm(state *PaymentFormState) *huh.Form {
func (m Model) InitPaymentForm() *PaymentFormState {
state := &PaymentFormState{}
// Default the save choice from the account privacy setting: privacy users
// default to NOT saving (a one-time card); everyone else keeps today's
// behaviour of saving the entered card.
state.SaveCard = m.User == nil || !m.User.PrivacyMode
// Pre-fill name and email from user if available
if m.User != nil {
if m.User.Name != "" {
@@ -261,7 +241,6 @@ func (m Model) UpdatePaymentForm(msg tea.Msg, state *PaymentFormState) tea.Cmd {
ExpiryYear: state.ExpiryYear,
CVC: state.CVC,
BillingZip: state.BillingZip,
SaveCard: state.SaveCard,
}
}
}
@@ -296,8 +275,10 @@ func (m Model) updatePaymentViewport() Model {
headerH := lipgloss.Height(m.BuildHeader())
breadH := lipgloss.Height(m.BuildBreadcrumbs())
footerH := lipgloss.Height(m.BuildFooter())
availH := m.heightContainer - headerH - footerH - breadH - 1
availH = max(availH, 1)
availH := m.heightContainer - headerH - footerH - breadH
if availH < 1 {
availH = 1
}
if !m.payment.viewportReady {
m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
m.payment.viewport.KeyMap = viewport.KeyMap{}
@@ -322,7 +303,7 @@ func (m Model) PaymentPageView() string {
m.widthContainer,
m.payment.viewport.Height(),
lipgloss.Center, lipgloss.Center,
m.renderPaymentHTTPSView(),
m.renderPaymentHttpsView(),
)
} else if m.payment.view == 0 && m.payment.form == nil {
content = m.RenderCardList()
@@ -393,7 +374,7 @@ func (m Model) RenderCardList() string {
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
func (m Model) renderPaymentHTTPSView() string {
func (m Model) renderPaymentHttpsView() string {
baseStyle := m.theme.TextLabel()
accentStyle := m.theme.TextHighlight()
@@ -440,16 +421,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
case PaymentFormCompleteMsg:
if m.payment.form != nil && !m.payment.form.submitting {
m.payment.form.submitting = true
if msg.SaveCard {
return m, m.saveCardOnlyCmd(msg)
}
// Privacy checkout: charge a one-time card without saving it. Jump to
// the review page in checking-out state so ReviewUpdate handles the
// resulting CheckoutResultMsg (same terminal handling as the saved-card
// path), then forget the card.
m, _ = m.ReviewSwitch()
m.CheckingOut = true
return m, m.checkoutEphemeralCmd(msg)
return m, m.saveCardOnlyCmd(msg)
}
return m, nil

View File

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

View File

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

View File

@@ -257,7 +257,7 @@ func (m Model) updateShippingViewport() Model {
headerH := lipgloss.Height(m.BuildHeader())
breadH := lipgloss.Height(m.BuildBreadcrumbs())
footerH := lipgloss.Height(m.BuildFooter())
availH := m.heightContainer - headerH - footerH - breadH - 1
availH := m.heightContainer - headerH - footerH - breadH
if availH < 1 {
availH = 1
}

View File

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

View File

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

View File

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

View File

@@ -93,8 +93,4 @@ func (t Theme) PanelError() lipgloss.Style {
return t.Base().Background(lipgloss.Color("52")).Foreground(lipgloss.Color("196"))
}
func (t Theme) PanelNotice() lipgloss.Style {
return t.Base().Background(lipgloss.Color("#009000")).Foreground(lipgloss.Color("#00FF00"))
}
func (t Theme) Form() huh.Theme { return t.form }

View File

@@ -16,6 +16,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if tick.seq == m.resizeSeq {
m = m.updateLayout(m.pendingWidth, m.pendingHeight)
switch m.currentPage {
case shopPage:
m = m.updateShopViewports()
case cartPage:
m = m.updateCartViewport()
case shippingPage:
@@ -98,11 +100,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if msg.Data.User.ID != 0 {
u := models.User{
ID: msg.Data.User.ID,
Name: msg.Data.User.Email,
Email: msg.Data.User.Email,
SelfLimitCents: msg.Data.User.SelfLimitCents,
PrivacyMode: msg.Data.User.PrivacyMode,
ID: msg.Data.User.ID,
Name: msg.Data.User.Email,
Email: msg.Data.User.Email,
}
m.User = &u
m.Username = u.Name
@@ -178,11 +178,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.shipping.form != nil || m.payment.form != nil {
break
}
// spend-limit input owns keys while focused - skip global nav so a
// keystroke edits the field instead of switching pages (s/c/a/q/etc...)
if m.account.spendLimitFocused {
break
}
// Refund overlay owns input when open; dispatched below before page handlers.
if m.refund.open {
break

View File

@@ -62,13 +62,11 @@ func (m Model) renderString() string {
if breadcrumbs != "" {
breadcrumbsHeight = lipgloss.Height(breadcrumbs)
}
footer := m.BuildFooter()
footerHeight := lipgloss.Height(footer)
footerHeight := 1
marginTop := 1
marginBottom := 1
bufferSpace := 1
bannerHeight := 1
reservedHeight := bannerHeight + headerHeight + breadcrumbsHeight + footerHeight + marginBottom + marginTop + bufferSpace
reservedHeight := headerHeight + breadcrumbsHeight + footerHeight + marginTop + marginBottom + bufferSpace
availableContentHeight := m.heightContainer - reservedHeight
// Ensure minimum content height
@@ -82,20 +80,23 @@ func (m Model) renderString() string {
if m.Loading {
loadingStyle := m.theme.TextLoading().Padding(2, 4)
content = loadingStyle.Render("Loading products from API...")
} else if m.error != nil {
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
// soft-wrap long messages instead of clipping.
if m.widthContent > 4 {
errorStyle = errorStyle.Width(m.widthContent - 4)
}
errorBanner := errorStyle.Render(m.error.message)
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
} else if m.notice != nil {
noticeStyle := m.theme.TextSuccess().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Success()).Padding(0, 1).MarginBottom(1)
noticeBanner := noticeStyle.Render(m.notice.message)
content = noticeBanner + "\n" + m.buildPageContent(availableContentHeight)
} else {
content = m.buildPageContent()
content = m.buildPageContent(availableContentHeight)
}
bannerW := lipgloss.Width(header) - 2
var topBanner string
switch {
case m.error != nil:
topBanner = m.theme.PanelError().Width(bannerW).MaxWidth(bannerW).Render(m.error.message)
case m.notice != nil:
topBanner = m.theme.PanelNotice().Width(bannerW).MaxWidth(bannerW).Render(m.notice.message)
default:
topBanner = lipgloss.NewStyle().Width(m.widthContent).Render("")
}
footer := m.BuildFooter()
isViewportPage := m.currentPage == shopPage || m.currentPage == cartPage ||
m.currentPage == shippingPage || m.currentPage == paymentPage ||
@@ -104,21 +105,20 @@ func (m Model) renderString() string {
var child string
if isViewportPage {
if breadcrumbs != "" {
child = lipgloss.JoinVertical(lipgloss.Left, topBanner, header, breadcrumbs, content, footer)
child = lipgloss.JoinVertical(lipgloss.Left, header, breadcrumbs, content, footer)
} else {
child = lipgloss.JoinVertical(lipgloss.Left, topBanner, header, content, footer)
child = lipgloss.JoinVertical(lipgloss.Left, header, content, footer)
}
} else if breadcrumbs != "" {
contentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content)
child = lipgloss.JoinVertical(lipgloss.Left, topBanner, header, breadcrumbs, contentWithPadding, footer)
child = lipgloss.JoinVertical(lipgloss.Left, header, breadcrumbs, contentWithPadding, footer)
} else {
contentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content)
child = lipgloss.JoinVertical(lipgloss.Left, topBanner, header, contentWithPadding, footer)
child = lipgloss.JoinVertical(lipgloss.Left, header, contentWithPadding, footer)
}
// Constrain the assembled layout to the container dimensions
constrained := lipgloss.NewStyle().
Width(m.widthContainer).
MaxWidth(m.widthContainer).
MaxHeight(m.heightContainer).
Render(child)
@@ -137,10 +137,10 @@ func (m Model) renderString() string {
)
}
func (m Model) buildPageContent() string {
func (m Model) buildPageContent(height int) string {
switch m.currentPage {
case accountPage:
return m.BuildAccountView()
return m.BuildAccountView(height)
case shippingPage:
return m.ShippingPageView()
case paymentPage:

View File

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

View File

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