Skip to main content

Portal-Access Seat Caps & Storage Caps Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Add hard caps on (a) patient portal accounts and (b) R2 storage per clinic, with monthly recurring addons requiring superadmin approval; replace the legacy patient-quota.ts / PATIENT_ADDON:-in-licenseRequests kludge with a clean addon_requests ledger. Architecture: Single source of truth for plan limits in server/src/lib/plan-limits.ts. Per-tenant counters live on the clinics table (portalSeatAddon, storageQuotaBytes, storageAddonBytes, storageUsedBytes). Portal access becomes an explicit patients.portalAccess boolean (backfilled from userId IS NOT NULL). Storage is tracked transactionally with atomic UPDATE ... SET col = col + delta. Addon requests use a dedicated addon_requests table that doubles as the active-addons ledger (status='approved' AND cancelledAt IS NULL). Tech Stack: Hono + Drizzle + Postgres (Neon), Cloudflare Workers + R2, React 19 + Vite UI, Vitest for tests. Spec: docs/superpowers/specs/2026-05-13-portal-seats-storage-addons.md

Context & gotchas an outside engineer needs to know

  1. Legacy module being replaced: server/src/lib/patient-quota.ts enforces an old model with totalPatientLimit (records cap) + includedPortalPatients (portal-within-records cap). The new spec makes records uncapped and portal seats the only cap. Old callers in server/src/routes/billing.ts (syncLicenseFromPlan, calculatePatientQuotaUsage) and ui/src/components/settings/AdminBillingSettings.tsx must be migrated.
  2. Legacy addon storage: Existing patient-portal addons are stored in license_requests table with reason field prefixed PATIENT_ADDON:<json>. Phase 9 migrates these into addon_requests.
  3. Drizzle migrations: Migrations live in server/drizzle/NNNN_<name>.sql. Apply with npm run db:migrate (calls scripts/run-drizzle-migrations.ts). Next number is 0033.
  4. Schema in DB is app.*: All tables are in the app Postgres schema. Migrations and queries must use app.<table>.
  5. R2Service: server/src/lib/r2.ts is the only upload entrypoint. Gating happens via a thin wrapper, not by modifying R2Service itself.
  6. Tests: vitest in server/. Run with npm test from server/ dir. Frontend has no automated tests; UI work verified by manual smoke + type check (npm run typecheck in ui/).
  7. Auth: All clinic routes mounted under protectedRoutes (see server/src/api.ts) already have dualAuthMiddleware + clinicContextMiddleware. Don’t re-apply.
  8. Permissions: Registered in server/src/lib/permissions.ts PERMISSION_KEYS array. Middleware: requirePermission('key') from server/src/middleware/permissions.ts.
  9. Superadmin auth: Lives in server/src/routes/superadmin/ under /api/v1/protected/superadmin/* with requireSuperadmin middleware.
  10. Audit logs: Use recordAuditLog() from server/src/lib/audit-helper.ts. Shape: { clinicId, userId, action, entityType, entityId, changes }.
  11. Email: sendEmailViaZepto(recipients, subject, html, opts) from server/src/lib/email.ts. Templates in server/src/emails/ as React components rendered via @react-email/render.

Phase 1: Foundation (data model + constants)

Subagent assignment: 1 subagent end-to-end. Blocks all other phases.

Task 1.1: Centralized plan limits config

Files:
  • Create: server/src/lib/plan-limits.ts
  • Test: server/src/lib/plan-limits.test.ts
  • Step 1: Write failing test
server/src/lib/plan-limits.test.ts:
import { describe, it, expect } from 'vitest';
import {
  PLAN_LIMITS,
  ADDON_PRICING,
  resolvePlanKey,
  getPortalSeatLimit,
  getStorageQuotaBytes,
  GB,
} from './plan-limits';

describe('plan-limits', () => {
  it('GB constant equals 1024^3 bytes', () => {
    expect(GB).toBe(1024 * 1024 * 1024);
  });

  it('Pro plan: 100 portal seats, 100 GB storage', () => {
    expect(PLAN_LIMITS.pro.portalSeats).toBe(100);
    expect(PLAN_LIMITS.pro.storageBytes).toBe(100 * GB);
  });

  it('Pro+ plan: 250 portal seats, 250 GB storage', () => {
    expect(PLAN_LIMITS.proPlus.portalSeats).toBe(250);
    expect(PLAN_LIMITS.proPlus.storageBytes).toBe(250 * GB);
  });

  it('Enterprise plan: null caps (custom)', () => {
    expect(PLAN_LIMITS.enterprise.portalSeats).toBeNull();
    expect(PLAN_LIMITS.enterprise.storageBytes).toBeNull();
  });

  it('addon pricing: four storage tiers at fixed monthly prices', () => {
    expect(ADDON_PRICING.storageTiers[50]).toBe(1499);
    expect(ADDON_PRICING.storageTiers[200]).toBe(4999);
    expect(ADDON_PRICING.storageTiers[500]).toBe(9999);
    expect(ADDON_PRICING.storageTiers[1000]).toBe(14999);
  });

  it('addon pricing: portal 3-pack is PKR 999 Pro, PKR 699 Pro+', () => {
    expect(ADDON_PRICING.portalSeats3PackPkr.pro).toBe(999);
    expect(ADDON_PRICING.portalSeats3PackPkr.proPlus).toBe(699);
  });

  it('resolvePlanKey normalizes plan name/tier variants', () => {
    expect(resolvePlanKey({ planName: 'Pro', planTier: null })).toBe('pro');
    expect(resolvePlanKey({ planName: 'pro+', planTier: null })).toBe('proPlus');
    expect(resolvePlanKey({ planName: 'Pro Plus', planTier: null })).toBe('proPlus');
    expect(resolvePlanKey({ planName: null, planTier: 'pro_plus' })).toBe('proPlus');
    expect(resolvePlanKey({ planName: null, planTier: 'enterprise' })).toBe('enterprise');
    expect(resolvePlanKey({ planName: 'unknown', planTier: null })).toBe('pro');
  });

  it('getPortalSeatLimit handles base + addon, returns null for enterprise', () => {
    expect(getPortalSeatLimit('pro', 6)).toBe(106);
    expect(getPortalSeatLimit('proPlus', 0)).toBe(250);
    expect(getPortalSeatLimit('enterprise', 50)).toBeNull();
  });

  it('getStorageQuotaBytes handles base + addon, returns null for enterprise', () => {
    expect(getStorageQuotaBytes('pro', 5 * GB)).toBe(105 * GB);
    expect(getStorageQuotaBytes('enterprise', 0)).toBeNull();
  });
});
  • Step 2: Run test, verify fail
cd server && npx vitest run src/lib/plan-limits.test.ts Expected: cannot find module.
  • Step 3: Implement module
server/src/lib/plan-limits.ts:
export const GB = 1024 * 1024 * 1024;

export type PlanKey = 'pro' | 'proPlus' | 'enterprise';

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 as number | null,
    storageBytes: null as number | null,
    doctors: 999,
    receptionists: 999,
    admins: 999,
  },
} as const;

export type StorageTierGb = 50 | 200 | 500 | 1000;

export const ADDON_PRICING = {
  storageTiers: {
    50: 1499,
    200: 4999,
    500: 9999,
    1000: 14999,
  } as Record<StorageTierGb, number>,
  portalSeats3PackPkr: {
    pro: 999,
    proPlus: 699,
  },
} as const;

export const STORAGE_TIER_LABELS: Record<StorageTierGb, string> = {
  50: '50 GB',
  200: '200 GB',
  500: '500 GB',
  1000: '1 TB',
};

export const STORAGE_TIER_DESCRIPTIONS: Record<StorageTierGb, string> = {
  50: 'Secure business storage inside your app — customer files, uploads, access control, backups, sharing, support, no technical setup.',
  200: 'Growing imaging library, mid-size clinic. Everything in 50 GB tier plus headroom for daily X-rays and treatment photos.',
  500: 'Multi-doctor clinic with heavy DICOM. Full audit trail, automatic backups, instant sharing — no IT involvement needed.',
  1000: 'Multi-branch or archive-heavy workloads. Enterprise-grade redundancy, fastest restore tier, priority support.',
};

const norm = (v?: string | null) => (v || '').trim().toLowerCase();

export function resolvePlanKey(input: { planName?: string | null; planTier?: string | null }): PlanKey {
  const name = norm(input.planName);
  const tier = norm(input.planTier);
  if (name.includes('enterprise') || tier.includes('enterprise')) return 'enterprise';
  if (
    name === 'pro+' ||
    name === 'pro plus' ||
    name === 'proplus' ||
    name.startsWith('pro+ ') ||
    tier === 'pro_plus' ||
    tier === 'pro-plus' ||
    tier === 'proplus' ||
    tier.startsWith('pro_plus')
  ) return 'proPlus';
  if (name.startsWith('pro') || tier.startsWith('pro')) return 'pro';
  return 'pro'; // safe default
}

export function getPortalSeatLimit(plan: PlanKey, addon: number): number | null {
  const base = PLAN_LIMITS[plan].portalSeats;
  if (base === null) return null;
  return base + Math.max(0, addon);
}

export function getStorageQuotaBytes(plan: PlanKey, addonBytes: number): number | null {
  const base = PLAN_LIMITS[plan].storageBytes;
  if (base === null) return null;
  return base + Math.max(0, addonBytes);
}
  • Step 4: Run test, verify pass
cd server && npx vitest run src/lib/plan-limits.test.ts Expected: 8 passed.
  • Step 5: Commit
git add server/src/lib/plan-limits.ts server/src/lib/plan-limits.test.ts
git commit -m "feat(billing): centralized plan limits + addon pricing config"

Task 1.2: SQL migration — clinics columns, patients.portalAccess, addon_requests

Files:
  • Create: server/drizzle/0033_portal_seats_storage_addons.sql
  • Step 1: Write migration
server/drizzle/0033_portal_seats_storage_addons.sql:
-- Phase 1: Portal seat caps + storage caps + addon ledger
-- Spec: docs/superpowers/specs/2026-05-13-portal-seats-storage-addons.md

-- ============================================================
-- 1) Extend clinics with quota + usage counters
-- ============================================================
ALTER TABLE app.clinics
  ADD COLUMN IF NOT EXISTS portal_seat_limit INTEGER,
  ADD COLUMN IF NOT EXISTS portal_seat_addon INTEGER NOT NULL DEFAULT 0,
  ADD COLUMN IF NOT EXISTS storage_quota_bytes BIGINT,
  ADD COLUMN IF NOT EXISTS storage_addon_bytes BIGINT NOT NULL DEFAULT 0,
  ADD COLUMN IF NOT EXISTS storage_used_bytes BIGINT NOT NULL DEFAULT 0;

-- Sanity guards: addon and usage cannot be negative
ALTER TABLE app.clinics
  ADD CONSTRAINT clinics_portal_seat_addon_nonneg CHECK (portal_seat_addon >= 0),
  ADD CONSTRAINT clinics_storage_addon_bytes_nonneg CHECK (storage_addon_bytes >= 0),
  ADD CONSTRAINT clinics_storage_used_bytes_nonneg CHECK (storage_used_bytes >= 0);

-- ============================================================
-- 2) patients.portal_access flag (backfilled below)
-- ============================================================
ALTER TABLE app.patients
  ADD COLUMN IF NOT EXISTS portal_access BOOLEAN NOT NULL DEFAULT false;

-- Backfill: existing patients with user_id (and not soft-deleted) are portal users
UPDATE app.patients
   SET portal_access = true
 WHERE user_id IS NOT NULL
   AND deleted_at IS NULL;

-- Enforce 1:1 between portal patients and user accounts
CREATE UNIQUE INDEX IF NOT EXISTS patients_user_id_unique
  ON app.patients(user_id)
  WHERE user_id IS NOT NULL;

-- Fast seat-count query (used on every toggle/check)
CREATE INDEX IF NOT EXISTS patients_clinic_portal_active_idx
  ON app.patients(clinic_id, portal_access)
  WHERE deleted_at IS NULL;

-- ============================================================
-- 3) addon_requests ledger
-- ============================================================
DO $$ BEGIN
  CREATE TYPE app.addon_type AS ENUM ('storage', 'portal_seats');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;

DO $$ BEGIN
  CREATE TYPE app.addon_request_status AS ENUM ('pending', 'approved', 'rejected', 'cancelled');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;

CREATE TABLE IF NOT EXISTS app.addon_requests (
  id              TEXT PRIMARY KEY,
  clinic_id       TEXT NOT NULL REFERENCES app.clinics(id) ON DELETE CASCADE,
  requested_by    TEXT NOT NULL,
  addon_type      app.addon_type NOT NULL,
  quantity        INTEGER NOT NULL CHECK (quantity > 0),
  unit_price_pkr  INTEGER NOT NULL CHECK (unit_price_pkr >= 0),
  total_price_pkr INTEGER NOT NULL CHECK (total_price_pkr >= 0),
  status          app.addon_request_status NOT NULL DEFAULT 'pending',
  approved_by     TEXT,
  approved_at     TIMESTAMP,
  cancelled_at    TIMESTAMP,
  cancelled_by    TEXT,
  rejected_reason TEXT,
  notes           TEXT,
  created_at      TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at      TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS addon_requests_clinic_status_idx
  ON app.addon_requests(clinic_id, status);

CREATE INDEX IF NOT EXISTS addon_requests_status_created_idx
  ON app.addon_requests(status, created_at DESC);

-- Active-addons partial index for fast quota arithmetic & superadmin views
CREATE INDEX IF NOT EXISTS addon_requests_active_idx
  ON app.addon_requests(clinic_id, addon_type)
  WHERE status = 'approved' AND cancelled_at IS NULL;
  • Step 2: Apply locally and verify
cd server && npm run db:migrate
Expected: “Applied 0033_portal_seats_storage_addons” with no errors. Run again — should be a no-op (IF NOT EXISTS guards).
  • Step 3: Sanity-check schema in Postgres
cd server && npx tsx -e "
import { getReadDb } from './src/lib/db';
const db = getReadDb({} as any);
const r = await db.execute(\`SELECT column_name FROM information_schema.columns WHERE table_schema='app' AND table_name='clinics' AND column_name LIKE '%portal%' OR column_name LIKE '%storage%';\`);
console.log(r);
"
Expected: 5 rows (portal_seat_limit, portal_seat_addon, storage_quota_bytes, storage_addon_bytes, storage_used_bytes). Note: the user has a strict rule — do NOT run anything against the production DB. Use a local/dev DATABASE_URL.
  • Step 4: Commit
git add server/drizzle/0033_portal_seats_storage_addons.sql
git commit -m "feat(db): migration 0033 — portal seat caps, storage caps, addon ledger"

Task 1.2b: Addon pricing config table

Files:
  • Modify: server/drizzle/0033_portal_seats_storage_addons.sql (append to same migration)
  • Step 1: Append to the migration
Add this block to the bottom of 0033_portal_seats_storage_addons.sql:
-- ============================================================
-- 4) Addon pricing config (superadmin-editable, single-row)
-- ============================================================
CREATE TABLE IF NOT EXISTS app.addon_pricing (
  id                              TEXT PRIMARY KEY,
  storage_50gb_pkr                INTEGER NOT NULL CHECK (storage_50gb_pkr >= 0),
  storage_200gb_pkr               INTEGER NOT NULL CHECK (storage_200gb_pkr >= 0),
  storage_500gb_pkr               INTEGER NOT NULL CHECK (storage_500gb_pkr >= 0),
  storage_1tb_pkr                 INTEGER NOT NULL CHECK (storage_1tb_pkr >= 0),
  portal_seats_3pack_pro_pkr      INTEGER NOT NULL CHECK (portal_seats_3pack_pro_pkr >= 0),
  portal_seats_3pack_pro_plus_pkr INTEGER NOT NULL CHECK (portal_seats_3pack_pro_plus_pkr >= 0),
  updated_at                      TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_by                      TEXT
);

-- Seed with launch defaults (idempotent)
INSERT INTO app.addon_pricing (id, storage_50gb_pkr, storage_200gb_pkr, storage_500gb_pkr, storage_1tb_pkr, portal_seats_3pack_pro_pkr, portal_seats_3pack_pro_plus_pkr)
VALUES ('current', 1499, 4999, 9999, 14999, 999, 699)
ON CONFLICT (id) DO NOTHING;

-- Audit table for pricing history
CREATE TABLE IF NOT EXISTS app.addon_pricing_history (
  id              TEXT PRIMARY KEY,
  changed_at      TIMESTAMP NOT NULL DEFAULT NOW(),
  changed_by      TEXT,
  before_values   JSONB NOT NULL,
  after_values    JSONB NOT NULL
);

CREATE INDEX IF NOT EXISTS addon_pricing_history_changed_at_idx
  ON app.addon_pricing_history(changed_at DESC);
  • Step 2: Re-apply migration
cd server && npm run db:migrate
Expected: idempotent — addon_pricing has 1 row (current).
  • Step 3: Commit
git add server/drizzle/0033_portal_seats_storage_addons.sql
git commit --amend --no-edit
Note for implementer: If migration 0033 was already pushed to a shared/remote branch, do NOT amend. Instead, create migration 0034_addon_pricing.sql containing only the addon_pricing tables.

Task 1.3: Drizzle schema updates

Files:
  • Modify: server/src/schema/clinics.ts
  • Modify: server/src/schema/patients.ts
  • Create: server/src/schema/addon_requests.ts
  • Modify: server/src/schema/index.ts
  • Step 1: Update clinics schema
In server/src/schema/clinics.ts, add to the imports if missing:
import { pgTable, text, timestamp, varchar, pgEnum, boolean, jsonb, numeric, integer, bigint } from 'drizzle-orm/pg-core';
Inside the clinics table definition (anywhere after referralPayoutMode), add:
  // Portal-seat and storage quotas (set on plan activation; null → derive from plan)
  portalSeatLimit: integer('portal_seat_limit'),
  portalSeatAddon: integer('portal_seat_addon').default(0).notNull(),
  storageQuotaBytes: bigint('storage_quota_bytes', { mode: 'number' }),
  storageAddonBytes: bigint('storage_addon_bytes', { mode: 'number' }).default(0).notNull(),
  storageUsedBytes: bigint('storage_used_bytes', { mode: 'number' }).default(0).notNull(),
  • Step 2: Update patients schema
In server/src/schema/patients.ts, replace the import line with:
import { pgTable, text, timestamp, pgEnum, boolean, index } from 'drizzle-orm/pg-core';
Add the portalAccess field after status:
  portalAccess: boolean('portal_access').default(false).notNull(),
In the indexes block (the (table) => { return { ... } } part), add:
    clinicPortalActiveIdx: index('patients_clinic_portal_active_idx').on(table.clinicId, table.portalAccess),
  • Step 3: Create addon_requests schema
server/src/schema/addon_requests.ts:
import { text, timestamp, integer, pgEnum, index } from 'drizzle-orm/pg-core';
import { appSchema } from './base';

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

export const addonRequests = appSchema.table('addon_requests', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  clinicId: text('clinic_id').notNull(),         // FK to clinics.id (enforced in SQL)
  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(),
  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'),
  cancelledBy: text('cancelled_by'),
  rejectedReason: text('rejected_reason'),
  notes: text('notes'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => ({
  clinicStatusIdx: index('addon_requests_clinic_status_idx').on(table.clinicId, table.status),
  statusCreatedIdx: index('addon_requests_status_created_idx').on(table.status, table.createdAt),
}));

export type AddonRequest = typeof addonRequests.$inferSelect;
export type NewAddonRequest = typeof addonRequests.$inferInsert;
  • Step 4: Update schema barrel
In server/src/schema/index.ts, add near the other admin/billing exports:
// Addon Requests (storage + portal seat addons)
export * from './addon_requests';
// Addon Pricing (superadmin-editable)
export * from './addon_pricing';
Also create server/src/schema/addon_pricing.ts:
import { text, timestamp, integer, jsonb, index } from 'drizzle-orm/pg-core';
import { appSchema } from './base';

export const addonPricing = appSchema.table('addon_pricing', {
  id: text('id').primaryKey(),  // always 'current' for the live row
  storage50GbPkr: integer('storage_50gb_pkr').notNull(),
  storage200GbPkr: integer('storage_200gb_pkr').notNull(),
  storage500GbPkr: integer('storage_500gb_pkr').notNull(),
  storage1TbPkr: integer('storage_1tb_pkr').notNull(),
  portalSeats3PackProPkr: integer('portal_seats_3pack_pro_pkr').notNull(),
  portalSeats3PackProPlusPkr: integer('portal_seats_3pack_pro_plus_pkr').notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
  updatedBy: text('updated_by'),
});

export const addonPricingHistory = appSchema.table('addon_pricing_history', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  changedAt: timestamp('changed_at').defaultNow().notNull(),
  changedBy: text('changed_by'),
  beforeValues: jsonb('before_values').notNull(),
  afterValues: jsonb('after_values').notNull(),
}, (table) => ({
  changedAtIdx: index('addon_pricing_history_changed_at_idx').on(table.changedAt),
}));

export type AddonPricing = typeof addonPricing.$inferSelect;
  • Step 5: Type-check
cd server && npx tsc --noEmit
Expected: 0 errors.
  • Step 6: Commit
git add server/src/schema/clinics.ts server/src/schema/patients.ts server/src/schema/addon_requests.ts server/src/schema/index.ts
git commit -m "feat(db): Drizzle schema for portal/storage quotas + addon_requests"

Phase 2: Server core (storage tracker + portal toggle)

Subagent assignment: 1 subagent. Depends on Phase 1.

Task 2.1: Storage tracker module

Files:
  • Create: server/src/lib/storage-tracker.ts
  • Test: server/src/lib/storage-tracker.test.ts
  • Step 1: Write failing test
server/src/lib/storage-tracker.test.ts:
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { checkUploadAllowed, recordUpload, recordDelete, getEffectiveStorageQuota } from './storage-tracker';

const mockDb = {
  select: vi.fn(),
  update: vi.fn(),
  execute: vi.fn(),
};

describe('storage-tracker', () => {
  beforeEach(() => vi.clearAllMocks());

  it('checkUploadAllowed returns allowed:true under quota', async () => {
    mockDb.select.mockReturnValue({
      from: () => ({ where: () => ({ limit: () => Promise.resolve([
        { storageUsedBytes: 1024, storageQuotaBytes: null, storageAddonBytes: 0, subscriptionStatus: 'active', subscriptionPlanId: 'pro' },
      ]) }) }),
    });
    const result = await checkUploadAllowed(mockDb as any, 'c1', 2048, { isTrial: false, planKey: 'pro' });
    expect(result.allowed).toBe(true);
  });

  it('checkUploadAllowed returns allowed:false over quota', async () => {
    const GB = 1024 * 1024 * 1024;
    mockDb.select.mockReturnValue({
      from: () => ({ where: () => ({ limit: () => Promise.resolve([
        { storageUsedBytes: 100 * GB, storageQuotaBytes: null, storageAddonBytes: 0, subscriptionStatus: 'active', subscriptionPlanId: 'pro' },
      ]) }) }),
    });
    const result = await checkUploadAllowed(mockDb as any, 'c1', 1024, { isTrial: false, planKey: 'pro' });
    expect(result.allowed).toBe(false);
    expect(result.reason).toContain('storage');
  });

  it('checkUploadAllowed bypasses cap during trial', async () => {
    const GB = 1024 * 1024 * 1024;
    mockDb.select.mockReturnValue({
      from: () => ({ where: () => ({ limit: () => Promise.resolve([
        { storageUsedBytes: 9999 * GB, storageQuotaBytes: null, storageAddonBytes: 0, subscriptionStatus: 'trial', subscriptionPlanId: 'pro' },
      ]) }) }),
    });
    const result = await checkUploadAllowed(mockDb as any, 'c1', 1024, { isTrial: true, planKey: 'pro' });
    expect(result.allowed).toBe(true);
  });

  it('getEffectiveStorageQuota uses override when set, else plan + addon', async () => {
    const GB = 1024 * 1024 * 1024;
    expect(getEffectiveStorageQuota({ storageQuotaBytes: 500 * GB, storageAddonBytes: 0, planKey: 'pro' })).toBe(500 * GB);
    expect(getEffectiveStorageQuota({ storageQuotaBytes: null, storageAddonBytes: 10 * GB, planKey: 'pro' })).toBe(110 * GB);
    expect(getEffectiveStorageQuota({ storageQuotaBytes: null, storageAddonBytes: 0, planKey: 'enterprise' })).toBeNull();
  });

  it('recordUpload increments atomically', async () => {
    const updateMock = vi.fn().mockReturnValue({ set: () => ({ where: () => Promise.resolve() }) });
    mockDb.update.mockImplementation(updateMock);
    await recordUpload(mockDb as any, 'c1', 4096);
    expect(updateMock).toHaveBeenCalled();
  });
});
  • Step 2: Run test — fail
cd server && npx vitest run src/lib/storage-tracker.test.ts Expected: cannot find module.
  • Step 3: Implement
server/src/lib/storage-tracker.ts:
import { eq, sql } from 'drizzle-orm';
import { clinics } from '../schema';
import { PLAN_LIMITS, type PlanKey } from './plan-limits';

export interface UploadCheckOptions {
  isTrial: boolean;
  planKey: PlanKey;
}

export interface UploadCheck {
  allowed: boolean;
  reason?: string;
  usedBytes?: number;
  quotaBytes?: number | null;
}

/**
 * Compute effective storage quota for a clinic. Order of precedence:
 *   1. Direct override on clinics.storage_quota_bytes (Enterprise tier)
 *   2. Plan base (from PLAN_LIMITS) + addons
 *   3. null → unlimited (Enterprise)
 */
