Compare commits

...

4 Commits

Author SHA1 Message Date
158a09b73d feat(sca): charge checkout as customer-initiated transaction
Drop off_session from the initial PaymentIntent so the checkout charge is a Customer-Initiated Transaction (the customer is present in the TUI), not a Merchant-Initiated Transaction. MIT exemptions require a stored mandate we never capture; as a CIT, an SCA challenge surfaces as status=requires_action, the path the handler already had. The re-confirm in respondRequiresAction and the authentication_required error branch remain as defensive fallbacks. Source: stripe-samples accept-a-payment custom-payment-flow (no off_session on customer-present confirms).

Also clarify the MAX_ORDER_CENTS TRA-ceiling anchor comment as an approximate USD/EUR figure (config.go), and carry an unrelated 1-line spend-limit input width fix in pkg/tui/account.go.

Tests: add paymentIntentNewFn stub seam, TestConvertCartIsCustomerInitiated (asserts no off_session) and TestConvertCartRequiresAction (202 + redirect + audit row).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:37:17 +02:00
0c0b8e8059 fix(tui): accept spend limit input in dollars (#56)
Spend-limit input now takes dollars (e.g. 25.50) instead of raw cents.
Parsed to integer cents as dollars*100 + cents (no float multiply) so an
amount like 50.01 never loses a cent to binary rounding; the persisted
column and API still use cents. Current limit and prefill render via
formatCentsDollars. Tests updated to the dollar grammar.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:34:03 +02:00
7148ff1d4d feat(sca): add customer-set spend limit (#55) 2026-06-17 13:25:04 +02:00
22eeeeeac1 feat(sca): enforce user self-limit as lower-only cap in convert-cart (#54) 2026-06-17 10:24:55 +02:00
14 changed files with 901 additions and 42 deletions

111
api/handlers/account.go Normal file
View File

@@ -0,0 +1,111 @@
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,
})
}

View File

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

@@ -36,6 +36,11 @@ 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
// 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 {
@@ -399,7 +404,21 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
}
}
if capCents > 0 && total > capCents {
// 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 {
middleware.RecordCartConversion("validation_over_limit")
audit.CartRejected(userID, total, capCents)
utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT",
@@ -446,14 +465,24 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
audit.OrderCreated(userID, order.ID, total)
// Charge the card. The idempotency key is tied to the order ID so retries
// never produce a double charge.
// 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.
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),
@@ -476,11 +505,16 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
}
stripeStart := time.Now()
pi, err := paymentintent.New(piParams)
pi, err := paymentIntentNewFn(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

View File

@@ -680,31 +680,48 @@ func TestConvertCartLimit(t *testing.T) {
capCents int
quantity int
expectOverCap bool
userCap *int // per-user override; nil = inherit the global capCents
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}, // $205 vs global $200
{"at cap accepts", 20000, 40, false, nil}, // $200 boundary
{"under cap accepts", 20000, 39, false, nil}, // $195
{"zero cap disables", 0, 1000, false, nil}, // explicit global off-switch
// Per-user override (User.MaxOrderCents) takes precedence over the global.
{"user override below global rejects", 20000, 12, true, intPtr(5000)}, // $60 > $50 user cap (global $200 would allow)
{"user override above global accepts", 20000, 41, false, intPtr(50000)}, // $205 < $500 user cap (global $200 would reject)
{"user override zero disables per user", 20000, 1000, false, intPtr(0)}, // per-user off-switch over a live global
{"nil override falls back to global", 20000, 41, true, nil}, // explicit nil == inherit global $200
{"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)},
{"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)
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Effective cap the handler should enforce: a non-negative override
// wins; nil or a negative value both fall back to the global cap.
// 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) }()
@@ -713,6 +730,7 @@ func TestConvertCartLimit(t *testing.T) {
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)
}
@@ -844,6 +862,171 @@ func TestConvertCartLimit(t *testing.T) {
}
}
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

@@ -54,6 +54,7 @@ func SetupRoutes(
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)
@@ -105,6 +106,11 @@ 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)
// Cart
r.Route("/cart", func(r chi.Router) {
r.Get("/", cartHandler.GetCart)

View File

@@ -453,6 +453,44 @@ 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
}
// ClearCart removes all items from the user's cart.
func (c *Client) ClearCart() error {
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL)

View File

@@ -51,6 +51,7 @@ const (
eventOrderDelivered eventType = "order_delivered"
eventTrackingMarkedManually eventType = "tracking_marked_manually"
eventUserOrderCapSet eventType = "user_order_cap_set"
eventUserSelfLimitSet eventType = "user_self_limit_set"
)
type event struct {
@@ -401,3 +402,26 @@ func UserOrderCapSet(actor string, userID uint, capCents *int) {
}
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()...)
}

