Compare commits
9 Commits
feat/roast
...
feat/sca-s
| Author | SHA1 | Date | |
|---|---|---|---|
| 26cf21ce2e | |||
| 22eeeeeac1 | |||
| aa446cdfa5 | |||
| 8df1058932 | |||
| 64b6f0d91a | |||
| 5dd196834f | |||
| e85dc7fd4d | |||
| ff562c7427 | |||
| aa34043e9b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@
|
||||
|
||||
docs/sca-psd2-compliance.md
|
||||
accept-a-payment/
|
||||
codemap/
|
||||
|
||||
|
||||
FULFILLMENT.md
|
||||
|
||||
3
api/doc.go
Normal file
3
api/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Command api is the HTTP backend: it boots the router, bolts on all the
|
||||
// middleware, and answers the requests the TUI throws at it.
|
||||
package main
|
||||
111
api/handlers/account.go
Normal file
111
api/handlers/account.go
Normal 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,
|
||||
})
|
||||
}
|
||||
118
api/handlers/account_test.go
Normal file
118
api/handlers/account_test.go
Normal 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")
|
||||
}
|
||||
97
api/handlers/admin_users.go
Normal file
97
api/handlers/admin_users.go
Normal file
@@ -0,0 +1,97 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
194
api/handlers/admin_users_test.go
Normal file
194
api/handlers/admin_users_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -380,14 +381,46 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
total := subtotal + cart.ShippingCost
|
||||
|
||||
if h.maxOrderCents > 0 && total > h.maxOrderCents {
|
||||
// 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 {
|
||||
middleware.RecordCartConversion("validation_over_limit")
|
||||
audit.CartRejected(userID, total, h.maxOrderCents)
|
||||
audit.CartRejected(userID, total, capCents)
|
||||
utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT",
|
||||
fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100),
|
||||
fmt.Sprintf("order total must be at most $%.2f", float64(capCents)/100),
|
||||
map[string]any{
|
||||
"total_cents": total,
|
||||
"limit_cents": h.maxOrderCents,
|
||||
"limit_cents": capCents,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -670,27 +670,67 @@ 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}, // $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
|
||||
{"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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -780,8 +820,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) != tc.capCents {
|
||||
t.Errorf("limit_cents in details: want %d, got %v", tc.capCents, resp.Error.Details["limit_cents"])
|
||||
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["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"])
|
||||
@@ -791,12 +831,22 @@ 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
|
||||
if err := json.Unmarshal(auditBuf.Bytes(), &rec); err != nil {
|
||||
t.Fatalf("audit: decode slog record: %v\nraw: %s", err, auditBuf.String())
|
||||
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 got := rec["event"]; got != "cart_rejected" {
|
||||
t.Errorf("audit event: want cart_rejected, got %v", got)
|
||||
if rec == nil {
|
||||
t.Fatalf("audit: no cart_rejected slog record found\nraw: %s", auditBuf.String())
|
||||
}
|
||||
if n, _ := rec["user_id"].(float64); uint(n) != user.ID {
|
||||
t.Errorf("audit user_id: want %d, got %v", user.ID, rec["user_id"])
|
||||
@@ -804,8 +854,8 @@ 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) != tc.capCents {
|
||||
t.Errorf("audit cap_cents: want %d, got %v", tc.capCents, rec["cap_cents"])
|
||||
if n, _ := rec["cap_cents"].(float64); int(n) != effectiveCap {
|
||||
t.Errorf("audit cap_cents: want %d, got %v", effectiveCap, rec["cap_cents"])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
3
api/handlers/doc.go
Normal file
3
api/handlers/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package handlers is where the real work happens, one function per endpoint:
|
||||
// carts, orders, products, addresses, cards, webhooks, and the rest.
|
||||
package handlers
|
||||
@@ -225,6 +225,23 @@ 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) {
|
||||
|
||||
@@ -518,3 +518,81 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -52,8 +53,17 @@ func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Verify the Stripe signature. Reject anything that doesn't match.
|
||||
if h.webhookSecret == "" {
|
||||
// No secret configured — log a loud warning but still process in dev.
|
||||
webhookLog().Warn("stripe webhook secret not set; skipping signature verification")
|
||||
// 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)")
|
||||
} else {
|
||||
sig := r.Header.Get("Stripe-Signature")
|
||||
if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{
|
||||
@@ -124,6 +134,20 @@ 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,
|
||||
|
||||
@@ -222,3 +222,38 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
72
api/main.go
72
api/main.go
@@ -25,6 +25,25 @@ 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)
|
||||
@@ -106,20 +125,8 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Catch orders charged but not recorded due to a crash. Every 5 minutes
|
||||
everyTick(ctx, 5*time.Minute, handlers.ReconcileOrders)
|
||||
|
||||
go func() {
|
||||
select {
|
||||
@@ -142,33 +149,18 @@ 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.
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileExpiredCards(ctx)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
// detach + audit + physical row delete asynchronously. Hourly.
|
||||
everyTick(ctx, 1*time.Hour, handlers.ReconcileExpiredCards)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}()
|
||||
// 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)
|
||||
|
||||
log.Println("API server started. Press Ctrl+C to shutdown.")
|
||||
|
||||
|
||||
92
api/main_test.go
Normal file
92
api/main_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
4
api/middleware/doc.go
Normal file
4
api/middleware/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// Package middleware holds the stuff that runs on every request before a
|
||||
// handler ever sees it: auth, CORS, logging, rate limiting, metrics, and a
|
||||
// recovery net for panics.
|
||||
package middleware
|
||||
3
api/routes/doc.go
Normal file
3
api/routes/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package routes is the switchboard: it maps every URL to the handler that
|
||||
// knows what to do with it.
|
||||
package routes
|
||||
@@ -53,6 +53,8 @@ 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)
|
||||
@@ -104,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)
|
||||
@@ -149,6 +156,8 @@ 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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
3
cmd/reconcile-cards/doc.go
Normal file
3
cmd/reconcile-cards/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Command reconcile-cards hunts down saved cards that Stripe no longer knows
|
||||
// about and detaches them. Run it with -dry-run first if you're nervous.
|
||||
package main
|
||||
@@ -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)
|
||||
|
||||
3
pkg/api/doc.go
Normal file
3
pkg/api/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package api is the client side of the house: it talks to our HTTP backend
|
||||
// so other Go code doesn't have to hand-roll requests.
|
||||
package api
|
||||
@@ -50,6 +50,8 @@ 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"
|
||||
)
|
||||
|
||||
type event struct {
|
||||
@@ -380,3 +382,46 @@ 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()...)
|
||||
}
|
||||
|
||||
3
pkg/auth/doc.go
Normal file
3
pkg/auth/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package auth sorts out who you are: logging in with an SSH key and minting
|
||||
// the JWT you carry around afterwards.
|
||||
package auth
|
||||
3
pkg/bring/doc.go
Normal file
3
pkg/bring/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package bring talks to Bring, the Norwegian postal service, for booking
|
||||
// shipments and printing labels.
|
||||
package bring
|
||||
@@ -92,6 +92,7 @@ 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
|
||||
|
||||
3
pkg/config/doc.go
Normal file
3
pkg/config/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package config reads everything out of the environment so secrets and
|
||||
// settings stay out of the source code.
|
||||
package config
|
||||
3
pkg/database/doc.go
Normal file
3
pkg/database/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package database opens the SQLite connection, runs the migrations, and
|
||||
// drops in some seed data so the shop isn't empty on a fresh boot.
|
||||
package database
|
||||
@@ -26,6 +26,7 @@ func Seed(db *gorm.DB) error {
|
||||
{
|
||||
Name: "Espresso",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 5,
|
||||
Ounces: 2,
|
||||
BeanType: "Arabica",
|
||||
Price: 1800, // $18.00
|
||||
@@ -35,6 +36,7 @@ func Seed(db *gorm.DB) error {
|
||||
{
|
||||
Name: "Americano",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 3,
|
||||
Ounces: 8,
|
||||
BeanType: "Arabica",
|
||||
Price: 2200, // $22.00
|
||||
@@ -44,6 +46,7 @@ func Seed(db *gorm.DB) error {
|
||||
{
|
||||
Name: "Cappuccino",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 3,
|
||||
Ounces: 6,
|
||||
BeanType: "Arabica",
|
||||
Price: 2200, // $22.00
|
||||
@@ -53,6 +56,7 @@ func Seed(db *gorm.DB) error {
|
||||
{
|
||||
Name: "Latte",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 2,
|
||||
Ounces: 12,
|
||||
BeanType: "Arabica",
|
||||
Price: 2500, // $25.00
|
||||
@@ -62,6 +66,7 @@ func Seed(db *gorm.DB) error {
|
||||
{
|
||||
Name: "Mocha",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 4,
|
||||
Ounces: 12,
|
||||
BeanType: "Arabica",
|
||||
Price: 3000, // $30.00
|
||||
@@ -71,6 +76,7 @@ func Seed(db *gorm.DB) error {
|
||||
{
|
||||
Name: "Macchiato",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 4,
|
||||
Ounces: 3,
|
||||
BeanType: "Robusta Blend",
|
||||
Price: 1800, // $18.00
|
||||
|
||||
@@ -11,6 +11,7 @@ type Coffee struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
RoastType string `gorm:"size:100" json:"roast_type"`
|
||||
RoastLevel int `json:"roast_level"` // 1-5 roast-intensity
|
||||
Ounces int `json:"ounces"`
|
||||
BeanType string `gorm:"size:100" json:"bean_type"`
|
||||
Price int `gorm:"not null" json:"price"`
|
||||
@@ -32,6 +33,7 @@ var AccountMenuItems = []string{
|
||||
"order history",
|
||||
"addresses",
|
||||
"cards",
|
||||
"spend limit",
|
||||
"faq",
|
||||
"about",
|
||||
}
|
||||
|
||||
3
pkg/models/doc.go
Normal file
3
pkg/models/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package models is the shared vocabulary of the app: coffee, carts, orders,
|
||||
// addresses, cards, users, and the other nouns everything passes around.
|
||||
package models
|
||||
@@ -9,16 +9,25 @@ 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:"-"`
|
||||
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:"-"`
|
||||
// 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:"-"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for the User model
|
||||
@@ -32,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"`
|
||||
}
|
||||
|
||||
@@ -42,6 +52,7 @@ func (u *User) ToPublic() PublicUser {
|
||||
SSHKeyFingerprint: u.SSHKeyFingerprint,
|
||||
Name: u.Name,
|
||||
Email: u.Email,
|
||||
SelfLimitCents: u.SelfLimitCents,
|
||||
CreatedAt: u.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
3
pkg/observability/doc.go
Normal file
3
pkg/observability/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package observability sets up tracing and structured logs so that when
|
||||
// something breaks at 3am you stand a chance of figuring out why.
|
||||
package observability
|
||||
3
pkg/shipping/doc.go
Normal file
3
pkg/shipping/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package shipping holds the plain shipping types that the rest of the app
|
||||
// passes back and forth.
|
||||
package shipping
|
||||
2
pkg/shippo/doc.go
Normal file
2
pkg/shippo/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package shippo talks to the Shippo API for shipping rates and labels.
|
||||
package shippo
|
||||
3
pkg/stripeclient/doc.go
Normal file
3
pkg/stripeclient/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package stripeclient wires up the Stripe SDK once at startup (with tracing
|
||||
// baked in) so everyone else can just make calls.
|
||||
package stripeclient
|
||||
@@ -1,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"
|
||||
}
|
||||
|
||||
115
pkg/tui/account_test.go
Normal file
115
pkg/tui/account_test.go
Normal file
@@ -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 }
|
||||
3
pkg/tui/doc.go
Normal file
3
pkg/tui/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package tui is the terminal UI you see over SSH: the shop, the cart, your
|
||||
// account, and the checkout flow, all drawn with Bubbletea.
|
||||
package tui
|
||||
@@ -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
|
||||
}
|
||||
@@ -837,6 +842,7 @@ func NewModel(username string) Model {
|
||||
{
|
||||
Name: "Espresso",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 5,
|
||||
Ounces: 2,
|
||||
BeanType: "Arabica",
|
||||
Price: 350, // $3.50
|
||||
@@ -846,6 +852,7 @@ func NewModel(username string) Model {
|
||||
{
|
||||
Name: "Latte",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 2,
|
||||
Ounces: 12,
|
||||
BeanType: "Arabica Blend",
|
||||
Price: 500, // $5.00
|
||||
@@ -855,6 +862,7 @@ func NewModel(username string) Model {
|
||||
{
|
||||
Name: "Cappuccino",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 3,
|
||||
Ounces: 8,
|
||||
BeanType: "Italian Blend",
|
||||
Price: 450, // $4.50
|
||||
@@ -864,6 +872,7 @@ func NewModel(username string) Model {
|
||||
{
|
||||
Name: "Americano",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 4,
|
||||
Ounces: 16,
|
||||
BeanType: "Arabica",
|
||||
Price: 400, // $4.00
|
||||
@@ -873,6 +882,7 @@ func NewModel(username string) Model {
|
||||
{
|
||||
Name: "Mocha",
|
||||
RoastType: "Medium Roast",
|
||||
RoastLevel: 3,
|
||||
Ounces: 16,
|
||||
BeanType: "Colombian",
|
||||
Price: 550, // $5.50
|
||||
@@ -882,6 +892,7 @@ func NewModel(username string) Model {
|
||||
{
|
||||
Name: "Macchiato",
|
||||
RoastType: "Dark Roast",
|
||||
RoastLevel: 4,
|
||||
Ounces: 3,
|
||||
BeanType: "Robusta Blend",
|
||||
Price: 425, // $4.25
|
||||
|
||||
@@ -276,9 +276,7 @@ func (m Model) updatePaymentViewport() Model {
|
||||
breadH := lipgloss.Height(m.BuildBreadcrumbs())
|
||||
footerH := lipgloss.Height(m.BuildFooter())
|
||||
availH := m.heightContainer - headerH - footerH - breadH
|
||||
if availH < 1 {
|
||||
availH = 1
|
||||
}
|
||||
availH = max(availH, 1)
|
||||
if !m.payment.viewportReady {
|
||||
m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||
m.payment.viewport.KeyMap = viewport.KeyMap{}
|
||||
@@ -303,7 +301,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()
|
||||
@@ -374,7 +372,7 @@ func (m Model) RenderCardList() string {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||
}
|
||||
|
||||
func (m Model) renderPaymentHttpsView() string {
|
||||
func (m Model) renderPaymentHTTPSView() string {
|
||||
baseStyle := m.theme.TextLabel()
|
||||
accentStyle := m.theme.TextHighlight()
|
||||
|
||||
|
||||
3
pkg/tui/qrfefe/doc.go
Normal file
3
pkg/tui/qrfefe/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package qrfefe turns text into a QR code drawn with terminal block
|
||||
// characters, handy for showing links you can scan right off the screen.
|
||||
package qrfefe
|
||||
@@ -24,9 +24,7 @@ const (
|
||||
// menu(menuW+4) + gap(1) + detail(detailW+4) = contentW = wideW + 4.
|
||||
func (m Model) shopLayout() (contentW, menuW, detailW, wideW int) {
|
||||
contentW = m.widthContent
|
||||
if contentW > 74 {
|
||||
contentW = 74
|
||||
}
|
||||
contentW = min(contentW, 74)
|
||||
wideW = contentW - 4
|
||||
if m.size < large {
|
||||
// Stacked layout: both panels span the full content width.
|
||||
@@ -38,6 +36,7 @@ func (m Model) shopLayout() (contentW, menuW, detailW, wideW int) {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -65,6 +64,7 @@ func clampLines(s string, n int) string {
|
||||
|
||||
// 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, " ")
|
||||
@@ -105,7 +105,11 @@ func (m Model) shopDetailContent(w int) string {
|
||||
pDim.Render(fmt.Sprintf("%dOZ", coffee.Ounces)) + sep +
|
||||
pDim.Render(strings.ToUpper(coffee.BeanType))
|
||||
|
||||
strength := pDim.Render("roast ") + roastMeter(roastLevel(coffee.RoastType))
|
||||
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)) +
|
||||
@@ -213,12 +217,8 @@ func (m Model) shopMoveSelected(previous bool) Model {
|
||||
} else {
|
||||
next++
|
||||
}
|
||||
if next < 0 {
|
||||
next = 0
|
||||
}
|
||||
if next >= len(m.Coffees) {
|
||||
next = len(m.Coffees) - 1
|
||||
}
|
||||
next = max(next, 0)
|
||||
next = min(next, len(m.Coffees)-1)
|
||||
m.shop.selected = next
|
||||
return m
|
||||
}
|
||||
|
||||
3
pkg/tui/theme/doc.go
Normal file
3
pkg/tui/theme/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package theme keeps the colors and styles in one spot so the whole UI
|
||||
// looks like it was built by the same person.
|
||||
package theme
|
||||
@@ -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
|
||||
|
||||
3
pkg/utils/doc.go
Normal file
3
pkg/utils/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package utils is the catch-all drawer for tiny HTTP helpers that didn't
|
||||
// earn a package of their own.
|
||||
package utils
|
||||
3
pkg/validate/doc.go
Normal file
3
pkg/validate/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package validate gives user input a once-over before the rest of the app
|
||||
// trusts it.
|
||||
package validate
|
||||
Reference in New Issue
Block a user