Configuring access rights in the CRM Super Admin
This commit is contained in:
@@ -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<SubscriptionSummaryDto> {
|
||||
const account = await this.resolveForOrganization(organizationId);
|
||||
if (!account) {
|
||||
throw new UnauthorizedException('اشتراک این محیط یافت نشد');
|
||||
}
|
||||
return this.assertActive(account);
|
||||
}
|
||||
|
||||
async requireActiveForOwner(ownerUserId: string): Promise<SubscriptionSummaryDto> {
|
||||
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<SubscriptionSummaryDto> {
|
||||
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<BillingAccountDto> {
|
||||
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() } },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user