Files
CRM_Montzar/apps/api/src/billing/billing.service.ts
T

303 lines
9.1 KiB
TypeScript

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<SubscriptionSummaryDto> {
const account = await this.resolveForOrganization(organizationId);
if (!account) {
throw new UnauthorizedException(
'اشتراک این محیط یافت نشد؛ مالک باید از پنل سوپرادمین پلن داشته باشد',
);
}
return this.assertActive(account);
}
async requireActiveForOwner(ownerUserId: string): Promise<SubscriptionSummaryDto> {
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<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) {
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<BillingAccountDto> {
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() } },
],
},
});
}
}