export function getEffectiveStorageQuota(opts: {
  storageQuotaBytes: number | null;
  storageAddonBytes: number;
  planKey: PlanKey;
}): number | null {
  if (opts.storageQuotaBytes !== null && opts.storageQuotaBytes !== undefined) {
    return opts.storageQuotaBytes + (opts.storageAddonBytes || 0);
  }
  const base = PLAN_LIMITS[opts.planKey].storageBytes;
  if (base === null) return null;
  return base + (opts.storageAddonBytes || 0);
}

export async function checkUploadAllowed(
  db: any,
  clinicId: string,
  fileSize: number,
  opts: UploadCheckOptions,
): Promise<UploadCheck> {
  const [row] = await db
    .select({
      storageUsedBytes: clinics.storageUsedBytes,
      storageQuotaBytes: clinics.storageQuotaBytes,
      storageAddonBytes: clinics.storageAddonBytes,
    })
    .from(clinics)
    .where(eq(clinics.id, clinicId))
    .limit(1);

  if (!row) return { allowed: false, reason: 'clinic not found' };
  if (opts.isTrial) return { allowed: true, usedBytes: row.storageUsedBytes ?? 0 };

  const quota = getEffectiveStorageQuota({
    storageQuotaBytes: row.storageQuotaBytes ?? null,
    storageAddonBytes: row.storageAddonBytes ?? 0,
    planKey: opts.planKey,
  });

  const used = row.storageUsedBytes ?? 0;
  if (quota === null) return { allowed: true, usedBytes: used, quotaBytes: null };

  if (used + fileSize > quota) {
    return {
      allowed: false,
      reason: 'storage quota exceeded',
      usedBytes: used,
      quotaBytes: quota,
    };
  }
  return { allowed: true, usedBytes: used, quotaBytes: quota };
}

export async function recordUpload(db: any, clinicId: string, delta: number): Promise<void> {
  if (delta <= 0) return;
  await db
    .update(clinics)
    .set({ storageUsedBytes: sql`${clinics.storageUsedBytes} + ${delta}` })
    .where(eq(clinics.id, clinicId));
}

export async function recordDelete(db: any, clinicId: string, delta: number): Promise<void> {
  if (delta <= 0) return;
  await db
    .update(clinics)
    .set({ storageUsedBytes: sql`GREATEST(0, ${clinics.storageUsedBytes} - ${delta})` })
    .where(eq(clinics.id, clinicId));
}

/**
 * Source-of-truth recomputation. Sums sizes across every file table.
 * Called from nightly cron and superadmin "Recompute storage" button.
 */
export async function recomputeUsage(db: any, clinicId: string): Promise<number> {
  const result = await db.execute(sql`
    SELECT
      COALESCE((SELECT SUM(size) FROM app.patient_files WHERE clinic_id = ${clinicId} AND deleted_at IS NULL), 0)
      AS total
  `);
  const total = Number((result as any).rows?.[0]?.total ?? (result as any)[0]?.total ?? 0);
  await db
    .update(clinics)
    .set({ storageUsedBytes: total })
    .where(eq(clinics.id, clinicId));
  return total;
}
  • Step 4: Run tests — pass
cd server && npx vitest run src/lib/storage-tracker.test.ts Expected: 5 passed.
  • Step 5: Commit
git add server/src/lib/storage-tracker.ts server/src/lib/storage-tracker.test.ts
git commit -m "feat(storage): storage-tracker module with atomic upload/delete + quota check"

Task 2.2: Trial helper

Files:
  • Create: server/src/lib/trial-status.ts
  • Test: server/src/lib/trial-status.test.ts
  • Step 1: Write test
import { describe, it, expect } from 'vitest';
import { isClinicOnTrial } from './trial-status';

