Skip to main content

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 a users 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
A clinic’s patient roster (“records”) and the subset of patients who can log in at id.odontox.io (“portal accounts”) are conceptually different but conflated in code. Pricing should treat them differently: records are essentially free at our scale, login surface area (auth attempts, password resets, session storage, support) is what costs money. Likewise R2 storage is real money per GB-month and must be metered.

Goals

  1. Distinguish patient records from patient portal accounts.
  2. Enforce per-plan caps: Pro = 100 portal seats + 100 GB storage; Pro+ = 250 portal seats + 250 GB storage; Enterprise = custom.
  3. Provide monthly-recurring addons for both caps with superadmin approval.
  4. Block uploads (cleanly, with upgrade CTA) when storage cap is reached.
  5. Allow bulk imports past the seat cap by importing overflow as records-only, never an error.
  6. Surface usage and addons in three places: clinic admin (Settings → My Billing), the upgrade page, and the superadmin tenant detail page.
  7. 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)

TierPortal seatsStorage capRecordsNotes
Pro100100 GB
Pro+250250 GB
EnterprisecustomcustomSet via superadmin tenant detail
Free trial14 daysmirrors chosen tier with no enforcementCaps 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 each addon_requests row at creation time, so already-pending requests preserve the price the clinic saw. Storage addons (monthly recurring, fixed tiers):
TierMonthly price (PKR)Effective PKR/GBUse case
50 GB1,499~30Small clinic with growing records
200 GB4,999~25Mid-size clinic with imaging
500 GB9,999~20Multi-doctor clinic, DICOM-heavy
1 TB14,999~15Multi-branch / archive-heavy
A clinic can stack multiple tiers (e.g. 200 GB + 50 GB = 250 GB additional). Each tier is requested independently; superadmin approves each. Portal-seat addons (monthly recurring, 3-pack):
  • 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.
Marketing/Q-site is excluded entirely (see disclosure rule below). All addons require superadmin approval before quota is allocated. On approval: quota bump applies immediately; the addon recurs monthly on the tenant’s billing cycle until superadmin cancels it. The 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 new addon_pricing config table with these fields:
  • storage_50gb_pkr
  • storage_200gb_pkr
  • storage_500gb_pkr
  • storage_1tb_pkr
  • portal_seats_3pack_pro_pkr
  • portal_seats_3pack_pro_plus_pkr
  • updated_at, updated_by
The 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

portalSeatLimit: integer('portal_seat_limit'),        // base limit from plan; null → derive from plan
portalSeatAddon: integer('portal_seat_addon').default(0).notNull(),  // approved extra seats
storageQuotaBytes: bigint('storage_quota_bytes', { mode: 'number' }),  // base from plan; null → derive
storageAddonBytes: bigint('storage_addon_bytes', { mode: 'number' }).default(0).notNull(),
storageUsedBytes: bigint('storage_used_bytes', { mode: 'number' }).default(0).notNull(),
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

portalAccess: boolean('portal_access').default(false).notNull(),
Backfill: every patient whose 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”:
CREATE UNIQUE INDEX patients_user_id_unique ON app.patients(user_id) WHERE user_id IS NOT NULL;

New addon_requests table

addonTypeEnum: pgEnum('addon_type', ['storage', 'portal_seats']);
addonRequestStatusEnum: pgEnum('addon_request_status', ['pending', 'approved', 'rejected', 'cancelled']);

