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
@@ -0,0 +1,90 @@
-- CreateTable
CREATE TABLE "SmsAccount" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"balanceRials" INTEGER NOT NULL DEFAULT 0,
"pricePerPartRials" INTEGER NOT NULL DEFAULT 2500,
"isBlocked" BOOLEAN NOT NULL DEFAULT false,
"autoBlockBelowRials" INTEGER,
"alertCriticalRials" INTEGER NOT NULL DEFAULT 10000,
"alertWarningRials" INTEGER NOT NULL DEFAULT 100000,
"panelMode" TEXT NOT NULL DEFAULT 'platform',
"ownPanelType" TEXT NOT NULL DEFAULT '0098',
"ownPanelUsername" TEXT,
"ownPanelPassword" TEXT,
"ownPanelFrom" TEXT,
"chargeCard" TEXT,
"chargeIban" TEXT,
"chargeHolder" TEXT,
"chargeInstruction" TEXT,
"alertDaysSent" INTEGER NOT NULL DEFAULT 0,
"lastAlertDate" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SmsAccount_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SmsLedger" (
"id" TEXT NOT NULL,
"smsAccountId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"amountRials" INTEGER NOT NULL,
"balanceAfterRials" INTEGER NOT NULL,
"parts" INTEGER,
"pricePerPartRials" INTEGER,
"note" TEXT,
"smsSendLogId" TEXT,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsLedger_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SmsBillingSendLog" (
"id" TEXT NOT NULL,
"smsAccountId" TEXT NOT NULL,
"receiver" TEXT NOT NULL,
"body" TEXT NOT NULL,
"parts" INTEGER NOT NULL,
"pricePerPartRials" INTEGER NOT NULL,
"totalAmountRials" INTEGER NOT NULL,
"provider" TEXT,
"source" TEXT,
"isAlert" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsBillingSendLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SmsAccount_organizationId_key" ON "SmsAccount"("organizationId");
-- CreateIndex
CREATE INDEX "SmsAccount_isBlocked_idx" ON "SmsAccount"("isBlocked");
-- CreateIndex
CREATE INDEX "SmsAccount_balanceRials_idx" ON "SmsAccount"("balanceRials");
-- CreateIndex
CREATE INDEX "SmsLedger_smsAccountId_createdAt_idx" ON "SmsLedger"("smsAccountId", "createdAt");
-- CreateIndex
CREATE INDEX "SmsLedger_type_idx" ON "SmsLedger"("type");
-- CreateIndex
CREATE INDEX "SmsBillingSendLog_smsAccountId_createdAt_idx" ON "SmsBillingSendLog"("smsAccountId", "createdAt");
-- CreateIndex
CREATE INDEX "SmsBillingSendLog_source_idx" ON "SmsBillingSendLog"("source");
-- AddForeignKey
ALTER TABLE "SmsAccount" ADD CONSTRAINT "SmsAccount_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SmsLedger" ADD CONSTRAINT "SmsLedger_smsAccountId_fkey" FOREIGN KEY ("smsAccountId") REFERENCES "SmsAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SmsBillingSendLog" ADD CONSTRAINT "SmsBillingSendLog_smsAccountId_fkey" FOREIGN KEY ("smsAccountId") REFERENCES "SmsAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+73
View File
@@ -206,6 +206,7 @@ model Organization {
webhookEndpoints WebhookEndpoint[]
smsCampaigns SmsCampaign[]
outboundSmsLogs OutboundSmsLog[]
smsAccount SmsAccount?
financialAccounts FinancialAccount[]
financialCategories FinancialCategory[]
financialDocuments FinancialDocument[]
@@ -2290,6 +2291,78 @@ model FinancialInstallment {
@@index([dueDate])
}
/// کیف‌پول و تنظیمات پیامک هر محیط (الگوی همکار / سوپرادمین آبشُده)
model SmsAccount {
id String @id @default(cuid())
organizationId String @unique
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
balanceRials Int @default(0)
pricePerPartRials Int @default(2500)
isBlocked Boolean @default(false)
/// اگر ست باشد: وقتی موجودی < این مقدار، ارسال قطع می‌شود (می‌تواند منفی باشد). null = فقط کلید دستی
autoBlockBelowRials Int?
alertCriticalRials Int @default(10000)
alertWarningRials Int @default(100000)
/// platform = سامانهٔ ما | own = پنل اختصاصی مشتری (بدون کسر/لاگ کیف)
panelMode String @default("platform")
/// وقتی panelMode=own: 0098 | asanak | trez
ownPanelType String @default("0098")
ownPanelUsername String?
ownPanelPassword String?
ownPanelFrom String?
chargeCard String?
chargeIban String?
chargeHolder String?
chargeInstruction String?
alertDaysSent Int @default(0)
lastAlertDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ledgers SmsLedger[]
sendLogs SmsBillingSendLog[]
@@index([isBlocked])
@@index([balanceRials])
}
model SmsLedger {
id String @id @default(cuid())
smsAccountId String
smsAccount SmsAccount @relation(fields: [smsAccountId], references: [id], onDelete: Cascade)
/// charge | debit | alert_debit
type String
amountRials Int
balanceAfterRials Int
parts Int?
pricePerPartRials Int?
note String?
smsSendLogId String?
createdById String?
createdAt DateTime @default(now())
@@index([smsAccountId, createdAt])
@@index([type])
}
/// لاگ ارسال صورتحساب‌شده (همهٔ مسیرها وقتی panelMode=platform)
model SmsBillingSendLog {
id String @id @default(cuid())
smsAccountId String
smsAccount SmsAccount @relation(fields: [smsAccountId], references: [id], onDelete: Cascade)
receiver String
body String
parts Int
pricePerPartRials Int
totalAmountRials Int
provider String?
source String?
isAlert Boolean @default(false)
createdAt DateTime @default(now())
@@index([smsAccountId, createdAt])
@@index([source])
}
/// لاگ یکپارچهٔ پیامک خروجی (دستی / کمپین / اتوماسیون)
model OutboundSmsLog {
id String @id @default(cuid())
+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],
})