Imported follow-ups with no person, company, or deal stayed on the board because only linked prior records were archived. Co-authored-by: Cursor <cursoragent@cursor.com>
365 lines
11 KiB
TypeScript
365 lines
11 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import type {
|
|
ApplyBillingPlanRequest,
|
|
BillingAccountDto,
|
|
BillingAccountStatus,
|
|
BillingPlanCode,
|
|
OrgModuleKey,
|
|
SubscriptionSummaryDto,
|
|
} from '@mento/shared';
|
|
import {
|
|
BILLING_PLAN_DAYS,
|
|
BILLING_PLAN_DEFAULT_MAX_ORGS,
|
|
BILLING_TRIAL_DAYS,
|
|
defaultEnabledModules,
|
|
isPermanentBillingPlan,
|
|
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)));
|
|
}
|
|
|
|
export 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: !isPermanentBillingPlan(row.planCode) && 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() + BILLING_TRIAL_DAYS * 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[];
|
|
const prev = await this.prisma.organization.findUnique({
|
|
where: { id: organizationId },
|
|
select: { priorArchiveEnabledAt: true },
|
|
});
|
|
const enablePrior = cleaned.includes('prior_data_archive');
|
|
const cutoff = prev?.priorArchiveEnabledAt ?? new Date();
|
|
await this.prisma.organization.update({
|
|
where: { id: organizationId },
|
|
data: {
|
|
enabledModules: cleaned,
|
|
...(enablePrior && !prev?.priorArchiveEnabledAt
|
|
? { priorArchiveEnabledAt: cutoff }
|
|
: {}),
|
|
},
|
|
});
|
|
if (enablePrior) await this.backfillPriorActivities(organizationId, cutoff);
|
|
return cleaned;
|
|
}
|
|
|
|
/** فعالیتهای منتقلشده را یکبار (و تکرار بیاثر) بهعنوان اطلاعات قبل علامت میزند. */
|
|
private async backfillPriorActivities(organizationId: string, cutoff: Date) {
|
|
await this.prisma.$executeRaw(Prisma.sql`
|
|
UPDATE "Activity" AS a
|
|
SET "importSource" = 'didar'
|
|
WHERE a."organizationId" = ${organizationId}
|
|
AND a."importSource" = 'manual'
|
|
AND (
|
|
(
|
|
a."dealId" IS NOT NULL
|
|
AND EXISTS (
|
|
SELECT 1 FROM "Deal" AS d
|
|
WHERE d."id" = a."dealId" AND d."importSource" = 'didar'
|
|
)
|
|
)
|
|
OR (
|
|
a."createdAt" < ${cutoff}
|
|
AND (
|
|
EXISTS (
|
|
SELECT 1 FROM "Person" AS p
|
|
WHERE p."id" = a."personId"
|
|
AND (
|
|
p."importSource" = 'didar'
|
|
OR p."previousImportSource" = 'didar'
|
|
)
|
|
)
|
|
OR EXISTS (
|
|
SELECT 1 FROM "Company" AS c
|
|
WHERE c."id" = a."companyId" AND c."importSource" = 'didar'
|
|
)
|
|
OR EXISTS (
|
|
SELECT 1 FROM "Deal" AS d2
|
|
WHERE d2."id" = a."dealId" AND d2."importSource" = 'didar'
|
|
)
|
|
OR (
|
|
a."personId" IS NULL
|
|
AND a."companyId" IS NULL
|
|
AND a."dealId" IS NULL
|
|
AND a."dueAt" IS NOT NULL
|
|
AND a."dueAt" < ${cutoff}
|
|
)
|
|
)
|
|
)
|
|
)
|
|
`);
|
|
}
|
|
|
|
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() } },
|
|
],
|
|
},
|
|
});
|
|
}
|
|
}
|