diff --git a/apps/api/prisma/migrations/20260920110000_billing_account_modules/migration.sql b/apps/api/prisma/migrations/20260920110000_billing_account_modules/migration.sql new file mode 100644 index 0000000..d89b59d --- /dev/null +++ b/apps/api/prisma/migrations/20260920110000_billing_account_modules/migration.sql @@ -0,0 +1,64 @@ +-- AlterTable +ALTER TABLE "Organization" ADD COLUMN IF NOT EXISTS "enabledModules" TEXT[] DEFAULT ARRAY[]::TEXT[]; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "BillingAccount" ( + "id" TEXT NOT NULL, + "ownerUserId" TEXT NOT NULL, + "planCode" TEXT NOT NULL DEFAULT 'trial', + "status" TEXT NOT NULL DEFAULT 'active', + "startsAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "maxOrganizations" INTEGER NOT NULL DEFAULT 1, + "externalPaymentRef" TEXT, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "BillingAccount_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "BillingAccount_ownerUserId_key" ON "BillingAccount"("ownerUserId"); +CREATE INDEX IF NOT EXISTS "BillingAccount_status_idx" ON "BillingAccount"("status"); +CREATE INDEX IF NOT EXISTS "BillingAccount_expiresAt_idx" ON "BillingAccount"("expiresAt"); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'BillingAccount_ownerUserId_fkey' + ) THEN + ALTER TABLE "BillingAccount" + ADD CONSTRAINT "BillingAccount_ownerUserId_fkey" + FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +-- Backfill: full module catalog for existing orgs (empty → all) +UPDATE "Organization" +SET "enabledModules" = ARRAY[ + 'customers','pipeline','products','invoices','quotes','commissions','sales_forecast', + 'activities','tickets','workflow_automations','message_automations','lead_forms', + 'project_boards','inventory','cheques','attachments','loyalty','reports','notes', + 'tahesab','custom_fields','currencies','team_settings','faq' +]::TEXT[] +WHERE cardinality("enabledModules") = 0; + +-- Backfill BillingAccount for every active owner membership (15-day trial) +INSERT INTO "BillingAccount" ( + "id", "ownerUserId", "planCode", "status", "startsAt", "expiresAt", + "maxOrganizations", "createdAt", "updatedAt" +) +SELECT + md5(random()::text || clock_timestamp()::text || m."userId"), + m."userId", + 'trial', + 'active', + NOW(), + NOW() + INTERVAL '15 days', + GREATEST(1, COUNT(*)::int), + NOW(), + NOW() +FROM "Membership" m +WHERE m."role" = 'owner' AND m."isActive" = true +GROUP BY m."userId" +ON CONFLICT ("ownerUserId") DO NOTHING; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2c0302f..0fbaf04 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -147,6 +147,8 @@ model Organization { businessField String? baseCurrency String @default("IRR") enabledCurrencies String[] @default(["IRR", "USD", "EUR"]) + /// ماژول‌های فعال منو برای این محیط + enabledModules String[] @default([]) isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -230,6 +232,7 @@ model User { ticketMessages TicketMessage[] @relation("TicketMessageAuthor") confirmedMemories MemoryItem[] @relation("MemoryConfirmedBy") memberships Membership[] + billingAccount BillingAccount? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -293,6 +296,28 @@ model PlatformAdmin { updatedAt DateTime @updatedAt } +/// اشتراک SaaS روی مالک حساب (نه روی هر عضویت) +model BillingAccount { + id String @id @default(cuid()) + ownerUserId String @unique + owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade) + /// trial | monthly | quarterly | semiannual | yearly | custom + planCode String @default("trial") + /// active | expired | suspended + status String @default("active") + startsAt DateTime @default(now()) + expiresAt DateTime + maxOrganizations Int @default(1) + /// رزرو برای درگاه/لندینگ بعدی + externalPaymentRef String? + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status]) + @@index([expiresAt]) +} + model Company { id String @id @default(cuid()) organizationId String diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index cf0e4ba..41e9b12 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -44,9 +44,19 @@ async function seedPlatformAdmin() { return; } + const passwordHash = await bcrypt.hash(password, 10); const existingByMobile = await prisma.platformAdmin.findUnique({ where: { mobile } }); if (existingByMobile) { - console.log('Platform admin exists:', mobile); + await prisma.platformAdmin.update({ + where: { id: existingByMobile.id }, + data: { + passwordHash, + fullName: envName, + isActive: true, + ...(email ? { email } : {}), + }, + }); + console.log('Platform admin password refreshed:', mobile); return; } if (email) { @@ -54,14 +64,38 @@ async function seedPlatformAdmin() { if (existingByEmail) { await prisma.platformAdmin.update({ where: { id: existingByEmail.id }, - data: { mobile, ...(envName ? { fullName: envName } : {}) }, + data: { + mobile, + passwordHash, + fullName: envName, + isActive: true, + }, }); - console.log('Platform admin linked mobile:', mobile, '→', email); + console.log('Platform admin linked mobile + password refreshed:', mobile, '→', email); return; } } - const passwordHash = await bcrypt.hash(password, 10); + const orphans = await prisma.platformAdmin.findMany({ + where: { mobile: null }, + take: 2, + }); + if (orphans.length === 1) { + const orphan = orphans[0]!; + await prisma.platformAdmin.update({ + where: { id: orphan.id }, + data: { + mobile, + passwordHash, + fullName: envName, + isActive: true, + ...(email ? { email } : {}), + }, + }); + console.log('Platform admin orphan linked mobile:', mobile); + return; + } + await prisma.platformAdmin.create({ data: { mobile, diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 58658a7..6111494 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -33,6 +33,7 @@ import { TahesabModule } from './integrations/tahesab/tahesab.module'; import { InventoryModule } from './inventory/inventory.module'; import { FinanceModule } from './finance/finance.module'; import { PlatformModule } from './platform/platform.module'; +import { BillingModule } from './billing/billing.module'; @Module({ imports: [ @@ -45,6 +46,7 @@ import { PlatformModule } from './platform/platform.module'; PrismaModule, StorageModule, HealthModule, + BillingModule, AuthModule, PlatformModule, UsersModule, diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index d03650b..85d1347 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -3,6 +3,7 @@ import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { MembershipsModule } from '../memberships/memberships.module'; +import { BillingModule } from '../billing/billing.module'; import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; import { JwtStrategy } from './jwt.strategy'; @@ -13,6 +14,7 @@ import { SmsService } from '../common/sms.service'; imports: [ ConfigModule, MembershipsModule, + BillingModule, PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.registerAsync({ imports: [ConfigModule], diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index d65de97..9e88530 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -10,12 +10,19 @@ import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcryptjs'; import * as path from 'path'; -import type { AuthUserDto, LoginResponse, LoginResult } from '@mento/shared'; +import type { + AuthUserDto, + LoginResponse, + LoginResult, + OrgModuleKey, + SubscriptionSummaryDto, +} from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { SmsService } from '../common/sms.service'; import { LocalFileStorage } from '../storage/local-file.storage'; import { MembershipsService } from '../memberships/memberships.service'; +import { BillingService } from '../billing/billing.service'; import { LoginDto, LoginOtpRequestDto, @@ -97,6 +104,7 @@ export class AuthService { private readonly sms: SmsService, private readonly files: LocalFileStorage, private readonly memberships: MembershipsService, + private readonly billing: BillingService, ) {} /** @@ -260,6 +268,11 @@ export class AuthService { organizationId: organization.id, role: 'owner', }); + await this.billing.ensureTrialForOwner(owner.id, { maxOrganizations: 1 }); + await this.prisma.organization.update({ + where: { id: organization.id }, + data: { enabledModules: this.billing.defaultModulesForNewOrg() }, + }); await this.prisma.authChallenge.update({ where: { id: challenge.id }, @@ -578,6 +591,9 @@ export class AuthService { if (!user || user.isActive === false) { throw new UnauthorizedException('حساب کاربری غیرفعال است'); } + const subscription = await this.billing.requireActiveForOrganization( + organizationId, + ); await this.memberships.syncUserLastOrg( userId, organizationId, @@ -597,6 +613,7 @@ export class AuthService { user: await this.toAuthUserDto( { ...user, role: membership.role, organizationId }, organizationId, + subscription, ), }; } @@ -611,19 +628,29 @@ export class AuthService { organizationId: string; }, organizationId: string, + subscription?: SubscriptionSummaryDto | null, ): Promise { const membership = await this.prisma.membership.findFirst({ where: { userId: user.id, organizationId, isActive: true }, - include: { organization: { select: { name: true } } }, + include: { + organization: { select: { name: true, enabledModules: true } }, + }, }); const role = membership?.role ?? user.role; const orgName = membership?.organization.name; + const sub = + subscription ?? + (await this.billing.requireActiveForOrganization(organizationId)); return this.toAuthUser({ ...user, role, organizationId, organizationName: orgName, avatarStorageKey: await this.readAvatarKey(user.id), + enabledModules: this.billing.normalizeModules( + membership?.organization.enabledModules, + ), + subscription: sub, }); } @@ -708,6 +735,8 @@ export class AuthService { organizationId: string; organizationName?: string; avatarStorageKey?: string | null; + enabledModules?: OrgModuleKey[]; + subscription?: SubscriptionSummaryDto | null; }): AuthUserDto { return { id: user.id, @@ -718,6 +747,8 @@ export class AuthService { organizationName: user.organizationName, hasAvatar: Boolean(user.avatarStorageKey), ...(user.mobile ? { mobile: user.mobile } : {}), + ...(user.enabledModules ? { enabledModules: user.enabledModules } : {}), + ...(user.subscription ? { subscription: user.subscription } : {}), }; } } diff --git a/apps/api/src/auth/jwt.strategy.ts b/apps/api/src/auth/jwt.strategy.ts index 182e909..82beb0b 100644 --- a/apps/api/src/auth/jwt.strategy.ts +++ b/apps/api/src/auth/jwt.strategy.ts @@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; +import { BillingService } from '../billing/billing.service'; import { PrismaService } from '../prisma/prisma.service'; export type JwtPayload = { @@ -15,6 +16,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { constructor( config: ConfigService, private readonly prisma: PrismaService, + private readonly billing: BillingService, ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), @@ -53,6 +55,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { if (!membership) { throw new UnauthorizedException(); } + await this.billing.requireActiveForOrganization(membership.organizationId); return { userId: user.id, organizationId: membership.organizationId, diff --git a/apps/api/src/billing/billing.module.ts b/apps/api/src/billing/billing.module.ts new file mode 100644 index 0000000..82398d8 --- /dev/null +++ b/apps/api/src/billing/billing.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; +import { BillingService } from './billing.service'; +import { OrgModuleGuard } from './org-module.guard'; + +@Global() +@Module({ + providers: [BillingService, OrgModuleGuard], + exports: [BillingService, OrgModuleGuard], +}) +export class BillingModule {} diff --git a/apps/api/src/billing/billing.service.ts b/apps/api/src/billing/billing.service.ts new file mode 100644 index 0000000..f12e08e --- /dev/null +++ b/apps/api/src/billing/billing.service.ts @@ -0,0 +1,287 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import type { + ApplyBillingPlanRequest, + BillingAccountDto, + BillingAccountStatus, + BillingPlanCode, + OrgModuleKey, + SubscriptionSummaryDto, +} from '@mento/shared'; +import { + BILLING_PLAN_DAYS, + BILLING_PLAN_DEFAULT_MAX_ORGS, + defaultEnabledModules, + isOrgModuleKey, +} from '@mento/shared'; +import { PrismaService } from '../prisma/prisma.service'; + +function daysRemaining(expiresAt: Date, now = new Date()): number { + const ms = expiresAt.getTime() - now.getTime(); + return Math.max(0, Math.ceil(ms / (24 * 60 * 60 * 1000))); +} + +function toSummary(row: { + planCode: string; + status: string; + startsAt: Date; + expiresAt: Date; + maxOrganizations: number; +}): SubscriptionSummaryDto { + const remaining = daysRemaining(row.expiresAt); + const status = (row.status as BillingAccountStatus) ?? 'active'; + return { + planCode: row.planCode as BillingPlanCode, + status, + startsAt: row.startsAt.toISOString(), + expiresAt: row.expiresAt.toISOString(), + maxOrganizations: row.maxOrganizations, + daysRemaining: remaining, + warn: remaining > 0 && remaining <= 3, + }; +} + +@Injectable() +export class BillingService { + constructor(private readonly prisma: PrismaService) {} + + toDto(row: { + id: string; + ownerUserId: string; + planCode: string; + status: string; + startsAt: Date; + expiresAt: Date; + maxOrganizations: number; + externalPaymentRef: string | null; + notes: string | null; + }): BillingAccountDto { + const summary = toSummary(row); + return { + id: row.id, + ownerUserId: row.ownerUserId, + ...summary, + externalPaymentRef: row.externalPaymentRef, + notes: row.notes, + }; + } + + async getByOwnerUserId(ownerUserId: string) { + return this.prisma.billingAccount.findUnique({ where: { ownerUserId } }); + } + + async ensureTrialForOwner( + ownerUserId: string, + opts?: { maxOrganizations?: number }, + ) { + const existing = await this.getByOwnerUserId(ownerUserId); + if (existing) return existing; + const now = new Date(); + const expiresAt = new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000); + return this.prisma.billingAccount.create({ + data: { + ownerUserId, + planCode: 'trial', + status: 'active', + startsAt: now, + expiresAt, + maxOrganizations: Math.max(1, opts?.maxOrganizations ?? 1), + }, + }); + } + + /** اشتراک محیط = BillingAccount اولین مالک فعال */ + async resolveForOrganization(organizationId: string) { + const owner = await this.prisma.membership.findFirst({ + where: { + organizationId, + role: 'owner', + isActive: true, + }, + orderBy: { createdAt: 'asc' }, + select: { userId: true }, + }); + if (!owner) return null; + let account = await this.getByOwnerUserId(owner.userId); + if (!account) { + account = await this.ensureTrialForOwner(owner.userId); + } + return account; + } + + async requireActiveForOrganization(organizationId: string): Promise { + const account = await this.resolveForOrganization(organizationId); + if (!account) { + throw new UnauthorizedException('اشتراک این محیط یافت نشد'); + } + return this.assertActive(account); + } + + async requireActiveForOwner(ownerUserId: string): Promise { + let account = await this.getByOwnerUserId(ownerUserId); + if (!account) { + account = await this.ensureTrialForOwner(ownerUserId); + } + return this.assertActive(account); + } + + private async assertActive(account: { + id: string; + planCode: string; + status: string; + startsAt: Date; + expiresAt: Date; + maxOrganizations: number; + }): Promise { + const now = new Date(); + if (account.status === 'suspended') { + throw new UnauthorizedException('اشتراک این حساب معلق شده است'); + } + if (account.status === 'expired' || account.expiresAt.getTime() < now.getTime()) { + if (account.status !== 'expired') { + await this.prisma.billingAccount.update({ + where: { id: account.id }, + data: { status: 'expired' }, + }); + } + throw new UnauthorizedException('اشتراک منقضی شده است'); + } + return toSummary(account); + } + + async assertCanCreateOrganization(ownerUserId: string) { + const summary = await this.requireActiveForOwner(ownerUserId); + const ownedCount = await this.prisma.membership.count({ + where: { userId: ownerUserId, role: 'owner', isActive: true }, + }); + if (ownedCount >= summary.maxOrganizations) { + throw new ForbiddenException( + `سقف تعداد محیط برای این اشتراک ${summary.maxOrganizations} است`, + ); + } + } + + async applyPlan( + ownerUserId: string, + dto: ApplyBillingPlanRequest, + ): Promise { + const planCode = dto.planCode; + const now = new Date(); + let expiresAt: Date; + let maxOrganizations: number; + + if (planCode === 'custom') { + if (!dto.expiresAt) { + throw new BadRequestException('برای پلن سفارشی تاریخ انقضا لازم است'); + } + expiresAt = new Date(dto.expiresAt); + if (Number.isNaN(expiresAt.getTime())) { + throw new BadRequestException('تاریخ انقضا معتبر نیست'); + } + maxOrganizations = Math.max(1, dto.maxOrganizations ?? 1); + } else { + const days = BILLING_PLAN_DAYS[planCode]; + expiresAt = dto.expiresAt + ? new Date(dto.expiresAt) + : new Date(now.getTime() + days * 24 * 60 * 60 * 1000); + if (Number.isNaN(expiresAt.getTime())) { + throw new BadRequestException('تاریخ انقضا معتبر نیست'); + } + maxOrganizations = Math.max( + 1, + dto.maxOrganizations ?? BILLING_PLAN_DEFAULT_MAX_ORGS[planCode], + ); + } + + const status: BillingAccountStatus = + dto.status ?? + (expiresAt.getTime() < now.getTime() ? 'expired' : 'active'); + + const existing = await this.getByOwnerUserId(ownerUserId); + const row = existing + ? await this.prisma.billingAccount.update({ + where: { id: existing.id }, + data: { + planCode, + status, + startsAt: existing.startsAt ?? now, + expiresAt, + maxOrganizations, + ...(dto.notes !== undefined ? { notes: dto.notes } : {}), + }, + }) + : await this.prisma.billingAccount.create({ + data: { + ownerUserId, + planCode, + status, + startsAt: now, + expiresAt, + maxOrganizations, + notes: dto.notes ?? null, + }, + }); + return this.toDto(row); + } + + async setOrgModules(organizationId: string, modules: string[]) { + const cleaned = [ + ...new Set( + modules.filter(isOrgModuleKey), + ), + ] as OrgModuleKey[]; + await this.prisma.organization.update({ + where: { id: organizationId }, + data: { enabledModules: cleaned }, + }); + return cleaned; + } + + normalizeModules(raw: string[] | null | undefined): OrgModuleKey[] { + if (!raw?.length) return defaultEnabledModules(); + const cleaned = raw.filter(isOrgModuleKey) as OrgModuleKey[]; + return cleaned.length ? cleaned : defaultEnabledModules(); + } + + async requireModule(organizationId: string, module: OrgModuleKey) { + const org = await this.prisma.organization.findUnique({ + where: { id: organizationId }, + select: { enabledModules: true }, + }); + if (!org) throw new ForbiddenException('محیط پیدا نشد'); + const enabled = this.normalizeModules(org.enabledModules); + if (!enabled.includes(module)) { + throw new ForbiddenException('این ماژول برای این محیط فعال نیست'); + } + } + + defaultModulesForNewOrg(): OrgModuleKey[] { + return defaultEnabledModules(); + } + + async countExpiringSoon() { + const now = new Date(); + const in3 = new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000); + return this.prisma.billingAccount.count({ + where: { + status: 'active', + expiresAt: { gt: now, lte: in3 }, + }, + }); + } + + async countExpired() { + return this.prisma.billingAccount.count({ + where: { + OR: [ + { status: 'expired' }, + { status: 'active', expiresAt: { lt: new Date() } }, + ], + }, + }); + } +} diff --git a/apps/api/src/billing/org-module.decorator.ts b/apps/api/src/billing/org-module.decorator.ts new file mode 100644 index 0000000..a76eaf9 --- /dev/null +++ b/apps/api/src/billing/org-module.decorator.ts @@ -0,0 +1,7 @@ +import { SetMetadata } from '@nestjs/common'; +import type { OrgModuleKey } from '@mento/shared'; + +export const ORG_MODULE_KEY = 'org_module'; + +export const RequireOrgModule = (module: OrgModuleKey) => + SetMetadata(ORG_MODULE_KEY, module); diff --git a/apps/api/src/billing/org-module.guard.ts b/apps/api/src/billing/org-module.guard.ts new file mode 100644 index 0000000..f87e6f8 --- /dev/null +++ b/apps/api/src/billing/org-module.guard.ts @@ -0,0 +1,32 @@ +import { + CanActivate, + ExecutionContext, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { OrgModuleKey } from '@mento/shared'; +import { BillingService } from './billing.service'; +import { ORG_MODULE_KEY } from './org-module.decorator'; + +@Injectable() +export class OrgModuleGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly billing: BillingService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const module = this.reflector.getAllAndOverride( + ORG_MODULE_KEY, + [context.getHandler(), context.getClass()], + ); + if (!module) return true; + const req = context.switchToHttp().getRequest<{ + user?: { organizationId?: string }; + }>(); + const organizationId = req.user?.organizationId; + if (!organizationId) return false; + await this.billing.requireModule(organizationId, module); + return true; + } +} diff --git a/apps/api/src/contacts/people.controller.ts b/apps/api/src/contacts/people.controller.ts index 0f0401b..969898f 100644 --- a/apps/api/src/contacts/people.controller.ts +++ b/apps/api/src/contacts/people.controller.ts @@ -18,13 +18,16 @@ import type { Response } from 'express'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import type { Actor } from '../common/record-scope'; +import { OrgModuleGuard } from '../billing/org-module.guard'; +import { RequireOrgModule } from '../billing/org-module.decorator'; import { CreatePersonDto, ListQueryDto, UpdatePersonDto } from './dto'; import { PeopleService } from './people.service'; import { ContactsExcelService } from './contacts-excel.service'; import { brand } from '@mento/shared'; @Controller('people') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, OrgModuleGuard) +@RequireOrgModule('customers') export class PeopleController { constructor( private readonly peopleService: PeopleService, diff --git a/apps/api/src/message-automations/message-automations.controller.ts b/apps/api/src/message-automations/message-automations.controller.ts index 005dedc..cd5a360 100644 --- a/apps/api/src/message-automations/message-automations.controller.ts +++ b/apps/api/src/message-automations/message-automations.controller.ts @@ -12,6 +12,8 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { Roles } from '../auth/roles.decorator'; import { RolesGuard } from '../auth/roles.guard'; +import { OrgModuleGuard } from '../billing/org-module.guard'; +import { RequireOrgModule } from '../billing/org-module.decorator'; import { CreateMessageAutomationRuleDto, UpdateMessageAutomationRuleDto, @@ -19,7 +21,8 @@ import { import { MessageAutomationsService } from './message-automations.service'; @Controller('message-automations') -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard) +@RequireOrgModule('message_automations') @Roles('owner', 'admin') export class MessageAutomationsController { constructor(private readonly messages: MessageAutomationsService) {} diff --git a/apps/api/src/organizations/organizations.module.ts b/apps/api/src/organizations/organizations.module.ts index 4b4da7d..a8fb1b4 100644 --- a/apps/api/src/organizations/organizations.module.ts +++ b/apps/api/src/organizations/organizations.module.ts @@ -3,11 +3,18 @@ import { AuthModule } from '../auth/auth.module'; import { MembershipsModule } from '../memberships/memberships.module'; import { OrgRolesModule } from '../org-roles/org-roles.module'; import { PipelinesModule } from '../pipelines/pipelines.module'; +import { BillingModule } from '../billing/billing.module'; import { OrganizationsController } from './organizations.controller'; import { OrganizationsService } from './organizations.service'; @Module({ - imports: [AuthModule, MembershipsModule, OrgRolesModule, PipelinesModule], + imports: [ + AuthModule, + MembershipsModule, + OrgRolesModule, + PipelinesModule, + BillingModule, + ], controllers: [OrganizationsController], providers: [OrganizationsService], }) diff --git a/apps/api/src/organizations/organizations.service.ts b/apps/api/src/organizations/organizations.service.ts index 99cd17d..9b7a363 100644 --- a/apps/api/src/organizations/organizations.service.ts +++ b/apps/api/src/organizations/organizations.service.ts @@ -15,6 +15,7 @@ import { isKnownCurrency } from '../common/currency'; import { MembershipsService } from '../memberships/memberships.service'; import { OrgRolesService } from '../org-roles/org-roles.service'; import { PipelinesService } from '../pipelines/pipelines.service'; +import { BillingService } from '../billing/billing.service'; function slugify(input: string): string { return ( @@ -34,6 +35,7 @@ export class OrganizationsService { private readonly memberships: MembershipsService, private readonly orgRoles: OrgRolesService, private readonly pipelines: PipelinesService, + private readonly billing: BillingService, ) {} async createForUser( @@ -57,6 +59,8 @@ export class OrganizationsService { throw new UnauthorizedException(); } + await this.billing.assertCanCreateOrganization(userId); + const baseSlug = slugify(name); let slug = baseSlug; let i = 1; @@ -69,6 +73,7 @@ export class OrganizationsService { name, slug, businessField, + enabledModules: this.billing.defaultModulesForNewOrg(), }, }); await this.memberships.createMembership({ @@ -76,6 +81,7 @@ export class OrganizationsService { organizationId: organization.id, role: 'owner', }); + await this.billing.ensureTrialForOwner(userId); await this.orgRoles.ensureDefaultRoles(organization.id); await this.pipelines.ensureDefaultPipeline(organization.id); return { id: organization.id }; @@ -219,6 +225,7 @@ export class OrganizationsService { businessField: string | null; baseCurrency: string; enabledCurrencies: string[]; + enabledModules?: string[]; exchangeRates: Array<{ currency: string; rateToBase: Prisma.Decimal; @@ -235,6 +242,7 @@ export class OrganizationsService { : null, baseCurrency: org.baseCurrency, enabledCurrencies: org.enabledCurrencies, + enabledModules: this.billing.normalizeModules(org.enabledModules), exchangeRates: org.exchangeRates.map((r) => ({ currency: r.currency, rateToBase: Number(r.rateToBase), diff --git a/apps/api/src/pipelines/deals.controller.ts b/apps/api/src/pipelines/deals.controller.ts index bc843b9..76a3170 100644 --- a/apps/api/src/pipelines/deals.controller.ts +++ b/apps/api/src/pipelines/deals.controller.ts @@ -18,13 +18,16 @@ import type { Response } from 'express'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import type { Actor } from '../common/record-scope'; +import { OrgModuleGuard } from '../billing/org-module.guard'; +import { RequireOrgModule } from '../billing/org-module.decorator'; import { CreateDealDto, MoveDealDto, UpdateDealDto } from './dto'; import { DealsService } from './deals.service'; import { DealsExcelService } from './deals-excel.service'; import { brand } from '@mento/shared'; @Controller('deals') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, OrgModuleGuard) +@RequireOrgModule('pipeline') export class DealsController { constructor( private readonly dealsService: DealsService, diff --git a/apps/api/src/platform/dto.ts b/apps/api/src/platform/dto.ts index 6e50c9a..8de5445 100644 --- a/apps/api/src/platform/dto.ts +++ b/apps/api/src/platform/dto.ts @@ -1,10 +1,13 @@ import { + IsArray, IsBoolean, IsEmail, IsIn, + IsInt, IsOptional, IsString, MaxLength, + Min, MinLength, ValidateIf, } from 'class-validator'; @@ -119,3 +122,32 @@ export class ResetPlatformUserPasswordDto { @MaxLength(120) password!: string; } + +export class ApplyBillingPlanDto { + @IsIn(['trial', 'monthly', 'quarterly', 'semiannual', 'yearly', 'custom']) + planCode!: 'trial' | 'monthly' | 'quarterly' | 'semiannual' | 'yearly' | 'custom'; + + @IsOptional() + @IsInt() + @Min(1) + maxOrganizations?: number; + + @IsOptional() + @IsString() + expiresAt?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + notes?: string | null; + + @IsOptional() + @IsIn(['active', 'expired', 'suspended']) + status?: 'active' | 'expired' | 'suspended'; +} + +export class UpdateOrgModulesDto { + @IsArray() + @IsString({ each: true }) + enabledModules!: string[]; +} diff --git a/apps/api/src/platform/platform.controller.ts b/apps/api/src/platform/platform.controller.ts index f09daaa..87c39a5 100644 --- a/apps/api/src/platform/platform.controller.ts +++ b/apps/api/src/platform/platform.controller.ts @@ -10,10 +10,12 @@ import { } from '@nestjs/common'; import { CurrentUser } from '../auth/current-user.decorator'; import { + ApplyBillingPlanDto, CreatePlatformAdminDto, CreatePlatformOrganizationDto, ResetPlatformAdminPasswordDto, ResetPlatformUserPasswordDto, + UpdateOrgModulesDto, UpdatePlatformMembershipRoleDto, UpdatePlatformOrganizationDto, UpdatePlatformStatusDto, @@ -105,6 +107,19 @@ export class PlatformController { return this.platform.resetUserPassword(id, dto); } + @Post('users/:id/billing/apply-plan') + applyBillingPlan(@Param('id') id: string, @Body() dto: ApplyBillingPlanDto) { + return this.platform.applyBillingPlan(id, dto); + } + + @Patch('organizations/:id/modules') + updateOrganizationModules( + @Param('id') id: string, + @Body() dto: UpdateOrgModulesDto, + ) { + return this.platform.updateOrganizationModules(id, dto); + } + @Patch('memberships/:id') setMembershipActive( @Param('id') id: string, diff --git a/apps/api/src/platform/platform.module.ts b/apps/api/src/platform/platform.module.ts index 67943b8..2f1bdfd 100644 --- a/apps/api/src/platform/platform.module.ts +++ b/apps/api/src/platform/platform.module.ts @@ -4,6 +4,7 @@ import { AuthModule } from '../auth/auth.module'; import { MembershipsModule } from '../memberships/memberships.module'; import { OrgRolesModule } from '../org-roles/org-roles.module'; import { PipelinesModule } from '../pipelines/pipelines.module'; +import { BillingModule } from '../billing/billing.module'; import { PlatformAuthController } from './platform-auth.controller'; import { PlatformAuthGuard } from './platform-auth.guard'; import { PlatformAuthService } from './platform-auth.service'; @@ -17,6 +18,7 @@ import { PlatformService } from './platform.service'; MembershipsModule, OrgRolesModule, PipelinesModule, + BillingModule, PassportModule, ], controllers: [PlatformAuthController, PlatformController], diff --git a/apps/api/src/platform/platform.service.ts b/apps/api/src/platform/platform.service.ts index 0fe4b32..3bf6b46 100644 --- a/apps/api/src/platform/platform.service.ts +++ b/apps/api/src/platform/platform.service.ts @@ -18,12 +18,15 @@ import { normalizeMobile } from '../auth/auth.service'; import { MembershipsService } from '../memberships/memberships.service'; import { OrgRolesService } from '../org-roles/org-roles.service'; import { PipelinesService } from '../pipelines/pipelines.service'; +import { BillingService } from '../billing/billing.service'; import { PrismaService } from '../prisma/prisma.service'; import type { + ApplyBillingPlanDto, CreatePlatformAdminDto, CreatePlatformOrganizationDto, ResetPlatformAdminPasswordDto, ResetPlatformUserPasswordDto, + UpdateOrgModulesDto, UpdatePlatformOrganizationDto, } from './dto'; @@ -45,6 +48,7 @@ export class PlatformService { private readonly memberships: MembershipsService, private readonly orgRoles: OrgRolesService, private readonly pipelines: PipelinesService, + private readonly billing: BillingService, ) {} async stats(): Promise { @@ -61,12 +65,18 @@ export class PlatformService { this.prisma.user.count({ where: { isActive: true } }), this.prisma.membership.count(), ]); + const [expiringSoonCount, expiredBillingCount] = await Promise.all([ + this.billing.countExpiringSoon(), + this.billing.countExpired(), + ]); return { organizationCount, activeOrganizationCount, userCount, activeUserCount, membershipCount, + expiringSoonCount, + expiredBillingCount, }; } @@ -89,6 +99,7 @@ export class PlatformService { businessField: true, isActive: true, createdAt: true, + enabledModules: true, _count: { select: { memberships: true, @@ -111,6 +122,7 @@ export class PlatformService { businessField: true, isActive: true, createdAt: true, + enabledModules: true, _count: { select: { memberships: true, @@ -213,6 +225,7 @@ export class PlatformService { name, slug, businessField, + enabledModules: this.billing.defaultModulesForNewOrg(), users: { create: { email: ownerEmail, @@ -238,6 +251,7 @@ export class PlatformService { organizationId: organization.id, role: 'owner', }); + await this.billing.ensureTrialForOwner(owner.id, { maxOrganizations: 1 }); await this.orgRoles.ensureDefaultRoles(organization.id); await this.pipelines.ensureDefaultPipeline(organization.id); @@ -331,6 +345,7 @@ export class PlatformService { orderBy: { createdAt: 'desc' }, take: 300, include: { + billingAccount: true, memberships: { include: { organization: { @@ -356,12 +371,33 @@ export class PlatformService { }, orderBy: { createdAt: 'asc' }, }, + billingAccount: true, }, }); if (!row) throw new NotFoundException('کاربر پیدا نشد'); return this.userToDto(row); } + async applyBillingPlan(userId: string, dto: ApplyBillingPlanDto) { + const existing = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { id: true }, + }); + if (!existing) throw new NotFoundException('کاربر پیدا نشد'); + await this.billing.applyPlan(userId, dto); + return this.getUser(userId); + } + + async updateOrganizationModules(id: string, dto: UpdateOrgModulesDto) { + const existing = await this.prisma.organization.findUnique({ + where: { id }, + select: { id: true }, + }); + if (!existing) throw new NotFoundException('محیط پیدا نشد'); + await this.billing.setOrgModules(id, dto.enabledModules ?? []); + return this.getOrganization(id); + } + async setUserActive(id: string, isActive: boolean): Promise { const existing = await this.prisma.user.findUnique({ where: { id }, @@ -609,6 +645,7 @@ export class PlatformService { businessField: string | null; isActive: boolean; createdAt: Date; + enabledModules?: string[]; _count: { memberships: number; people: number; deals: number }; }): PlatformOrganizationDto { return { @@ -621,6 +658,7 @@ export class PlatformService { memberCount: row._count.memberships, personCount: row._count.people, dealCount: row._count.deals, + enabledModules: this.billing.normalizeModules(row.enabledModules), }; } @@ -632,6 +670,17 @@ export class PlatformService { isActive: boolean; lastLoginAt: Date | null; createdAt: Date; + billingAccount?: { + id: string; + ownerUserId: string; + planCode: string; + status: string; + startsAt: Date; + expiresAt: Date; + maxOrganizations: number; + externalPaymentRef: string | null; + notes: string | null; + } | null; memberships: Array<{ id: string; role: PlatformUserDto['memberships'][number]['role']; @@ -647,6 +696,9 @@ export class PlatformService { isActive: row.isActive, lastLoginAt: row.lastLoginAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), + billing: row.billingAccount + ? this.billing.toDto(row.billingAccount) + : null, memberships: row.memberships.map((m) => ({ membershipId: m.id, organizationId: m.organization.id, diff --git a/apps/web/src/layouts/AdmLayout.vue b/apps/web/src/layouts/AdmLayout.vue index a4fb64a..3efe177 100644 --- a/apps/web/src/layouts/AdmLayout.vue +++ b/apps/web/src/layouts/AdmLayout.vue @@ -68,7 +68,9 @@ function onLogout() { min-height: 100dvh; display: grid; grid-template-columns: 240px minmax(0, 1fr); - background: var(--bg); + background: + radial-gradient(ellipse 60% 40% at 100% 0%, rgba(45, 212, 191, 0.08), transparent 55%), + var(--bg); } .sidebar { @@ -78,6 +80,7 @@ function onLogout() { padding: 1.15rem 0.9rem; background: #fff; border-inline-end: 1px solid var(--line); + box-shadow: 8px 0 24px rgba(15, 23, 42, 0.03); } .brand { diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index 4caf264..98880c0 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -79,17 +79,43 @@ const moreSectionActive = computed( settingsActive.value, ); +const enabledModules = computed(() => auth.user?.enabledModules ?? null); +const subscription = computed(() => auth.user?.subscription ?? null); + +function moduleAllowed(module?: string) { + if (!module) return true; + if (!enabledModules.value) return true; + return enabledModules.value.includes(module as never); +} + const openHub = computed(() => { if (!openHubKey.value) return null; const hub = NAV_HUBS.find((item) => item.key === openHubKey.value); if (!hub) return null; return { ...hub, - cards: hub.cards.filter((card) => !card.admin || canSettings.value), - shortcuts: hub.shortcuts.filter((item) => !item.admin || canSettings.value), + cards: hub.cards.filter( + (card) => + (!card.admin || canSettings.value) && moduleAllowed(card.module), + ), + shortcuts: hub.shortcuts.filter( + (item) => + (!item.admin || canSettings.value) && moduleAllowed(item.module), + ), }; }); +const hubVisible = computed(() => { + const map: Record = {}; + for (const hub of NAV_HUBS) { + map[hub.key] = hub.cards.some( + (card) => + (!card.admin || canSettings.value) && moduleAllowed(card.module), + ); + } + return map; +}); + function givenName(fullName: string): string { const parts = fullName.trim().split(/\s+/).filter(Boolean); if (parts.length <= 1) return fullName.trim(); @@ -368,7 +394,7 @@ onUnmounted(() => { داشبورد -