feat(platform): text admins when an account opens or a subscription ends

Signup and admin-created accounts notify platform managers, and an ended plan sends one SMS instead of repeating for old expirations.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-27 22:27:05 +03:30
co-authored by Cursor
parent f15c5b9141
commit 463a1652a6
11 changed files with 323 additions and 4 deletions
+1 -1
View File
@@ -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": "*",
@@ -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;
+2
View File
@@ -457,6 +457,8 @@ model BillingAccount {
/// رزرو برای درگاه/لندینگ بعدی
externalPaymentRef String?
notes String?
/// پیامک پایان اشتراک برای مدیران پنل؛ خالی یعنی هنوز اطلاع داده نشده
expiryNotifiedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+8
View File
@@ -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 },
@@ -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<void> {
await this.notifyEnded();
}
async notifyEnded(): Promise<number> {
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;
}
}
+4 -2
View File
@@ -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 {}
+15 -1
View File
@@ -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);
}
@@ -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<void> {
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<void> {
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<boolean> {
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;
}
}
@@ -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');
}
@@ -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<PlatformStatsDto> {
@@ -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) {
@@ -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, /۱۴۰۵/);
});
});