Portal-Access Seat Caps & Storage Caps with Monthly Addons
Summary
Add hard caps on (a) the number of patients who can log in at id.odontox.io and (b) total R2 storage consumed per clinic, with monthly-recurring addons that bump either cap subject to superadmin approval. Today every patient created in the system is silently provisioned ausers row, and there is no storage accounting at all. Both gaps become urgent now that one Pro+ tenant is importing 6,500 records over the next few weeks.
Background
OdontoX is sold on three subdomains today:- go.odontox.io — main app for all roles
- id.odontox.io — sign-in surface for every role (staff and patient)
- portal.odontox.io — public file-share links with no login
Goals
- Distinguish patient records from patient portal accounts.
- Enforce per-plan caps: Pro = 100 portal seats + 100 GB storage; Pro+ = 250 portal seats + 250 GB storage; Enterprise = custom.
- Provide monthly-recurring addons for both caps with superadmin approval.
- Block uploads (cleanly, with upgrade CTA) when storage cap is reached.
- Allow bulk imports past the seat cap by importing overflow as records-only, never an error.
- Surface usage and addons in three places: clinic admin (Settings → My Billing), the upgrade page, and the superadmin tenant detail page.
- Ship a help article that disambiguates the three subdomains.
Non-goals
- Annual billing for addons (monthly recurring only for v1).
- International currency for addons (PKR only for v1).
- MAU-based billing (we use model (a): portal_access=true count is the hard cap; MAU is reported as a stat, not enforced).
- Auto-approval of addon requests (every request manual for v1).
- Per-file storage limits (only aggregate per-tenant matters).
- Refunds, proration, cancellations of addons mid-month (handled out-of-band by superadmin).
Plan tiers (locked)
| Tier | Portal seats | Storage cap | Records | Notes |
|---|---|---|---|---|
| Pro | 100 | 100 GB | ∞ | — |
| Pro+ | 250 | 250 GB | ∞ | — |
| Enterprise | custom | custom | ∞ | Set via superadmin tenant detail |
| Free trial | 14 days | mirrors chosen tier with no enforcement | ∞ | Caps engage at day-15 |
Pricing (initial defaults — superadmin-editable, PKR-only)
These are the seed values at launch. Superadmin can change them at any time from a new “Addon Pricing” panel in the superadmin settings. Changes are global (apply to all clinics from that moment forward) and are snapshotted onto eachaddon_requests row at creation time, so already-pending requests preserve the price the clinic saw.
Storage addons (monthly recurring, fixed tiers):
| Tier | Monthly price (PKR) | Effective PKR/GB | Use case |
|---|---|---|---|
| 50 GB | 1,499 | ~30 | Small clinic with growing records |
| 200 GB | 4,999 | ~25 | Mid-size clinic with imaging |
| 500 GB | 9,999 | ~20 | Multi-doctor clinic, DICOM-heavy |
| 1 TB | 14,999 | ~15 | Multi-branch / archive-heavy |
- Pro tenant: PKR 999 / 3 seats
- Pro+ tenant: PKR 699 / 3 seats
Positioning rationale (justifies vs. consumer-cloud comparisons)
Storage tiers are priced as a bundled business service, not raw bytes. Pricing copy in the clinic-facing UI and any private collateral must lead with the bundled value so the comparison is “OdontoX vs. building this yourself” not “OdontoX vs. iCloud per-GB”. Required copy elements for every storage addon CTA:- Lead with a benefit-anchored description, e.g. “50 GB of secure business storage inside your app — customer files, uploads, access control, backups, sharing, support, no technical setup.”
- Avoid PKR-per-GB framing; show the monthly price alongside what’s included.
- Mention “HIPAA-compliant”, “automatic backups”, “audit logged”, “no extra IT setup” when space permits.
addon_requests table doubles as the active-addons ledger — status='approved' rows with no cancelledAt timestamp are the currently-billing addons, and the clinic’s portalSeatAddon / storageAddonBytes columns are the sum of all active addons of each type. Cancelling an approved addon decrements the corresponding clinics column and stops future billing.
Public-pricing disclosure rule
Addon prices are NEVER displayed on q.odontox.io (the public marketing site). The marketing page may mention that paid addons are available for portal seats and storage in general terms (“Need more capacity? Contact us about additional portal seats or storage.”), but never the concrete PKR figures. Concrete prices are visible only to logged-in clinic admins inside go.odontox.io (Settings → My Billing, in-app upgrade page) and to superadmins. This protects pricing flexibility and avoids competitor visibility.Addon pricing storage
Pricing lives in a newaddon_pricing config table with these fields:
storage_50gb_pkrstorage_200gb_pkrstorage_500gb_pkrstorage_1tb_pkrportal_seats_3pack_pro_pkrportal_seats_3pack_pro_plus_pkrupdated_at,updated_by
addon-pricing.ts calculator reads from this table (cached for 60 s). Initial migration seeds the defaults above. The addon request shape is { addonType: 'storage', tier: 50 | 200 | 500 | 1000 } — quantity is implied by the tier (one tier per request).
Data model changes
clinics table — new columns
portal_seat_addon and storage_addon_bytes track active addon quota separately from the base plan limit so that downgrades or plan changes preserve addon allocations.
patients table — new column
user_id is currently non-null AND deleted_at IS NULL gets portal_access = true (those are existing portal users). Everyone else gets false. If a clinic’s backfilled portal_access=true count exceeds its plan cap, no retroactive revocation — existing users keep access; new toggles are blocked until they free a seat or buy an addon. The seat-count query everywhere uses COUNT(*) WHERE clinic_id = ? AND portal_access = true AND deleted_at IS NULL so soft-deleted patients reclaim their seat automatically. After backfill, a partial unique index enforces “at most one patient per user_id”:
New addon_requests table
addon_requests from upgrade_requests because the lifecycle differs: addons recur monthly, can be cancelled independently, and need quota arithmetic on approval. Mixing the two would muddy the schema.
New centralized plan-limits config
server/src/lib/plan-limits.ts — single source of truth replacing the inline limits in AdminBillingSettings.tsx and billing.ts.
Storage usage tracking
A helper moduleserver/src/lib/storage-tracker.ts exposes:
storageUsedBytes is maintained transactionally on every upload/delete via the R2Service wrapper using atomic UPDATE clinics SET storage_used_bytes = storage_used_bytes + $1 WHERE id = $2 (no read-modify-write). recomputeUsage is the source-of-truth fallback used by a nightly cron and on-demand by superadmin. The cap check (checkUploadAllowed) reads storageUsedBytes and the effective limit; race-condition tolerance is ±a few MB which is acceptable.
Upload paths to instrument: patient_files, signatures, prescription docs, letterhead, lab files, insurance claim files, anything that puts a key in *_r2_key columns. List of upload routes lives at the end of this spec.
API endpoints
Clinic-facing
GET /api/v1/protected/billing/usage→{ portalSeats: { used, limit }, storage: { usedBytes, quotaBytes }, mau: number }POST /api/v1/protected/billing/addon-requests— body{ addonType, quantity }. Server computes price, validates plan, inserts pending row, fires superadmin alert.GET /api/v1/protected/billing/addon-requests— list this clinic’s requestsDELETE /api/v1/protected/billing/addon-requests/:id— cancel a pending requestPATCH /api/v1/protected/patients/:id/portal-access— body{ enabled: boolean }. Server validates seat cap, links/unlinksusersrow.
Superadmin
GET /api/v1/protected/superadmin/addon-requests?status=pending— across all tenantsPOST /api/v1/protected/superadmin/addon-requests/:id/approve— applies quota delta, writes audit log, emails clinic adminPOST /api/v1/protected/superadmin/addon-requests/:id/reject— body{ reason }POST /api/v1/protected/superadmin/clinics/:id/storage-quota— direct override for Enterprise tierPOST /api/v1/protected/superadmin/clinics/:id/portal-seat-limit— direct override for Enterprise tierPOST /api/v1/protected/superadmin/clinics/:id/recompute-storage— triggersrecomputeUsage
Existing upload routes — must gate on checkUploadAllowed
patient-files, signatures, prescriptions, letterhead, lab-cases, insurance-claims, clinic logos. Inventory will be enumerated in the implementation plan.
UI surfaces
1. Patient create/edit form
A new checkbox:☐ Provide portal access with a (?) icon. Below it, a counter: "47 of 100 seats used". When at cap, the checkbox is disabled with helper text "Seat limit reached. Request more seats to enroll." and an inline upgrade button.
The (?) icon opens a side drawer rendering the help article (see § Help article).
Wording is Provide portal access (sentence case). Avoid portal standalone in copy elsewhere to prevent confusion with portal.odontox.io.
2. Bulk import (DataImportManager)
When importing patients, a column Provide portal access (yes/no) is supported in the CSV. If unspecified, defaults to no.
Pre-flight: count rows with yes plus current portal_access=true count. If total exceeds cap, the wizard shows a banner: "X rows will be imported as records-only because they exceed your portal-seat cap. Existing seats won't change." and offers a downloadable not-enrolled.csv after import listing overflow rows. The import proceeds — never an error.
3. Settings → My Billing (AdminBillingSettings.tsx)
Existing component grows three new sub-sections:
- Usage meters — portal seats (used/limit), storage (used/limit + bar). Both with addon-allocated amounts shown as a separate row (“+30 from addons”). MAU shown as a stat below portal seats.
- Request more storage — quantity selector (1, 5, 10 GB or custom), shows price calculation inline, submit → creates
addon_requestsrow. - Request more portal seats — 3-pack count selector, shows price calculation, submit.
4. In-app upgrade page (/dashboard/upgrade on go.odontox.io — NOT q.odontox.io)
Same addon request widgets as in Settings, plus the existing plan-selection UI. Two access points for the same workflow (clinic admin convenience). The public marketing site q.odontox.io must not show addon pricing — see “Public-pricing disclosure rule” above. The marketing site’s pricing copy mentions that addons exist but defers concrete numbers to in-app.
5. Storage-full block UI
When a file upload would exceed the cap, the upload component (used in patient files, X-rays, etc.) catches the 413 response and renders an inline blocker: title"Storage full", body "You've used 100 GB of 100 GB. Request more storage to keep uploading.", two CTAs: Request 10 GB (PKR 2,999/mo) and View billing settings. The blocker replaces the upload area until storage is freed or addon approved.
6. Superadmin → Clinic detail (ClinicDetailsPage.tsx)
New tab: Addons & Quotas
- Current effective limits (base + addons) for portal seats and storage
- Override controls for Enterprise tier
- Pending addon requests list (approve/reject inline with confirmation modal)
- Approved addons list (cancel/refund handled out-of-band; just shows active addons with monthly amount)
- “Recompute storage usage” button
(N pending) on the existing Superadmin → Dashboard alerts panel surfaces total pending addon requests across all tenants.
7. Superadmin → Platform Settings → “Addon Pricing”
New global settings panel for superadmin only:- Four numeric inputs: storage per-GB, storage 10GB pack, portal 3-pack (Pro), portal 3-pack (Pro+)
- “Save” button → updates
addon_pricingconfig row, invalidates 60-s cache, writes audit log entryaddon_pricing.updatedwith before/after values - Shows last-updated timestamp and updater’s email
- Read-only history table (last 10 changes) below the editor
Free-trial handling
During the 14-day trial:portal_seat_limitandstorage_quota_bytesare set to the trial’s tier value, but enforcement is bypassed via aisTrialcheck on every cap-enforcement path.- UI shows the cap on the usage meter but with a
"Unlimited during trial"tag. - Day-15: enforcement turns on. If the tenant already exceeded the cap during trial, no retroactive deletion: existing records stay, but new portal-access toggles and new uploads are blocked until they request addons or downgrade usage.
Bulk-import backfill plan for the 6,500-patient tenant
A one-time script (run by superadmin via existing import flow, not auto):- Identify the tenant’s clinic_id.
- Import the first 250 patients with
portal_access=true(these are the ones the clinic admin nominates as “active”). - Import the remaining 6,250 with
portal_access=false. - The clinic admin can later toggle individual patients on/off subject to the cap.
Provide portal access column — no special tooling needed.
Help article
Path:docs/help/patient-portal-access.md (consumed by the (?) drawer and surfaced at help.odontox.io)
Sections:
- What is “Provide portal access”? — One paragraph: it enables this patient to log in at id.odontox.io. Without it, the patient is a record only — visible to clinic staff but with no login.
- What it does NOT change — Records, X-rays, appointments, treatment plans, file storage all work identically for both.
-
Three subdomains — Comparison table:
Subdomain Purpose Login required? go.odontox.io Main app — where everyone works after signing in Yes (redirected from id.odontox.io) id.odontox.io Sign-in page for all roles This is the sign-in surface itself portal.odontox.io Public file-share links sent to recipients No — link-based access only -
How seats are counted — Only patients with
portal_access = oncount toward the limit. - What happens at the cap — Toggle disables on new patients. Existing logged-in patients are unaffected.
- Freeing a seat — Toggle off on a patient whose access is no longer needed; their login is suspended, records remain.
- Adding more seats — Request a 3-pack addon via Settings → My Billing or the upgrade page.
- MAU stat — Explains the “monthly active patients” number is informational, not a limit.
- FAQ — Cap reached during import, deletion vs revoke, re-enabling, what counts as “active”.
Permissions
Two new permission keys inserver/src/lib/permissions.ts:
billing.request_addon— clinic admin (default true), receptionist (default false)superadmin.addon.approve— superadmin only
portal_access toggle reuses existing patients.edit permission.
Audit log entries
addon_request.created— clinicaddon_request.cancelled— clinicaddon_request.approved— superadmin (records quota delta)addon_request.rejected— superadminportal_access.granted/portal_access.revoked— clinic admin actions on patientsstorage_quota.override— superadmin direct override
Edge cases
- Plan downgrade with active addons: Addons survive the downgrade (separate column). If the new plan’s base limit + addon is below current usage, no retroactive action; new toggles/uploads blocked.
- Addon approval after cap reached and user already past their limit: Approval applies retroactively — quota just goes up; usage stays. No special handling needed.
- User cancels pending request, then submits a new one: No de-dupe required; multiple pending allowed (rare, harmless).
- Bulk import exceeds storage cap mid-flight: Import treats storage like portal seats — overflow files fail individually with a row-level error and are listed in a “not-imported.csv”. File rows already in DB stay.
- Race condition on portal_access toggle at cap: Server uses
SELECT … FOR UPDATEon the clinics row when toggling to make the cap check atomic. - Patient with portal_access=true is soft-deleted: Seat is reclaimed on soft delete. Reactivation re-checks cap.
- Storage usage drift from R2 actual: Nightly cron compares
storageUsedBytesagainst the sum ofpatient_files.size(and all other file tables); reconciles with audit log entry if drift > 1%.
Out-of-scope (explicit)
- Annual addon billing (monthly only).
- Pause/resume of addons (cancel = full removal).
- Patient-side notification when their portal access is revoked.
- Storage compression / cold-storage migration.
- Proration on mid-month addon approval.
- Self-serve cancellation of approved addons (must contact superadmin).
Acceptance criteria
- New columns added to
clinicsandpatients; migration runs cleanly on prod-shaped data. -
plan-limits.tsis the only place limits are defined; AdminBillingSettings hardcoded values removed. - Every upload route gates on
checkUploadAllowed; bypass disallowed. - Patient create/edit form shows checkbox + counter; disabled at cap.
- Bulk import handles overflow with warning +
not-enrolled.csv, no errors. - Storage-full upload block renders correctly with two CTAs.
- Addon request flow end-to-end: create (clinic) → approve (superadmin) → quota applied → audit log → email.
- Superadmin clinic detail has new Addons & Quotas tab with override + recompute.
- Free-trial bypass works for first 14 days; enforcement engages on day 15.
- Help article published and reachable from the
(?)icon. - MAU shown as a stat (count of
portal_access=truepatients with login within 30 days) but not enforced. - All addon prices and limits match the locked table above; no drift between server and UI.
Open implementation decisions (deferred to plan stage)
- Whether to use Stripe for the recurring addon billing or handle invoicing manually (impacts cancellation logic).
- Exact email templates for addon-approved / addon-rejected.
- Whether to render the help article in-app via React or via an external help.odontox.io site.
References
- Cloudflare R2 pricing —
$0.015/GB-monthstandard storage - Existing tenant schema —
server/src/schema/clinics.ts - Existing patient schema —
server/src/schema/patients.ts - Existing upgrade requests —
server/src/schema/upgrade_requests.ts - Existing billing UI —
ui/src/components/settings/AdminBillingSettings.tsx - Existing superadmin tenant detail —
ui/src/components/superadmin/ClinicDetailsPage.tsx - Existing R2 service —
server/src/lib/r2.ts - Existing permissions tree —
server/src/lib/permissions.ts

