Per-Tenant Plan Limits — set resource & PAC-folio limits at provisioning time¶
Date: 2026-06-18 Status: 🟢 Shipped — admin can set each plan limit (per-resource creation caps and the monthly PAC folio allowance) when creating a tenant, seeded from the plan default and fully editable, and edit them later from the tenant detail page.
1. Problem / motivation¶
Two plan-based limits already existed but could not be configured per tenant from the admin panel:
-
Resource creation quotas — enforced at write time by
QuotaService.check_create: the effective limit for a(plan, resource)pair is the per-tenant override inTenant.quota_overrides(JSONB), falling back to the plan default inPLAN_QUOTAS. The gap: no way to writequota_overrides— the column was read into request context but no mutation populated it, so negotiated limits needed raw SQL. -
Monthly PAC folio allowance —
pac_metrics.allowed_eventsis seeded fromTenant.monthly_folio_allowance, falling back to a single flatPolarSettings.default_monthly_folio_allowance(20) for every plan. There was no plan-tiered default and no way to set the override at create/update time.
This feature closes both write paths, makes the PAC folio allowance plan-tiered, and surfaces the plan defaults so the admin panel pre-fills each limit from the plan, then lets an operator edit any of them per tenant.
2. What changed¶
No schema change — both quota_overrides and monthly_folio_allowance already
exist. This is GraphQL + service + frontend wiring only.
Backend — app/admin¶
ResourceTypeis now a GraphQL enum. DecoratedResourceTypewith@strawberry.enum. GraphQL values are the member names (CLIENT,SUPPLIER,ITEM,USER); the lowercase.valueremains the JSONB key.- PAC folio plan defaults live next to the resource quotas in
plan_quotas.py(one place for every plan-based limit):PLAN_FOLIO_ALLOWANCES+ helperfolio_allowance_for— FREE 0, BASIC 50, STARTER 100, PROFESSIONAL 250, ENTERPRISE 500. The folio stays a separate map, not aResourceTypeentry, because everyResourceTypemust map to a real tableQuotaServicecan row-COUNT; the folio is a metered monthly counter with Polar overage, not a row count. PacMetricsService(pac_metrics_service.py) now falls back to the plan allowance (folio_allowance_for(plan)) instead of the flatPolarSettingsdefault, in both_effective_allowance(per-event) andseed_month(monthly cron). The per-tenantmonthly_folio_allowanceoverride still wins when set.- New GraphQL shapes in tenant/strawberry/stypes.py:
ResourceQuotaInput { resource: ResourceType!, limit: Int! }andResourceQuota { resource, limit }(exposed onTenant.quotaOverrides).ResourceQuotaDefault { resource, limit: Int }(null= unlimited) andPlanQuotaDefaults { plan, folioAllowance: Int!, quotas: [...] }.- Helpers
quota_overrides_to_list/quota_input_to_dictconvert between the JSONB dict and the GraphQL list. planQuotaDefaultsquery (queries.py) returns every plan's per-resource default and folio allowance, sourced fromquota_for/folio_allowance_for(the same functions enforcement uses), so the panel pre-fills exactly what would apply.createTenant/updateTenant(mutations.py) acceptquotaOverrides: [ResourceQuotaInput!]andmonthlyFolioAllowance: Int. Create passes them to the service; update replacesquota_overrides(publishing a Redis cache invalidation — quotas are read from the cachedTenantPG) and setsmonthly_folio_allowance(no invalidation — read straight from the master DB byPacMetricsService). EmptyquotaOverridesclears overrides; omitting an arg (UNSET) leaves it untouched.TenantService.create_tenant(tenant_service.py) takesquota_overrides+monthly_folio_allowanceand stores them on the newTenant.
Frontend — cifras-admin¶
planQuotaDefaultsquery +quotaOverrides/monthlyFolioAllowanceon the tenant fields/mutations (src/api/tenants.ts,src/types/index.ts). Shared helpers insrc/lib/quotas.tsand aQuotaLimitsFieldscomponent.- Create dialog (
TenantsPage.tsx) and Edit dialog (TenantDetailPage.tsx): a "Resource Limits" section — one numeric input per resource plus a PAC-folios input — seeded from the selected plan's defaults and re-seeded on plan change. The edit dialog pre-fills from the tenant's stored overrides (falling back to the plan default) and shows current limits read-only in the overview.
3. Data semantics¶
- A resource override is an int (matches
Tenant.quota_overrides: dict[str, int]);monthly_folio_allowanceis an int column. - A limit with no override follows the plan default (
PLAN_QUOTAS/PLAN_FOLIO_ALLOWANCES). Resource plan defaults may beNone(unlimited); folio defaults are always concrete ints. - A blank input in the UI = no override (follow the plan default). To pin a limit, type a number.
updateTenant(quotaOverrides: [])clears all resource overrides; omitting the arg leaves them untouched. Same formonthlyFolioAllowance(UNSET leaves it).
⚠️ Behavior change¶
Tenants with a null monthly_folio_allowance previously fell back to the
flat default of 20 folios/month. They now fall back to their plan
allowance (FREE 0 … ENTERPRISE 500). FREE tenants drop from 20 → 0 included
folios. This is intentional (the requested plan tiering); tenants needing a
different number get an explicit monthly_folio_allowance override.
4. RBAC¶
None. These mutations/queries live on the admin schema
(app.admin.graphql.schema), already gated by admin auth. No tenant-facing
Path/Resource and no backfill.
5. Frontend contract¶
query { planQuotaDefaults { plan folioAllowance quotas { resource limit } } }→ seed the form.quotas[].limit == nullmeans unlimited.Tenant.quotaOverrides { resource limit }+Tenant.monthlyFolioAllowance→ the tenant's pinned limits.createTenant(..., quotaOverrides: [{ resource: CLIENT, limit: 50 }], monthlyFolioAllowance: 100).updateTenant(id, quotaOverrides: [...], monthlyFolioAllowance: 100)— send the full desired set;[]clears resource overrides; omit to leave unchanged.
6. Testing¶
tests/admin/test_tenant_quota.py
covers the dict↔list converters, the planQuotaDefaults source (all_plan_quotas
matches quota_for for every plan/resource), and the folio plan map (every plan
present, the requested per-plan values). Frontend tsc -b, ESLint, and
vite build are clean.
7. Future additions¶
- Allow an explicit unlimited resource override (would widen
quota_overridestodict[str, int | None]and the GraphQLlimitto nullable on input). - Surface current usage vs. limit per resource/folio in the tenant detail view.
- Move plan defaults out of the static maps into an editable master-DB table so non-engineers can tune plan tiers.