From f5d68d83fc5d38ea15fbfd0eafd41798d4eae347 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Mon, 19 Oct 2026 15:55:48 +0330 Subject: [PATCH] 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. --- .../20261019160000_sms_billing/migration.sql | 90 +++ apps/api/prisma/schema.prisma | 73 +++ apps/api/src/app.module.ts | 2 + apps/api/src/attendance/attendance.module.ts | 3 +- apps/api/src/attendance/attendance.service.ts | 11 +- apps/api/src/auth/auth.module.ts | 2 - apps/api/src/auth/auth.service.ts | 13 +- .../api/src/automations/automations.module.ts | 3 +- .../src/automations/automations.service.ts | 24 +- apps/api/src/common/sms.service.ts | 37 +- apps/api/src/contacts/contacts.module.ts | 3 +- apps/api/src/contacts/people.service.ts | 34 +- .../src/finance/cheque-reminders.service.ts | 21 +- apps/api/src/finance/finance.module.ts | 3 +- apps/api/src/leads/leads.module.ts | 3 +- apps/api/src/leads/leads.service.ts | 19 +- apps/api/src/loyalty/loyalty.module.ts | 3 +- apps/api/src/loyalty/loyalty.service.ts | 21 +- .../message-automations.module.ts | 3 +- .../message-automations.service.ts | 24 +- .../src/notifications/notifications.module.ts | 3 +- .../notifications/notifications.service.ts | 18 +- apps/api/src/portal/portal.module.ts | 3 +- apps/api/src/portal/portal.service.ts | 21 +- apps/api/src/sms-billing/dto.ts | 111 ++++ .../api/src/sms-billing/org-sms.controller.ts | 27 + .../sms-billing/platform-sms.controller.ts | 83 +++ .../api/src/sms-billing/sms-billing.module.ts | 14 + .../src/sms-billing/sms-gateway.service.ts | 145 +++++ apps/api/src/sms-billing/sms-segment.util.ts | 14 + .../api/src/sms-billing/sms-wallet.service.ts | 424 ++++++++++++++ .../src/sms-campaigns/sms-campaigns.module.ts | 3 +- .../sms-campaigns/sms-campaigns.service.ts | 14 +- .../treasury/treasury-receivables.service.ts | 15 +- apps/api/src/treasury/treasury.module.ts | 2 - apps/web/src/components/SmsComposeModal.vue | 4 +- apps/web/src/components/SmsHeaderWallet.vue | 240 ++++++++ apps/web/src/layouts/AdmLayout.vue | 3 + apps/web/src/layouts/AppLayout.vue | 3 + apps/web/src/router/index.ts | 5 + apps/web/src/views/adm/AdmSmsView.vue | 547 ++++++++++++++++++ packages/shared/src/index.ts | 103 ++++ 42 files changed, 2073 insertions(+), 121 deletions(-) create mode 100644 apps/api/prisma/migrations/20261019160000_sms_billing/migration.sql create mode 100644 apps/api/src/sms-billing/dto.ts create mode 100644 apps/api/src/sms-billing/org-sms.controller.ts create mode 100644 apps/api/src/sms-billing/platform-sms.controller.ts create mode 100644 apps/api/src/sms-billing/sms-billing.module.ts create mode 100644 apps/api/src/sms-billing/sms-gateway.service.ts create mode 100644 apps/api/src/sms-billing/sms-segment.util.ts create mode 100644 apps/api/src/sms-billing/sms-wallet.service.ts create mode 100644 apps/web/src/components/SmsHeaderWallet.vue create mode 100644 apps/web/src/views/adm/AdmSmsView.vue diff --git a/apps/api/prisma/migrations/20261019160000_sms_billing/migration.sql b/apps/api/prisma/migrations/20261019160000_sms_billing/migration.sql new file mode 100644 index 0000000..714ceb3 --- /dev/null +++ b/apps/api/prisma/migrations/20261019160000_sms_billing/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 57214c6..4160431 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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()) diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index f6ca709..a99fc5b 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/attendance/attendance.module.ts b/apps/api/src/attendance/attendance.module.ts index 5000b26..63a0ac0 100644 --- a/apps/api/src/attendance/attendance.module.ts +++ b/apps/api/src/attendance/attendance.module.ts @@ -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 {} diff --git a/apps/api/src/attendance/attendance.service.ts b/apps/api/src/attendance/attendance.service.ts index 4d25fdf..85a9a2c 100644 --- a/apps/api/src/attendance/attendance.service.ts +++ b/apps/api/src/attendance/attendance.service.ts @@ -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', + }); } } diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index c161805..45fed1d 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -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, diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 054df42..6527d42 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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( 'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید', diff --git a/apps/api/src/automations/automations.module.ts b/apps/api/src/automations/automations.module.ts index 9551e10..106d878 100644 --- a/apps/api/src/automations/automations.module.ts +++ b/apps/api/src/automations/automations.module.ts @@ -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 {} diff --git a/apps/api/src/automations/automations.service.ts b/apps/api/src/automations/automations.service.ts index 1b371cd..844cd31 100644 --- a/apps/api/src/automations/automations.service.ts +++ b/apps/api/src/automations/automations.service.ts @@ -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 { @@ -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'}`); } diff --git a/apps/api/src/common/sms.service.ts b/apps/api/src/common/sms.service.ts index 51d6eea..33f7ecb 100644 --- a/apps/api/src/common/sms.service.ts +++ b/apps/api/src/common/sms.service.ts @@ -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 { - const username = this.config.get('SMS_USERNAME'); - const password = this.getPassword(); - const sender = this.config.get('SMS_SENDER'); + buildOtpText(code: string): string { + const template = + this.config.get('SMS_OTP_TEMPLATE')?.trim() || + `کد ورود منتکت: {code}`; + return template.replace('{code}', code); + } + + async sendSmsDetailed( + phone: string, + text: string, + override?: SmsTransportOverride, + ): Promise { + const username = override?.username || this.config.get('SMS_USERNAME'); + const password = override?.password || this.getPassword(); + const sender = override?.sender || this.config.get('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); diff --git a/apps/api/src/contacts/contacts.module.ts b/apps/api/src/contacts/contacts.module.ts index d661135..c374471 100644 --- a/apps/api/src/contacts/contacts.module.ts +++ b/apps/api/src/contacts/contacts.module.ts @@ -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 {} diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index d029ea3..3967729 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -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 }; diff --git a/apps/api/src/finance/cheque-reminders.service.ts b/apps/api/src/finance/cheque-reminders.service.ts index 4d30e64..f6896c4 100644 --- a/apps/api/src/finance/cheque-reminders.service.ts +++ b/apps/api/src/finance/cheque-reminders.service.ts @@ -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', + }); } } } diff --git a/apps/api/src/finance/finance.module.ts b/apps/api/src/finance/finance.module.ts index 0211522..519218e 100644 --- a/apps/api/src/finance/finance.module.ts +++ b/apps/api/src/finance/finance.module.ts @@ -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 {} diff --git a/apps/api/src/leads/leads.module.ts b/apps/api/src/leads/leads.module.ts index 683b8df..4f6799b 100644 --- a/apps/api/src/leads/leads.module.ts +++ b/apps/api/src/leads/leads.module.ts @@ -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 {} diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index 57de40b..560a438 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -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); diff --git a/apps/api/src/loyalty/loyalty.module.ts b/apps/api/src/loyalty/loyalty.module.ts index 3080236..d432750 100644 --- a/apps/api/src/loyalty/loyalty.module.ts +++ b/apps/api/src/loyalty/loyalty.module.ts @@ -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 {} diff --git a/apps/api/src/loyalty/loyalty.service.ts b/apps/api/src/loyalty/loyalty.service.ts index 0eb91cd..11f46f5 100644 --- a/apps/api/src/loyalty/loyalty.service.ts +++ b/apps/api/src/loyalty/loyalty.service.ts @@ -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 { @@ -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; } diff --git a/apps/api/src/message-automations/message-automations.module.ts b/apps/api/src/message-automations/message-automations.module.ts index 82b4f91..f158284 100644 --- a/apps/api/src/message-automations/message-automations.module.ts +++ b/apps/api/src/message-automations/message-automations.module.ts @@ -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 {} diff --git a/apps/api/src/message-automations/message-automations.service.ts b/apps/api/src/message-automations/message-automations.service.ts index e4ff3bd..00f58e2 100644 --- a/apps/api/src/message-automations/message-automations.service.ts +++ b/apps/api/src/message-automations/message-automations.service.ts @@ -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, diff --git a/apps/api/src/notifications/notifications.module.ts b/apps/api/src/notifications/notifications.module.ts index bf6d7fb..e311df4 100644 --- a/apps/api/src/notifications/notifications.module.ts +++ b/apps/api/src/notifications/notifications.module.ts @@ -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 {} diff --git a/apps/api/src/notifications/notifications.service.ts b/apps/api/src/notifications/notifications.service.ts index ba50a6c..841ddfc 100644 --- a/apps/api/src/notifications/notifications.service.ts +++ b/apps/api/src/notifications/notifications.service.ts @@ -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'}`); } diff --git a/apps/api/src/portal/portal.module.ts b/apps/api/src/portal/portal.module.ts index d3de2e2..20b026d 100644 --- a/apps/api/src/portal/portal.module.ts +++ b/apps/api/src/portal/portal.module.ts @@ -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 {} diff --git a/apps/api/src/portal/portal.service.ts b/apps/api/src/portal/portal.service.ts index 3c9b62e..ba7bea7 100644 --- a/apps/api/src/portal/portal.service.ts +++ b/apps/api/src/portal/portal.service.ts @@ -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 { @@ -99,16 +99,13 @@ export class PortalService { (this.config.get('NODE_ENV') !== 'production' && this.config.get('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}`); } diff --git a/apps/api/src/sms-billing/dto.ts b/apps/api/src/sms-billing/dto.ts new file mode 100644 index 0000000..047eb4c --- /dev/null +++ b/apps/api/src/sms-billing/dto.ts @@ -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; +} diff --git a/apps/api/src/sms-billing/org-sms.controller.ts b/apps/api/src/sms-billing/org-sms.controller.ts new file mode 100644 index 0000000..63bd874 --- /dev/null +++ b/apps/api/src/sms-billing/org-sms.controller.ts @@ -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, + }; + } +} diff --git a/apps/api/src/sms-billing/platform-sms.controller.ts b/apps/api/src/sms-billing/platform-sms.controller.ts new file mode 100644 index 0000000..f905e1b --- /dev/null +++ b/apps/api/src/sms-billing/platform-sms.controller.ts @@ -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)); + } +} diff --git a/apps/api/src/sms-billing/sms-billing.module.ts b/apps/api/src/sms-billing/sms-billing.module.ts new file mode 100644 index 0000000..af52c66 --- /dev/null +++ b/apps/api/src/sms-billing/sms-billing.module.ts @@ -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 {} diff --git a/apps/api/src/sms-billing/sms-gateway.service.ts b/apps/api/src/sms-billing/sms-gateway.service.ts new file mode 100644 index 0000000..fda6a04 --- /dev/null +++ b/apps/api/src/sms-billing/sms-gateway.service.ts @@ -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 { + 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 { + 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 }; + } +} diff --git a/apps/api/src/sms-billing/sms-segment.util.ts b/apps/api/src/sms-billing/sms-segment.util.ts new file mode 100644 index 0000000..0c1c574 --- /dev/null +++ b/apps/api/src/sms-billing/sms-segment.util.ts @@ -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); +} diff --git a/apps/api/src/sms-billing/sms-wallet.service.ts b/apps/api/src/sms-billing/sms-wallet.service.ts new file mode 100644 index 0000000..185949e --- /dev/null +++ b/apps/api/src/sms-billing/sms-wallet.service.ts @@ -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 { + 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); + } +} diff --git a/apps/api/src/sms-campaigns/sms-campaigns.module.ts b/apps/api/src/sms-campaigns/sms-campaigns.module.ts index d9d825c..bc3ef8c 100644 --- a/apps/api/src/sms-campaigns/sms-campaigns.module.ts +++ b/apps/api/src/sms-campaigns/sms-campaigns.module.ts @@ -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 {} diff --git a/apps/api/src/sms-campaigns/sms-campaigns.service.ts b/apps/api/src/sms-campaigns/sms-campaigns.service.ts index 36cead6..75a6f5d 100644 --- a/apps/api/src/sms-campaigns/sms-campaigns.service.ts +++ b/apps/api/src/sms-campaigns/sms-campaigns.service.ts @@ -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, diff --git a/apps/api/src/treasury/treasury-receivables.service.ts b/apps/api/src/treasury/treasury-receivables.service.ts index 7841657..38378c1 100644 --- a/apps/api/src/treasury/treasury-receivables.service.ts +++ b/apps/api/src/treasury/treasury-receivables.service.ts @@ -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 { @@ -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({ diff --git a/apps/api/src/treasury/treasury.module.ts b/apps/api/src/treasury/treasury.module.ts index 40d07f8..c48cca6 100644 --- a/apps/api/src/treasury/treasury.module.ts +++ b/apps/api/src/treasury/treasury.module.ts @@ -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], }) diff --git a/apps/web/src/components/SmsComposeModal.vue b/apps/web/src/components/SmsComposeModal.vue index 3688336..e3c2433 100644 --- a/apps/web/src/components/SmsComposeModal.vue +++ b/apps/web/src/components/SmsComposeModal.vue @@ -2,7 +2,7 @@ import { computed, ref, watch } from 'vue'; import axios from 'axios'; import type { OutboundSmsHistoryResponse, OutboundSmsLogDto } from '@mento/shared'; -import { MESSAGE_AUTOMATION_FIELDS, MESSAGE_AUTOMATION_SMS_SEGMENT } from '@mento/shared'; +import { MESSAGE_AUTOMATION_FIELDS, countSmsParts } from '@mento/shared'; import { api } from '@/lib/api'; import { formatJalaliDay, formatJalaliNumeric, toFaDigits } from '@/lib/jalali'; import { UiButton, UiModal } from '@/components/ui'; @@ -46,7 +46,7 @@ const historyLoading = ref(false); const preview = computed(() => fill(text.value)); const count = computed(() => text.value.length); -const parts = computed(() => (count.value === 0 ? 0 : Math.ceil(count.value / MESSAGE_AUTOMATION_SMS_SEGMENT))); +const parts = computed(() => countSmsParts(text.value)); watch( () => props.open, diff --git a/apps/web/src/components/SmsHeaderWallet.vue b/apps/web/src/components/SmsHeaderWallet.vue new file mode 100644 index 0000000..3420214 --- /dev/null +++ b/apps/web/src/components/SmsHeaderWallet.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/apps/web/src/layouts/AdmLayout.vue b/apps/web/src/layouts/AdmLayout.vue index 2e2d4b8..2aec556 100644 --- a/apps/web/src/layouts/AdmLayout.vue +++ b/apps/web/src/layouts/AdmLayout.vue @@ -22,6 +22,8 @@ const pageTitle = computed(() => { return 'کاربران'; case 'adm-admins': return 'ادمین‌ها'; + case 'adm-sms': + return 'پیامک'; default: return 'سوپرادمین'; } @@ -31,6 +33,7 @@ const tabs = [ { name: 'adm-dashboard', label: 'خانه', icon: 'home' as const }, { name: 'adm-organizations', label: 'محیط‌ها', icon: 'building' as const }, { name: 'adm-users', label: 'کاربران', icon: 'users' as const }, + { name: 'adm-sms', label: 'پیامک', icon: 'mail' as const }, { name: 'adm-admins', label: 'ادمین‌ها', icon: 'lock' as const }, ]; diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index d38c4e5..306528c 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -10,6 +10,7 @@ import QuickCreate from '@/components/QuickCreate.vue'; import OrgSwitcher from '@/components/OrgSwitcher.vue'; import NavHubPanel from '@/components/nav/NavHubPanel.vue'; import GamificationToaster from '@/components/gamification/GamificationToaster.vue'; +import SmsHeaderWallet from '@/components/SmsHeaderWallet.vue'; import { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs'; import type { ActivitySummaryDto, @@ -769,6 +770,7 @@ onUnmounted(() => {
+