addonRequests = appSchema.table('addon_requests', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  clinicId: text('clinic_id').notNull(),
  requestedBy: text('requested_by').notNull(),    // users.id
  addonType: addonTypeEnum('addon_type').notNull(),
  quantity: integer('quantity').notNull(),         // GB for storage, seats for portal
  unitPricePkr: integer('unit_price_pkr').notNull(),  // snapshot at request time
  totalPricePkr: integer('total_price_pkr').notNull(),
  status: addonRequestStatusEnum('status').default('pending').notNull(),
  approvedBy: text('approved_by'),                 // users.id (superadmin)
  approvedAt: timestamp('approved_at'),
  cancelledAt: timestamp('cancelled_at'),          // null = active addon, non-null = no longer billing
  cancelledBy: text('cancelled_by'),               // users.id (superadmin)
  rejectedReason: text('rejected_reason'),
  notes: text('notes'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
We separate 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.
export const PLAN_LIMITS = {
  pro: { portalSeats: 100, storageBytes: 100 * GB, doctors: 3, receptionists: 1, admins: 1 },
  proPlus: { portalSeats: 250, storageBytes: 250 * GB, doctors: 6, receptionists: 2, admins: 2 },
  enterprise: { portalSeats: null, storageBytes: null, /* custom */ },
} as const;

export const ADDON_PRICING = {
  storagePerGbPkr: 399,
  storage10GbPackPkr: 2999,
  portalSeats3PackPkr: { pro: 999, proPlus: 699 },
} as const;
UI and server import from here. AdminBillingSettings hardcoded values are removed.

Storage usage tracking

A helper module server/src/lib/storage-tracker.ts exposes:
async function checkUploadAllowed(clinicId, fileSize): Promise<{ allowed: boolean; reason?: string }>
async function recordUpload(clinicId, fileSize, fileId): Promise<void>
async function recordDelete(clinicId, fileSize): Promise<void>
async function recomputeUsage(clinicId): Promise<number>  // sum from patient_files table
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 requests
  • DELETE /api/v1/protected/billing/addon-requests/:id — cancel a pending request
  • PATCH /api/v1/protected/patients/:id/portal-access — body { enabled: boolean }. Server validates seat cap, links/unlinks users row.

Superadmin

  • GET /api/v1/protected/superadmin/addon-requests?status=pending — across all tenants
  • POST /api/v1/protected/superadmin/addon-requests/:id/approve — applies quota delta, writes audit log, emails clinic admin
  • POST /api/v1/protected/superadmin/addon-requests/:id/reject — body { reason }
  • POST /api/v1/protected/superadmin/clinics/:id/storage-quota — direct override for Enterprise tier
  • POST /api/v1/protected/superadmin/clinics/:id/portal-seat-limit — direct override for Enterprise tier
  • POST /api/v1/protected/superadmin/clinics/:id/recompute-storage — triggers recomputeUsage

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_requests row.
  • Request more portal seats — 3-pack count selector, shows price calculation, submit.
A “Recent requests” table at the bottom shows pending/approved/rejected with cancel button for pending.

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
A small badge (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_pricing config row, invalidates 60-s cache, writes audit log entry addon_pricing.updated with before/after values
  • Shows last-updated timestamp and updater’s email
  • Read-only history table (last 10 changes) below the editor
Pricing changes do NOT retroactively repurpose pending or approved addons — those keep their original snapshotted price. New requests submitted after the change use the new price.

Free-trial handling

During the 14-day trial:
  • portal_seat_limit and storage_quota_bytes are set to the trial’s tier value, but enforcement is bypassed via a isTrial check 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):
  1. Identify the tenant’s clinic_id.
  2. Import the first 250 patients with portal_access=true (these are the ones the clinic admin nominates as “active”).
  3. Import the remaining 6,250 with portal_access=false.
  4. The clinic admin can later toggle individual patients on/off subject to the cap.
The import wizard supports this directly via the CSV 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:
  1. 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.
  2. What it does NOT change — Records, X-rays, appointments, treatment plans, file storage all work identically for both.
  3. Three subdomains — Comparison table:
    SubdomainPurposeLogin required?
    go.odontox.ioMain app — where everyone works after signing inYes (redirected from id.odontox.io)
    id.odontox.ioSign-in page for all rolesThis is the sign-in surface itself
    portal.odontox.ioPublic file-share links sent to recipientsNo — link-based access only
  4. How seats are counted — Only patients with portal_access = on count toward the limit.
  5. What happens at the cap — Toggle disables on new patients. Existing logged-in patients are unaffected.
  6. Freeing a seat — Toggle off on a patient whose access is no longer needed; their login is suspended, records remain.
  7. Adding more seats — Request a 3-pack addon via Settings → My Billing or the upgrade page.
  8. MAU stat — Explains the “monthly active patients” number is informational, not a limit.
  9. FAQ — Cap reached during import, deletion vs revoke, re-enabling, what counts as “active”.

Permissions

Two new permission keys in server/src/lib/permissions.ts:
  • billing.request_addon — clinic admin (default true), receptionist (default false)
  • superadmin.addon.approve — superadmin only
Patient portal_access toggle reuses existing patients.edit permission.

Audit log entries

  • addon_request.created — clinic
  • addon_request.cancelled — clinic
  • addon_request.approved — superadmin (records quota delta)
  • addon_request.rejected — superadmin
  • portal_access.granted / portal_access.revoked — clinic admin actions on patients
  • storage_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 UPDATE on 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 storageUsedBytes against the sum of patient_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 clinics and patients; migration runs cleanly on prod-shaped data.
  • plan-limits.ts is 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=true patients 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-month standard 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