View File

@@ -138,11 +138,14 @@ func getEnvOrDefault(key, defaultValue string) string {
return defaultValue
}
// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $500 — aligns with the
// PSD2 TRA exemption ceiling (above this, Stripe always requests SCA, so the
// cap is only meaningful below it). Explicit 0 disables the cap. Garbage or
// negative values fall back to the default with a warning so an operator
// typo cannot silently disable a fraud control.
// 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.
func loadMaxOrderCents() int {
const defaultCap = 50000
raw := os.Getenv("MAX_ORDER_CENTS")

View File

@@ -33,6 +33,7 @@ var AccountMenuItems = []string{
"order history",
"addresses",
"cards",
"spend limit",
"faq",
"about",
}

View File

@@ -23,10 +23,11 @@ type User struct {
// 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:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
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:"-"`
}
// TableName specifies the table name for the User model
@@ -40,6 +41,7 @@ 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"`
CreatedAt time.Time `json:"created_at"`
}
@@ -50,6 +52,7 @@ func (u *User) ToPublic() PublicUser {
SSHKeyFingerprint: u.SSHKeyFingerprint,
Name: u.Name,
Email: u.Email,
SelfLimitCents: u.SelfLimitCents,
CreatedAt: u.CreatedAt,
}
}

View File

@@ -1,8 +1,11 @@
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"
@@ -19,9 +22,7 @@ func (m Model) accountBodyHeight() int {
footerHeight := lipgloss.Height(m.BuildFooter())
budget := m.heightContainer - headerHeight - footerHeight - 2
cards := budget / orderCardHeight
if cards < 1 {
cards = 1
}
cards = max(cards, 1)
return cards*orderCardHeight - 2
}
@@ -82,6 +83,8 @@ func (m Model) getAccountDetailContent() string {
return m.AddressesView(detailContentWidth)
case "cards":
return m.CardsView(detailContentWidth)
case "spend limit":
return m.SpendLimitView(detailContentWidth)
case "faq":
questionStyle := m.theme.TextHighlight().Bold(true)
contentStyle := m.theme.TextBody().Width(detailContentWidth)
@@ -170,7 +173,7 @@ func (m Model) BuildAccountView() string {
// 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.cardListFocused || m.account.faqFocused || m.account.spendLimitFocused
pillBg := m.theme.Highlight()
if focusedInside {
pillBg = cPill
@@ -257,6 +260,25 @@ func (m Model) AboutView(width int) string {
}
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
}
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
// Pass non-key messages to viewport (e.g. window resize)
@@ -265,6 +287,24 @@ 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]
@@ -423,6 +463,26 @@ 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 "faq":
if !m.account.faqFocused {
m.account.faqFocused = true
@@ -438,14 +498,7 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
}
case "esc":
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"},
}
accountDefaultFooter := defaultAccountFooter()
if m.account.addressDeleting != nil {
m.account.addressDeleting = nil
} else if m.account.addressListFocused {
@@ -485,3 +538,158 @@ 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}
}
}
// 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"
}

119
pkg/tui/account_test.go Normal file
View File

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

@@ -8,6 +8,7 @@ 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"
@@ -191,6 +192,10 @@ type accountState struct {
cardCursor int
addressDeleting *int
cardDeleting *int
spendLimitFocused bool
spendLimitInput textinput.Model
spendLimitSaving bool
spendLimitErr string
detailViewport viewport.Model
viewportReady bool
}

View File

@@ -98,9 +98,10 @@ 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,
ID: msg.Data.User.ID,
Name: msg.Data.User.Email,
Email: msg.Data.User.Email,
SelfLimitCents: msg.Data.User.SelfLimitCents,
}
m.User = &u
m.Username = u.Name
@@ -176,6 +177,11 @@ 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