Add design spec for BillingAccount, per-org modules, and /adm polish.

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 10:54:29 +03:30
co-authored by Cursor
parent 605e1d3340
commit 04d37a9d12
@@ -0,0 +1,202 @@
# 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.