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:
@@ -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;
|
||||
@@ -457,6 +457,8 @@ model BillingAccount {
|
||||
/// رزرو برای درگاه/لندینگ بعدی
|
||||
externalPaymentRef String?
|
||||
notes String?
|
||||
/// پیامک پایان اشتراک برای مدیران پنل؛ خالی یعنی هنوز اطلاع داده نشده
|
||||
expiryNotifiedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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, /۱۴۰۵/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user