From aa446cdfa528b0b1c6d1dd4129fcd4cb69d7d191 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Tue, 16 Jun 2026 14:56:38 +0200 Subject: [PATCH] feat(sca): add per-user spend cap override (#53) * feat(sca): add per-user spend cap override * feat(sca): add admin endpoint to set per-user order cap - PATCH /api/v1/users/{id}/order-cap (RequireAdmin / X-Admin-Key) sets or clears User.MaxOrderCents; null clears (revert to global), >=0 sets, negative rejected 400 INVALID_CAP at the write boundary - harden ConvertCart: a negative override falls back to the global cap with a slog.Warn (mirrors loadMaxOrderCents) so a bad DB write cannot silently void the fraud control - emit user_order_cap_set audit event (set/cleared) - tests: admin handler table + RequireAdmin gate router test + negative read-path fallback case Co-Authored-By: Claude Opus 4.8 * docs(api): drop orphan comment referencing nonexistent request struct The paragraph above SetUserOrderCap documented a setUserOrderCapRequest struct that does not exist; the handler decodes into a raw map instead. The raw-map rationale already lives inline at the decode site. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- api/handlers/admin_users.go | 97 ++++++++++++++++ api/handlers/admin_users_test.go | 194 +++++++++++++++++++++++++++++++ api/handlers/cart.go | 27 ++++- api/handlers/cart_test.go | 56 +++++++-- api/routes/routes.go | 3 + pkg/audit/audit.go | 21 ++++ pkg/models/user.go | 28 +++-- 7 files changed, 400 insertions(+), 26 deletions(-) create mode 100644 api/handlers/admin_users.go create mode 100644 api/handlers/admin_users_test.go diff --git a/api/handlers/admin_users.go b/api/handlers/admin_users.go new file mode 100644 index 0000000..80f2c3c --- /dev/null +++ b/api/handlers/admin_users.go @@ -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": }. +// - 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, + }) +} diff --git a/api/handlers/admin_users_test.go b/api/handlers/admin_users_test.go new file mode 100644 index 0000000..0745009 --- /dev/null +++ b/api/handlers/admin_users_test.go @@ -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 "" + } + 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) + } +} diff --git a/api/handlers/cart.go b/api/handlers/cart.go index a4b5d76..5493ead 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "net/http" "strconv" "time" @@ -380,14 +381,32 @@ 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 + } + } + + if capCents > 0 && 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 } diff --git a/api/handlers/cart_test.go b/api/handlers/cart_test.go index 3402d0b..d5587ad 100644 --- a/api/handlers/cart_test.go +++ b/api/handlers/cart_test.go @@ -670,27 +670,49 @@ 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 override; nil = inherit the global capCents }{ - {"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}, // $205 vs global $200 + {"at cap accepts", 20000, 40, false, nil}, // $200 boundary + {"under cap accepts", 20000, 39, false, nil}, // $195 + {"zero cap disables", 0, 1000, false, nil}, // explicit global off-switch + // Per-user override (User.MaxOrderCents) takes precedence over the global. + {"user override below global rejects", 20000, 12, true, intPtr(5000)}, // $60 > $50 user cap (global $200 would allow) + {"user override above global accepts", 20000, 41, false, intPtr(50000)}, // $205 < $500 user cap (global $200 would reject) + {"user override zero disables per user", 20000, 1000, false, intPtr(0)}, // per-user off-switch over a live global + {"nil override falls back to global", 20000, 41, true, nil}, // explicit nil == inherit global $200 + // 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)}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { + // Effective cap the handler should enforce: a non-negative override + // wins; nil or a negative value both fall back to the global cap. + effectiveCap := tc.capCents + if tc.userCap != nil && *tc.userCap >= 0 { + effectiveCap = *tc.userCap + } + testDB, user := setupCartTestDB(t) defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() db := database.GetDB() user.StripeCustomerID = "cus_test_overlimit" + user.MaxOrderCents = tc.userCap if err := db.Save(&user).Error; err != nil { t.Fatalf("saved user: %v", err) } @@ -780,8 +802,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 +813,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 +836,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"]) } } }) diff --git a/api/routes/routes.go b/api/routes/routes.go index eb1816d..41af750 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -53,6 +53,7 @@ func SetupRoutes( viewHandler := handlers.NewViewHandler() webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, shippoWebhookSecret) slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort, shippoWebhookSecret) + adminUserHandler := handlers.NewAdminUserHandler() // Short payment redirect and success page — no auth required. r.Get("/pay/{token}", handlers.PayRedirect) @@ -149,6 +150,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) }) }) diff --git a/pkg/audit/audit.go b/pkg/audit/audit.go index 247d62e..980be60 100644 --- a/pkg/audit/audit.go +++ b/pkg/audit/audit.go @@ -50,6 +50,7 @@ const ( eventTrackingUpdated eventType = "tracking_updated" eventOrderDelivered eventType = "order_delivered" eventTrackingMarkedManually eventType = "tracking_marked_manually" + eventUserOrderCapSet eventType = "user_order_cap_set" ) type event struct { @@ -380,3 +381,23 @@ 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()...) +} diff --git a/pkg/models/user.go b/pkg/models/user.go index a7a5d29..11570d9 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -9,16 +9,24 @@ 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:"-"` + 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