describe('trial-status', () => {
  it('returns true when subscriptionStatus is trial and trial not yet ended', () => {
    const tomorrow = new Date(Date.now() + 86400000);
    expect(isClinicOnTrial({ subscriptionStatus: 'trial', trialEndDate: tomorrow })).toBe(true);
  });

  it('returns false when subscriptionStatus is trial but trial has expired', () => {
    const yesterday = new Date(Date.now() - 86400000);
    expect(isClinicOnTrial({ subscriptionStatus: 'trial', trialEndDate: yesterday })).toBe(false);
  });

  it('returns false when subscriptionStatus is active', () => {
    const tomorrow = new Date(Date.now() + 86400000);
    expect(isClinicOnTrial({ subscriptionStatus: 'active', trialEndDate: tomorrow })).toBe(false);
  });

  it('returns false when trialEndDate is null', () => {
    expect(isClinicOnTrial({ subscriptionStatus: 'trial', trialEndDate: null })).toBe(false);
  });
});
  • Step 2: Implement
server/src/lib/trial-status.ts:
export function isClinicOnTrial(clinic: {
  subscriptionStatus?: string | null;
  trialEndDate?: Date | string | null;
}): boolean {
  if (clinic.subscriptionStatus !== 'trial') return false;
  if (!clinic.trialEndDate) return false;
  const end = clinic.trialEndDate instanceof Date ? clinic.trialEndDate : new Date(clinic.trialEndDate);
  return end.getTime() > Date.now();
}
  • Step 3: Run tests, commit
cd server && npx vitest run src/lib/trial-status.test.ts
git add server/src/lib/trial-status.ts server/src/lib/trial-status.test.ts
git commit -m "feat(billing): isClinicOnTrial helper for cap-enforcement bypass"

Task 2.3: Wire checkUploadAllowed into all R2 upload routes

Files (modify each):
  • server/src/routes/patient-files.ts
  • server/src/routes/signatures.ts (if separate; else inline in users.ts)
  • server/src/routes/prescriptions.ts
  • server/src/routes/letterhead.ts (or wherever clinic doc uploads live)
  • server/src/routes/lab-cases.ts
  • server/src/routes/insurance-claims.ts
  • server/src/routes/branding.ts (clinic logo/favicon)
  • server/src/routes/dicom.ts (if exists)
  • server/src/routes/messages.ts (attachment uploads)
For each route file: find every r2.uploadFile(...) call and wrap with the check + record pattern below.
  • Step 1: Identify all upload sites
cd server && grep -rn "uploadFile\|r2.put\|R2Service" src/routes --include="*.ts" -l
List every file. Process one at a time.
  • Step 2: For each upload site, apply this pattern
Before the existing await r2.uploadFile(...):
import { checkUploadAllowed, recordUpload } from '../lib/storage-tracker';
import { isClinicOnTrial } from '../lib/trial-status';
import { resolvePlanKey } from '../lib/plan-limits';

// At top of the handler, after clinic loaded:
const planKey = resolvePlanKey({ planName: clinic.subscriptionPlanName, planTier: clinic.subscriptionPlanTier });
const check = await checkUploadAllowed(db, clinicId, fileSize, {
  isTrial: isClinicOnTrial(clinic),
  planKey,
});
if (!check.allowed) {
  return c.json({
    error: 'STORAGE_QUOTA_EXCEEDED',
    message: 'Your clinic has reached its storage limit. Request more storage to keep uploading.',
    usedBytes: check.usedBytes,
    quotaBytes: check.quotaBytes,
  }, 413);
}
After successful uploadFile:
await recordUpload(db, clinicId, result.size);
For deletes:
import { recordDelete } from '../lib/storage-tracker';
// after successful r2.deleteFile:
await recordDelete(db, clinicId, deletedRow.size);
Subscription plan name/tier must be available. If the existing handler doesn’t load them, join subscription_plans into the clinic lookup, e.g.:
const [row] = await db.select({
  clinic: clinics,
  planName: subscriptionPlans.planName,
  planTier: subscriptionPlans.planTier,
}).from(clinics).leftJoin(subscriptionPlans, eq(clinics.subscriptionPlanId, subscriptionPlans.id)).where(eq(clinics.id, clinicId)).limit(1);
  • Step 3: Type-check each modified route
cd server && npx tsc --noEmit Expected: 0 errors.
  • Step 4: Verify with a manual integration test
Spin up npm run dev, upload one X-ray, query SELECT storage_used_bytes FROM app.clinics WHERE id = '<your-test-clinic>' — should equal the file size.
  • Step 5: Commit once all upload sites are gated
git add server/src/routes/
git commit -m "feat(storage): gate every R2 upload route on per-clinic quota"

Task 2.4: Portal-access toggle endpoint

Files:
  • Modify: server/src/routes/patients.ts (add PATCH /:id/portal-access)
  • Modify: server/src/routes/patients.ts (patient create — respect portalAccess body field)
  • Step 1: Write integration test (skip if no integration test infrastructure; rely on smoke)
  • Step 2: Add seat-counting helper
In server/src/lib/portal-seats.ts (new file):
import { and, eq, isNull, sql } from 'drizzle-orm';
import { clinics, patients } from '../schema';
import { PLAN_LIMITS, type PlanKey, getPortalSeatLimit } from './plan-limits';

export async function countActivePortalSeats(db: any, clinicId: string): Promise<number> {
  const [row] = await db
    .select({ n: sql<number>`count(*)::int` })
    .from(patients)
    .where(and(eq(patients.clinicId, clinicId), eq(patients.portalAccess, true), isNull(patients.deletedAt)));
  return Number(row?.n ?? 0);
}

export function getEffectivePortalSeatLimit(opts: {
  portalSeatLimit: number | null;
  portalSeatAddon: number;
  planKey: PlanKey;
}): number | null {
  if (opts.portalSeatLimit !== null && opts.portalSeatLimit !== undefined) {
    return opts.portalSeatLimit + (opts.portalSeatAddon || 0);
  }
  return getPortalSeatLimit(opts.planKey, opts.portalSeatAddon || 0);
}
Add a test server/src/lib/portal-seats.test.ts:
import { describe, it, expect } from 'vitest';
import { getEffectivePortalSeatLimit } from './portal-seats';

describe('portal-seats', () => {
  it('uses override when set', () => {
    expect(getEffectivePortalSeatLimit({ portalSeatLimit: 500, portalSeatAddon: 0, planKey: 'pro' })).toBe(500);
    expect(getEffectivePortalSeatLimit({ portalSeatLimit: 500, portalSeatAddon: 6, planKey: 'pro' })).toBe(506);
  });

  it('falls back to plan + addon', () => {
    expect(getEffectivePortalSeatLimit({ portalSeatLimit: null, portalSeatAddon: 3, planKey: 'pro' })).toBe(103);
    expect(getEffectivePortalSeatLimit({ portalSeatLimit: null, portalSeatAddon: 9, planKey: 'proPlus' })).toBe(259);
  });

  it('returns null for enterprise with no override', () => {
    expect(getEffectivePortalSeatLimit({ portalSeatLimit: null, portalSeatAddon: 0, planKey: 'enterprise' })).toBeNull();
  });
});
Run tests, expect 3 passed.
  • Step 3: Add the PATCH endpoint
In server/src/routes/patients.ts, register:
import { countActivePortalSeats, getEffectivePortalSeatLimit } from '../lib/portal-seats';
import { resolvePlanKey } from '../lib/plan-limits';
import { isClinicOnTrial } from '../lib/trial-status';
import { recordAuditLog } from '../lib/audit-helper';

patientsRoute.patch('/:id/portal-access', requirePermission('patients.edit'), async (c) => {
  const db = getReadDb(c.env);
  const writeDb = getWriteDb(c.env);
  const patientId = c.req.param('id');
  const body = await c.req.json();
  const enabled = !!body.enabled;
  const clinicId = c.get('clinicId') as string;
  const userId = c.get('userId') as string;

  const [patient] = await db.select().from(patients).where(and(eq(patients.id, patientId), eq(patients.clinicId, clinicId))).limit(1);
  if (!patient) return c.json({ error: 'not found' }, 404);

  if (enabled && !patient.portalAccess) {
    // Check seat cap
    const [clinic] = await db.select({
      clinic: clinics,
      planName: subscriptionPlans.planName,
      planTier: subscriptionPlans.planTier,
    }).from(clinics).leftJoin(subscriptionPlans, eq(clinics.subscriptionPlanId, subscriptionPlans.id)).where(eq(clinics.id, clinicId)).limit(1);

    if (!isClinicOnTrial(clinic.clinic)) {
      const planKey = resolvePlanKey({ planName: clinic.planName, planTier: clinic.planTier });
      const limit = getEffectivePortalSeatLimit({
        portalSeatLimit: clinic.clinic.portalSeatLimit,
        portalSeatAddon: clinic.clinic.portalSeatAddon ?? 0,
        planKey,
      });
      const used = await countActivePortalSeats(db, clinicId);
      if (limit !== null && used >= limit) {
        return c.json({
          error: 'PORTAL_SEAT_LIMIT_REACHED',
          message: `You've reached your ${limit}-seat portal limit. Request more seats to enroll this patient.`,
          used, limit,
        }, 409);
      }
    }

    // If no user exists yet, the caller should hit the existing "create patient user" endpoint first.
    // Here we only flip the flag; user-row provisioning lives in patient-create handler.
  }

  await writeDb.update(patients).set({ portalAccess: enabled, updatedAt: new Date() }).where(eq(patients.id, patientId));

  await recordAuditLog(writeDb, {
    clinicId,
    userId,
    action: enabled ? 'portal_access.granted' : 'portal_access.revoked',
    entityType: 'patient',
    entityId: patientId,
    changes: { portalAccess: { from: patient.portalAccess, to: enabled } },
  });

  return c.json({ ok: true, portalAccess: enabled });
});
  • Step 4: Update patient-create handler
Find the existing POST / in server/src/routes/patients.ts. When the body has portalAccess: true, perform the same seat-cap check before inserting. Set portalAccess: body.portalAccess === true on the new patient row. If portalAccess=true AND no user row exists, also provision one (reuse existing patient-user creation code path; do not duplicate).
  • Step 5: Type-check, smoke, commit
cd server && npx tsc --noEmit
git add server/src/routes/patients.ts server/src/lib/portal-seats.ts server/src/lib/portal-seats.test.ts
git commit -m "feat(patients): portal-access toggle endpoint with seat-cap enforcement"

Phase 3: Addon request flow

Subagent assignment: 1 subagent. Depends on Phase 1.

Task 3.1: Pricing calculator helper (DB-backed)

Files:
  • Create: server/src/lib/addon-pricing.ts
  • Test: server/src/lib/addon-pricing.test.ts
Note: The calculator reads live prices from app.addon_pricing (row id = 'current'). The constants in plan-limits.ADDON_PRICING become fallback defaults only (used when DB read fails). Pricing snapshotted onto each addon_requests row at request creation time; subsequent price changes don’t affect pending/approved requests.
  • Step 1: Test
import { describe, it, expect } from 'vitest';
import { calculateAddonPrice, type LivePricing } from './addon-pricing';

const PRICING: LivePricing = {
  storage50GbPkr: 1499,
  storage200GbPkr: 4999,
  storage500GbPkr: 9999,
  storage1TbPkr: 14999,
  portalSeats3PackProPkr: 999,
  portalSeats3PackProPlusPkr: 699,
};

describe('calculateAddonPrice', () => {
  it('storage uses the requested tier price', () => {
    expect(calculateAddonPrice({ addonType: 'storage', tier: 50, planKey: 'pro', pricing: PRICING })).toEqual({ unitPrice: 1499, totalPrice: 1499, gbAllocated: 50 });
    expect(calculateAddonPrice({ addonType: 'storage', tier: 200, planKey: 'pro', pricing: PRICING })).toEqual({ unitPrice: 4999, totalPrice: 4999, gbAllocated: 200 });
    expect(calculateAddonPrice({ addonType: 'storage', tier: 500, planKey: 'proPlus', pricing: PRICING })).toEqual({ unitPrice: 9999, totalPrice: 9999, gbAllocated: 500 });
    expect(calculateAddonPrice({ addonType: 'storage', tier: 1000, planKey: 'proPlus', pricing: PRICING })).toEqual({ unitPrice: 14999, totalPrice: 14999, gbAllocated: 1000 });
  });

  it('throws on unknown storage tier', () => {
    expect(() => calculateAddonPrice({ addonType: 'storage', tier: 75 as any, planKey: 'pro', pricing: PRICING })).toThrow();
  });

  it('portal_seats 3-pack pricing depends on plan', () => {
    expect(calculateAddonPrice({ addonType: 'portal_seats', quantity: 3, planKey: 'pro', pricing: PRICING })).toEqual({ unitPrice: 999, totalPrice: 999 });
    expect(calculateAddonPrice({ addonType: 'portal_seats', quantity: 6, planKey: 'pro', pricing: PRICING })).toEqual({ unitPrice: 999, totalPrice: 1998 });
    expect(calculateAddonPrice({ addonType: 'portal_seats', quantity: 3, planKey: 'proPlus', pricing: PRICING })).toEqual({ unitPrice: 699, totalPrice: 699 });
    expect(calculateAddonPrice({ addonType: 'portal_seats', quantity: 9, planKey: 'proPlus', pricing: PRICING })).toEqual({ unitPrice: 699, totalPrice: 2097 });
  });

  it('throws on non-multiple-of-3 portal_seats', () => {
    expect(() => calculateAddonPrice({ addonType: 'portal_seats', quantity: 4, planKey: 'pro', pricing: PRICING })).toThrow();
  });

  it('honors live pricing override (e.g. superadmin raises 50GB tier)', () => {
    const raised = { ...PRICING, storage50GbPkr: 1999 };
    expect(calculateAddonPrice({ addonType: 'storage', tier: 50, planKey: 'pro', pricing: raised })).toEqual({ unitPrice: 1999, totalPrice: 1999, gbAllocated: 50 });
  });
});
  • Step 2: Implement
server/src/lib/addon-pricing.ts:
import { eq } from 'drizzle-orm';
import { ADDON_PRICING, type PlanKey, type StorageTierGb } from './plan-limits';
import { addonPricing } from '../schema';

export type AddonType = 'storage' | 'portal_seats';

export type AddonPriceInput =
  | { addonType: 'storage'; tier: StorageTierGb; planKey: PlanKey; pricing: LivePricing }
  | { addonType: 'portal_seats'; quantity: number; planKey: PlanKey; pricing: LivePricing };

export interface AddonPriceResult {
  unitPrice: number;
  totalPrice: number;
  gbAllocated?: number;  // populated for storage addons
}

