feat: add per-org SMS wallet with adm panel and header credit

Bill all platform SMS (including OTP), allow negative balance with manual/auto cutoffs, and support own-panel bypass without wallet debit.
This commit is contained in:
2026-10-19 15:55:48 +03:30
parent f15ebb72ea
commit f5d68d83fc
42 changed files with 2073 additions and 121 deletions
@@ -1,11 +1,10 @@
import { Module } from '@nestjs/common';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
import { SmsService } from '../common/sms.service';
@Module({
controllers: [NotificationsController],
providers: [NotificationsService, SmsService],
providers: [NotificationsService],
exports: [NotificationsService],
})
export class NotificationsModule {}
@@ -3,7 +3,7 @@ import { Cron } from '@nestjs/schedule';
import { brand } from '@mento/shared';
import type { NotificationDto, NotificationsListDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
/** یادآورهایی که بیش از این دیرتر پردازش شوند (مثلاً قطعی سرور) فقط اعلان می‌گیرند، نه پیامک. */
const REMINDER_SMS_MAX_DELAY_MS = 6 * 60 * 60 * 1000;
@@ -41,7 +41,7 @@ export class NotificationsService {
constructor(
private readonly prisma: PrismaService,
private readonly sms: SmsService,
private readonly sms: SmsGatewayService,
) {}
async list(
@@ -249,7 +249,7 @@ export class NotificationsService {
});
}
if (activity.reminderNotifySms && !stale) {
await this.sendActivityReminderSms(mobile, {
await this.sendActivityReminderSms(activity.organizationId, mobile, {
title: activity.title,
related,
relatedMobile: activity.person?.mobile ?? null,
@@ -295,6 +295,7 @@ export class NotificationsService {
}
private async sendActivityReminderSms(
organizationId: string,
mobile: string | null | undefined,
info: { title: string; related: string; relatedMobile: string | null; dueAt: Date | null },
) {
@@ -302,10 +303,6 @@ export class NotificationsService {
this.logger.warn('Activity reminder SMS skipped: assignee has no mobile');
return;
}
if (!this.sms.isConfigured()) {
this.logger.warn('Activity reminder SMS skipped: SMS not configured');
return;
}
const lines = [`${brand.reminderPrefixFa} ${info.title}`];
if (info.related) {
lines.push(
@@ -314,7 +311,12 @@ export class NotificationsService {
}
if (info.dueAt) lines.push(`زمان: ${formatTehranDateTime(info.dueAt)}`);
const text = lines.join('\n').slice(0, 300);
const result = await this.sms.sendSmsDetailed(mobile, text);
const result = await this.sms.send({
organizationId,
phone: mobile,
text,
source: 'activity_reminder',
});
if (!result.ok) {
this.logger.warn(`Activity reminder SMS failed: ${result.reason ?? 'unknown'}`);
}