Skip to content

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:

  1. Resource creation quotas — enforced at write time by QuotaService.check_create: the effective limit for a (plan, resource) pair is the per-tenant override in Tenant.quota_overrides (JSONB), falling back to the plan default in PLAN_QUOTAS. The gap: no way to write quota_overrides — the column was read into request context but no mutation populated it, so negotiated limits needed raw SQL.

  2. Monthly PAC folio allowancepac_metrics.allowed_events is seeded from Tenant.monthly_folio_allowance, falling back to a single flat PolarSettings.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

  • ResourceType is now a GraphQL enum. Decorated ResourceType with @strawberry.enum. GraphQL values are the member names (CLIENT, SUPPLIER, ITEM, USER); the lowercase .value remains 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 + helper folio_allowance_for — FREE 0, BASIC 50, STARTER 100, PROFESSIONAL 250, ENTERPRISE 500. The folio stays a separate map, not a ResourceType entry, because every ResourceType must map to a real table QuotaService can 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 flat PolarSettings default, in both _effective_allowance (per-event) and seed_month (monthly cron). The per-tenant monthly_folio_allowance override still wins when set.
  • New GraphQL shapes in tenant/strawberry/stypes.py:
  • ResourceQuotaInput { resource: ResourceType!, limit: Int! } and ResourceQuota { resource, limit } (exposed on Tenant.quotaOverrides).
  • ResourceQuotaDefault { resource, limit: Int } (null = unlimited) and PlanQuotaDefaults { plan, folioAllowance: Int!, quotas: [...] }.
  • Helpers quota_overrides_to_list / quota_input_to_dict convert between the JSONB dict and the GraphQL list.
  • planQuotaDefaults query (queries.py) returns every plan's per-resource default and folio allowance, sourced from quota_for / folio_allowance_for (the same functions enforcement uses), so the panel pre-fills exactly what would apply.
  • createTenant / updateTenant (mutations.py) accept quotaOverrides: [ResourceQuotaInput!] and monthlyFolioAllowance: Int. Create passes them to the service; update replaces quota_overrides (publishing a Redis cache invalidation — quotas are read from the cached TenantPG) and sets monthly_folio_allowance (no invalidation — read straight from the master DB by PacMetricsService). Empty quotaOverrides clears overrides; omitting an arg (UNSET) leaves it untouched.
  • TenantService.create_tenant (tenant_service.py) takes quota_overrides + monthly_folio_allowance and stores them on the new Tenant.

Frontend — cifras-admin

  • planQuotaDefaults query + quotaOverrides/monthlyFolioAllowance on the tenant fields/mutations (src/api/tenants.ts, src/types/index.ts). Shared helpers in src/lib/quotas.ts and a QuotaLimitsFields component.
  • 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_allowance is an int column.
  • A limit with no override follows the plan default (PLAN_QUOTAS / PLAN_FOLIO_ALLOWANCES). Resource plan defaults may be None (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 for monthlyFolioAllowance (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 == null means 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_overrides to dict[str, int | None] and the GraphQL limit to 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.