# Design: BillingAccount + per-org modules + /adm polish **Date:** 2026-09-20 **Status:** Approved for planning **Scope:** Superadmin (`/adm`) subscription control, per-organization module entitlements, CRM enforcement, and visual polish of the platform admin UI. ## Goals 1. **Subscription on account owner** via a dedicated `BillingAccount` (not raw fields on `Organization`). 2. **Modules per environment** — every CRM menu surface can be enabled/disabled per org from `/adm`. 3. **Manual plan activation now**; structure ready for a future landing-page payment gateway (no gateway in this phase). 4. **Expiry UX:** show remaining days in the CRM app header; warn from 3 days before; after expiry **block login** with a clear “اشتراک منقضی شده” message. 5. **Migrate existing accounts:** create a `BillingAccount` with plan `trial` and **15 days from migration time** so current customers keep working until extended. 6. **Polish `/adm` UI** to feel more app-like (same Vue + CRM CSS tokens; no separate Ant Design/Tailwind app). ## Non-goals (later) - Online payment gateway / landing checkout - Impersonate user login - Complex multi-tier platform-admin roles - Automatic invoice generation for Mentekt SaaS itself ## Decisions (locked) | Topic | Choice | |---|---| | Account vs org | Both: subscription on owner (`BillingAccount`); modules on each `Organization` | | Toggleable modules | Entire menu surface (not only add-ons) | | Payment | Manual in `/adm` now; `planCode` + optional payment ref reserved for gateway | | On expiry | Block CRM login; header shows remaining days; warn ≤3 days | | Existing orgs/users | Backfill trial **15 days** from migrate/seed time | | Data model style | Dedicated `BillingAccount` entity (approach 2) | --- ## Data model ### `BillingAccount` One billing account per **owner user** (the SaaS customer). | Field | Type | Notes | |---|---|---| | `id` | cuid | PK | | `ownerUserId` | string unique | FK → `User` — the account holder | | `planCode` | string | `trial` \| `monthly` \| `quarterly` \| `semiannual` \| `yearly` \| `custom` | | `status` | string | `active` \| `expired` \| `suspended` | | `startsAt` | DateTime | | | `expiresAt` | DateTime | | | `maxOrganizations` | int | e.g. trial demo = 1 | | `externalPaymentRef` | string? | Reserved for future gateway / landing | | `notes` | string? | Superadmin internal note | | `createdAt` / `updatedAt` | DateTime | | Indexes: `ownerUserId` unique; `expiresAt`; `status`. **Ownership rule:** The billing owner is the user who first registered / is treated as account holder. Membership in other orgs does not create extra billing accounts. Creating a new org as owner still counts against `maxOrganizations` of that user’s `BillingAccount`. ### `Organization.enabledModules` - `enabledModules String[]` on `Organization` (Prisma). - Canonical keys live in `@mento/shared` (see Module catalog). - Empty array after migration for existing orgs means **all modules enabled** (backward compatible), OR we backfill the full catalog — prefer **backfill full catalog** so “empty” can later mean “nothing enabled” if needed. **Locked choice:** migration sets `enabledModules` to the **full default catalog** for existing orgs. New orgs get a default catalog from the plan template (trial = core set configurable in code; superadmin can change immediately). ### Plan presets (manual buttons in `/adm`) | planCode | Default duration | Default maxOrgs | |---|---|---| | `trial` | 15 days | 1 | | `monthly` | 30 days | configurable (default 3) | | `quarterly` | 90 days | configurable (default 5) | | `semiannual` | 180 days | configurable (default 10) | | `yearly` | 365 days | configurable (default 20) | | `custom` | superadmin picks `expiresAt` + `maxOrganizations` | as set | Extending an active plan from `/adm` sets `startsAt` (if missing), `expiresAt` = now + duration (or custom date), `status = active`, updates `planCode`. ### Backfill migration 1. For every `User` who has at least one `Membership` with `role = owner` (or is the sole owner of an org), ensure one `BillingAccount`: - Prefer: one account per distinct owner user who owns ≥1 org. - `planCode = trial`, `status = active`, `startsAt = now`, `expiresAt = now + 15 days`, `maxOrganizations = max(1, current org count they own)` so nobody is suddenly blocked for having multiple orgs already. 2. Set `Organization.enabledModules` to full catalog for all existing orgs. --- ## Module catalog Keys align with nav hubs/cards (and API surfaces). Shared constant e.g. `ORG_MODULE_KEYS` / labels in `@mento/shared`. Suggested keys (final list in implementation may split cards 1:1 with nav): - `customers` (people + companies) - `pipeline` (sales pipeline / deals) - `products` - `invoices` - `quotes` - `commissions` - `sales_forecast` - `activities` - `tickets` - `workflow_automations` - `message_automations` - `lead_forms` - `project_boards` - `inventory` - `cheques` - `attachments` - `loyalty` (if present in nav/settings) - `reports` (if in nav) - `notes` / smart notes if separately gated - `tahesab` - `custom_fields` - `currencies` - `team_settings` (keep always-on for owners? **Locked:** still toggleable, but warn in UI that disabling team settings locks self-service; login/org switcher remain available) Settings hub items are individually toggleable. Core shell (home, profile, logout, org switcher) stays available when subscription is active. --- ## Enforcement ### Login / session On CRM password login and org switch / token refresh: 1. Resolve `BillingAccount` for the user (by `ownerUserId`). - If user is **not** a billing owner (invited member only): use the **org owner’s** billing account for the active organization (subscription is per owner account that owns the env). - **Locked rule:** Entitlement for entering an org is the billing account of that org’s **active owner membership** (any active owner). If multiple owners, any one’s active billing is enough; prefer the earliest owner membership’s user. 2. If `status !== active` OR `expiresAt < now` → reject with message اشتراک منقضی / معلق؛ mark status `expired` lazily when detected. 3. Members of an expired owner cannot log into that org either. ### Create organization - Only if caller has `BillingAccount` with `status = active` and `expiresAt >= now`. - Count orgs where user has `role = owner` membership; must be `< maxOrganizations`. ### Modules - **Web:** filter `NAV_HUBS` cards/shortcuts and router guards by `organization.enabledModules` (loaded with org/session payload). - **API:** guard controllers/services for module-gated routes; return 403 with clear Persian message if module disabled. - Role permissions (`RolePermissionKey`) remain orthogonal: module must be on **and** role must allow. ### Remaining days UI - Include `subscription: { planCode, status, expiresAt, daysRemaining, warn }` in login/me/org bootstrap DTO. - App header chip: «N روز باقی‌مانده». - If `daysRemaining <= 3` and `> 0`: warning style banner/chip. - If expired: user never reaches shell (login blocked). --- ## Platform admin (`/adm`) API Authenticated with existing platform-admin JWT. - `GET/PATCH` billing for a user: plan, dates, maxOrgs, status, notes. - Shortcuts: `POST .../billing/apply-plan` with `{ planCode, maxOrganizations?, expiresAt? }`. - `PATCH /platform/organizations/:id/modules` with `{ enabledModules: string[] }`. - List users/orgs already exists; extend DTOs with billing summary + module flags. --- ## Platform admin UI polish Keep Vue 3 + existing CSS variables (`--accent`, `--line`, cards). Improvements: - Denserv app-like shell: clearer hierarchy, sticky subheaders, soft gradients consistent with CRM auth pages. - User detail: billing card with plan chips + date picker (Jalali) + max orgs. - Org detail: module checklist grouped by hub (مشتریان / فروش / …) with select-all per group. - Dashboard KPIs: optional counts of expiring ≤3 days / expired (nice-to-have in same phase if cheap). No Ant Design / Tailwind. --- ## Future gateway hook Landing page later will: 1. Create/find `BillingAccount` by owner. 2. After payment success, set `planCode`, `expiresAt`, `externalPaymentRef`, `status = active`. No payment code in this phase; do not invent webhook handlers yet. --- ## Risks & edge cases - Users with multiple owned orgs today: backfill `maxOrganizations` ≥ current owned count. - Invited users: gated by org owner’s subscription. - Disabling a module mid-use: existing data kept; API/UI blocked until re-enabled. - Superadmin `/adm` login unaffected by BillingAccount. ## Testing (manual) 1. Fresh seed/migrate → existing owner has 15-day trial; can log in; header shows days. 2. Set expiresAt to yesterday → login fails with expiry message. 3. Disable `message_automations` on an org → menu gone + API 403. 4. Trial maxOrgs=1 with one owned org → create second org rejected. 5. Apply `monthly` from `/adm` → expiresAt ~+30d, status active.