8.9 KiB
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
- Subscription on account owner via a dedicated
BillingAccount(not raw fields onOrganization). - Modules per environment — every CRM menu surface can be enabled/disabled per org from
/adm. - Manual plan activation now; structure ready for a future landing-page payment gateway (no gateway in this phase).
- Expiry UX: show remaining days in the CRM app header; warn from 3 days before; after expiry block login with a clear “اشتراک منقضی شده” message.
- Migrate existing accounts: create a
BillingAccountwith plantrialand 15 days from migration time so current customers keep working until extended. - Polish
/admUI 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[]onOrganization(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 setsenabledModulesto 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
- For every
Userwho has at least oneMembershipwithrole = owner(or is the sole owner of an org), ensure oneBillingAccount:- 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.
- Set
Organization.enabledModulesto 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)productsinvoicesquotescommissionssales_forecastactivitiesticketsworkflow_automationsmessage_automationslead_formsproject_boardsinventorychequesattachmentsloyalty(if present in nav/settings)reports(if in nav)notes/ smart notes if separately gatedtahesabcustom_fieldscurrenciesteam_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:
- Resolve
BillingAccountfor the user (byownerUserId).- 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.
- If
status !== activeORexpiresAt < now→ reject with message اشتراک منقضی / معلق؛ mark statusexpiredlazily when detected. - Members of an expired owner cannot log into that org either.
Create organization
- Only if caller has
BillingAccountwithstatus = activeandexpiresAt >= now. - Count orgs where user has
role = ownermembership; must be< maxOrganizations.
Modules
- Web: filter
NAV_HUBScards/shortcuts and router guards byorganization.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 <= 3and> 0: warning style banner/chip. - If expired: user never reaches shell (login blocked).
Platform admin (/adm) API
Authenticated with existing platform-admin JWT.
GET/PATCHbilling for a user: plan, dates, maxOrgs, status, notes.- Shortcuts:
POST .../billing/apply-planwith{ planCode, maxOrganizations?, expiresAt? }. PATCH /platform/organizations/:id/moduleswith{ 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:
- Create/find
BillingAccountby owner. - 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
/admlogin unaffected by BillingAccount.
Testing (manual)
- Fresh seed/migrate → existing owner has 15-day trial; can log in; header shows days.
- Set expiresAt to yesterday → login fails with expiry message.
- Disable
message_automationson an org → menu gone + API 403. - Trial maxOrgs=1 with one owned org → create second org rejected.
- Apply
monthlyfrom/adm→ expiresAt ~+30d, status active.