diff --git a/apps/api/package.json b/apps/api/package.json index a3b53cd..3019d81 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,7 +12,7 @@ "prisma:seed": "tsx prisma/seed.ts", "lint": "echo \"api lint placeholder\"", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json", - "test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts test/behavior.test.ts test/attendance-time.test.ts test/sales-factors.test.ts" + "test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts test/behavior.test.ts test/attendance-time.test.ts test/sales-factors.test.ts test/platform-admin-alert.test.ts" }, "dependencies": { "@mento/shared": "*", diff --git a/apps/api/prisma/migrations/20261027220000_billing_expiry_notified/migration.sql b/apps/api/prisma/migrations/20261027220000_billing_expiry_notified/migration.sql new file mode 100644 index 0000000..ae30b75 --- /dev/null +++ b/apps/api/prisma/migrations/20261027220000_billing_expiry_notified/migration.sql @@ -0,0 +1,8 @@ +-- پیامک پایان اشتراک فقط برای انقضاهای بعدی؛ حساب‌های قبلاً تمام‌شده دوباره خبر نمی‌دهند +ALTER TABLE "BillingAccount" ADD COLUMN "expiryNotifiedAt" TIMESTAMP(3); + +UPDATE "BillingAccount" +SET "expiryNotifiedAt" = CURRENT_TIMESTAMP +WHERE "planCode" = 'lifetime' + OR "status" = 'expired' + OR "expiresAt" < CURRENT_TIMESTAMP; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a66ab65..8be016b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -457,6 +457,8 @@ model BillingAccount { /// رزرو برای درگاه/لندینگ بعدی externalPaymentRef String? notes String? + /// پیامک پایان اشتراک برای مدیران پنل؛ خالی یعنی هنوز اطلاع داده نشده + expiryNotifiedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index d510313..32b186d 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -26,6 +26,7 @@ import { SmsGatewayService } from '../sms-billing/sms-gateway.service'; import { LocalFileStorage } from '../storage/local-file.storage'; import { MembershipsService } from '../memberships/memberships.service'; import { BillingService } from '../billing/billing.service'; +import { PlatformAdminAlertService } from '../billing/platform-admin-alert.service'; import { AttendanceService } from '../attendance/attendance.service'; import type { Actor } from '../common/record-scope'; import { @@ -111,6 +112,7 @@ export class AuthService { private readonly files: LocalFileStorage, private readonly memberships: MembershipsService, private readonly billing: BillingService, + private readonly adminAlerts: PlatformAdminAlertService, private readonly attendance: AttendanceService, ) {} @@ -280,6 +282,12 @@ export class AuthService { where: { id: organization.id }, data: { enabledModules: this.billing.defaultModulesForNewOrg() }, }); + await this.adminAlerts.notifyNewAccount({ + source: 'signup', + organizationName: organization.name, + ownerName: owner.fullName, + mobile: owner.mobile, + }); await this.prisma.authChallenge.update({ where: { id: challenge.id }, diff --git a/apps/api/src/billing/billing-expiry.service.ts b/apps/api/src/billing/billing-expiry.service.ts new file mode 100644 index 0000000..2051393 --- /dev/null +++ b/apps/api/src/billing/billing-expiry.service.ts @@ -0,0 +1,43 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { PrismaService } from '../prisma/prisma.service'; +import { PlatformAdminAlertService } from './platform-admin-alert.service'; + +@Injectable() +export class BillingExpiryService { + private readonly logger = new Logger(BillingExpiryService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly alerts: PlatformAdminAlertService, + ) {} + + @Cron('15 * * * *', { timeZone: 'Asia/Tehran' }) + async handleHourly(): Promise { + await this.notifyEnded(); + } + + async notifyEnded(): Promise { + const rows = await this.prisma.billingAccount.findMany({ + where: { + expiryNotifiedAt: null, + planCode: { not: 'lifetime' }, + status: { not: 'suspended' }, + expiresAt: { lte: new Date() }, + }, + select: { id: true }, + take: 100, + }); + for (const row of rows) { + try { + await this.alerts.notifySubscriptionEnded(row.id); + } catch (error) { + this.logger.warn( + `expiry notify failed ${row.id}: ${error instanceof Error ? error.message : 'unknown'}`, + ); + } + } + if (rows.length) this.logger.log(`subscription expiry checked: ${rows.length}`); + return rows.length; + } +} diff --git a/apps/api/src/billing/billing.module.ts b/apps/api/src/billing/billing.module.ts index 82398d8..f187b92 100644 --- a/apps/api/src/billing/billing.module.ts +++ b/apps/api/src/billing/billing.module.ts @@ -1,10 +1,12 @@ import { Global, Module } from '@nestjs/common'; +import { BillingExpiryService } from './billing-expiry.service'; import { BillingService } from './billing.service'; import { OrgModuleGuard } from './org-module.guard'; +import { PlatformAdminAlertService } from './platform-admin-alert.service'; @Global() @Module({ - providers: [BillingService, OrgModuleGuard], - exports: [BillingService, OrgModuleGuard], + providers: [BillingService, BillingExpiryService, PlatformAdminAlertService, OrgModuleGuard], + exports: [BillingService, BillingExpiryService, PlatformAdminAlertService, OrgModuleGuard], }) export class BillingModule {} diff --git a/apps/api/src/billing/billing.service.ts b/apps/api/src/billing/billing.service.ts index 4f4857f..61a8659 100644 --- a/apps/api/src/billing/billing.service.ts +++ b/apps/api/src/billing/billing.service.ts @@ -22,6 +22,7 @@ import { isOrgModuleKey, } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; +import { PlatformAdminAlertService } from './platform-admin-alert.service'; function daysRemaining(expiresAt: Date, now = new Date()): number { const ms = expiresAt.getTime() - now.getTime(); @@ -50,7 +51,10 @@ export function toSummary(row: { @Injectable() export class BillingService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly adminAlerts: PlatformAdminAlertService, + ) {} toDto(row: { id: string; @@ -151,6 +155,7 @@ export class BillingService { data: { status: 'expired' }, }); } + await this.adminAlerts.notifySubscriptionEnded(account.id); throw new UnauthorizedException('اشتراک منقضی شده است'); } return toSummary(account); @@ -220,6 +225,10 @@ export class BillingService { (expiresAt.getTime() < now.getTime() ? 'expired' : 'active'); const existing = await this.getByOwnerUserId(ownerUserId); + const expiresChanged = + !existing || existing.expiresAt.getTime() !== expiresAt.getTime(); + const expiryNotifiedAt = + status === 'active' || expiresChanged ? null : existing?.expiryNotifiedAt ?? null; const row = existing ? await this.prisma.billingAccount.update({ where: { id: existing.id }, @@ -229,6 +238,7 @@ export class BillingService { startsAt: existing.startsAt ?? now, expiresAt, maxOrganizations, + expiryNotifiedAt, ...(dto.notes !== undefined ? { notes: dto.notes } : {}), }, }) @@ -241,8 +251,12 @@ export class BillingService { expiresAt, maxOrganizations, notes: dto.notes ?? null, + expiryNotifiedAt, }, }); + if (status === 'expired' && !expiryNotifiedAt) { + await this.adminAlerts.notifySubscriptionEnded(row.id); + } return this.toDto(row); } diff --git a/apps/api/src/billing/platform-admin-alert.service.ts b/apps/api/src/billing/platform-admin-alert.service.ts new file mode 100644 index 0000000..7ed1bd3 --- /dev/null +++ b/apps/api/src/billing/platform-admin-alert.service.ts @@ -0,0 +1,151 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { isPermanentBillingPlan } from '@mento/shared'; +import { SmsGatewayService } from '../sms-billing/sms-gateway.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { + newAccountSms, + subscriptionEndedSms, + type NewAccountSource, +} from './platform-admin-alert'; + +@Injectable() +export class PlatformAdminAlertService { + private readonly logger = new Logger(PlatformAdminAlertService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly sms: SmsGatewayService, + ) {} + + /** ساخت حساب جدید؛ خطای پیامک جلوی ثبت‌نام یا ساخت محیط را نمی‌گیرد. */ + async notifyNewAccount(input: { + source: NewAccountSource; + organizationName: string; + ownerName: string; + mobile: string | null; + }): Promise { + try { + const delivered = await this.sendToAdmins(newAccountSms(input)); + if (!delivered) { + this.logger.warn(`new account SMS was not delivered (${input.source})`); + } + } catch (error) { + this.logger.warn( + `new account SMS failed: ${error instanceof Error ? error.message : 'unknown'}`, + ); + } + } + + /** + * یک‌بار هنگام تمام شدن اشتراک. اگر ارسال نشود پرچم خالی می‌ماند تا بعداً دوباره تلاش شود. + */ + async notifySubscriptionEnded(billingAccountId: string): Promise { + const row = await this.prisma.billingAccount.findUnique({ + where: { id: billingAccountId }, + select: { + id: true, + planCode: true, + status: true, + expiresAt: true, + expiryNotifiedAt: true, + owner: { + select: { + fullName: true, + mobile: true, + memberships: { + where: { role: 'owner', isActive: true }, + select: { organization: { select: { name: true } } }, + }, + }, + }, + }, + }); + if (!row || row.expiryNotifiedAt) return; + if (row.status === 'suspended') return; + if (isPermanentBillingPlan(row.planCode)) { + await this.prisma.billingAccount.update({ + where: { id: row.id }, + data: { expiryNotifiedAt: new Date() }, + }); + return; + } + if (row.expiresAt.getTime() > Date.now() && row.status !== 'expired') return; + + const claimed = await this.prisma.billingAccount.updateMany({ + where: { id: row.id, expiryNotifiedAt: null }, + data: { status: 'expired', expiryNotifiedAt: new Date() }, + }); + if (claimed.count !== 1) return; + + try { + const delivered = await this.sendToAdmins( + subscriptionEndedSms({ + organizationNames: row.owner.memberships.map((item) => item.organization.name), + ownerName: row.owner.fullName, + mobile: row.owner.mobile, + planCode: row.planCode, + expiresAt: row.expiresAt, + }), + ); + if (!delivered) { + await this.prisma.billingAccount.update({ + where: { id: row.id }, + data: { expiryNotifiedAt: null }, + }); + } + } catch (error) { + this.logger.warn( + `subscription SMS failed ${row.id}: ${error instanceof Error ? error.message : 'unknown'}`, + ); + await this.prisma.billingAccount + .update({ + where: { id: row.id }, + data: { expiryNotifiedAt: null }, + }) + .catch(() => undefined); + } + } + + /** true یعنی حداقل یک پیامک رفت، یا مدیری با موبایل فعال وجود نداشت. */ + private async sendToAdmins(text: string): Promise { + const admins = await this.prisma.platformAdmin.findMany({ + where: { isActive: true, mobile: { not: null } }, + select: { mobile: true }, + }); + const mobiles = [ + ...new Set( + admins + .map((admin) => admin.mobile?.trim() ?? '') + .filter((mobile) => mobile.length > 0), + ), + ]; + if (!mobiles.length) { + this.logger.warn('platform admin SMS skipped: no active admin mobile'); + return true; + } + + let delivered = false; + for (const mobile of mobiles) { + try { + const result = await this.sms.send({ + phone: mobile, + text, + source: 'platform-admin', + }); + if (result.ok) delivered = true; + else { + this.logger.warn( + `platform admin SMS failed to=…${mobile.slice(-4)} reason=${result.reason ?? 'unknown'}`, + ); + } + } catch (error) { + this.logger.warn( + `platform admin SMS failed to=…${mobile.slice(-4)}: ${ + error instanceof Error ? error.message : 'unknown' + }`, + ); + } + } + return delivered; + } +} diff --git a/apps/api/src/billing/platform-admin-alert.ts b/apps/api/src/billing/platform-admin-alert.ts new file mode 100644 index 0000000..c702b7d --- /dev/null +++ b/apps/api/src/billing/platform-admin-alert.ts @@ -0,0 +1,43 @@ +import { BILLING_PLAN_LABELS, type BillingPlanCode } from '@mento/shared'; +import { formatTehranJalali } from '../common/jalali'; + +export type NewAccountSource = 'signup' | 'admin'; + +export function newAccountSms(input: { + source: NewAccountSource; + organizationName: string; + ownerName: string; + mobile: string | null; +}): string { + const via = input.source === 'signup' ? 'ثبت‌نام' : 'پنل ادمین'; + return [ + 'منتکت', + `حساب جدید (${via})`, + `محیط: ${input.organizationName.trim() || '—'}`, + `مالک: ${input.ownerName.trim() || '—'}`, + `موبایل: ${input.mobile?.trim() || 'ندارد'}`, + ].join('\n'); +} + +export function subscriptionEndedSms(input: { + organizationNames: string[]; + ownerName: string; + mobile: string | null; + planCode: string; + expiresAt: Date; +}): string { + const names = input.organizationNames.map((name) => name.trim()).filter(Boolean); + const orgs = names.slice(0, 3).join('، ') || '—'; + const extra = names.length > 3 ? ` و ${names.length - 3} محیط دیگر` : ''; + const label = + BILLING_PLAN_LABELS[input.planCode as BillingPlanCode] ?? input.planCode; + return [ + 'منتکت', + 'پایان اشتراک', + `محیط: ${orgs}${extra}`, + `مالک: ${input.ownerName.trim() || '—'}`, + `موبایل: ${input.mobile?.trim() || 'ندارد'}`, + `پلن: ${label}`, + `تاریخ: ${formatTehranJalali(input.expiresAt)}`, + ].join('\n'); +} diff --git a/apps/api/src/platform/platform.service.ts b/apps/api/src/platform/platform.service.ts index f1f5e78..425205f 100644 --- a/apps/api/src/platform/platform.service.ts +++ b/apps/api/src/platform/platform.service.ts @@ -19,6 +19,7 @@ import { MembershipsService } from '../memberships/memberships.service'; import { OrgRolesService } from '../org-roles/org-roles.service'; import { PipelinesService } from '../pipelines/pipelines.service'; import { BillingService, toSummary } from '../billing/billing.service'; +import { PlatformAdminAlertService } from '../billing/platform-admin-alert.service'; import { PrismaService } from '../prisma/prisma.service'; import { buildSearchWhere, ci } from '../common/search'; import type { @@ -50,6 +51,7 @@ export class PlatformService { private readonly orgRoles: OrgRolesService, private readonly pipelines: PipelinesService, private readonly billing: BillingService, + private readonly adminAlerts: PlatformAdminAlertService, ) {} async stats(): Promise { @@ -275,6 +277,12 @@ export class PlatformService { await this.billing.ensureTrialForOwner(owner.id, { maxOrganizations: 1 }); await this.orgRoles.ensureDefaultRoles(organization.id); await this.pipelines.ensureDefaultPipeline(organization.id); + await this.adminAlerts.notifyNewAccount({ + source: 'admin', + organizationName: organization.name, + ownerName: owner.fullName, + mobile: owner.mobile, + }); return this.getOrganization(organization.id); } catch (e) { diff --git a/apps/api/test/platform-admin-alert.test.ts b/apps/api/test/platform-admin-alert.test.ts new file mode 100644 index 0000000..717f317 --- /dev/null +++ b/apps/api/test/platform-admin-alert.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { newAccountSms, subscriptionEndedSms } from '../src/billing/platform-admin-alert'; + +describe('پیامک مدیران پنل ادمین', () => { + it('ثبت‌نام و ساخت از پنل را جدا می‌نویسد', () => { + const signup = newAccountSms({ + source: 'signup', + organizationName: 'بی اس جی', + ownerName: 'رضا بهرامی', + mobile: '09120000000', + }); + assert.match(signup, /حساب جدید \(ثبت‌نام\)/); + assert.match(signup, /بی اس جی/); + assert.match(signup, /09120000000/); + + const admin = newAccountSms({ + source: 'admin', + organizationName: 'کارگاه', + ownerName: 'علی', + mobile: null, + }); + assert.match(admin, /پنل ادمین/); + assert.match(admin, /ندارد/); + }); + + it('پایان اشتراک پلن و تاریخ شمسی دارد', () => { + const text = subscriptionEndedSms({ + organizationNames: ['الف', 'ب', 'ج', 'د'], + ownerName: 'مریم', + mobile: '09121111111', + planCode: 'monthly', + expiresAt: new Date('2026-10-27T12:00:00.000Z'), + }); + assert.match(text, /پایان اشتراک/); + assert.match(text, /ماهانه/); + assert.match(text, /و 1 محیط دیگر/); + assert.match(text, /۱۴۰۵/); + }); +});