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; return this.getByOwnerUserId(owner.userId); } 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 { const account = await this.getByOwnerUserId(ownerUserId); if (!account) { throw new UnauthorizedException( 'اشتراک یافت نشد؛ ابتدا پلن را از سوپرادمین اعمال کنید', ); } 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) { let account = await this.getByOwnerUserId(ownerUserId); if (!account) { // اولین محیط مالک: trial خودکار account = await this.ensureTrialForOwner(ownerUserId, { maxOrganizations: 1 }); } const summary = await this.assertActive(account); 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 ownerMembership = await this.prisma.membership.findFirst({ where: { userId: ownerUserId, role: 'owner', isActive: true }, select: { id: true }, }); if (!ownerMembership) { throw new BadRequestException( 'اشتراک فقط برای مالک محیط تعریف می‌شود؛ این کاربر مالک هیچ محیطی نیست', ); } 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() } }, ], }, }); } }