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],
})
+2 -2
View File
@@ -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,
+240
View File
@@ -0,0 +1,240 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import type { SmsWalletSummaryDto } from '@mento/shared';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
const auth = useAuthStore();
const summary = ref<SmsWalletSummaryDto | null>(null);
const loading = ref(false);
const chargeOpen = ref(false);
const warnOpen = ref(false);
const NEGATIVE_KEY = 'mento_sms_negative_warning_shown';
const visible = computed(() => Boolean(summary.value?.billingEnabled));
const balanceLabel = computed(() => {
const n = summary.value?.balanceRials ?? 0;
return toFaDigits(new Intl.NumberFormat('fa-IR').format(n));
});
const toneClass = computed(() => {
const level = summary.value?.balanceLevel;
if (level === 'critical') return 'critical';
if (level === 'warning') return 'warning';
if ((summary.value?.balanceRials ?? 0) < 0) return 'negative';
return 'ok';
});
async function load() {
if (!auth.isAuthenticated || !auth.user?.organizationId) {
summary.value = null;
return;
}
loading.value = true;
try {
const { data } = await api.get<SmsWalletSummaryDto>('/sms-wallet/summary');
summary.value = data;
syncWarn(data);
} catch {
summary.value = null;
} finally {
loading.value = false;
}
}
function syncWarn(data: SmsWalletSummaryDto) {
if (!data.billingEnabled || data.balanceRials >= 0) {
warnOpen.value = false;
try {
sessionStorage.removeItem(NEGATIVE_KEY);
} catch {
/* ignore */
}
return;
}
try {
if (sessionStorage.getItem(NEGATIVE_KEY) === '1') return;
sessionStorage.setItem(NEGATIVE_KEY, '1');
warnOpen.value = true;
} catch {
warnOpen.value = true;
}
}
watch(
() => auth.user?.organizationId,
() => {
void load();
},
);
onMounted(() => {
void load();
});
defineExpose({ refresh: load });
</script>
<template>
<div v-if="visible" class="sms-wallet">
<div class="chip" :class="toneClass" :title="loading ? '…' : undefined">
<span>اعتبار پیامک: {{ balanceLabel }} ریال</span>
<button type="button" class="charge" @click="chargeOpen = true">شارژ</button>
</div>
<div v-if="chargeOpen" class="layer">
<div class="backdrop" @click="chargeOpen = false" />
<div class="modal" role="dialog" aria-label="شارژ پیامک">
<header>
<strong>شارژ اعتبار پیامک</strong>
<button type="button" class="x" @click="chargeOpen = false">×</button>
</header>
<p class="muted">
برای شارژ با پشتیبانی هماهنگ کنید یا فیش واریز را ارسال کنید.
</p>
<template v-if="summary?.chargeInfo">
<dl>
<div v-if="summary.chargeInfo.card">
<dt>کارت</dt>
<dd dir="ltr">{{ summary.chargeInfo.card }}</dd>
</div>
<div v-if="summary.chargeInfo.iban">
<dt>شبا</dt>
<dd dir="ltr">{{ summary.chargeInfo.iban }}</dd>
</div>
<div v-if="summary.chargeInfo.accountHolder">
<dt>صاحب حساب</dt>
<dd>{{ summary.chargeInfo.accountHolder }}</dd>
</div>
</dl>
<p class="instruction">{{ summary.chargeInfo.instruction }}</p>
</template>
<button type="button" class="ok" @click="chargeOpen = false">متوجه شدم</button>
</div>
</div>
<div v-if="warnOpen" class="layer">
<div class="backdrop" @click="warnOpen = false" />
<div class="modal warn" role="dialog" aria-label="هشدار موجودی منفی">
<header>
<strong>موجودی پیامک منفی است</strong>
<button type="button" class="x" @click="warnOpen = false">×</button>
</header>
<p>
اعتبار پیامک محیط شما منفی شده
({{ balanceLabel }} ریال). ارسال تا رسیدن به آستانه قطع یا مسدودسازی دستی ادامه دارد؛ لطفاً
شارژ کنید.
</p>
<button type="button" class="ok" @click="warnOpen = false">متوجه شدم</button>
</div>
</div>
</div>
</template>
<style scoped>
.chip {
display: flex;
align-items: center;
gap: 0.45rem;
white-space: nowrap;
border-radius: 12px;
border: 1px solid #a7f3d0;
background: rgba(236, 253, 245, 0.7);
padding: 0.35rem 0.65rem;
font-size: 0.82rem;
font-weight: 600;
color: #065f46;
}
.chip.warning {
border-color: #fde68a;
background: rgba(255, 251, 235, 0.85);
color: #92400e;
}
.chip.critical,
.chip.negative {
border-color: #fecaca;
background: rgba(254, 242, 242, 0.9);
color: #991b1b;
}
.charge {
border: 0;
background: #059669;
color: #fff;
border-radius: 8px;
padding: 0.2rem 0.55rem;
font: inherit;
font-size: 0.78rem;
cursor: pointer;
}
.chip.critical .charge,
.chip.negative .charge,
.chip.warning .charge {
background: #b45309;
}
.layer {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
}
.backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.35);
}
.modal {
position: relative;
width: min(420px, calc(100vw - 2rem));
background: #fff;
border-radius: 16px;
padding: 1rem 1.1rem;
display: grid;
gap: 0.75rem;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.18);
}
.modal header {
display: flex;
justify-content: space-between;
align-items: center;
}
.x {
border: 0;
background: transparent;
font-size: 1.3rem;
cursor: pointer;
line-height: 1;
}
.muted,
.instruction {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.6;
}
dl {
margin: 0;
display: grid;
gap: 0.45rem;
}
dl > div {
display: flex;
justify-content: space-between;
gap: 0.75rem;
font-size: 0.9rem;
}
dt {
color: var(--muted);
}
.ok {
border: 0;
background: #0f766e;
color: #fff;
border-radius: 10px;
padding: 0.55rem 0.9rem;
font: inherit;
cursor: pointer;
}
</style>
+3
View File
@@ -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 },
];
+3
View File
@@ -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(() => {
<BrandName lang="en" />
</div>
<div class="top-tools">
<SmsHeaderWallet />
<button class="round-btn" type="button" aria-label="اعلان‌ها" @click="toggleNotif">
<UiIcon name="bell" :size="18" />
<span v-if="hasUnread" class="badge">{{ toFaDigits(unreadCount) }}</span>
@@ -805,6 +807,7 @@ onUnmounted(() => {
</form>
<div class="topbar-actions">
<SmsHeaderWallet />
<RouterLink class="round-btn accent" :to="{ name: 'notes' }" title="هوش مصنوعی">
<UiIcon name="sparkle" :size="18" />
</RouterLink>
+5
View File
@@ -61,6 +61,11 @@ export const router = createRouter({
name: 'adm-admins',
component: () => import('@/views/adm/AdmAdminsView.vue'),
},
{
path: 'sms',
name: 'adm-sms',
component: () => import('@/views/adm/AdmSmsView.vue'),
},
],
},
{
+547
View File
@@ -0,0 +1,547 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { PlatformSmsOverviewDto, PlatformSmsTenantRowDto } from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { toFaDigits } from '@/lib/jalali';
const route = useRoute();
const router = useRouter();
const loading = ref(true);
const saving = ref(false);
const error = ref('');
const q = ref('');
const tenants = ref<PlatformSmsTenantRowDto[]>([]);
const overview = ref<PlatformSmsOverviewDto | null>(null);
const selectedId = computed(() => String(route.query.org || ''));
const chargeAmount = ref('');
const chargeNote = ref('');
const priceInput = ref('');
const autoBlockInput = ref('');
const ownUser = ref('');
const ownPass = ref('');
const ownFrom = ref('');
const ownType = ref('0098');
function formatRials(n: number) {
return toFaDigits(new Intl.NumberFormat('fa-IR').format(n)) + ' ریال';
}
async function loadTenants() {
const { data } = await platformApi.get<PlatformSmsTenantRowDto[]>('/platform/sms/tenants', {
params: q.value.trim() ? { q: q.value.trim() } : undefined,
});
tenants.value = data;
}
async function loadOverview(orgId: string) {
const { data } = await platformApi.get<PlatformSmsOverviewDto>(
`/platform/sms/organizations/${orgId}`,
);
overview.value = data;
priceInput.value = String(data.account.pricePerPartRials);
autoBlockInput.value =
data.account.autoBlockBelowRials == null
? ''
: String(data.account.autoBlockBelowRials);
ownUser.value = data.account.ownPanelUsername || '';
ownPass.value = '';
ownFrom.value = data.account.ownPanelFrom || '';
ownType.value = data.account.ownPanelType || '0098';
}
async function load() {
error.value = '';
loading.value = true;
try {
await loadTenants();
if (selectedId.value) {
await loadOverview(selectedId.value);
} else {
overview.value = null;
}
} catch {
error.value = 'بارگذاری پنل پیامک ناموفق بود';
} finally {
loading.value = false;
}
}
function openOrg(id: string) {
void router.replace({ name: 'adm-sms', query: { org: id } });
}
async function runSave(fn: () => Promise<void>) {
saving.value = true;
error.value = '';
try {
await fn();
if (selectedId.value) await loadOverview(selectedId.value);
await loadTenants();
} catch {
error.value = 'ذخیره ناموفق بود';
} finally {
saving.value = false;
}
}
function charge() {
const amount = Number(String(chargeAmount.value).replace(/[^\d-]/g, ''));
if (!selectedId.value || !amount) return;
void runSave(async () => {
await platformApi.post(`/platform/sms/organizations/${selectedId.value}/charge`, {
amountRials: amount,
note: chargeNote.value || undefined,
});
chargeAmount.value = '';
chargeNote.value = '';
});
}
function savePrice() {
const price = Number(priceInput.value);
if (!selectedId.value || !Number.isFinite(price) || price < 0) return;
void runSave(async () => {
await platformApi.put(`/platform/sms/organizations/${selectedId.value}/price`, {
pricePerPartRials: Math.trunc(price),
});
});
}
function toggleBlock() {
if (!overview.value || !selectedId.value) return;
const next = !overview.value.account.isBlocked;
void runSave(async () => {
await platformApi.put(`/platform/sms/organizations/${selectedId.value}/block`, {
isBlocked: next,
});
});
}
function saveAutoBlock() {
if (!selectedId.value) return;
const raw = autoBlockInput.value.trim();
const value = raw === '' ? null : Number(raw.replace(/[^\d-]/g, ''));
if (raw !== '' && !Number.isFinite(value)) return;
void runSave(async () => {
await platformApi.patch(`/platform/sms/organizations/${selectedId.value}`, {
autoBlockBelowRials: value,
});
});
}
function setPanelMode(mode: 'platform' | 'own') {
if (!selectedId.value) return;
void runSave(async () => {
await platformApi.patch(`/platform/sms/organizations/${selectedId.value}`, {
panelMode: mode,
});
});
}
function saveOwnPanel() {
if (!selectedId.value) return;
void runSave(async () => {
await platformApi.patch(`/platform/sms/organizations/${selectedId.value}`, {
panelMode: 'own',
ownPanelType: ownType.value,
ownPanelUsername: ownUser.value || null,
ownPanelFrom: ownFrom.value || null,
...(ownPass.value ? { ownPanelPassword: ownPass.value } : {}),
});
});
}
watch(
() => route.query.org,
() => {
void load();
},
);
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else class="grid">
<aside class="list-card">
<div class="list-head">
<strong>محیط‌ها</strong>
<input v-model="q" type="search" placeholder="جستجو…" @keyup.enter="load" />
</div>
<button
v-for="t in tenants"
:key="t.id"
type="button"
class="tenant"
:class="{ active: t.id === selectedId }"
@click="openOrg(t.id)"
>
<span class="name">{{ t.name }}</span>
<small>
{{ t.sms.billingEnabled ? formatRials(t.sms.balanceRials) : 'پنل اختصاصی' }}
<span v-if="t.isBlocked" class="badge bad">قطع</span>
</small>
</button>
<p v-if="!tenants.length" class="muted">محیطی یافت نشد.</p>
</aside>
<section v-if="overview" class="detail-card">
<header class="detail-head">
<div>
<h2>پنل پیامک</h2>
<p class="muted">
{{ overview.organization.name }} · مدیریت کیف پول، تعرفه و گزارش ارسال
</p>
</div>
<button
type="button"
class="btn"
:class="overview.account.isBlocked ? 'btn-ok' : 'btn-danger'"
:disabled="saving"
@click="toggleBlock"
>
{{ overview.account.isBlocked ? 'وصل کردن ارسال' : 'قطع ارسال' }}
</button>
</header>
<div class="kpis">
<article>
<span>موجودی کیف پول</span>
<strong>{{ formatRials(overview.account.balanceRials) }}</strong>
</article>
<article>
<span>قیمت هر پارت</span>
<strong>{{ formatRials(overview.account.pricePerPartRials) }}</strong>
</article>
<article>
<span>امروز</span>
<strong>{{ toFaDigits(overview.today.messages) }} پیامک</strong>
<small>
{{ toFaDigits(overview.today.parts) }} پارت ·
{{ formatRials(overview.today.amountRials) }}
</small>
</article>
<article>
<span>ماه جاری</span>
<strong>{{ toFaDigits(overview.month.messages) }} پیامک</strong>
<small>
{{ toFaDigits(overview.month.parts) }} پارت ·
{{ formatRials(overview.month.amountRials) }}
</small>
</article>
</div>
<div class="panels">
<div class="panel">
<h3>شارژ</h3>
<input v-model="chargeAmount" type="text" placeholder="مبلغ ریال" />
<input v-model="chargeNote" type="text" placeholder="یادداشت (اختیاری)" />
<button type="button" class="btn btn-primary" :disabled="saving" @click="charge">
ثبت شارژ
</button>
</div>
<div class="panel">
<h3>تعرفه</h3>
<input v-model="priceInput" type="text" placeholder="قیمت هر پارت (ریال)" />
<button type="button" class="btn" :disabled="saving" @click="savePrice">
ذخیره تعرفه
</button>
</div>
<div class="panel">
<h3>آستانه قطع خودکار</h3>
<p class="hint">
معمولاً منفی؛ مثلاً ‎-500000‎ یعنی تا ۵۰۰ هزار ریال بدهی ارسال مجاز است. خالی =
فقط کلید دستی.
</p>
<input v-model="autoBlockInput" type="text" placeholder="مثلاً -500000" />
<button type="button" class="btn" :disabled="saving" @click="saveAutoBlock">
ذخیره آستانه
</button>
</div>
<div class="panel">
<h3>سامانه پیامک</h3>
<div class="mode-row">
<button
type="button"
class="btn"
:class="{ 'btn-primary': overview.account.panelMode === 'platform' }"
:disabled="saving"
@click="setPanelMode('platform')"
>
سامانه ما
</button>
<button
type="button"
class="btn"
:class="{ 'btn-primary': overview.account.panelMode === 'own' }"
:disabled="saving"
@click="setPanelMode('own')"
>
پنل اختصاصی مشتری
</button>
</div>
<template v-if="overview.account.panelMode === 'own'">
<p class="hint">با پنل اختصاصی، از کیف ما کسر و لاگ billing ثبت نمی‌شود.</p>
<select v-model="ownType">
<option value="0098">۰۰۹۸</option>
<option value="trez">ترز</option>
</select>
<input v-model="ownUser" type="text" placeholder="نام کاربری" />
<input v-model="ownPass" type="password" placeholder="رمز (خالی = بدون تغییر)" />
<input v-model="ownFrom" type="text" placeholder="شماره فرستنده" />
<button type="button" class="btn" :disabled="saving" @click="saveOwnPanel">
ذخیره پنل اختصاصی
</button>
</template>
</div>
</div>
<div class="tables">
<div>
<h3>آخرین ارسال‌ها</h3>
<table>
<thead>
<tr>
<th>گیرنده</th>
<th>پارت</th>
<th>مبلغ</th>
<th>منبع</th>
</tr>
</thead>
<tbody>
<tr v-for="row in overview.sendLogs" :key="row.id">
<td dir="ltr">{{ row.receiver }}</td>
<td>{{ toFaDigits(row.parts) }}</td>
<td>{{ formatRials(row.totalAmountRials) }}</td>
<td>{{ row.source || '—' }}</td>
</tr>
</tbody>
</table>
<p v-if="!overview.sendLogs.length" class="muted">هنوز لاگی نیست.</p>
</div>
<div>
<h3>گردش کیف</h3>
<table>
<thead>
<tr>
<th>نوع</th>
<th>مبلغ</th>
<th>مانده</th>
</tr>
</thead>
<tbody>
<tr v-for="row in overview.ledgers" :key="row.id">
<td>{{ row.type }}</td>
<td>{{ formatRials(row.amountRials) }}</td>
<td>{{ formatRials(row.balanceAfterRials) }}</td>
</tr>
</tbody>
</table>
<p v-if="!overview.ledgers.length" class="muted">گردشی ثبت نشده.</p>
</div>
</div>
</section>
<section v-else class="detail-card empty">
<p class="muted">یک محیط را از فهرست انتخاب کنید.</p>
</section>
</div>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
}
.muted {
color: var(--muted);
}
.error {
color: #b91c1c;
}
.grid {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
.list-card,
.detail-card {
background: #fff;
border: 1px solid var(--line);
border-radius: 16px;
padding: 1rem;
}
.list-head {
display: grid;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.list-head input,
.panel input,
.panel select {
width: 100%;
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.55rem 0.7rem;
font: inherit;
}
.tenant {
width: 100%;
text-align: start;
border: 1px solid transparent;
background: #f8fafc;
border-radius: 12px;
padding: 0.65rem 0.75rem;
display: grid;
gap: 0.2rem;
margin-bottom: 0.4rem;
cursor: pointer;
}
.tenant.active {
border-color: #14b8a6;
background: #f0fdfa;
}
.tenant .name {
font-weight: 700;
}
.tenant small {
color: var(--muted);
display: flex;
gap: 0.4rem;
align-items: center;
}
.badge.bad {
background: #fee2e2;
color: #b91c1c;
border-radius: 999px;
padding: 0.05rem 0.45rem;
font-size: 0.75rem;
}
.detail-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: start;
margin-bottom: 1rem;
}
.detail-head h2 {
margin: 0 0 0.25rem;
}
.kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.65rem;
margin-bottom: 1rem;
}
.kpis article {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.75rem;
display: grid;
gap: 0.25rem;
background: #f8fafc;
}
.kpis span,
.kpis small {
color: var(--muted);
font-size: 0.82rem;
}
.kpis strong {
font-size: 1.05rem;
}
.panels {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.panel {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.85rem;
display: grid;
gap: 0.5rem;
}
.panel h3 {
margin: 0;
font-size: 0.95rem;
}
.hint {
margin: 0;
color: var(--muted);
font-size: 0.8rem;
line-height: 1.5;
}
.mode-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.btn {
border: 1px solid var(--line);
background: #fff;
border-radius: 10px;
padding: 0.5rem 0.8rem;
cursor: pointer;
font: inherit;
}
.btn-primary {
background: #0f766e;
border-color: #0f766e;
color: #fff;
}
.btn-danger {
background: #b91c1c;
border-color: #b91c1c;
color: #fff;
}
.btn-ok {
background: #15803d;
border-color: #15803d;
color: #fff;
}
.tables {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
th,
td {
border-bottom: 1px solid var(--line);
padding: 0.4rem 0.25rem;
text-align: start;
}
.empty {
min-height: 240px;
display: grid;
place-items: center;
}
@media (max-width: 960px) {
.grid,
.kpis,
.panels,
.tables {
grid-template-columns: 1fr;
}
}
</style>
+103
View File
@@ -3866,8 +3866,23 @@ export const MESSAGE_AUTOMATION_FIELDS = [
{ token: "{شرکت}", label: "نام شرکت" },
] as const;
/** First-part Persian SMS limit (legacy UI estimate). Prefer countSmsParts(). */
export const MESSAGE_AUTOMATION_SMS_SEGMENT = 70;
/** Hamkar-compatible Persian multipart limits: 70 / 64 / 67 */
export function countSmsParts(text: string): number {
const length = [...String(text ?? "").trim()].length;
if (length <= 0) return 0;
const first = 70;
const second = 64;
const rest = 67;
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);
}
export const MESSAGE_AUTOMATION_CHANNEL_LABELS: Record<
MessageAutomationChannel,
string
@@ -5734,6 +5749,94 @@ export interface TreasuryDashboardDto {
upcomingPayables: FinancialDocumentDto[];
}
/* ───────── کیف‌پول پیامک (صورتحساب محیط) ───────── */
export type SmsBalanceLevel = "healthy" | "warning" | "critical" | "unknown";
export type SmsPanelMode = "platform" | "own";
export interface SmsWalletSummaryDto {
billingEnabled: boolean;
balanceRials: number;
pricePerPartRials: number;
isBlocked: boolean;
autoBlocked: boolean;
balanceLevel: SmsBalanceLevel;
panelMode: SmsPanelMode | string;
chargeInfo: SmsChargeInfoDto | null;
}
export interface SmsChargeInfoDto {
card: string;
iban: string;
accountHolder: string;
instruction: string;
}
export interface PlatformSmsTenantRowDto {
id: string;
name: string;
slug: string;
isActive: boolean;
isBlocked: boolean;
panelMode: string;
sms: {
balanceRials: number;
pricePerPartRials: number;
autoBlockBelowRials: number | null;
balanceLevel: SmsBalanceLevel;
billingEnabled: boolean;
};
}
export interface PlatformSmsOverviewDto {
organization: {
id: string;
name: string;
slug: string;
isActive: boolean;
};
account: {
id: string;
organizationId: string;
balanceRials: number;
pricePerPartRials: number;
isBlocked: boolean;
autoBlockBelowRials: number | null;
alertCriticalRials: number;
alertWarningRials: number;
panelMode: SmsPanelMode;
ownPanelType: string;
ownPanelUsername: string | null;
ownPanelFrom: string | null;
hasOwnPanelPassword: boolean;
balanceLevel: SmsBalanceLevel;
billingEnabled: boolean;
};
chargeInfo: SmsChargeInfoDto;
today: { messages: number; parts: number; amountRials: number };
month: { messages: number; parts: number; amountRials: number };
ledgers: Array<{
id: string;
type: string;
amountRials: number;
balanceAfterRials: number;
parts: number | null;
note: string | null;
createdAt: string;
}>;
sendLogs: Array<{
id: string;
receiver: string;
body: string;
parts: number;
totalAmountRials: number;
provider: string | null;
source: string | null;
isAlert: boolean;
createdAt: string;
}>;
}
/* ───────── باشگاه امتیازات (گیمیفیکیشن کارکنان) ───────── */
export * from "./gamification";