export interface LivePricing {
  storage50GbPkr: number;
  storage200GbPkr: number;
  storage500GbPkr: number;
  storage1TbPkr: number;
  portalSeats3PackProPkr: number;
  portalSeats3PackProPlusPkr: number;
}

let pricingCache: { value: LivePricing; expires: number } | null = null;
const CACHE_MS = 60_000;

export async function getLivePricing(db: any): Promise<LivePricing> {
  const now = Date.now();
  if (pricingCache && pricingCache.expires > now) return pricingCache.value;
  try {
    const [row] = await db.select().from(addonPricing).where(eq(addonPricing.id, 'current')).limit(1);
    if (row) {
      const value: LivePricing = {
        storage50GbPkr: row.storage50GbPkr,
        storage200GbPkr: row.storage200GbPkr,
        storage500GbPkr: row.storage500GbPkr,
        storage1TbPkr: row.storage1TbPkr,
        portalSeats3PackProPkr: row.portalSeats3PackProPkr,
        portalSeats3PackProPlusPkr: row.portalSeats3PackProPlusPkr,
      };
      pricingCache = { value, expires: now + CACHE_MS };
      return value;
    }
  } catch {
    // fall through to defaults
  }
  return {
    storage50GbPkr: ADDON_PRICING.storageTiers[50],
    storage200GbPkr: ADDON_PRICING.storageTiers[200],
    storage500GbPkr: ADDON_PRICING.storageTiers[500],
    storage1TbPkr: ADDON_PRICING.storageTiers[1000],
    portalSeats3PackProPkr: ADDON_PRICING.portalSeats3PackPkr.pro,
    portalSeats3PackProPlusPkr: ADDON_PRICING.portalSeats3PackPkr.proPlus,
  };
}

export function invalidatePricingCache() {
  pricingCache = null;
}

function storageTierPrice(p: LivePricing, tier: StorageTierGb): number {
  switch (tier) {
    case 50: return p.storage50GbPkr;
    case 200: return p.storage200GbPkr;
    case 500: return p.storage500GbPkr;
    case 1000: return p.storage1TbPkr;
    default: throw new Error(`unknown storage tier ${tier}`);
  }
}

export function calculateAddonPrice(input: AddonPriceInput): AddonPriceResult {
  const p = input.pricing;

  if (input.addonType === 'storage') {
    if (![50, 200, 500, 1000].includes(input.tier)) {
      throw new Error(`storage tier must be 50, 200, 500, or 1000 GB`);
    }
    const price = storageTierPrice(p, input.tier);
    return { unitPrice: price, totalPrice: price, gbAllocated: input.tier };
  }

  if (!Number.isInteger(input.quantity) || input.quantity <= 0 || input.quantity % 3 !== 0) {
    throw new Error('portal_seats addons must be a positive multiple of 3');
  }
  if (input.planKey === 'enterprise') {
    throw new Error('enterprise tier does not use addons; set quota directly');
  }
  const packs = input.quantity / 3;
  const perPack = input.planKey === 'proPlus' ? p.portalSeats3PackProPlusPkr : p.portalSeats3PackProPkr;
  return { unitPrice: perPack, totalPrice: perPack * packs };
}

// Convenience wrapper that loads pricing from DB
export async function calculateAddonPriceFromDb(db: any, input: Omit<AddonPriceInput, 'pricing'>): Promise<AddonPriceResult> {
  const pricing = await getLivePricing(db);
  return calculateAddonPrice({ ...input, pricing } as AddonPriceInput);
}
Test update needed: Update the test in step 1 to pass pricing in each calculateAddonPrice call (the unit test no longer needs DB).
  • Step 3: Run + commit
cd server && npx vitest run src/lib/addon-pricing.test.ts
git add server/src/lib/addon-pricing.ts server/src/lib/addon-pricing.test.ts
git commit -m "feat(billing): addon pricing calculator (storage + portal seats)"

Task 3.2: Clinic-facing addon-request routes

Files:
  • Create: server/src/routes/addon-requests.ts
  • Modify: server/src/api.ts (mount new router)
  • Step 1: Build the router
server/src/routes/addon-requests.ts:
import { Hono } from 'hono';
import { and, desc, eq } from 'drizzle-orm';
import { getReadDb, getWriteDb } from '../lib/db';
import { addonRequests, clinics, subscriptionPlans } from '../schema';
import { calculateAddonPriceFromDb, type AddonType } from '../lib/addon-pricing';
import { resolvePlanKey } from '../lib/plan-limits';
import { requirePermission } from '../middleware/permissions';
import { recordAuditLog } from '../lib/audit-helper';
import { alertNewAddonRequest } from '../lib/superadmin-alerts';

const route = new Hono();

route.post('/', requirePermission('billing.request_addon'), async (c) => {
  const db = getReadDb(c.env);
  const writeDb = getWriteDb(c.env);
  const clinicId = c.get('clinicId') as string;
  const userId = c.get('userId') as string;
  const body = await c.req.json();
  const addonType = body.addonType as AddonType;
  const notes = typeof body.notes === 'string' ? body.notes.slice(0, 1000) : null;

  if (!['storage', 'portal_seats'].includes(addonType)) {
    return c.json({ error: 'invalid addonType' }, 400);
  }

  const [clinic] = await db.select({
    clinic: clinics,
    planName: subscriptionPlans.planName,
    planTier: subscriptionPlans.planTier,
  }).from(clinics).leftJoin(subscriptionPlans, eq(clinics.subscriptionPlanId, subscriptionPlans.id)).where(eq(clinics.id, clinicId)).limit(1);
  if (!clinic) return c.json({ error: 'clinic not found' }, 404);

  const planKey = resolvePlanKey({ planName: clinic.planName, planTier: clinic.planTier });
  let priced;
  let quantity: number;  // GB for storage, seats for portal_seats — what gets stored
  try {
    if (addonType === 'storage') {
      const tier = Number(body.tier);
      priced = await calculateAddonPriceFromDb(db, { addonType: 'storage', tier: tier as any, planKey });
      quantity = priced.gbAllocated!;
    } else {
      quantity = Number(body.quantity);
      priced = await calculateAddonPriceFromDb(db, { addonType: 'portal_seats', quantity, planKey });
    }
  } catch (e: any) {
    return c.json({ error: e.message }, 400);
  }

  const [inserted] = await writeDb.insert(addonRequests).values({
    id: crypto.randomUUID(),
    clinicId,
    requestedBy: userId,
    addonType,
    quantity,
    unitPricePkr: priced.unitPrice,
    totalPricePkr: priced.totalPrice,
    notes,
  }).returning();

  await recordAuditLog(writeDb, {
    clinicId,
    userId,
    action: 'addon_request.created',
    entityType: 'addon_request',
    entityId: inserted.id,
    changes: { addonType, quantity, totalPricePkr: priced.totalPrice },
  });

  await alertNewAddonRequest({ env: c.env, clinicId, requestId: inserted.id, addonType, quantity, totalPricePkr: priced.totalPrice });

  return c.json(inserted, 201);
});

route.get('/', requirePermission('billing.invoices.view'), async (c) => {
  const db = getReadDb(c.env);
  const clinicId = c.get('clinicId') as string;
  const rows = await db.select().from(addonRequests).where(eq(addonRequests.clinicId, clinicId)).orderBy(desc(addonRequests.createdAt));
  return c.json(rows);
});

route.delete('/:id', requirePermission('billing.request_addon'), async (c) => {
  const writeDb = getWriteDb(c.env);
  const clinicId = c.get('clinicId') as string;
  const userId = c.get('userId') as string;
  const id = c.req.param('id');
  const [row] = await writeDb.select().from(addonRequests).where(and(eq(addonRequests.id, id), eq(addonRequests.clinicId, clinicId))).limit(1);
  if (!row) return c.json({ error: 'not found' }, 404);
  if (row.status !== 'pending') return c.json({ error: 'only pending requests can be cancelled' }, 409);
  await writeDb.update(addonRequests).set({ status: 'cancelled', cancelledAt: new Date(), cancelledBy: userId, updatedAt: new Date() }).where(eq(addonRequests.id, id));
  await recordAuditLog(writeDb, { clinicId, userId, action: 'addon_request.cancelled', entityType: 'addon_request', entityId: id });
  return c.json({ ok: true });
});

export default route;
  • Step 2: Add superadmin-alerts helper
In server/src/lib/superadmin-alerts.ts, append:
export async function alertNewAddonRequest(opts: {
  env: any;
  clinicId: string;
  requestId: string;
  addonType: 'storage' | 'portal_seats';
  quantity: number;
  totalPricePkr: number;
}) {
  // Reuses existing superadmin notification plumbing. Follow the pattern in alertUpgradeRequest.
  // If alertUpgradeRequest is unsuitable, write a new row to superadmin_alerts with kind='addon_request'.
}
Note for implementer: look at the existing alertUpgradeRequest in this same file and mirror its shape. Save the email subject as New addon request: <Pro / Pro+> · <type> x<qty> · PKR <total>.
  • Step 3: Mount router
In server/src/api.ts, find where billing route is mounted and add:
import addonRequestsRoute from './routes/addon-requests';
// ...
protectedRoutes.route('/billing/addon-requests', addonRequestsRoute);
  • Step 4: Type-check, commit
cd server && npx tsc --noEmit
git add server/src/routes/addon-requests.ts server/src/lib/superadmin-alerts.ts server/src/api.ts
git commit -m "feat(billing): clinic-facing addon request API"

Task 3.3: Superadmin approval API

Files:
  • Create: server/src/routes/superadmin/addon-requests.ts
  • Modify: server/src/api.ts
  • Create: server/src/emails/AddonApprovedEmail.tsx
  • Create: server/src/emails/AddonRejectedEmail.tsx
  • Step 1: Build the router
server/src/routes/superadmin/addon-requests.ts:
import { Hono } from 'hono';
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
import { getReadDb, getWriteDb } from '../../lib/db';
import { addonRequests, clinics, users } from '../../schema';
import { GB } from '../../lib/plan-limits';
import { recordAuditLog } from '../../lib/audit-helper';
import { sendEmailViaZepto } from '../../lib/email';
import { render } from '@react-email/render';

const route = new Hono();

route.get('/', async (c) => {
  const db = getReadDb(c.env);
  const status = c.req.query('status') ?? 'pending';
  const rows = await db
    .select({
      request: addonRequests,
      clinicName: clinics.name,
    })
    .from(addonRequests)
    .leftJoin(clinics, eq(addonRequests.clinicId, clinics.id))
    .where(eq(addonRequests.status, status as any))
    .orderBy(desc(addonRequests.createdAt));
  return c.json(rows);
});

route.post('/:id/approve', async (c) => {
  const writeDb = getWriteDb(c.env);
  const id = c.req.param('id');
  const userId = c.get('userId') as string;

  const [row] = await writeDb.select().from(addonRequests).where(eq(addonRequests.id, id)).limit(1);
  if (!row) return c.json({ error: 'not found' }, 404);
  if (row.status !== 'pending') return c.json({ error: 'not pending' }, 409);

  // Apply quota delta atomically
  if (row.addonType === 'storage') {
    const deltaBytes = row.quantity * GB;
    await writeDb.update(clinics)
      .set({ storageAddonBytes: sql`${clinics.storageAddonBytes} + ${deltaBytes}` })
      .where(eq(clinics.id, row.clinicId));
  } else {
    await writeDb.update(clinics)
      .set({ portalSeatAddon: sql`${clinics.portalSeatAddon} + ${row.quantity}` })
      .where(eq(clinics.id, row.clinicId));
  }

  await writeDb.update(addonRequests).set({
    status: 'approved',
    approvedBy: userId,
    approvedAt: new Date(),
    updatedAt: new Date(),
  }).where(eq(addonRequests.id, id));

  await recordAuditLog(writeDb, {
    clinicId: row.clinicId,
    userId,
    action: 'addon_request.approved',
    entityType: 'addon_request',
    entityId: id,
    changes: { addonType: row.addonType, quantity: row.quantity, totalPricePkr: row.totalPricePkr },
  });

  // Email clinic admin(s)
  const [clinic] = await writeDb.select().from(clinics).where(eq(clinics.id, row.clinicId)).limit(1);
  const admins = await writeDb.select().from(users).where(and(eq(users.clinicId, row.clinicId), eq(users.role, 'admin'), eq(users.isActive, true)));
  for (const admin of admins) {
    if (!admin.email) continue;
    const { AddonApprovedEmail } = await import('../../emails/AddonApprovedEmail');
    const html = await render(AddonApprovedEmail({
      clinicName: clinic.name,
      addonType: row.addonType,
      quantity: row.quantity,
      totalPricePkr: row.totalPricePkr,
    }));
    await sendEmailViaZepto([{ email: admin.email, name: `${admin.firstName ?? ''} ${admin.lastName ?? ''}`.trim() || admin.email }], `Your addon was approved`, html, { fromName: 'OdontoX Billing' });
  }

  return c.json({ ok: true });
});

route.post('/:id/reject', async (c) => {
  const writeDb = getWriteDb(c.env);
  const id = c.req.param('id');
  const userId = c.get('userId') as string;
  const body = await c.req.json();
  const reason = typeof body.reason === 'string' ? body.reason.slice(0, 500) : 'No reason provided';

  const [row] = await writeDb.select().from(addonRequests).where(eq(addonRequests.id, id)).limit(1);
  if (!row) return c.json({ error: 'not found' }, 404);
  if (row.status !== 'pending') return c.json({ error: 'not pending' }, 409);

  await writeDb.update(addonRequests).set({
    status: 'rejected',
    rejectedReason: reason,
    updatedAt: new Date(),
  }).where(eq(addonRequests.id, id));

  await recordAuditLog(writeDb, {
    clinicId: row.clinicId,
    userId,
    action: 'addon_request.rejected',
    entityType: 'addon_request',
    entityId: id,
    changes: { reason },
  });
  // Email omitted for brevity — mirror approve path with AddonRejectedEmail.
  return c.json({ ok: true });
});

route.post('/:id/cancel-active', async (c) => {
  // Cancel a previously-approved active addon (stops future billing, decrements quota)
  const writeDb = getWriteDb(c.env);
  const id = c.req.param('id');
  const userId = c.get('userId') as string;

  const [row] = await writeDb.select().from(addonRequests).where(eq(addonRequests.id, id)).limit(1);
  if (!row || row.status !== 'approved' || row.cancelledAt) return c.json({ error: 'no active addon' }, 409);

  if (row.addonType === 'storage') {
    const deltaBytes = row.quantity * GB;
    await writeDb.update(clinics)
      .set({ storageAddonBytes: sql`GREATEST(0, ${clinics.storageAddonBytes} - ${deltaBytes})` })
      .where(eq(clinics.id, row.clinicId));
  } else {
    await writeDb.update(clinics)
      .set({ portalSeatAddon: sql`GREATEST(0, ${clinics.portalSeatAddon} - ${row.quantity})` })
      .where(eq(clinics.id, row.clinicId));
  }

  await writeDb.update(addonRequests).set({
    cancelledAt: new Date(),
    cancelledBy: userId,
    updatedAt: new Date(),
  }).where(eq(addonRequests.id, id));

  await recordAuditLog(writeDb, {
    clinicId: row.clinicId,
    userId,
    action: 'addon.cancelled',
    entityType: 'addon_request',
    entityId: id,
  });
  return c.json({ ok: true });
});

