From 26cf21ce2e0627ce440b190536c909fab3f8c2ca Mon Sep 17 00:00:00 2001 From: marcusDenslow Date: Wed, 17 Jun 2026 13:17:48 +0200 Subject: [PATCH] feat(sca): add customer-set spend limit --- api/handlers/account.go | 111 ++++++++++++++++++++ api/handlers/account_test.go | 118 +++++++++++++++++++++ api/routes/routes.go | 6 ++ pkg/api/client.go | 38 +++++++ pkg/audit/audit.go | 24 +++++ pkg/models/coffee.go | 1 + pkg/models/user.go | 2 + pkg/tui/account.go | 193 +++++++++++++++++++++++++++++++++-- pkg/tui/account_test.go | 115 +++++++++++++++++++++ pkg/tui/model.go | 5 + pkg/tui/update.go | 12 ++- 11 files changed, 613 insertions(+), 12 deletions(-) create mode 100644 api/handlers/account.go create mode 100644 api/handlers/account_test.go create mode 100644 pkg/tui/account_test.go diff --git a/api/handlers/account.go b/api/handlers/account.go new file mode 100644 index 0000000..ecf8aed --- /dev/null +++ b/api/handlers/account.go @@ -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": }. +// - 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, + }) +} diff --git a/api/handlers/account_test.go b/api/handlers/account_test.go new file mode 100644 index 0000000..c05ca19 --- /dev/null +++ b/api/handlers/account_test.go @@ -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") +} diff --git a/api/routes/routes.go b/api/routes/routes.go index 41af750..ce2576b 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -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) diff --git a/pkg/api/client.go b/pkg/api/client.go index 1c49728..82b7d6f 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -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) diff --git a/pkg/audit/audit.go b/pkg/audit/audit.go index 980be60..2fe80dc 100644 --- a/pkg/audit/audit.go +++ b/pkg/audit/audit.go @@ -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()...) +} diff --git a/pkg/models/coffee.go b/pkg/models/coffee.go index fd38a3e..4174879 100644 --- a/pkg/models/coffee.go +++ b/pkg/models/coffee.go @@ -33,6 +33,7 @@ var AccountMenuItems = []string{ "order history", "addresses", "cards", + "spend limit", "faq", "about", } diff --git a/pkg/models/user.go b/pkg/models/user.go index b797c8e..dc4c6ec 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -41,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"` } @@ -51,6 +52,7 @@ func (u *User) ToPublic() PublicUser { SSHKeyFingerprint: u.SSHKeyFingerprint, Name: u.Name, Email: u.Email, + SelfLimitCents: u.SelfLimitCents, CreatedAt: u.CreatedAt, } } diff --git a/pkg/tui/account.go b/pkg/tui/account.go index 62cc47f..9ec9fb1 100644 --- a/pkg/tui/account.go +++ b/pkg/tui/account.go @@ -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" @@ -82,6 +85,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 +175,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 +262,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 +289,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 +465,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 = "cents (blank = no limit)" + ti.CharLimit = 9 + // Prefill with the current limit so the user edits rather than retypes. + if m.User != nil && m.User.SelfLimitCents != nil { + ti.SetValue(strconv.Itoa(*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 +500,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 +540,123 @@ 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 { + c := *m.User.SelfLimitCents + current = fmt.Sprintf("$%.2f (%d cents)", float64(c)/100, c) + } + + 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)) + out += labelStyle.Render("New limit (cents)") + "\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 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 or non-numeric input is rejected. These rules +// match the server boundary in api/handlers/account.go:SetSpendLimit. +func parseSpendLimitInput(s string) (*int, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, nil + } + n, err := strconv.Atoi(s) + if err != nil { + return nil, fmt.Errorf("enter a whole number of cents, or blank to clear") + } + if n < 0 { + return nil, fmt.Errorf("limit cannot be negative") + } + return &n, nil +} + +// 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" +} diff --git a/pkg/tui/account_test.go b/pkg/tui/account_test.go new file mode 100644 index 0000000..93f2a2f --- /dev/null +++ b/pkg/tui/account_test.go @@ -0,0 +1,115 @@ +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 + {"1500", 1500, false, false}, // plain value + {" 250 ", 250, false, false}, // trims whitespace + {"-5", 0, false, true}, // negative rejected + {"abc", 0, false, true}, // non-numeric rejected + {"12.5", 0, false, true}, // non-integer 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 != "2500" { + t.Errorf("prefill: want \"2500\", 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 } diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 88e95f2..964ef9f 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -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 } diff --git a/pkg/tui/update.go b/pkg/tui/update.go index 5981dd3..465016b 100644 --- a/pkg/tui/update.go +++ b/pkg/tui/update.go @@ -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