Reseller Overselling Mode for Quotas (F5.20)
Status: 🟢 COMPLETED Feature: Optional, ADMIN-controlled per-reseller setting that lets a reseller's sub-account quotas nominally sum above the reseller's own limit, with enforcement switching to real aggregate usage.
What It Does
Before F5.20, Vexlyx quotas (apps/api/src/utils/quota.ts) were flat, independent caps — a reseller's maxProjects and each sub-account's maxProjects were checked in total isolation, with no cross-check between the two. F5.20 adds the WHM-style "overselling" model that's standard practice in reseller hosting, since most sub-accounts never use their full allocation:
- Overselling off (default): a reseller can never set sub-account quotas whose nominal sum exceeds the reseller's own limit. This is new — it did not exist before F5.20 — and is now the baseline "today's behavior."
- Overselling on (ADMIN-only, per reseller): the nominal-sum restriction is lifted. Instead, resource creation is capped by real aggregate usage across the reseller + all their sub-accounts, checked at creation time.
Applies to maxProjects / maxDomains / maxDatabases / maxMailboxes. maxSubAccounts is excluded — there's no reseller-of-reseller nesting in the schema, so sub-account counts don't pool the same way.
Architecture
Browser (/users — EditUserDialog "Overselling mode" switch, ADMIN + RESELLER target only)
│ REST: PATCH /api/users/:id/quotas { ...quotas, oversellingEnabled }
▼
apps/api/src/modules/users/service.ts — UserService.updateQuotas
│ Prisma: User.oversellingEnabled (Boolean @default(false))
▼
apps/api/src/utils/quota.ts — assertNominalPoolWithinCap / assertUnderQuota / getUsageSummary
▼
Postgres — users.overselling_enabled columnData model
oversellingEnabled Boolean @default(false) @map("overselling_enabled")Reseller-only in meaning (ignored on plain USER accounts), ADMIN-only to change.
Enforcement — two different checks, two different times
assertNominalPoolWithinCap(apps/api/src/utils/quota.ts) — called fromUserService.updateQuotaswhenever a sub-account's quotas are being written. Skips entirely if the reseller has overselling enabled. Otherwise, for each resource where the reseller's own limit is finite, sums every sibling sub-account's current value plus the target's new value; throws403 OVERSELL_NOT_ENABLEDif that total exceeds the reseller's limit, or if any sibling (or the target) would be leftnull(unlimited) while the reseller enforces a finite cap — unlimited trivially breaks "nominal sum ≤ limit."assertUnderQuota(same file) — called at resource-creation time by every resource service (projects,domains,databases,mailboxes). Unchanged for everyone except: when the creating user sits under a reseller with overselling enabled (or is themselves such a reseller), it additionally sums real usage across the reseller + all sub-accounts (sumAcrossPool) and throws403 QUOTA_EXCEEDEDif that aggregate is at or over the reseller's own limit — even though each sub-account's individual quota still applies on top.
Both checks are pure additions — a user with no reseller, or a reseller with overselling off, sees exactly the pre-F5.20 per-user quota check.
Reporting oversold status
getUsageSummary (also in quota.ts, backs GET /api/users/me/usage) computes, for a RESELLER's own project/domain/database/mailbox entries, nominalSum (sum of all sub-accounts' quota for that resource; null if any sub-account is unlimited) and oversold (true when that nominal sum exceeds the reseller's limit). This is reported regardless of whether overselling is enabled — it's a description of current allocation risk, not the enforcement mode itself.
API surface
No new routes — PATCH /api/users/:id/quotas (apps/api/src/modules/users/routes.ts) already forwards whatever UpdateUserQuotasSchema defines, which now includes oversellingEnabled: z.boolean().optional(). Setting it requires requester.role === "ADMIN" and target.role === "RESELLER", both enforced in UserService.updateQuotas (403 FORBIDDEN / 400 INVALID_TARGET otherwise).
Frontend
EditUserDialog.tsx(apps/dashboard/src/components/users/) — a "Overselling mode"Switch, shown only whencanEditRole && role === "RESELLER"(ADMIN editing a reseller). Included in the samePATCH .../quotaspayload as the other quota fields.UsersPage.tsx— when the signed-in user is a RESELLER anduseUsage()reports any resourceoversold, an amber warning card lists which resources and their nominal-sum-vs-limit numbers, above the sub-account list.
Scope note: an ADMIN browsing /users doesn't see a live oversold badge on each reseller row in this pass — GET /api/users/me/usage only reports the caller's own usage. A per-id admin usage endpoint (GET /api/users/:id/usage) is a natural follow-up if that's wanted.
How To Test
- Reseller with
maxProjects: 10creates 3 sub-accounts, eachmaxProjects: 5(nominal sum 15) → the 2nd and 3rd sub-account quota updates are blocked withOVERSELL_NOT_ENABLEDwhile overselling is off. - An ADMIN enables overselling on that reseller (
/usersedit dialog) → the same sub-account quota updates now succeed. - With overselling on, project creation across those sub-accounts is still blocked once real aggregate usage across the reseller + sub-accounts hits 10, even though nominal sum is 15.
- Overselling off (default), a single sub-account quota within the reseller's remaining nominal capacity → succeeds, unchanged from pre-F5.20 behavior.
- As the reseller, visit
/users— the amber "oversold territory" banner appears once nominal sub-account allocations exceed your own limit, and disappears once quotas are brought back within it (or overselling is enabled and the banner condition no longer holds — nominal sum can still be reported oversold by design, since it describes allocation risk independent of the enforcement mode).
Automated coverage: apps/api/src/utils/quota.test.ts (nominal-pool and aggregate-pool checks, oversold reporting) and apps/api/src/modules/users/service.test.ts (describe("UserService.updateQuotas — F5.20 overselling")).
Extending
A per-id GET /api/users/:id/usage (ADMIN-only) would let the admin /users table show oversold badges per reseller row without the reseller having to visit their own page first — same getUsageSummary function, just called with a path param instead of request.userId.