route.post('/clinics/:id/storage-quota', async (c) => {
  // Enterprise override
  const writeDb = getWriteDb(c.env);
  const clinicId = c.req.param('id');
  const userId = c.get('userId') as string;
  const body = await c.req.json();
  const bytes = body.bytes === null ? null : Number(body.bytes);
  if (bytes !== null && (!Number.isFinite(bytes) || bytes < 0)) return c.json({ error: 'invalid bytes' }, 400);

  await writeDb.update(clinics).set({ storageQuotaBytes: bytes, updatedAt: new Date() }).where(eq(clinics.id, clinicId));
  await recordAuditLog(writeDb, { clinicId, userId, action: 'storage_quota.override', entityType: 'clinic', entityId: clinicId, changes: { storageQuotaBytes: bytes } });
  return c.json({ ok: true });
});

route.post('/clinics/:id/portal-seat-limit', async (c) => {
  const writeDb = getWriteDb(c.env);
  const clinicId = c.req.param('id');
  const userId = c.get('userId') as string;
  const body = await c.req.json();
  const limit = body.limit === null ? null : Number(body.limit);
  if (limit !== null && (!Number.isFinite(limit) || limit < 0)) return c.json({ error: 'invalid limit' }, 400);

  await writeDb.update(clinics).set({ portalSeatLimit: limit, updatedAt: new Date() }).where(eq(clinics.id, clinicId));
  await recordAuditLog(writeDb, { clinicId, userId, action: 'portal_seat_limit.override', entityType: 'clinic', entityId: clinicId, changes: { portalSeatLimit: limit } });
  return c.json({ ok: true });
});

route.post('/clinics/:id/recompute-storage', async (c) => {
  const writeDb = getWriteDb(c.env);
  const clinicId = c.req.param('id');
  const userId = c.get('userId') as string;
  const { recomputeUsage } = await import('../../lib/storage-tracker');
  const total = await recomputeUsage(writeDb, clinicId);
  await recordAuditLog(writeDb, { clinicId, userId, action: 'storage_usage.recomputed', entityType: 'clinic', entityId: clinicId, changes: { total } });
  return c.json({ ok: true, total });
});

export default route;
  • Step 2: Email templates
server/src/emails/AddonApprovedEmail.tsx:
import { Html, Body, Container, Heading, Text } from '@react-email/components';

export function AddonApprovedEmail({ clinicName, addonType, quantity, totalPricePkr }: {
  clinicName: string;
  addonType: 'storage' | 'portal_seats';
  quantity: number;
  totalPricePkr: number;
}) {
  const label = addonType === 'storage' ? `${quantity} GB of storage` : `${quantity} portal seats`;
  return (
    <Html>
      <Body style={{ fontFamily: 'system-ui, sans-serif' }}>
        <Container style={{ padding: 24, maxWidth: 540 }}>
          <Heading style={{ fontSize: 20 }}>Addon approved for {clinicName}</Heading>
          <Text>Your request for {label} has been approved and your quota has been increased.</Text>
          <Text>This addon recurs monthly at PKR {totalPricePkr.toLocaleString()} on your next billing date until cancelled.</Text>
          <Text style={{ marginTop: 24, color: '#666', fontSize: 13 }}>OdontoX Billing</Text>
        </Container>
      </Body>
    </Html>
  );
}
server/src/emails/AddonRejectedEmail.tsx: same shape with a reason prop and copy explaining the rejection.
  • Step 3: Mount router
In server/src/api.ts, find superadmin routes section, add:
import superadminAddonRoute from './routes/superadmin/addon-requests';
superadminProtected.route('/addon-requests', superadminAddonRoute);
superadminProtected.route('/', superadminAddonRoute);  // mounts /clinics/:id/* under superadmin
Check existing mount pattern — adapt to whatever superadmin uses. Likely a single superadminProtected.route('/addons', superadminAddonRoute).
  • Step 4: Type-check, commit
cd server && npx tsc --noEmit
git add server/src/routes/superadmin/addon-requests.ts server/src/emails/AddonApprovedEmail.tsx server/src/emails/AddonRejectedEmail.tsx server/src/api.ts
git commit -m "feat(superadmin): approve/reject addon requests with quota allocation"

Task 3.3b: Superadmin pricing-management endpoints

Files:
  • Create: server/src/routes/superadmin/addon-pricing.ts
  • Modify: server/src/api.ts (mount)
  • Step 1: Build the router
server/src/routes/superadmin/addon-pricing.ts:
import { Hono } from 'hono';
import { desc, eq } from 'drizzle-orm';
import { getReadDb, getWriteDb } from '../../lib/db';
import { addonPricing, addonPricingHistory } from '../../schema';
import { invalidatePricingCache } from '../../lib/addon-pricing';
import { recordAuditLog } from '../../lib/audit-helper';

const route = new Hono();

route.get('/', async (c) => {
  const db = getReadDb(c.env);
  const [current] = await db.select().from(addonPricing).where(eq(addonPricing.id, 'current')).limit(1);
  const history = await db.select().from(addonPricingHistory).orderBy(desc(addonPricingHistory.changedAt)).limit(10);
  return c.json({ current, history });
});

route.put('/', async (c) => {
  const writeDb = getWriteDb(c.env);
  const userId = c.get('userId') as string;
  const body = await c.req.json();

  const fields = ['storage50GbPkr', 'storage200GbPkr', 'storage500GbPkr', 'storage1TbPkr', 'portalSeats3PackProPkr', 'portalSeats3PackProPlusPkr'] as const;
  for (const f of fields) {
    const v = Number(body[f]);
    if (!Number.isInteger(v) || v < 0) return c.json({ error: `invalid ${f}` }, 400);
  }

  const [before] = await writeDb.select().from(addonPricing).where(eq(addonPricing.id, 'current')).limit(1);
  if (!before) return c.json({ error: 'pricing row missing — re-run migration' }, 500);

  const after = {
    storage50GbPkr: Number(body.storage50GbPkr),
    storage200GbPkr: Number(body.storage200GbPkr),
    storage500GbPkr: Number(body.storage500GbPkr),
    storage1TbPkr: Number(body.storage1TbPkr),
    portalSeats3PackProPkr: Number(body.portalSeats3PackProPkr),
    portalSeats3PackProPlusPkr: Number(body.portalSeats3PackProPlusPkr),
  };

  await writeDb.update(addonPricing).set({
    ...after,
    updatedAt: new Date(),
    updatedBy: userId,
  }).where(eq(addonPricing.id, 'current'));

  await writeDb.insert(addonPricingHistory).values({
    id: crypto.randomUUID(),
    changedBy: userId,
    beforeValues: {
      storage50GbPkr: before.storage50GbPkr,
      storage200GbPkr: before.storage200GbPkr,
      storage500GbPkr: before.storage500GbPkr,
      storage1TbPkr: before.storage1TbPkr,
      portalSeats3PackProPkr: before.portalSeats3PackProPkr,
      portalSeats3PackProPlusPkr: before.portalSeats3PackProPlusPkr,
    },
    afterValues: after,
  });

  invalidatePricingCache();

  await recordAuditLog(writeDb, {
    clinicId: null,    // platform-wide change
    userId,
    action: 'addon_pricing.updated',
    entityType: 'addon_pricing',
    entityId: 'current',
    changes: { before, after },
  });

  return c.json({ ok: true });
});

export default route;
If recordAuditLog doesn’t accept clinicId: null, either pass a sentinel like 'platform' or extend the helper. Use whatever pattern the existing platform-wide audit logs (e.g. cron_jobs, platform_settings) use.
  • Step 2: Mount the router
In server/src/api.ts superadmin section:
import superadminAddonPricingRoute from './routes/superadmin/addon-pricing';
superadminProtected.route('/addon-pricing', superadminAddonPricingRoute);
  • Step 3: Type-check, commit
cd server && npx tsc --noEmit
git add server/src/routes/superadmin/addon-pricing.ts server/src/api.ts
git commit -m "feat(superadmin): editable addon pricing with audit history"

Task 3.4: Permission keys + usage endpoint

Files:
  • Modify: server/src/lib/permissions.ts
  • Modify: server/src/routes/billing.ts (or create server/src/routes/billing-usage.ts)
  • Step 1: Register new permissions
In server/src/lib/permissions.ts, find the PERMISSION_KEYS array and add (in the billing section):
  'billing.request_addon',
Update the role-default map to grant it to admin and deny to receptionist. The file already has a DEFAULT_ROLE_PERMISSIONS (or similar) shape — add the key in the admin list.
  • Step 2: Build usage endpoint
Inside server/src/routes/billing.ts (add at the bottom before export default billingRoute):
import { countActivePortalSeats, getEffectivePortalSeatLimit } from '../lib/portal-seats';
import { getEffectiveStorageQuota } from '../lib/storage-tracker';
import { resolvePlanKey } from '../lib/plan-limits';
import { isClinicOnTrial } from '../lib/trial-status';

billingRoute.get('/usage', async (c) => {
  const db = getReadDb(c.env);
  const clinicId = c.get('clinicId') as string;
  const [clinic] = await db.select({
    clinic: clinics,
    planName: subscriptionPlans.planName,
    planTier: subscriptionPlans.planTier,
  }).from(clinics).leftJoin(subscriptionPlans, eq(clinics.subscriptionPlanId, subscriptionPlans.id)).where(eq(clinics.id, clinicId)).limit(1);
  if (!clinic) return c.json({ error: 'clinic not found' }, 404);
  const planKey = resolvePlanKey({ planName: clinic.planName, planTier: clinic.planTier });
  const portalLimit = getEffectivePortalSeatLimit({
    portalSeatLimit: clinic.clinic.portalSeatLimit,
    portalSeatAddon: clinic.clinic.portalSeatAddon ?? 0,
    planKey,
  });
  const storageQuota = getEffectiveStorageQuota({
    storageQuotaBytes: clinic.clinic.storageQuotaBytes,
    storageAddonBytes: clinic.clinic.storageAddonBytes ?? 0,
    planKey,
  });
  const portalUsed = await countActivePortalSeats(db, clinicId);

  // MAU stat: distinct portal patients with login in last 30 days
  const since = new Date(Date.now() - 30 * 86400_000);
  const mauRow = await db.execute(sql`
    SELECT COUNT(DISTINCT p.id)::int AS n
    FROM app.patients p
    INNER JOIN app.users u ON u.id = p.user_id
    WHERE p.clinic_id = ${clinicId}
      AND p.portal_access = true
      AND p.deleted_at IS NULL
      AND u.last_login_at >= ${since}
  `);
  const mau = Number((mauRow as any).rows?.[0]?.n ?? 0);

  return c.json({
    portalSeats: {
      used: portalUsed,
      limit: portalLimit,
      addon: clinic.clinic.portalSeatAddon ?? 0,
      mau,
    },
    storage: {
      usedBytes: clinic.clinic.storageUsedBytes ?? 0,
      quotaBytes: storageQuota,
      addonBytes: clinic.clinic.storageAddonBytes ?? 0,
    },
    plan: planKey,
    isTrial: isClinicOnTrial(clinic.clinic),
  });
});
Note: Check that users.lastLoginAt exists in server/src/schema/users.ts. If not, the MAU stat returns 0 — that’s acceptable for v1. Add a TODO comment to instrument last_login_at separately.
  • Step 3: Type-check, commit
cd server && npx tsc --noEmit
git add server/src/lib/permissions.ts server/src/routes/billing.ts
git commit -m "feat(billing): GET /billing/usage with portal+storage meters and MAU"

Phase 4: Replace legacy patient-quota module

Subagent assignment: 1 subagent. Depends on Phase 1, 3.

Task 4.1: Migrate billing.ts callers off patient-quota.ts

Files:
  • Modify: server/src/routes/billing.ts
  • Modify: server/src/lib/patient-quota.ts (delete or replace)
  • Step 1: Find every call site
cd server && grep -rn "calculatePatientQuotaUsage\|resolvePatientQuotaRules\|PATIENT_ADDON" src/ --include="*.ts"
  • Step 2: For each call, replace with new portal-seats/plan-limits APIs
In server/src/routes/billing.ts:
  • getLicenseLimitsForPlan already exists and is correct — leave it.
  • syncLicenseFromPlan calls resolvePatientQuotaRules to derive maxPatients for licenses.maxPatients. Replace this so licenses.maxPatients is set to null (records are unlimited under the new model). The license-check middleware that gates on this value also needs adjustment — find callers of licenses.maxPatients and stop enforcing it for new tenants.
  • Any code that reads PATIENT_ADDON: prefix from licenseRequests.reason becomes legacy. Leave it for historical display but do NOT process new ones; new addons use addon_requests.
Concretely, in syncLicenseFromPlan (around line 164–195), change:
const patientRules = resolvePatientQuotaRules({ ... });
// ... maxPatients: patientRules.totalPatientLimit,
to:
// Records are unlimited under the new portal-seat model; legacy column kept null.
// ...
maxPatients: null,
  • Step 3: Remove patient-quota.ts
git rm server/src/lib/patient-quota.ts
If type errors surface elsewhere, replace those callers with plan-limits / portal-seats / addon-pricing APIs.
  • Step 4: Type-check, commit
cd server && npx tsc --noEmit
git add -A server/src/routes/billing.ts server/src/lib/
git commit -m "refactor(billing): retire patient-quota.ts in favor of plan-limits/portal-seats"

Task 4.2: Data migration for legacy PATIENT_ADDON: license_requests

Files:
  • Create: server/scripts/migrate-patient-addons.ts
  • Step 1: Write script
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { eq, sql } from 'drizzle-orm';
import { licenseRequests, addonRequests, clinics, subscriptionPlans } from '../src/schema';
import { resolvePlanKey } from '../src/lib/plan-limits';
import { calculateAddonPrice } from '../src/lib/addon-pricing';

