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
+2
View File
@@ -53,6 +53,7 @@ import { GamificationModule } from './gamification/gamification.module';
import { DeletionsModule } from './deletions/deletions.module';
import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
import { BehaviorModule } from './behavior/behavior.module';
import { SmsBillingModule } from './sms-billing/sms-billing.module';
@Module({
imports: [
@@ -63,6 +64,7 @@ import { BehaviorModule } from './behavior/behavior.module';
ScheduleModule.forRoot(),
AiModule,
PrismaModule,
SmsBillingModule,
OutboundSmsModule,
StorageModule,
HealthModule,
+1 -2
View File
@@ -1,9 +1,8 @@
import { Module } from '@nestjs/common';
import { SmsService } from '../common/sms.service';
import { AttendanceService } from './attendance.service';
@Module({
providers: [AttendanceService, SmsService],
providers: [AttendanceService],
exports: [AttendanceService],
})
export class AttendanceModule {}
@@ -1,8 +1,8 @@
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { Actor } from '../common/record-scope';
import { SmsService } from '../common/sms.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import {
loggedInAfterCutoff,
tehranClock,
@@ -29,7 +29,7 @@ export class AttendanceService {
constructor(
private readonly prisma: PrismaService,
private readonly sms: SmsService,
private readonly sms: SmsGatewayService,
) {}
async recordLogin(organizationId: string, userId: string, role: string, at = new Date()) {
@@ -185,6 +185,11 @@ export class AttendanceService {
: input.kind === 'forced_logout'
? `${name} در ساعت تعیین‌شده از منتکت خارج شد. ساعت ${clock}`
: `${name} از منتکت خارج شد. ساعت ${clock}`;
await this.sms.sendSmsDetailed(owner.user.mobile, text);
await this.sms.send({
organizationId: input.organizationId,
phone: owner.user.mobile,
text,
source: 'attendance',
});
}
}
-2
View File
@@ -12,7 +12,6 @@ import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { RolesGuard } from './roles.guard';
import { ViewAsReadOnlyInterceptor } from './view-as-readonly.interceptor';
import { SmsService } from '../common/sms.service';
@Module({
imports: [
@@ -37,7 +36,6 @@ import { SmsService } from '../common/sms.service';
AuthService,
JwtStrategy,
RolesGuard,
SmsService,
{
provide: APP_INTERCEPTOR,
useClass: ViewAsReadOnlyInterceptor,
+7 -6
View File
@@ -22,7 +22,7 @@ import { Prisma } from '@prisma/client';
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
import type {} from 'multer';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.service';
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';
@@ -107,7 +107,7 @@ export class AuthService {
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly sms: SmsService,
private readonly sms: SmsGatewayService,
private readonly files: LocalFileStorage,
private readonly memberships: MembershipsService,
private readonly billing: BillingService,
@@ -174,7 +174,7 @@ export class AuthService {
if (emailOtpEnabled && emailCode) {
await this.dispatchEmailOtp(email, emailCode, returnOtp);
}
await this.dispatchMobileOtp(mobile, mobileCode, returnOtp);
await this.dispatchMobileOtp(mobile, mobileCode, returnOtp, null);
return {
challengeId: challenge.id,
@@ -353,7 +353,7 @@ export class AuthService {
},
});
await this.dispatchMobileOtp(mobile, code, returnOtp);
await this.dispatchMobileOtp(mobile, code, returnOtp, user.organizationId);
return {
challengeId: challenge.id,
@@ -875,9 +875,10 @@ export class AuthService {
mobile: string,
code: string,
returnOtp: boolean,
organizationId: string | null,
) {
if (this.sms.isConfigured()) {
const sent = await this.sms.sendOtpSms(mobile, code);
if (this.sms.isConfigured() || organizationId) {
const sent = await this.sms.sendOtp(organizationId, mobile, code);
if (!sent && !returnOtp) {
throw new BadRequestException(
'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید',
@@ -2,14 +2,13 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { LoyaltyModule } from '../loyalty/loyalty.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { SmsService } from '../common/sms.service';
import { AutomationsController } from './automations.controller';
import { AutomationsService } from './automations.service';
@Module({
imports: [AuthModule, NotificationsModule, LoyaltyModule],
controllers: [AutomationsController],
providers: [AutomationsService, SmsService],
providers: [AutomationsService],
exports: [AutomationsService],
})
export class AutomationsModule {}
@@ -32,7 +32,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { LoyaltyService } from '../loyalty/loyalty.service';
import { SmsService } from '../common/sms.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import {
formatFormAnswers,
pickRoundRobin,
@@ -79,7 +79,7 @@ export class AutomationsService {
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly loyalty: LoyaltyService,
private readonly sms: SmsService,
private readonly sms: SmsGatewayService,
) {}
async list(organizationId: string): Promise<AutomationRuleDto[]> {
@@ -537,7 +537,11 @@ export class AutomationsService {
select: { mobile: true, phone: true },
})
: null;
await this.sendAutomationSms(person?.mobile || person?.phone, smsText);
await this.sendAutomationSms(
ctx.organizationId,
person?.mobile || person?.phone,
smsText,
);
}
return;
}
@@ -570,11 +574,12 @@ export class AutomationsService {
},
select: { mobile: true },
});
await this.sendAutomationSms(user?.mobile, smsText);
await this.sendAutomationSms(ctx.organizationId, user?.mobile, smsText);
}
}
private async sendAutomationSms(
organizationId: string,
mobile: string | null | undefined,
text: string,
) {
@@ -582,11 +587,12 @@ export class AutomationsService {
this.logger.warn('Automation SMS skipped: no mobile');
return;
}
if (!this.sms.isConfigured()) {
this.logger.warn('Automation SMS skipped: SMS not configured');
return;
}
const result = await this.sms.sendSmsDetailed(mobile, text);
const result = await this.sms.send({
organizationId,
phone: mobile,
text,
source: 'automation',
});
if (!result.ok) {
this.logger.warn(`Automation SMS failed: ${result.reason ?? 'unknown'}`);
}
+30 -7
View File
@@ -10,8 +10,19 @@ const SMS_0098_SOAP_URL = 'https://webservice.0098sms.com/service.asmx';
export type SmsSendResult = {
ok: boolean;
transport: 'trez' | 'soap' | 'link';
blocked?: boolean;
transport: 'trez' | 'soap' | 'link' | 'none';
reason?: string;
parts?: number;
costRials?: number;
};
export type SmsTransportOverride = {
username: string;
password: string;
sender: string;
/** trez | 0098 — default 0098 for own panels */
provider?: 'trez' | '0098';
};
@Injectable()
@@ -50,17 +61,29 @@ export class SmsService {
return result.ok;
}
async sendSmsDetailed(phone: string, text: string): Promise<SmsSendResult> {
const username = this.config.get<string>('SMS_USERNAME');
const password = this.getPassword();
const sender = this.config.get<string>('SMS_SENDER');
buildOtpText(code: string): string {
const template =
this.config.get<string>('SMS_OTP_TEMPLATE')?.trim() ||
`کد ورود منتکت: {code}`;
return template.replace('{code}', code);
}
async sendSmsDetailed(
phone: string,
text: string,
override?: SmsTransportOverride,
): Promise<SmsSendResult> {
const username = override?.username || this.config.get<string>('SMS_USERNAME');
const password = override?.password || this.getPassword();
const sender = override?.sender || this.config.get<string>('SMS_SENDER');
if (!username || !password || !sender) {
return { ok: false, transport: 'trez', reason: 'SMS not configured' };
return { ok: false, transport: 'none', reason: 'SMS not configured' };
}
const to = this.normalizePhoneForSms(phone);
const provider = this.resolveProvider();
const provider =
override?.provider ?? this.resolveProvider();
if (provider === 'trez') {
return this.sendViaTrez(username, password, sender, to, text);
+1 -2
View File
@@ -8,7 +8,6 @@ import { CompaniesService } from './companies.service';
import { PeopleController } from './people.controller';
import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service';
import { SmsService } from '../common/sms.service';
import { BehaviorModule } from '../behavior/behavior.module';
@Module({
@@ -20,7 +19,7 @@ import { BehaviorModule } from '../behavior/behavior.module';
BehaviorModule,
],
controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService, SmsService],
providers: [PeopleService, CompaniesService, ContactsExcelService],
exports: [PeopleService, CompaniesService],
})
export class ContactsModule {}
+24 -10
View File
@@ -41,7 +41,7 @@ import { MessageAutomationsService } from '../message-automations/message-automa
import { WebhooksService } from '../public-api/webhooks.service';
import { GamificationService } from '../gamification/gamification.service';
import { parseBirthDate } from '../common/excel';
import { SmsService } from '../common/sms.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { selectMonthBirthdays } from './birthday-month';
import { renderSmsText } from '../common/sms-text';
import { normalizeTags } from '../common/tags';
@@ -131,7 +131,7 @@ export class PeopleService implements OnModuleInit {
private readonly messageAutomations: MessageAutomationsService,
private readonly webhooks: WebhooksService,
private readonly game: GamificationService,
private readonly sms: SmsService,
private readonly sms: SmsGatewayService,
private readonly outboundSms: OutboundSmsLogService,
private readonly behaviorAccess: BehaviorAccessService,
) {}
@@ -662,7 +662,12 @@ export class PeopleService implements OnModuleInit {
company: person.company?.name ?? '',
});
if (!message) throw new BadRequestException('متن پیامک خالی است.');
const result = await this.sms.sendSmsDetailed(mobile, message);
const result = await this.sms.send({
organizationId: actor.organizationId,
phone: mobile,
text: message,
source: 'manual',
});
await this.outboundSms.record({
organizationId: actor.organizationId,
personId: person.id,
@@ -675,9 +680,11 @@ export class PeopleService implements OnModuleInit {
});
if (!result.ok) {
throw new ServiceUnavailableException(
result.reason === 'SMS not configured'
? 'ارسال پیامک روی سرور فعال نیست.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
result.blocked
? 'ارسال پیامک به‌خاطر محدودیت اعتبار مسدود است.'
: result.reason === 'SMS not configured'
? 'ارسال پیامک روی سرور فعال نیست.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
);
}
return { ok: true };
@@ -698,7 +705,12 @@ export class PeopleService implements OnModuleInit {
company: person.company?.name ?? '',
});
if (!message) throw new BadRequestException('متن پیامک خالی است.');
const result = await this.sms.sendSmsDetailed(mobile, message);
const result = await this.sms.send({
organizationId: actor.organizationId,
phone: mobile,
text: message,
source: 'manual',
});
await this.outboundSms.record({
organizationId: actor.organizationId,
personId: person.id,
@@ -711,9 +723,11 @@ export class PeopleService implements OnModuleInit {
});
if (!result.ok) {
throw new ServiceUnavailableException(
result.reason === 'SMS not configured'
? 'ارسال پیامک روی سرور فعال نیست.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
result.blocked
? 'ارسال پیامک به‌خاطر محدودیت اعتبار مسدود است.'
: result.reason === 'SMS not configured'
? 'ارسال پیامک روی سرور فعال نیست.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
);
}
return { ok: true };
@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { NotificationsService } from '../notifications/notifications.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import {
CHEQUE_REMINDER_DAYS,
isDueInDays,
@@ -18,7 +18,7 @@ export class ChequeRemindersService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly sms: SmsService,
private readonly sms: SmsGatewayService,
) {}
@Cron('0 8 * * *', { timeZone: 'Asia/Tehran' })
@@ -134,20 +134,27 @@ export class ChequeRemindersService {
dedupe: true,
});
if (user.mobile) {
await this.sms.sendSmsDetailed(user.mobile, staffText);
await this.sms.send({
organizationId: row.organizationId,
phone: user.mobile,
text: staffText,
source: 'cheque_reminder',
});
}
}
if (incoming && row.person?.mobile) {
await this.sms.sendSmsDetailed(
row.person.mobile,
personIncomingSms({
await this.sms.send({
organizationId: row.organizationId,
phone: row.person.mobile,
text: personIncomingSms({
number: row.number,
amount,
currency: row.currency,
dueDate: row.dueDate,
}),
);
source: 'cheque_reminder',
});
}
}
}
+1 -2
View File
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { TreasuryModule } from '../treasury/treasury.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { SmsService } from '../common/sms.service';
import { ChequesService } from './cheques.service';
import { ChequeRemindersService } from './cheque-reminders.service';
import { CommissionsService } from './commissions.service';
@@ -11,7 +10,7 @@ import { FinanceController } from './finance.controller';
@Module({
imports: [AuthModule, NotificationsModule, TreasuryModule],
controllers: [FinanceController],
providers: [ChequesService, ChequeRemindersService, CommissionsService, SmsService],
providers: [ChequesService, ChequeRemindersService, CommissionsService],
exports: [ChequesService, CommissionsService],
})
export class FinanceModule {}
+1 -2
View File
@@ -5,12 +5,11 @@ import { LeadStagesService } from './lead-stages.service';
import { LeadsController } from './leads.controller';
import { LeadsImportService } from './leads-import.service';
import { LeadsService } from './leads.service';
import { SmsService } from '../common/sms.service';
@Module({
imports: [AuthModule, BillingModule],
controllers: [LeadsController],
providers: [LeadsService, LeadStagesService, LeadsImportService, SmsService],
providers: [LeadsService, LeadStagesService, LeadsImportService],
exports: [LeadsService, LeadStagesService],
})
export class LeadsModule {}
+13 -6
View File
@@ -21,7 +21,7 @@ import type {
} from '@mento/shared';
import { FILTERED_IDS_MAX } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { renderSmsText } from '../common/sms-text';
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
import {
@@ -87,7 +87,7 @@ export class LeadsService {
private readonly stages: LeadStagesService,
private readonly files: LocalFileStorage,
private readonly game: GamificationService,
private readonly sms: SmsService,
private readonly sms: SmsGatewayService,
private readonly outboundSms: OutboundSmsLogService,
) {}
@@ -1291,7 +1291,12 @@ export class LeadsService {
company: lead.contactName ? lead.title : '',
});
if (!message) throw new BadRequestException('متن پیامک خالی است.');
const result = await this.sms.sendSmsDetailed(mobile, message);
const result = await this.sms.send({
organizationId: actor.organizationId,
phone: mobile,
text: message,
source: 'manual',
});
await this.outboundSms.record({
organizationId: actor.organizationId,
leadId: id,
@@ -1304,9 +1309,11 @@ export class LeadsService {
});
if (!result.ok) {
throw new ServiceUnavailableException(
result.reason === 'SMS not configured'
? 'ارسال پیامک روی سرور فعال نیست.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
result.blocked
? 'ارسال پیامک به‌خاطر محدودیت اعتبار مسدود است.'
: result.reason === 'SMS not configured'
? 'ارسال پیامک روی سرور فعال نیست.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
);
}
await this.addEvent(actor, id, 'note', 'پیامک ارسال شد', message);
+1 -2
View File
@@ -1,13 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { SmsService } from '../common/sms.service';
import { LoyaltyController } from './loyalty.controller';
import { LoyaltyService } from './loyalty.service';
@Module({
imports: [AuthModule],
controllers: [LoyaltyController],
providers: [LoyaltyService, SmsService],
providers: [LoyaltyService],
exports: [LoyaltyService],
})
export class LoyaltyModule {}
+14 -7
View File
@@ -22,7 +22,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search';
import { type Actor, ownerWhere } from '../common/record-scope';
import { SmsService } from '../common/sms.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
const DEFAULT_TIERS = [
{ name: 'برنزی', minPoints: 0, color: '#c9843a' },
@@ -57,7 +57,7 @@ const DAY_MS = 86_400_000;
export class LoyaltyService {
constructor(
private readonly prisma: PrismaService,
private readonly sms: SmsService,
private readonly sms: SmsGatewayService,
) {}
async ensureDefaults(organizationId: string): Promise<void> {
@@ -447,10 +447,12 @@ export class LoyaltyService {
const settings = await this.ensureSettings(actor.organizationId);
if (settings.notifyOnAdjust && updated.mobile) {
const sign = delta > 0 ? '+' : '';
void this.sms.sendSmsDetailed(
updated.mobile,
`باشگاه مشتریان: ${sign}${Math.trunc(delta)} امتیاز. موجودی: ${updated.loyaltyPoints}`,
);
void this.sms.send({
organizationId: actor.organizationId,
phone: updated.mobile,
text: `باشگاه مشتریان: ${sign}${Math.trunc(delta)} امتیاز. موجودی: ${updated.loyaltyPoints}`,
source: 'loyalty',
});
}
return updated;
}
@@ -504,7 +506,12 @@ export class LoyaltyService {
skipped += 1;
continue;
}
const result = await this.sms.sendSmsDetailed(p.mobile, body);
const result = await this.sms.send({
organizationId: actor.organizationId,
phone: p.mobile,
text: body,
source: 'loyalty',
});
if (result.ok) sent += 1;
else failed += 1;
}
@@ -1,14 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { SmsService } from '../common/sms.service';
import { MessageAutomationsController } from './message-automations.controller';
import { MessageAutomationsService } from './message-automations.service';
@Module({
imports: [AuthModule, NotificationsModule],
controllers: [MessageAutomationsController],
providers: [MessageAutomationsService, SmsService],
providers: [MessageAutomationsService],
exports: [MessageAutomationsService],
})
export class MessageAutomationsModule {}
@@ -13,7 +13,7 @@ import type {
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SmsService } from '../common/sms.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { dateToJalali, jalaliMonthName } from '../common/jalali';
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
import {
@@ -47,7 +47,7 @@ export class MessageAutomationsService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly sms: SmsService,
private readonly sms: SmsGatewayService,
private readonly outboundSms: OutboundSmsLogService,
) {}
@@ -397,7 +397,10 @@ export class MessageAutomationsService {
},
select: { mobile: true },
});
await this.sendSms(user?.mobile, smsText);
await this.sendSms(user?.mobile, smsText, {
organizationId: job.organizationId,
ruleId: job.ruleId || job.rule.id || '',
});
}
}
@@ -413,7 +416,7 @@ export class MessageAutomationsService {
private async sendSms(
mobile: string | null | undefined,
text: string,
log?: {
log: {
organizationId: string;
personId?: string | null;
ruleId: string;
@@ -423,12 +426,13 @@ export class MessageAutomationsService {
this.logger.warn('Message automation SMS skipped: no mobile');
return;
}
if (!this.sms.isConfigured()) {
this.logger.warn('Message automation SMS skipped: SMS not configured');
return;
}
const result = await this.sms.sendSmsDetailed(mobile, text);
if (log?.personId) {
const result = await this.sms.send({
organizationId: log.organizationId,
phone: mobile,
text,
source: 'automation',
});
if (log.personId) {
await this.outboundSms.record({
organizationId: log.organizationId,
personId: log.personId,
@@ -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'}`);
}
+1 -2
View File
@@ -1,6 +1,5 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { SmsService } from '../common/sms.service';
import { PortalController } from './portal.controller';
import { PortalService } from './portal.service';
import { PortalAuthGuard } from './portal-auth';
@@ -8,6 +7,6 @@ import { PortalAuthGuard } from './portal-auth';
@Module({
imports: [AuthModule],
controllers: [PortalController],
providers: [PortalService, PortalAuthGuard, SmsService],
providers: [PortalService, PortalAuthGuard],
})
export class PortalModule {}
+9 -12
View File
@@ -19,7 +19,7 @@ import type {
} from '@mento/shared';
import { TICKET_SLA_HOURS } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import type { PortalActor } from './portal-auth';
const OTP_TTL_SECONDS = 5 * 60;
@@ -31,7 +31,7 @@ export class PortalService {
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly sms: SmsService,
private readonly sms: SmsGatewayService,
) {}
async getOrgInfo(slug: string): Promise<PortalOrgInfoDto> {
@@ -99,16 +99,13 @@ export class PortalService {
(this.config.get<string>('NODE_ENV') !== 'production' &&
this.config.get<string>('PORTAL_SHOW_OTP') !== 'false');
if (this.sms.isConfigured()) {
const sent = await this.sms.sendOtpSms(mobile, code);
if (!sent && !returnOtp) {
throw new BadRequestException(
'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید',
);
}
} else if (!returnOtp) {
throw new BadRequestException('سامانه پیامک پیکربندی نشده است');
} else {
const sent = await this.sms.sendOtp(org.id, mobile, code);
if (!sent && !returnOtp) {
throw new BadRequestException(
'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید',
);
}
if (!sent) {
// eslint-disable-next-line no-console
console.log(`[portal-otp] ${org.slug} ${mobile} => ${code}`);
}
+111
View File
@@ -0,0 +1,111 @@
import {
IsBoolean,
IsIn,
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
ValidateIf,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
export class PlatformSmsChargeDto {
@Type(() => Number)
@IsInt()
amountRials!: number;
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}
export class PlatformSmsUpdateAccountDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
pricePerPartRials?: number;
@IsOptional()
@IsBoolean()
isBlocked?: boolean;
/** null = غیرفعال کردن آستانه خودکار */
@IsOptional()
@Transform(({ value }) =>
value === null || value === '' || value === undefined ? null : Number(value),
)
@ValidateIf((_, v) => v !== null && v !== undefined)
@IsInt()
autoBlockBelowRials?: number | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
alertCriticalRials?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
alertWarningRials?: number;
@IsOptional()
@IsIn(['platform', 'own'])
panelMode?: 'platform' | 'own';
@IsOptional()
@IsString()
@MaxLength(32)
ownPanelType?: string;
@IsOptional()
@IsString()
@MaxLength(120)
ownPanelUsername?: string | null;
@IsOptional()
@IsString()
@MaxLength(120)
ownPanelPassword?: string | null;
@IsOptional()
@IsString()
@MaxLength(32)
ownPanelFrom?: string | null;
@IsOptional()
@IsString()
@MaxLength(64)
chargeCard?: string | null;
@IsOptional()
@IsString()
@MaxLength(64)
chargeIban?: string | null;
@IsOptional()
@IsString()
@MaxLength(120)
chargeHolder?: string | null;
@IsOptional()
@IsString()
@MaxLength(2000)
chargeInstruction?: string | null;
}
export class PlatformSmsBlockDto {
@IsBoolean()
isBlocked!: boolean;
}
export class PlatformSmsPriceDto {
@Type(() => Number)
@IsInt()
@Min(0)
pricePerPartRials!: number;
}
@@ -0,0 +1,27 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { SmsWalletService } from './sms-wallet.service';
@Controller('sms-wallet')
@UseGuards(JwtAuthGuard)
export class OrgSmsController {
constructor(private readonly wallet: SmsWalletService) {}
/** خلاصه برای نشان اعتبار در هدر CRM */
@Get('summary')
async summary(@CurrentUser() user: { organizationId: string }) {
const account = await this.wallet.ensureAccount(user.organizationId);
const billingEnabled = this.wallet.isBillingEnabled(account);
return {
billingEnabled,
balanceRials: account.balanceRials,
pricePerPartRials: account.pricePerPartRials,
isBlocked: account.isBlocked,
autoBlocked: this.wallet.isAutoBlocked(account),
balanceLevel: this.wallet.balanceLevel(account),
panelMode: account.panelMode,
chargeInfo: billingEnabled ? this.wallet.chargeInfoOf(account) : null,
};
}
}
@@ -0,0 +1,83 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import { PlatformAuthGuard } from '../platform/platform-auth.guard';
import type { PlatformActor } from '../platform/platform-jwt.strategy';
import {
PlatformSmsBlockDto,
PlatformSmsChargeDto,
PlatformSmsPriceDto,
PlatformSmsUpdateAccountDto,
} from './dto';
import { SmsWalletService } from './sms-wallet.service';
@Controller('platform/sms')
@UseGuards(PlatformAuthGuard)
export class PlatformSmsController {
constructor(private readonly wallet: SmsWalletService) {}
@Get('tenants')
listTenants(@Query('q') q?: string) {
return this.wallet.listTenantSummaries(q);
}
@Get('organizations/:organizationId')
overview(@Param('organizationId') organizationId: string) {
return this.wallet.getOverview(organizationId);
}
@Post('organizations/:organizationId/charge')
charge(
@Param('organizationId') organizationId: string,
@Body() dto: PlatformSmsChargeDto,
@CurrentUser() actor: PlatformActor,
) {
return this.wallet
.charge(organizationId, dto.amountRials, actor.platformAdminId, dto.note)
.then((account) => this.wallet.getOverview(organizationId).then((o) => ({
...o,
account: this.wallet.publicAccount(account),
})));
}
@Put('organizations/:organizationId/price')
updatePrice(
@Param('organizationId') organizationId: string,
@Body() dto: PlatformSmsPriceDto,
) {
return this.wallet
.updateAccount(organizationId, {
pricePerPartRials: dto.pricePerPartRials,
})
.then(() => this.wallet.getOverview(organizationId));
}
@Put('organizations/:organizationId/block')
setBlocked(
@Param('organizationId') organizationId: string,
@Body() dto: PlatformSmsBlockDto,
) {
return this.wallet
.updateAccount(organizationId, { isBlocked: dto.isBlocked })
.then(() => this.wallet.getOverview(organizationId));
}
@Patch('organizations/:organizationId')
updateAccount(
@Param('organizationId') organizationId: string,
@Body() dto: PlatformSmsUpdateAccountDto,
) {
return this.wallet
.updateAccount(organizationId, dto)
.then(() => this.wallet.getOverview(organizationId));
}
}
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { SmsService } from '../common/sms.service';
import { OrgSmsController } from './org-sms.controller';
import { PlatformSmsController } from './platform-sms.controller';
import { SmsGatewayService } from './sms-gateway.service';
import { SmsWalletService } from './sms-wallet.service';
@Global()
@Module({
controllers: [PlatformSmsController, OrgSmsController],
providers: [SmsService, SmsWalletService, SmsGatewayService],
exports: [SmsService, SmsWalletService, SmsGatewayService],
})
export class SmsBillingModule {}
@@ -0,0 +1,145 @@
import { Injectable, Logger } from '@nestjs/common';
import {
SmsService,
type SmsSendResult,
type SmsTransportOverride,
} from '../common/sms.service';
import { countSmsParts } from './sms-segment.util';
import { SmsWalletService } from './sms-wallet.service';
export type SmsGatewaySendInput = {
/** اگر نباشد (مثلاً ثبت‌نام قبل از ساخت محیط) فقط ارسال پلتفرم بدون کسر */
organizationId?: string | null;
phone: string;
text: string;
source?: string | null;
isAlert?: boolean;
};
@Injectable()
export class SmsGatewayService {
private readonly logger = new Logger(SmsGatewayService.name);
constructor(
private readonly transport: SmsService,
private readonly wallet: SmsWalletService,
) {}
isConfigured(): boolean {
return this.transport.isConfigured();
}
normalizePhoneForSms(mobile: string): string {
return this.transport.normalizePhoneForSms(mobile);
}
async sendOtp(
organizationId: string | null | undefined,
mobile: string,
code: string,
): Promise<boolean> {
const text = this.transport.buildOtpText(code);
const result = await this.send({
organizationId,
phone: mobile,
text,
source: 'otp',
});
return result.ok;
}
async send(input: SmsGatewaySendInput): Promise<SmsSendResult> {
const text = String(input.text ?? '').trim();
if (!text) {
return { ok: false, transport: 'none', reason: 'empty body' };
}
const partsEstimate = Math.max(1, countSmsParts(text));
let override: SmsTransportOverride | undefined;
let billable = false;
let providerLabel = 'platform';
if (input.organizationId) {
const account = await this.wallet.ensureAccount(input.organizationId);
const billingEnabled = this.wallet.isBillingEnabled(account);
if (billingEnabled) {
if (!this.wallet.canSend(account, Boolean(input.isAlert))) {
return {
ok: false,
blocked: true,
transport: 'none',
reason: account.isBlocked ? 'blocked' : 'auto_blocked',
parts: partsEstimate,
};
}
billable = true;
} else {
// پنل اختصاصی مشتری: بدون کسر/لاگ کیف
const user = account.ownPanelUsername?.trim();
const pass = account.ownPanelPassword?.trim();
const from = account.ownPanelFrom?.trim();
if (!user || !pass || !from) {
return {
ok: false,
transport: 'none',
reason: 'own SMS panel not configured',
};
}
const type = (account.ownPanelType || '0098').toLowerCase();
override = {
username: user,
password: pass,
sender: from,
provider: type === 'trez' ? 'trez' : '0098',
};
providerLabel = `own:${type || '0098'}`;
}
}
if (!override && !this.transport.isConfigured()) {
return { ok: false, transport: 'none', reason: 'SMS not configured' };
}
const result = await this.transport.sendSmsDetailed(
input.phone,
text,
override,
);
if (!result.ok) {
return result;
}
const parts = Math.max(1, countSmsParts(text));
const to = this.transport.normalizePhoneForSms(input.phone);
if (billable && input.organizationId) {
try {
const debit = await this.wallet.debitSuccessfulSms({
organizationId: input.organizationId,
receiver: to,
body: text,
parts,
provider: result.transport || providerLabel,
source: input.source,
isAlert: input.isAlert,
});
return {
...result,
parts,
costRials: debit.costRials,
};
} catch (e) {
this.logger.error(
`SMS debit failed after send org=${input.organizationId}: ${
e instanceof Error ? e.message : String(e)
}`,
);
return { ...result, parts };
}
}
return { ...result, parts, costRials: 0 };
}
}
@@ -0,0 +1,14 @@
/** Persian SMS parts (same rules as Hamkar SmsSegmentCalculator). */
const PERSIAN_LIMITS = [70, 64, 67] as const;
export function countSmsParts(text: string): number {
const length = [...String(text ?? '').trim()].length;
if (length <= 0) return 0;
const [first, second, rest] = PERSIAN_LIMITS;
if (length <= first) return 1;
if (length <= first + second) return 2;
const thirdThreshold = first + second + rest;
if (length <= thirdThreshold) return 3;
return 3 + Math.ceil((length - thirdThreshold) / rest);
}
@@ -0,0 +1,424 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import type { SmsAccount } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { countSmsParts } from './sms-segment.util';
export type SmsBalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown';
const DEFAULT_CHARGE_INSTRUCTION =
'لطفا مبلغ مورد نظر خود جهت شارژ سامانه پیامکی را به شماره کارت یا حساب زیر واریز نمایید و تصویر فیش پرداختی را برای پشتیبانی بفرستید.';
@Injectable()
export class SmsWalletService {
constructor(private readonly prisma: PrismaService) {}
async ensureAccount(organizationId: string): Promise<SmsAccount> {
return this.prisma.smsAccount.upsert({
where: { organizationId },
create: { organizationId },
update: {},
});
}
isBillingEnabled(account: SmsAccount): boolean {
return (account.panelMode ?? 'platform') !== 'own';
}
isAutoBlocked(account: SmsAccount): boolean {
return (
account.autoBlockBelowRials != null &&
account.balanceRials < account.autoBlockBelowRials
);
}
canSend(account: SmsAccount, isAlert = false): boolean {
if (!this.isBillingEnabled(account)) return true;
if (isAlert) return true;
if (account.isBlocked) return false;
if (this.isAutoBlocked(account)) return false;
return true;
}
balanceLevel(account: SmsAccount): SmsBalanceLevel {
if (!this.isBillingEnabled(account)) return 'unknown';
const bal = account.balanceRials;
if (bal <= account.alertCriticalRials) return 'critical';
if (bal <= account.alertWarningRials) return 'warning';
return 'healthy';
}
estimateCost(account: SmsAccount, text: string): { parts: number; costRials: number } {
const parts = Math.max(1, countSmsParts(text));
return { parts, costRials: parts * account.pricePerPartRials };
}
chargeInfoOf(account: SmsAccount) {
return {
card: account.chargeCard?.trim() || '',
iban: account.chargeIban?.trim() || '',
accountHolder: account.chargeHolder?.trim() || '',
instruction:
account.chargeInstruction?.trim() || DEFAULT_CHARGE_INSTRUCTION,
};
}
async status(organizationId: string, parts = 1, isAlert = false) {
const account = await this.ensureAccount(organizationId);
const safeParts = Math.max(1, Math.min(50, Math.floor(parts) || 1));
const estimatedCostRials = safeParts * account.pricePerPartRials;
const billingEnabled = this.isBillingEnabled(account);
let reason: string | null = null;
let allowed = true;
if (billingEnabled) {
if (account.isBlocked && !isAlert) {
allowed = false;
reason = 'blocked';
} else if (this.isAutoBlocked(account) && !isAlert) {
allowed = false;
reason = 'auto_blocked';
} else if (!isAlert && account.balanceRials < estimatedCostRials) {
reason = 'insufficient_balance';
}
}
return {
billingEnabled,
allowed,
blocked: billingEnabled && (account.isBlocked || this.isAutoBlocked(account)),
reason,
balanceRials: account.balanceRials,
pricePerPartRials: account.pricePerPartRials,
autoBlockBelowRials: account.autoBlockBelowRials,
panelMode: account.panelMode,
parts: safeParts,
estimatedCostRials,
balanceLevel: this.balanceLevel(account),
isBlocked: account.isBlocked,
};
}
async charge(
organizationId: string,
amountRials: number,
createdById?: string | null,
note?: string | null,
) {
if (!Number.isFinite(amountRials) || amountRials === 0) {
throw new BadRequestException('مبلغ شارژ نامعتبر است');
}
return this.prisma.$transaction(async (tx) => {
const account = await tx.smsAccount.upsert({
where: { organizationId },
create: { organizationId },
update: {},
});
const next = account.balanceRials + Math.trunc(amountRials);
const updated = await tx.smsAccount.update({
where: { id: account.id },
data: {
balanceRials: next,
...(next > 0 ? { alertDaysSent: 0, lastAlertDate: null } : {}),
},
});
await tx.smsLedger.create({
data: {
smsAccountId: account.id,
type: 'charge',
amountRials: Math.trunc(amountRials),
balanceAfterRials: next,
note: note?.trim() || null,
createdById: createdById ?? null,
},
});
return updated;
});
}
async debitSuccessfulSms(input: {
organizationId: string;
receiver: string;
body: string;
parts: number;
provider: string;
source?: string | null;
isAlert?: boolean;
}) {
const parts = Math.max(1, input.parts);
return this.prisma.$transaction(async (tx) => {
const account = await tx.smsAccount.findUnique({
where: { organizationId: input.organizationId },
});
if (!account) {
throw new BadRequestException('حساب پیامک یافت نشد');
}
if (!this.isBillingEnabled(account)) {
return { account, costRials: 0, logId: null as string | null };
}
const costRials = parts * account.pricePerPartRials;
const next = account.balanceRials - costRials;
const updated = await tx.smsAccount.update({
where: { id: account.id },
data: { balanceRials: next },
});
const log = await tx.smsBillingSendLog.create({
data: {
smsAccountId: account.id,
receiver: input.receiver.slice(0, 32),
body: input.body.slice(0, 2000),
parts,
pricePerPartRials: account.pricePerPartRials,
totalAmountRials: costRials,
provider: input.provider.slice(0, 64),
source: input.source?.slice(0, 64) ?? null,
isAlert: Boolean(input.isAlert),
},
});
await tx.smsLedger.create({
data: {
smsAccountId: account.id,
type: input.isAlert ? 'alert_debit' : 'debit',
amountRials: -costRials,
balanceAfterRials: next,
parts,
pricePerPartRials: account.pricePerPartRials,
smsSendLogId: log.id,
note: input.isAlert
? 'هشدار موجودی پیامک'
: input.source
? `مصرف ارسال (${input.source})`
: 'مصرف ارسال',
},
});
return { account: updated, costRials, logId: log.id };
});
}
async listTenantSummaries(q?: string) {
const term = q?.trim();
const orgs = await this.prisma.organization.findMany({
where: term
? {
OR: [
{ name: { contains: term, mode: 'insensitive' } },
{ slug: { contains: term, mode: 'insensitive' } },
],
}
: undefined,
orderBy: { name: 'asc' },
take: 500,
include: { smsAccount: true },
});
const rows = await Promise.all(
orgs.map(async (org) => {
const account = org.smsAccount ?? (await this.ensureAccount(org.id));
return {
id: org.id,
name: org.name,
slug: org.slug,
isActive: org.isActive,
isBlocked: account.isBlocked,
panelMode: account.panelMode,
sms: {
balanceRials: account.balanceRials,
pricePerPartRials: account.pricePerPartRials,
autoBlockBelowRials: account.autoBlockBelowRials,
balanceLevel: this.balanceLevel(account),
billingEnabled: this.isBillingEnabled(account),
},
};
}),
);
return rows;
}
async getOverview(organizationId: string) {
const account = await this.ensureAccount(organizationId);
const org = await this.prisma.organization.findUniqueOrThrow({
where: { id: organizationId },
select: { id: true, name: true, slug: true, isActive: true },
});
const now = new Date();
const startOfDay = new Date(now);
startOfDay.setHours(0, 0, 0, 0);
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const [todayAgg, monthAgg, recentLedgers, recentLogs] = await Promise.all([
this.prisma.smsBillingSendLog.aggregate({
where: {
smsAccountId: account.id,
createdAt: { gte: startOfDay },
isAlert: false,
},
_count: { _all: true },
_sum: { parts: true, totalAmountRials: true },
}),
this.prisma.smsBillingSendLog.aggregate({
where: {
smsAccountId: account.id,
createdAt: { gte: startOfMonth },
isAlert: false,
},
_count: { _all: true },
_sum: { parts: true, totalAmountRials: true },
}),
this.prisma.smsLedger.findMany({
where: { smsAccountId: account.id },
orderBy: { createdAt: 'desc' },
take: 30,
}),
this.prisma.smsBillingSendLog.findMany({
where: { smsAccountId: account.id },
orderBy: { createdAt: 'desc' },
take: 30,
}),
]);
return {
organization: org,
account: this.publicAccount(account),
chargeInfo: this.chargeInfoOf(account),
today: {
messages: todayAgg._count._all,
parts: todayAgg._sum.parts ?? 0,
amountRials: todayAgg._sum.totalAmountRials ?? 0,
},
month: {
messages: monthAgg._count._all,
parts: monthAgg._sum.parts ?? 0,
amountRials: monthAgg._sum.totalAmountRials ?? 0,
},
ledgers: recentLedgers.map((l) => ({
id: l.id,
type: l.type,
amountRials: l.amountRials,
balanceAfterRials: l.balanceAfterRials,
parts: l.parts,
note: l.note,
createdAt: l.createdAt.toISOString(),
})),
sendLogs: recentLogs.map((l) => ({
id: l.id,
receiver: l.receiver,
body: l.body,
parts: l.parts,
totalAmountRials: l.totalAmountRials,
provider: l.provider,
source: l.source,
isAlert: l.isAlert,
createdAt: l.createdAt.toISOString(),
})),
};
}
publicAccount(account: SmsAccount) {
return {
id: account.id,
organizationId: account.organizationId,
balanceRials: account.balanceRials,
pricePerPartRials: account.pricePerPartRials,
isBlocked: account.isBlocked,
autoBlockBelowRials: account.autoBlockBelowRials,
alertCriticalRials: account.alertCriticalRials,
alertWarningRials: account.alertWarningRials,
panelMode: account.panelMode as 'platform' | 'own',
ownPanelType: account.ownPanelType,
ownPanelUsername: account.ownPanelUsername,
ownPanelFrom: account.ownPanelFrom,
hasOwnPanelPassword: Boolean(account.ownPanelPassword),
balanceLevel: this.balanceLevel(account),
billingEnabled: this.isBillingEnabled(account),
};
}
async updateAccount(
organizationId: string,
data: {
pricePerPartRials?: number;
isBlocked?: boolean;
autoBlockBelowRials?: number | null;
alertCriticalRials?: number;
alertWarningRials?: number;
panelMode?: 'platform' | 'own';
ownPanelType?: string;
ownPanelUsername?: string | null;
ownPanelPassword?: string | null;
ownPanelFrom?: string | null;
chargeCard?: string | null;
chargeIban?: string | null;
chargeHolder?: string | null;
chargeInstruction?: string | null;
},
) {
await this.ensureAccount(organizationId);
if (
data.alertCriticalRials != null &&
data.alertWarningRials != null &&
data.alertCriticalRials > data.alertWarningRials
) {
throw new BadRequestException('آستانه قرمز نباید از آستانه زرد بزرگ‌تر باشد');
}
const updated = await this.prisma.smsAccount.update({
where: { organizationId },
data: {
pricePerPartRials:
data.pricePerPartRials === undefined
? undefined
: Math.max(0, Math.trunc(data.pricePerPartRials)),
isBlocked: data.isBlocked,
autoBlockBelowRials:
data.autoBlockBelowRials === undefined
? undefined
: data.autoBlockBelowRials,
alertCriticalRials:
data.alertCriticalRials === undefined
? undefined
: Math.max(0, Math.trunc(data.alertCriticalRials)),
alertWarningRials:
data.alertWarningRials === undefined
? undefined
: Math.max(0, Math.trunc(data.alertWarningRials)),
panelMode: data.panelMode,
ownPanelType: data.ownPanelType?.trim() || undefined,
ownPanelUsername:
data.ownPanelUsername === undefined
? undefined
: data.ownPanelUsername?.trim() || null,
ownPanelPassword:
data.ownPanelPassword === undefined
? undefined
: data.ownPanelPassword?.trim() || null,
ownPanelFrom:
data.ownPanelFrom === undefined
? undefined
: data.ownPanelFrom?.trim() || null,
chargeCard:
data.chargeCard === undefined
? undefined
: data.chargeCard?.trim() || null,
chargeIban:
data.chargeIban === undefined
? undefined
: data.chargeIban?.trim() || null,
chargeHolder:
data.chargeHolder === undefined
? undefined
: data.chargeHolder?.trim() || null,
chargeInstruction:
data.chargeInstruction === undefined
? undefined
: data.chargeInstruction?.trim() || null,
},
});
return this.publicAccount(updated);
}
}
@@ -1,7 +1,6 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module';
import { SmsService } from '../common/sms.service';
import { TrackingModule } from '../tracking/tracking.module';
import { SmsCampaignsController } from './sms-campaigns.controller';
import { SmsCampaignsService } from './sms-campaigns.service';
@@ -9,7 +8,7 @@ import { SmsCampaignsService } from './sms-campaigns.service';
@Module({
imports: [AuthModule, BillingModule, TrackingModule],
controllers: [SmsCampaignsController],
providers: [SmsCampaignsService, SmsService],
providers: [SmsCampaignsService],
exports: [SmsCampaignsService],
})
export class SmsCampaignsModule {}
@@ -15,7 +15,7 @@ import type {
} from '@mento/shared';
import { SMS_CAMPAIGN_CHUNK_SIZE } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { renderSmsText } from '../common/sms-text';
import {
type Actor,
@@ -45,7 +45,7 @@ function leadAccessWhere(baseActor: Actor): Prisma.LeadWhereInput {
export class SmsCampaignsService {
constructor(
private readonly prisma: PrismaService,
private readonly sms: SmsService,
private readonly sms: SmsGatewayService,
private readonly tracking: TrackingService,
private readonly outboundSms: OutboundSmsLogService,
) {}
@@ -583,10 +583,12 @@ export class SmsCampaignsService {
body: withTokens,
});
const result = await this.sms.sendSmsDetailed(
recipient.mobile,
personalized.body,
);
const result = await this.sms.send({
organizationId: campaign.organizationId,
phone: recipient.mobile,
text: personalized.body,
source: 'campaign',
});
await this.outboundSms.record({
organizationId: campaign.organizationId,
personId: recipient.personId,
@@ -13,7 +13,7 @@ import type {
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SmsService } from '../common/sms.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import ExcelJS from 'exceljs';
import type { Actor } from '../common/record-scope';
@@ -47,7 +47,7 @@ export class TreasuryReceivablesService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly sms: SmsService,
private readonly sms: SmsGatewayService,
) {}
async list(actor: Actor): Promise<FinancialReceivableDto[]> {
@@ -306,8 +306,15 @@ export class TreasuryReceivablesService {
where: { id: admin.userId },
select: { mobile: true },
});
if (user?.mobile && this.sms.isConfigured()) {
await this.sms.sendSmsDetailed(user.mobile, `${title}: ${body}`).catch(() => undefined);
if (user?.mobile) {
await this.sms
.send({
organizationId: row.receivable.organizationId,
phone: user.mobile,
text: `${title}: ${body}`,
source: 'treasury_reminder',
})
.catch(() => undefined);
}
}
await this.prisma.financialInstallment.update({
-2
View File
@@ -1,7 +1,6 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { SmsService } from '../common/sms.service';
import { TreasuryController } from './treasury.controller';
import { TreasuryAccessGuard } from './treasury-access.guard';
import { TreasuryBridgeService } from './treasury-bridge.service';
@@ -18,7 +17,6 @@ import { TreasuryService } from './treasury.service';
TreasuryOpsService,
TreasuryBridgeService,
TreasuryAccessGuard,
SmsService,
],
exports: [TreasuryService, TreasuryBridgeService],
})