async function main() {
  const dryRun = process.argv.includes('--dry-run');
  const client = postgres(process.env.DATABASE_URL!);
  const db = drizzle(client);

  const legacy = await db.execute(sql`
    SELECT id, clinic_id, requested_by, reason, status, created_at
      FROM app.license_requests
     WHERE reason LIKE 'PATIENT_ADDON:%'
  `);
  const rows = (legacy as any).rows ?? legacy;
  console.log(`Found ${rows.length} legacy PATIENT_ADDON rows`);

  for (const row of rows) {
    let payload: any;
    try { payload = JSON.parse(row.reason.replace('PATIENT_ADDON:', '')); }
    catch { console.warn('skip unparseable', row.id); continue; }

    const [clinic] = await db.select({ planName: subscriptionPlans.planName, planTier: subscriptionPlans.planTier })
      .from(clinics).leftJoin(subscriptionPlans, eq(clinics.subscriptionPlanId, subscriptionPlans.id))
      .where(eq(clinics.id, row.clinic_id)).limit(1);
    const planKey = resolvePlanKey({ planName: clinic?.planName, planTier: clinic?.planTier });
    if (planKey === 'enterprise') continue;

    const quantity = Number(payload.requestedPatients) || 0;
    if (quantity <= 0) continue;
    const rounded = Math.ceil(quantity / 3) * 3;

    let priced;
    try { priced = calculateAddonPrice({ addonType: 'portal_seats', quantity: rounded, planKey }); }
    catch { continue; }

    const newRow = {
      id: crypto.randomUUID(),
      clinicId: row.clinic_id,
      requestedBy: row.requested_by,
      addonType: 'portal_seats' as const,
      quantity: rounded,
      unitPricePkr: priced.unitPrice,
      totalPricePkr: priced.totalPrice,
      status: row.status === 'approved' ? 'approved' as const : row.status === 'rejected' ? 'rejected' as const : 'pending' as const,
      approvedAt: row.status === 'approved' ? row.created_at : null,
      notes: `Migrated from license_request ${row.id}`,
      createdAt: row.created_at,
      updatedAt: new Date(),
    };

    if (dryRun) {
      console.log('would insert', newRow);
    } else {
      await db.insert(addonRequests).values(newRow);
      // If it was approved, also bump clinics.portal_seat_addon
      if (newRow.status === 'approved') {
        await db.update(clinics)
          .set({ portalSeatAddon: sql`${clinics.portalSeatAddon} + ${rounded}` })
          .where(eq(clinics.id, row.clinic_id));
      }
    }
  }
  await client.end();
  console.log('Done.');
}
main().catch((e) => { console.error(e); process.exit(1); });
  • Step 2: Dry-run locally
cd server && DATABASE_URL=$LOCAL_DATABASE_URL npx tsx scripts/migrate-patient-addons.ts --dry-run
Inspect output. DO NOT run against prod without explicit user confirmation (memory rule).
  • Step 3: Commit script (do not run on prod yet)
git add server/scripts/migrate-patient-addons.ts
git commit -m "chore(migration): script to backfill PATIENT_ADDON: license_requests into addon_requests"

Phase 5: Clinic-facing UI

Subagent assignment: 1–3 subagents (these tasks can run in parallel after Phase 1–4 land). Each touches different files.

Task 5.1: useUsage hook + types

Files:
  • Create: ui/src/hooks/use-usage.ts
  • Step 1: Implement
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';

export interface UsageResponse {
  portalSeats: { used: number; limit: number | null; addon: number; mau: number };
  storage: { usedBytes: number; quotaBytes: number | null; addonBytes: number };
  plan: 'pro' | 'proPlus' | 'enterprise';
  isTrial: boolean;
}

export function useUsage() {
  return useQuery<UsageResponse>({
    queryKey: ['billing', 'usage'],
    queryFn: async () => {
      const res = await api.get('/billing/usage');
      return res.data;
    },
    staleTime: 30_000,
  });
}
  • Step 2: Commit
git add ui/src/hooks/use-usage.ts
git commit -m "feat(ui): useUsage hook for portal+storage meters"

Task 5.2: Patient form — “Provide portal access” checkbox

Files:
  • Modify: ui/src/components/patients/PatientForm.tsx (or equivalent)
  • Create: ui/src/components/patients/PortalAccessField.tsx
  • Create: ui/src/components/patients/PortalAccessHelpDrawer.tsx
  • Step 1: Locate the patient form
cd ui/src && grep -rn "firstName.*lastName" components/patients --include="*.tsx" -l
  • Step 2: Build the field component
ui/src/components/patients/PortalAccessField.tsx:
import { useState } from 'react';
import { HelpCircle } from 'lucide-react';
import { useUsage } from '@/hooks/use-usage';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { PortalAccessHelpDrawer } from './PortalAccessHelpDrawer';
import { Link } from 'react-router-dom';

interface Props {
  value: boolean;
  onChange: (next: boolean) => void;
  // when editing, this patient's CURRENT portal_access doesn't count as a "new seat"
  editingPatientId?: string;
}

export function PortalAccessField({ value, onChange, editingPatientId }: Props) {
  const { data: usage } = useUsage();
  const [helpOpen, setHelpOpen] = useState(false);
  const used = usage?.portalSeats.used ?? 0;
  const limit = usage?.portalSeats.limit;
  const atCap = limit !== null && limit !== undefined && used >= limit && !value;
  const isTrial = usage?.isTrial ?? false;

  return (
    <div className="flex flex-col gap-2">
      <div className="flex items-center gap-2">
        <Checkbox
          id="portal-access"
          checked={value}
          onCheckedChange={(v) => onChange(!!v)}
          disabled={atCap && !isTrial}
        />
        <Label htmlFor="portal-access" className="font-medium">Provide portal access</Label>
        <button
          type="button"
          onClick={() => setHelpOpen(true)}
          aria-label="What is portal access?"
          className="text-muted-foreground hover:text-foreground"
        >
          <HelpCircle className="h-4 w-4" />
        </button>
      </div>
      <p className="text-xs text-muted-foreground">
        {isTrial ? (
          <>Unlimited during trial.</>
        ) : limit === null ? (
          <>Unlimited seats on your plan.</>
        ) : (
          <>{used} of {limit} seats used.</>
        )}
        {atCap && !isTrial && (
          <>{' '}<Link to="/dashboard/settings?tab=billing" className="text-primary underline">Request more seats</Link></>
        )}
      </p>
      <PortalAccessHelpDrawer open={helpOpen} onOpenChange={setHelpOpen} />
    </div>
  );
}
  • Step 3: Build the help drawer
ui/src/components/patients/PortalAccessHelpDrawer.tsx:
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet';

export function PortalAccessHelpDrawer({ open, onOpenChange }: { open: boolean; onOpenChange: (b: boolean) => void }) {
  return (
    <Sheet open={open} onOpenChange={onOpenChange}>
      <SheetContent className="overflow-y-auto sm:max-w-lg">
        <SheetHeader>
          <SheetTitle>What is "Provide portal access"?</SheetTitle>
          <SheetDescription>
            Enabling this gives the patient a login at <strong>id.odontox.io</strong> so they can view records, appointments, and messages. It does not change anything else about the patient's record.
          </SheetDescription>
        </SheetHeader>

        <div className="mt-4 space-y-4 text-sm">
          <section>
            <h3 className="font-semibold mb-1">Records vs. portal accounts</h3>
            <p>Every patient you add is a <em>record</em> — visible to clinic staff with full chart history, X-rays, and appointments. <em>Portal access</em> is the optional extra of giving them a login.</p>
          </section>

          <section>
            <h3 className="font-semibold mb-1">Three OdontoX subdomains</h3>
            <table className="w-full text-xs border">
              <thead className="bg-muted">
                <tr><th className="text-left p-2">Subdomain</th><th className="text-left p-2">Purpose</th><th className="text-left p-2">Login?</th></tr>
              </thead>
              <tbody>
                <tr className="border-t"><td className="p-2 font-mono">go.odontox.io</td><td className="p-2">Main app for all roles</td><td className="p-2">Yes</td></tr>
                <tr className="border-t"><td className="p-2 font-mono">id.odontox.io</td><td className="p-2">Sign-in page</td><td className="p-2">Sign-in surface itself</td></tr>
                <tr className="border-t"><td className="p-2 font-mono">portal.odontox.io</td><td className="p-2">Public file-share links</td><td className="p-2">No, link-based</td></tr>
              </tbody>
            </table>
          </section>

          <section>
            <h3 className="font-semibold mb-1">How seats are counted</h3>
            <p>Only patients with <em>Provide portal access</em> ON count toward your seat limit. Soft-deleted patients release their seat. MAU (monthly active patients) is shown in your billing dashboard but is not a billing trigger — your seat limit is the hard cap.</p>
          </section>

          <section>
            <h3 className="font-semibold mb-1">At the limit?</h3>
            <p>Toggle off an inactive patient to free a seat, or request a 3-pack addon from <strong>Settings → My Billing</strong>.</p>
          </section>
        </div>
      </SheetContent>
    </Sheet>
  );
}
  • Step 4: Wire into PatientForm
In the patient form (probably PatientForm.tsx), find the form-state shape, add portalAccess: boolean (default false), and render <PortalAccessField value={form.portalAccess} onChange={...} editingPatientId={patient?.id} /> near the contact info block. On submit, include portalAccess in the POST/PATCH body. For editing, if the user flips from true → false, surface a confirm modal first (“Revoke portal access? This will sign the patient out of id.odontox.io. Their records stay.”).
  • Step 5: Commit
git add ui/src/components/patients/
git commit -m "feat(patients): Provide portal access checkbox + help drawer"

Task 5.3: Bulk import overflow handling

Files:
  • Modify: ui/src/components/superadmin/DataImportManager.tsx
  • Modify: server/src/routes/superadmin/import.ts (or wherever the import endpoint lives)
  • Step 1: Find the import endpoint
cd server && grep -rn "/api/v1/protected/import" src/ --include="*.ts" -l
  • Step 2: Update the import server to honor portal_access column
Find the patients-branch of the importer. When it parses each row:
  • If column provide_portal_access (or portal_access) equals yes/true/1, set portalAccess: true on the insert.
  • Before inserting the batch, run a cap pre-check: count current portal_access=true plus rows in this batch with yes. If over the cap, downgrade the overflow rows in batch order to portalAccess: false and collect them into a notEnrolled list returned to the client.
Returned response shape:
{
  imported: number;
  failed: number;
  notEnrolled: Array<{ row: number; patientNumber?: string; firstName?: string; lastName?: string; reason: string }>;
}
  • Step 3: Update DataImportManager UI
After import completes, if notEnrolled.length > 0, render a callout:
{result.notEnrolled.length > 0 && (
  <Alert>
    <AlertTitle>{result.notEnrolled.length} rows imported as records-only</AlertTitle>
    <AlertDescription>
      These rows exceeded your portal-seat cap. Their records are imported normally — only the login was skipped.
      <Button variant="link" onClick={() => downloadCsv(result.notEnrolled, 'not-enrolled.csv')}>Download CSV</Button>
    </AlertDescription>
  </Alert>
)}
Implement downloadCsv(rows, name) as a small helper if not already present.
  • Step 4: Commit
git add server/src/routes/superadmin/import.ts ui/src/components/superadmin/DataImportManager.tsx
git commit -m "feat(import): patient bulk import respects portal-seat cap with not-enrolled.csv"

Task 5.4: AdminBillingSettings — usage meters + addon request widgets

Files:
  • Modify: ui/src/components/settings/AdminBillingSettings.tsx
  • Create: ui/src/components/settings/StorageRequestCard.tsx
  • Create: ui/src/components/settings/PortalSeatRequestCard.tsx
  • Create: ui/src/components/settings/UsageMeters.tsx
  • Create: ui/src/components/settings/RecentAddonRequests.tsx
  • Step 1: Remove old patient-quota import
In AdminBillingSettings.tsx, remove the import of calculatePatientQuotaUsage / resolvePatientQuotaRules and the hardcoded limits map. Replace them with useUsage().
  • Step 2: Build UsageMeters.tsx
import { Progress } from '@/components/ui/progress';
import { useUsage } from '@/hooks/use-usage';

function fmtBytes(b: number) {
  const gb = b / (1024 ** 3);
  return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(b / (1024 ** 2)).toFixed(0)} MB`;
}

export function UsageMeters() {
  const { data } = useUsage();
  if (!data) return null;
  const portalPct = data.portalSeats.limit ? Math.min(100, (data.portalSeats.used / data.portalSeats.limit) * 100) : 0;
  const storagePct = data.storage.quotaBytes ? Math.min(100, (data.storage.usedBytes / data.storage.quotaBytes) * 100) : 0;
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
      <div className="border rounded-lg p-4">
        <div className="flex justify-between items-baseline mb-2">
          <h3 className="font-medium">Patient portal seats</h3>
          <span className="text-sm text-muted-foreground">{data.portalSeats.used} / {data.portalSeats.limit ?? '∞'}</span>
        </div>
        {data.portalSeats.limit !== null && <Progress value={portalPct} />}
        {data.portalSeats.addon > 0 && (
          <p className="text-xs text-muted-foreground mt-2">+{data.portalSeats.addon} from addons</p>
        )}
        <p className="text-xs text-muted-foreground mt-1">MAU: {data.portalSeats.mau} active in last 30 days</p>
      </div>
      <div className="border rounded-lg p-4">
        <div className="flex justify-between items-baseline mb-2">
          <h3 className="font-medium">File storage</h3>
          <span className="text-sm text-muted-foreground">{fmtBytes(data.storage.usedBytes)} / {data.storage.quotaBytes ? fmtBytes(data.storage.quotaBytes) : '∞'}</span>
        </div>
        {data.storage.quotaBytes !== null && <Progress value={storagePct} />}
        {data.storage.addonBytes > 0 && (
          <p className="text-xs text-muted-foreground mt-2">+{fmtBytes(data.storage.addonBytes)} from addons</p>
        )}
      </div>
    </div>
  );
}
  • Step 3: Build StorageRequestCard.tsx
Pricing is loaded from the public live-pricing endpoint (clinic-authenticated, returns the same LivePricing shape). Add this endpoint inside server/src/routes/billing.ts:
import { getLivePricing } from '../lib/addon-pricing';

billingRoute.get('/addon-pricing', async (c) => {
  const db = getReadDb(c.env);
  return c.json(await getLivePricing(db));
});
Then the component (tier-based, benefit-led copy):
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Check } from 'lucide-react';
import { api } from '@/lib/api';

type Tier = 50 | 200 | 500 | 1000;

const TIER_LABELS: Record<Tier, string> = { 50: '50 GB', 200: '200 GB', 500: '500 GB', 1000: '1 TB' };

const TIER_DESCRIPTIONS: Record<Tier, string> = {
  50: 'Secure business storage inside your app — customer files, uploads, access control, backups, sharing, support, no technical setup.',
  200: 'Growing imaging library, mid-size clinic. Daily X-rays and treatment photos with full audit history.',
  500: 'Multi-doctor clinic with heavy DICOM. Automatic backups, instant sharing, zero IT involvement.',
  1000: 'Multi-branch or archive-heavy workloads. Enterprise-grade redundancy and priority support.',
};

const INCLUDED = [
  'HIPAA-compliant encryption at rest',
  'Daily automatic backups',
  'Role-based access control',
  'Audit log for every file action',
  'Patient + staff sharing built in',
  'No IT setup or maintenance',
];

export function StorageRequestCard() {
  const [tier, setTier] = useState<Tier>(50);
  const qc = useQueryClient();

  const { data: pricing } = useQuery({
    queryKey: ['billing', 'addon-pricing'],
    queryFn: async () => (await api.get('/billing/addon-pricing')).data as {
      storage50GbPkr: number; storage200GbPkr: number; storage500GbPkr: number; storage1TbPkr: number;
    },
    staleTime: 60_000,
  });

  const mut = useMutation({
    mutationFn: () => api.post('/billing/addon-requests', { addonType: 'storage', tier }),
    onSuccess: () => {
      toast.success('Storage request submitted. Superadmin will review shortly.');
      qc.invalidateQueries({ queryKey: ['billing', 'addon-requests'] });
    },
    onError: (e: any) => toast.error(e?.response?.data?.error ?? 'Failed to submit'),
  });

  const priceForTier = (t: Tier) => {
    if (!pricing) return null;
    if (t === 50) return pricing.storage50GbPkr;
    if (t === 200) return pricing.storage200GbPkr;
    if (t === 500) return pricing.storage500GbPkr;
    return pricing.storage1TbPkr;
  };

  return (
    <Card>
      <CardHeader>
        <CardTitle>Add more business storage</CardTitle>
        <p className="text-xs text-muted-foreground mt-1">
          Recurring monthly. Includes everything your clinic needs to securely store, share, and back up files — no extra services required.
        </p>
      </CardHeader>
      <CardContent className="space-y-4">
        <div className="grid grid-cols-2 md:grid-cols-4 gap-2">
          {([50, 200, 500, 1000] as Tier[]).map(t => {
            const price = priceForTier(t);
            const selected = tier === t;
            return (
              <button
                key={t}
                type="button"
                onClick={() => setTier(t)}
                className={`text-left rounded-lg border-2 p-3 transition ${selected ? 'border-primary bg-primary/5' : 'border-muted hover:border-foreground/30'}`}
              >
                <div className="font-semibold">{TIER_LABELS[t]}</div>
                <div className="text-sm">{price ? `PKR ${price.toLocaleString()}` : '—'} <span className="text-xs text-muted-foreground">/ month</span></div>
              </button>
            );
          })}
        </div>

        <p className="text-sm">{TIER_DESCRIPTIONS[tier]}</p>

        <details className="text-xs">
          <summary className="cursor-pointer text-muted-foreground hover:text-foreground">What's included</summary>
          <ul className="mt-2 space-y-1">
            {INCLUDED.map(item => (
              <li key={item} className="flex items-start gap-2"><Check className="h-3 w-3 mt-0.5 text-primary" /><span>{item}</span></li>
            ))}
          </ul>
        </details>

        <Button onClick={() => mut.mutate()} disabled={mut.isPending || !pricing} className="w-full">
          Request {TIER_LABELS[tier]}{pricing && ` · PKR ${priceForTier(tier)!.toLocaleString()}/mo`}
        </Button>
      </CardContent>
    </Card>
  );
}
  • Step 4: Build PortalSeatRequestCard.tsx
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { api } from '@/lib/api';
import { useUsage } from '@/hooks/use-usage';

export function PortalSeatRequestCard() {
  const [packs, setPacks] = useState(1);
  const { data: usage } = useUsage();
  const qc = useQueryClient();
  const { data: pricing } = useQuery({
    queryKey: ['billing', 'addon-pricing'],
    queryFn: async () => (await api.get('/billing/addon-pricing')).data as { portalSeats3PackProPkr: number; portalSeats3PackProPlusPkr: number },
    staleTime: 60_000,
  });
  const perPack = !pricing ? 0 : (usage?.plan === 'proPlus' ? pricing.portalSeats3PackProPlusPkr : pricing.portalSeats3PackProPkr);
  const totalPkr = packs * perPack;

  const mut = useMutation({
    mutationFn: () => api.post('/billing/addon-requests', { addonType: 'portal_seats', quantity: packs * 3 }),
    onSuccess: () => {
      toast.success('Seat addon request submitted. Superadmin will review shortly.');
      qc.invalidateQueries({ queryKey: ['billing', 'addon-requests'] });
    },
    onError: (e: any) => toast.error(e?.response?.data?.error ?? 'Failed to submit'),
  });

  return (
    <Card>
      <CardHeader><CardTitle>Request more portal seats</CardTitle></CardHeader>
      <CardContent className="space-y-3">
        <div className="flex gap-2 items-center">
          {[1, 2, 3, 5].map(n => (
            <Button key={n} size="sm" variant={packs === n ? 'default' : 'outline'} onClick={() => setPacks(n)}>{n} pack(s)</Button>
          ))}
        </div>
        <p className="text-sm">{packs * 3} seats · <strong>PKR {totalPkr.toLocaleString()}</strong> / month</p>
        <Button onClick={() => mut.mutate()} disabled={mut.isPending || !pricing}>Submit request</Button>
      </CardContent>
    </Card>
  );
}
  • Step 5: Build RecentAddonRequests.tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { api } from '@/lib/api';

export function RecentAddonRequests() {
  const qc = useQueryClient();
  const { data = [] } = useQuery({
    queryKey: ['billing', 'addon-requests'],
    queryFn: async () => (await api.get('/billing/addon-requests')).data,
  });
  const cancel = useMutation({
    mutationFn: (id: string) => api.delete(`/billing/addon-requests/${id}`),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['billing', 'addon-requests'] }),
  });

  if (!data.length) return null;
  return (
    <div>
      <h3 className="font-medium mb-2">Recent addon requests</h3>
      <table className="w-full text-sm">
        <thead><tr><th className="text-left p-2">Type</th><th className="text-left p-2">Qty</th><th className="text-left p-2">Total</th><th className="text-left p-2">Status</th><th></th></tr></thead>
        <tbody>
          {data.map((r: any) => (
            <tr key={r.id} className="border-t">
              <td className="p-2">{r.addonType === 'storage' ? 'Storage' : 'Portal seats'}</td>
              <td className="p-2">{r.addonType === 'storage' ? `${r.quantity} GB` : `${r.quantity} seats`}</td>
              <td className="p-2">PKR {r.totalPricePkr.toLocaleString()}</td>
              <td className="p-2"><Badge variant={r.status === 'pending' ? 'secondary' : r.status === 'approved' ? 'default' : 'destructive'}>{r.status}</Badge></td>
              <td className="p-2">{r.status === 'pending' && <Button size="sm" variant="ghost" onClick={() => cancel.mutate(r.id)}>Cancel</Button>}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
  • Step 6: Wire into AdminBillingSettings
Replace the hardcoded “Pro = 50/50, Pro+ = 100/100” block with:
<section className="space-y-4">
  <UsageMeters />
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
    <StorageRequestCard />
    <PortalSeatRequestCard />
  </div>
  <RecentAddonRequests />
</section>
  • Step 7: Type-check, commit
cd ui && npx tsc --noEmit
git add ui/src/components/settings/
git commit -m "feat(billing-ui): usage meters + addon request cards in AdminBillingSettings"

Task 5.5: Storage-full block component

Files:
  • Create: ui/src/components/storage/StorageFullBlock.tsx
  • Modify: file-upload components (find all that handle 413 from upload routes)
  • Step 1: Build component
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { AlertCircle } from 'lucide-react';
import { api } from '@/lib/api';

export function StorageFullBlock({ usedBytes, quotaBytes }: { usedBytes?: number; quotaBytes?: number | null }) {
  const { data: pricing } = useQuery({
    queryKey: ['billing', 'addon-pricing'],
    queryFn: async () => (await api.get('/billing/addon-pricing')).data as { storage50GbPkr: number },
    staleTime: 60_000,
  });
  const usedGb = usedBytes ? (usedBytes / 1024 ** 3).toFixed(0) : '?';
  const quotaGb = quotaBytes ? (quotaBytes / 1024 ** 3).toFixed(0) : '?';
  const ctaLabel = pricing ? `Add 50 GB · PKR ${pricing.storage50GbPkr.toLocaleString()}/mo` : 'Add storage';
  return (
    <div className="border-2 border-dashed rounded-lg p-6 text-center">
      <AlertCircle className="mx-auto h-8 w-8 text-destructive mb-2" />
      <h3 className="font-semibold">Storage full</h3>
      <p className="text-sm text-muted-foreground mb-1">You've used {usedGb} GB of {quotaGb} GB.</p>
      <p className="text-xs text-muted-foreground mb-4 max-w-md mx-auto">Adding storage gives you secure business storage inside the app — backups, sharing, audit log, and support included. No IT setup needed.</p>
      <div className="flex gap-2 justify-center">
        <Button asChild><Link to="/dashboard/settings?tab=billing">{ctaLabel}</Link></Button>
        <Button variant="outline" asChild><Link to="/dashboard/settings?tab=billing">See all tiers</Link></Button>
      </div>
    </div>
  );
}
  • Step 2: Wire into upload components
For each upload component (X-ray uploader, attachment uploader, signature uploader, etc.), catch the 413 response from the API and render <StorageFullBlock /> in place of the dropzone. Locate them:
cd ui/src && grep -rn "/patient-files\|/signatures\|/files/upload" components --include="*.tsx" -l
For each, wrap the upload mutation onError:
const [storageFull, setStorageFull] = useState<{ used: number; quota: number | null } | null>(null);
// in onError:
if (err?.response?.status === 413) {
  setStorageFull({ used: err.response.data.usedBytes, quota: err.response.data.quotaBytes });
}
// in JSX:
{storageFull ? <StorageFullBlock usedBytes={storageFull.used} quotaBytes={storageFull.quota} /> : <YourExistingDropzone />}
  • Step 3: Commit
git add ui/src/components/storage/ ui/src/components/  # whatever you touched
git commit -m "feat(storage-ui): StorageFullBlock + 413 handling in upload components"

Task 5.6: In-app upgrade page addon widgets

Files:
  • Modify: ui/src/pages/UpgradePage.tsx (or wherever in-app upgrade UI lives — must be inside go.odontox.io / authenticated, NOT q.odontox.io)
Public-pricing rule: Concrete addon prices must NEVER appear on q.odontox.io. The marketing site may mention “addons available” in plain copy, but no PKR figures. This task only edits the authenticated in-app upgrade page.
  • Step 1: Verify the file you’re editing is inside the authenticated app
cd ui/src && grep -rn "UpgradePage\|/upgrade" pages --include="*.tsx" -l
The component must live inside the authenticated routes (/dashboard/upgrade or similar). If you find a public/marketing upgrade page (under q-odontox/ or similar marketing app folder), STOP — the marketing site keeps its current “talk to us” copy with no prices.
  • Step 2: Add the same two cards (StorageRequestCard, PortalSeatRequestCard) below the existing plan-selection section
Reuse the components from Task 5.4 — do not duplicate.
  • Step 3: Audit the q.odontox.io codebase for any leaked prices
cd ui/src && grep -rn "PKR 399\|PKR 2,999\|PKR 999\|PKR 699\|399/GB\|3-pack" --include="*.tsx" --include="*.ts"
If any marketing/public-facing component renders these literals (especially anything under a q-odontox, public-marketing, or similar root), open a follow-up task to remove them. Within the authenticated app the prices are fine. Also check q.odontox.io source repo (it may be a separate repo or app). If it lives here, search:
grep -rn "addon\|storage" --include="*.tsx" packages/marketing/ public/marketing/ 2>/dev/null || true
Report findings in the commit message.
  • Step 4: Commit
git commit -am "feat(upgrade): expose addon request cards in authenticated upgrade page (in-app only)"

Phase 6: Superadmin UI

Subagent assignment: 1 subagent.

Task 6.1: Addons & Quotas tab on ClinicDetailsPage

Files:
  • Create: ui/src/components/superadmin/AddonsQuotasTab.tsx
  • Modify: ui/src/components/superadmin/ClinicDetailsPage.tsx
  • Step 1: Build the tab component
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { api } from '@/lib/api';
import { useState } from 'react';

export function AddonsQuotasTab({ clinicId }: { clinicId: string }) {
  const qc = useQueryClient();
  const { data: requests = [] } = useQuery({
    queryKey: ['superadmin', 'addon-requests', clinicId],
    queryFn: async () => (await api.get(`/superadmin/addon-requests?clinicId=${clinicId}`)).data,
  });

  const approve = useMutation({
    mutationFn: (id: string) => api.post(`/superadmin/addon-requests/${id}/approve`),
    onSuccess: () => { toast.success('Approved'); qc.invalidateQueries({ queryKey: ['superadmin', 'addon-requests'] }); },
  });
  const reject = useMutation({
    mutationFn: ({ id, reason }: { id: string; reason: string }) => api.post(`/superadmin/addon-requests/${id}/reject`, { reason }),
    onSuccess: () => { toast.success('Rejected'); qc.invalidateQueries({ queryKey: ['superadmin', 'addon-requests'] }); },
  });
  const cancelActive = useMutation({
    mutationFn: (id: string) => api.post(`/superadmin/addon-requests/${id}/cancel-active`),
    onSuccess: () => { toast.success('Cancelled active addon'); qc.invalidateQueries({ queryKey: ['superadmin', 'addon-requests'] }); },
  });

  const pending = requests.filter((r: any) => r.request.status === 'pending');
  const active = requests.filter((r: any) => r.request.status === 'approved' && !r.request.cancelledAt);

  return (
    <div className="space-y-6">
      <EnterpriseOverrideCard clinicId={clinicId} />
      <RecomputeStorageButton clinicId={clinicId} />

      <section>
        <h3 className="font-semibold mb-2">Pending addon requests ({pending.length})</h3>
        {!pending.length && <p className="text-sm text-muted-foreground">No pending requests.</p>}
        {pending.map((r: any) => (
          <div key={r.request.id} className="border rounded p-3 mb-2 flex items-center justify-between">
            <div>
              <p className="font-medium">{r.request.addonType === 'storage' ? `${r.request.quantity} GB storage` : `${r.request.quantity} portal seats`}</p>
              <p className="text-xs text-muted-foreground">PKR {r.request.totalPricePkr.toLocaleString()}/mo · requested {new Date(r.request.createdAt).toLocaleDateString()}</p>
              {r.request.notes && <p className="text-xs italic">Note: {r.request.notes}</p>}
            </div>
            <div className="flex gap-2">
              <Button size="sm" onClick={() => approve.mutate(r.request.id)}>Approve</Button>
              <RejectButton onSubmit={(reason) => reject.mutate({ id: r.request.id, reason })} />
            </div>
          </div>
        ))}
      </section>

      <section>
        <h3 className="font-semibold mb-2">Active addons ({active.length})</h3>
        {!active.length && <p className="text-sm text-muted-foreground">No active addons.</p>}
        {active.map((r: any) => (
          <div key={r.request.id} className="border rounded p-3 mb-2 flex items-center justify-between">
            <div>
              <p>{r.request.addonType === 'storage' ? `${r.request.quantity} GB storage` : `${r.request.quantity} portal seats`}</p>
              <p className="text-xs text-muted-foreground">PKR {r.request.totalPricePkr.toLocaleString()}/mo · since {new Date(r.request.approvedAt).toLocaleDateString()}</p>
            </div>
            <Button size="sm" variant="destructive" onClick={() => cancelActive.mutate(r.request.id)}>Cancel addon</Button>
          </div>
        ))}
      </section>
    </div>
  );
}

function RejectButton({ onSubmit }: { onSubmit: (reason: string) => void }) {
  const [open, setOpen] = useState(false);
  const [reason, setReason] = useState('');
  if (!open) return <Button size="sm" variant="outline" onClick={() => setOpen(true)}>Reject</Button>;
  return (
    <div className="flex gap-1">
      <Input placeholder="Reason" value={reason} onChange={(e) => setReason(e.target.value)} className="h-9" />
      <Button size="sm" onClick={() => { onSubmit(reason); setOpen(false); }}>OK</Button>
    </div>
  );
}

function EnterpriseOverrideCard({ clinicId }: { clinicId: string }) {
  const [storageGb, setStorageGb] = useState('');
  const [seats, setSeats] = useState('');
  const setStorage = useMutation({
    mutationFn: (gb: number | null) => api.post(`/superadmin/clinics/${clinicId}/storage-quota`, { bytes: gb === null ? null : gb * 1024 ** 3 }),
    onSuccess: () => toast.success('Storage quota set'),
  });
  const setSeatsLimit = useMutation({
    mutationFn: (limit: number | null) => api.post(`/superadmin/clinics/${clinicId}/portal-seat-limit`, { limit }),
    onSuccess: () => toast.success('Seat limit set'),
  });

  return (
    <section className="border rounded p-3">
      <h3 className="font-semibold mb-2">Enterprise overrides</h3>
      <div className="flex gap-2 items-center mb-2">
        <Input placeholder="Storage GB" value={storageGb} onChange={(e) => setStorageGb(e.target.value)} className="w-32" />
        <Button size="sm" onClick={() => setStorage.mutate(Number(storageGb) || 0)}>Set storage</Button>
        <Button size="sm" variant="outline" onClick={() => setStorage.mutate(null)}>Reset to plan</Button>
      </div>
      <div className="flex gap-2 items-center">
        <Input placeholder="Portal seats" value={seats} onChange={(e) => setSeats(e.target.value)} className="w-32" />
        <Button size="sm" onClick={() => setSeatsLimit.mutate(Number(seats) || 0)}>Set seats</Button>
        <Button size="sm" variant="outline" onClick={() => setSeatsLimit.mutate(null)}>Reset to plan</Button>
      </div>
    </section>
  );
}

function RecomputeStorageButton({ clinicId }: { clinicId: string }) {
  const m = useMutation({
    mutationFn: () => api.post(`/superadmin/clinics/${clinicId}/recompute-storage`),
    onSuccess: (res) => toast.success(`Recomputed: ${(res.data.total / 1024 ** 3).toFixed(2)} GB`),
  });
  return <Button size="sm" onClick={() => m.mutate()} disabled={m.isPending}>Recompute storage usage</Button>;
}
  • Step 2: Register the tab on ClinicDetailsPage
Find the tabs definition and add:
<TabsTrigger value="addons">Addons & Quotas</TabsTrigger>
// ...
<TabsContent value="addons"><AddonsQuotasTab clinicId={clinicId} /></TabsContent>
  • Step 3: Commit
git add ui/src/components/superadmin/AddonsQuotasTab.tsx ui/src/components/superadmin/ClinicDetailsPage.tsx
git commit -m "feat(superadmin): Addons & Quotas tab with approve/reject/override"

Task 6.1b: Superadmin “Addon Pricing” settings panel

Files:
  • Create: ui/src/components/superadmin/AddonPricingSettings.tsx
  • Modify: superadmin settings/platform router (wherever “platform settings” tab lives — likely ui/src/pages/superadmin/Settings.tsx or similar)
  • Step 1: Locate superadmin platform settings page
cd ui/src && grep -rn "platform.settings\|PlatformSettings\|superadmin/settings" --include="*.tsx" -l
  • Step 2: Build the editor
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { api } from '@/lib/api';

export function AddonPricingSettings() {
  const qc = useQueryClient();
  const { data, isLoading } = useQuery({
    queryKey: ['superadmin', 'addon-pricing'],
    queryFn: async () => (await api.get('/superadmin/addon-pricing')).data,
  });

  const [form, setForm] = useState({
    storage50GbPkr: 0,
    storage200GbPkr: 0,
    storage500GbPkr: 0,
    storage1TbPkr: 0,
    portalSeats3PackProPkr: 0,
    portalSeats3PackProPlusPkr: 0,
  });

  useEffect(() => {
    if (data?.current) {
      setForm({
        storage50GbPkr: data.current.storage50GbPkr,
        storage200GbPkr: data.current.storage200GbPkr,
        storage500GbPkr: data.current.storage500GbPkr,
        storage1TbPkr: data.current.storage1TbPkr,
        portalSeats3PackProPkr: data.current.portalSeats3PackProPkr,
        portalSeats3PackProPlusPkr: data.current.portalSeats3PackProPlusPkr,
      });
    }
  }, [data]);

  const save = useMutation({
    mutationFn: () => api.put('/superadmin/addon-pricing', form),
    onSuccess: () => {
      toast.success('Pricing updated');
      qc.invalidateQueries({ queryKey: ['superadmin', 'addon-pricing'] });
    },
    onError: (e: any) => toast.error(e?.response?.data?.error ?? 'Failed to save'),
  });

  if (isLoading) return <div>Loading…</div>;

  return (
    <Card>
      <CardHeader>
        <CardTitle>Addon pricing</CardTitle>
        <p className="text-xs text-muted-foreground">
          Global PKR-only prices applied to new addon requests. Pending/approved requests keep their snapshotted price.
        </p>
      </CardHeader>
      <CardContent className="space-y-4">
        <div>
          <h4 className="text-sm font-medium mb-2">Storage tiers (monthly, PKR)</h4>
          <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
            <Field label="50 GB" value={form.storage50GbPkr} onChange={(v) => setForm({ ...form, storage50GbPkr: v })} />
            <Field label="200 GB" value={form.storage200GbPkr} onChange={(v) => setForm({ ...form, storage200GbPkr: v })} />
            <Field label="500 GB" value={form.storage500GbPkr} onChange={(v) => setForm({ ...form, storage500GbPkr: v })} />
            <Field label="1 TB" value={form.storage1TbPkr} onChange={(v) => setForm({ ...form, storage1TbPkr: v })} />
          </div>
        </div>
        <div>
          <h4 className="text-sm font-medium mb-2">Portal-seat 3-packs (monthly, PKR)</h4>
          <div className="grid grid-cols-2 gap-4">
            <Field label="Pro tenant" value={form.portalSeats3PackProPkr} onChange={(v) => setForm({ ...form, portalSeats3PackProPkr: v })} />
            <Field label="Pro+ tenant" value={form.portalSeats3PackProPlusPkr} onChange={(v) => setForm({ ...form, portalSeats3PackProPlusPkr: v })} />
          </div>
        </div>

        <Button onClick={() => save.mutate()} disabled={save.isPending}>Save pricing</Button>

        {data?.current && (
          <p className="text-xs text-muted-foreground">
            Last updated {new Date(data.current.updatedAt).toLocaleString()} by {data.current.updatedBy ?? 'system'}
          </p>
        )}

        {data?.history?.length > 0 && (
          <details className="text-sm">
            <summary className="cursor-pointer font-medium">Change history</summary>
            <table className="w-full mt-2 text-xs">
              <thead><tr><th className="text-left p-1">When</th><th className="text-left p-1">By</th><th className="text-left p-1">Change</th></tr></thead>
              <tbody>
                {data.history.map((h: any) => (
                  <tr key={h.id} className="border-t">
                    <td className="p-1">{new Date(h.changedAt).toLocaleString()}</td>
                    <td className="p-1">{h.changedBy ?? '—'}</td>
                    <td className="p-1 font-mono whitespace-pre">
                      {Object.keys(h.afterValues).filter(k => h.beforeValues[k] !== h.afterValues[k]).map(k => `${k}: ${h.beforeValues[k]}${h.afterValues[k]}`).join('\n')}
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </details>
        )}
      </CardContent>
    </Card>
  );
}

function Field({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {
  return (
    <div>
      <Label>{label}</Label>
      <Input type="number" min={0} value={value} onChange={(e) => onChange(Number(e.target.value))} />
    </div>
  );
}
  • Step 3: Register on superadmin platform settings
Mount <AddonPricingSettings /> as a section/tab on the platform settings page.
  • Step 4: Commit
git add ui/src/components/superadmin/AddonPricingSettings.tsx ui/src/pages/superadmin/
git commit -m "feat(superadmin): addon pricing editor with audit history"

Task 6.2: Pending requests badge on superadmin dashboard

Files:
  • Modify: ui/src/pages/superadmin/Dashboard.tsx (or alerts panel)
  • Step 1: Add a small query that counts pending requests across all tenants
const { data: pendingAddons = 0 } = useQuery({
  queryKey: ['superadmin', 'pending-addons-count'],
  queryFn: async () => (await api.get('/superadmin/addon-requests?status=pending')).data.length,
  staleTime: 60_000,
});
Render as a badge next to the existing alerts widget.
  • Step 2: Commit
git commit -am "feat(superadmin): pending addon requests badge on dashboard"

Phase 7: Help article

Subagent assignment: 1 subagent (lightweight, can run in parallel with UI).

Task 7.1: Write the help article

Files:
  • Create: docs/help/patient-portal-access.md (consumed by drawer; mirror inline content used in Phase 5.2’s drawer for now)
  • Step 1: Write the article
Content matches the spec’s “Help article outline” — copy the same body as the drawer renders, but as plain markdown so we can lift it to help.odontox.io later. No code changes here, just the file.
  • Step 2: Commit
git add docs/help/patient-portal-access.md
git commit -m "docs(help): patient portal access article"

Phase 8: End-to-end smoke

Subagent assignment: do this last, after all other phases land.

Task 8.1: Manual smoke checklist

Run npm run dev in both server/ and ui/. Login as a clinic admin on a Pro-tier test tenant. Verify in order:
  • Settings → My Billing renders usage meters reflecting current state (0 portal seats used if fresh).
  • Create a new patient with “Provide portal access” checked → seat count increments to 1.
  • Click (?) next to the checkbox → drawer opens, shows the three-subdomain table.
  • Edit a patient with portal access ON, toggle OFF → confirm modal appears, on confirm the seat count drops.
  • Submit a 10 GB storage addon request → toast confirms; row appears in “Recent addon requests” with status pending.
  • Submit a 1-pack portal seat addon (3 seats) → same.
  • Switch to superadmin, open the tenant’s “Addons & Quotas” tab → both pending requests visible.
  • Approve the portal-seat request → seat limit increments on the clinic side (refresh meter).
  • Approve the storage request → storage quota increments.
  • Reject a fresh request with a reason → status flips to rejected.
  • Cancel an active addon as superadmin → quota decrements back.
  • On Pro tenant at 100/100 seats, attempt to enable portal-access on a new patient → checkbox disabled with link to billing.
  • Fill the storage to the cap (mock by setting storageUsedBytes directly in DB or by uploading), try to upload a file → 413 response, StorageFullBlock renders.
  • Bulk import 5 patients on a tenant 1 seat under the cap with all rows portal_access=yes → 1 enrolls, 4 land in not-enrolled.csv.
  • Verify free-trial tenant has caps shown but never enforced (toggle the trial flag, retry).
  • Audit logs include addon_request.created, .approved, .rejected, .cancelled, portal_access.granted/revoked, storage_quota.override.
  • Step 1: Document failures
For each failing item, file a follow-up issue or commit a fix.
  • Step 2: Tag the release
After all green:
git tag v1.7-portal-storage-caps
git push origin v1.7-portal-storage-caps

Self-review against the spec

Spec sectionTask(s) covering it
Plan tier table1.1 (plan-limits.ts)
Pricing defaults1.1, 3.1
Dynamic pricing config table1.2b, 1.3 (schema)
Pricing snapshot on request3.2
Live pricing API for clinic UI5.4 (step 3)
Superadmin pricing editor3.3b (API), 6.1b (UI)
Pricing history / audit3.3b
No-pricing-on-q.odontox.io5.6
clinics columns1.2, 1.3
patients.portal_access1.2, 1.3
addon_requests table1.2, 1.3
plan-limits.ts central config1.1
Storage usage tracking2.1, 2.3
Clinic-facing API3.2, 3.4
Superadmin API3.3
Patient form checkbox5.2
Bulk import overflow5.3
Settings → My Billing5.1, 5.4
Storage-full block UI5.5
In-app upgrade page5.6
Superadmin tenant detail tab6.1
Pending requests badge6.2
Help article5.2 (in-app), 7.1 (file)
Free-trial bypass2.2 (helper), 2.3 (upload gating), 2.4 (toggle), 5.2 (UI)
Permissions3.4
Audit logs2.4, 3.2, 3.3, 3.3b
Legacy patient-quota replacement4.1
Legacy PATIENT_ADDON: migration4.2
Coverage: complete. Types are consistent across tasks (PlanKey, AddonType, getEffective* helpers). Ready to dispatch.

Execution

Per the user’s auto-pilot rule, the next step is to dispatch parallel subagents. Phase dependency graph:
  • Phase 1 → blocks everything
  • Phase 2 → blocks Phase 5 (UI needs APIs)
  • Phase 3 → blocks Phase 5.4, 5.6, Phase 6
  • Phase 4 → blocks nothing (cleanup; can run last)
  • Phase 5–7 → can parallelize after Phase 2+3
  • Phase 8 → after everything
Suggested dispatch:
  1. Run Phase 1 as a single subagent (sequential, blocking).
  2. Once Phase 1 lands: dispatch Phase 2, Phase 3 in parallel.
  3. Once Phase 2+3 land: dispatch Phase 4, Phase 5 (split into 5.1+5.2+5.3 and 5.4+5.5+5.6), Phase 6, Phase 7 in parallel.
  4. Phase 8 smoke after all others green.