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[] webhookEndpoints WebhookEndpoint[]
smsCampaigns SmsCampaign[] smsCampaigns SmsCampaign[]
outboundSmsLogs OutboundSmsLog[] outboundSmsLogs OutboundSmsLog[]
smsAccount SmsAccount?
financialAccounts FinancialAccount[] financialAccounts FinancialAccount[]
financialCategories FinancialCategory[] financialCategories FinancialCategory[]
financialDocuments FinancialDocument[] financialDocuments FinancialDocument[]
@@ -2290,6 +2291,78 @@ model FinancialInstallment {
@@index([dueDate]) @@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 { model OutboundSmsLog {
id String @id @default(cuid()) 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 { DeletionsModule } from './deletions/deletions.module';
import { OutboundSmsModule } from './outbound-sms/outbound-sms.module'; import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
import { BehaviorModule } from './behavior/behavior.module'; import { BehaviorModule } from './behavior/behavior.module';
import { SmsBillingModule } from './sms-billing/sms-billing.module';
@Module({ @Module({
imports: [ imports: [
@@ -63,6 +64,7 @@ import { BehaviorModule } from './behavior/behavior.module';
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
AiModule, AiModule,
PrismaModule, PrismaModule,
SmsBillingModule,
OutboundSmsModule, OutboundSmsModule,
StorageModule, StorageModule,
HealthModule, HealthModule,
+1 -2
View File
@@ -1,9 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { SmsService } from '../common/sms.service';
import { AttendanceService } from './attendance.service'; import { AttendanceService } from './attendance.service';
@Module({ @Module({
providers: [AttendanceService, SmsService], providers: [AttendanceService],
exports: [AttendanceService], exports: [AttendanceService],
}) })
export class AttendanceModule {} export class AttendanceModule {}
@@ -1,8 +1,8 @@
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { SmsService } from '../common/sms.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { import {
loggedInAfterCutoff, loggedInAfterCutoff,
tehranClock, tehranClock,
@@ -29,7 +29,7 @@ export class AttendanceService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly sms: SmsService, private readonly sms: SmsGatewayService,
) {} ) {}
async recordLogin(organizationId: string, userId: string, role: string, at = new Date()) { async recordLogin(organizationId: string, userId: string, role: string, at = new Date()) {
@@ -185,6 +185,11 @@ export class AttendanceService {
: input.kind === 'forced_logout' : input.kind === 'forced_logout'
? `${name} در ساعت تعیین‌شده از منتکت خارج شد. ساعت ${clock}` ? `${name} در ساعت تعیین‌شده از منتکت خارج شد. ساعت ${clock}`
: `${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 { JwtStrategy } from './jwt.strategy';
import { RolesGuard } from './roles.guard'; import { RolesGuard } from './roles.guard';
import { ViewAsReadOnlyInterceptor } from './view-as-readonly.interceptor'; import { ViewAsReadOnlyInterceptor } from './view-as-readonly.interceptor';
import { SmsService } from '../common/sms.service';
@Module({ @Module({
imports: [ imports: [
@@ -37,7 +36,6 @@ import { SmsService } from '../common/sms.service';
AuthService, AuthService,
JwtStrategy, JwtStrategy,
RolesGuard, RolesGuard,
SmsService,
{ {
provide: APP_INTERCEPTOR, provide: APP_INTERCEPTOR,
useClass: ViewAsReadOnlyInterceptor, useClass: ViewAsReadOnlyInterceptor,
+7 -6
View File
@@ -22,7 +22,7 @@ import { Prisma } from '@prisma/client';
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */ /** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
import type {} from 'multer'; import type {} from 'multer';
import { PrismaService } from '../prisma/prisma.service'; 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 { LocalFileStorage } from '../storage/local-file.storage';
import { MembershipsService } from '../memberships/memberships.service'; import { MembershipsService } from '../memberships/memberships.service';
import { BillingService } from '../billing/billing.service'; import { BillingService } from '../billing/billing.service';
@@ -107,7 +107,7 @@ export class AuthService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly jwt: JwtService, private readonly jwt: JwtService,
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly sms: SmsService, private readonly sms: SmsGatewayService,
private readonly files: LocalFileStorage, private readonly files: LocalFileStorage,
private readonly memberships: MembershipsService, private readonly memberships: MembershipsService,
private readonly billing: BillingService, private readonly billing: BillingService,
@@ -174,7 +174,7 @@ export class AuthService {
if (emailOtpEnabled && emailCode) { if (emailOtpEnabled && emailCode) {
await this.dispatchEmailOtp(email, emailCode, returnOtp); await this.dispatchEmailOtp(email, emailCode, returnOtp);
} }
await this.dispatchMobileOtp(mobile, mobileCode, returnOtp); await this.dispatchMobileOtp(mobile, mobileCode, returnOtp, null);
return { return {
challengeId: challenge.id, 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 { return {
challengeId: challenge.id, challengeId: challenge.id,
@@ -875,9 +875,10 @@ export class AuthService {
mobile: string, mobile: string,
code: string, code: string,
returnOtp: boolean, returnOtp: boolean,
organizationId: string | null,
) { ) {
if (this.sms.isConfigured()) { if (this.sms.isConfigured() || organizationId) {
const sent = await this.sms.sendOtpSms(mobile, code); const sent = await this.sms.sendOtp(organizationId, mobile, code);
if (!sent && !returnOtp) { if (!sent && !returnOtp) {
throw new BadRequestException( throw new BadRequestException(
'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید', 'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید',
@@ -2,14 +2,13 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { LoyaltyModule } from '../loyalty/loyalty.module'; import { LoyaltyModule } from '../loyalty/loyalty.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { SmsService } from '../common/sms.service';
import { AutomationsController } from './automations.controller'; import { AutomationsController } from './automations.controller';
import { AutomationsService } from './automations.service'; import { AutomationsService } from './automations.service';
@Module({ @Module({
imports: [AuthModule, NotificationsModule, LoyaltyModule], imports: [AuthModule, NotificationsModule, LoyaltyModule],
controllers: [AutomationsController], controllers: [AutomationsController],
providers: [AutomationsService, SmsService], providers: [AutomationsService],
exports: [AutomationsService], exports: [AutomationsService],
}) })
export class AutomationsModule {} export class AutomationsModule {}
@@ -32,7 +32,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { LoyaltyService } from '../loyalty/loyalty.service'; import { LoyaltyService } from '../loyalty/loyalty.service';
import { SmsService } from '../common/sms.service'; import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { import {
formatFormAnswers, formatFormAnswers,
pickRoundRobin, pickRoundRobin,
@@ -79,7 +79,7 @@ export class AutomationsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly loyalty: LoyaltyService, private readonly loyalty: LoyaltyService,
private readonly sms: SmsService, private readonly sms: SmsGatewayService,
) {} ) {}
async list(organizationId: string): Promise<AutomationRuleDto[]> { async list(organizationId: string): Promise<AutomationRuleDto[]> {
@@ -537,7 +537,11 @@ export class AutomationsService {
select: { mobile: true, phone: true }, select: { mobile: true, phone: true },
}) })
: null; : null;
await this.sendAutomationSms(person?.mobile || person?.phone, smsText); await this.sendAutomationSms(
ctx.organizationId,
person?.mobile || person?.phone,
smsText,
);
} }
return; return;
} }
@@ -570,11 +574,12 @@ export class AutomationsService {
}, },
select: { mobile: true }, select: { mobile: true },
}); });
await this.sendAutomationSms(user?.mobile, smsText); await this.sendAutomationSms(ctx.organizationId, user?.mobile, smsText);
} }
} }
private async sendAutomationSms( private async sendAutomationSms(
organizationId: string,
mobile: string | null | undefined, mobile: string | null | undefined,
text: string, text: string,
) { ) {
@@ -582,11 +587,12 @@ export class AutomationsService {
this.logger.warn('Automation SMS skipped: no mobile'); this.logger.warn('Automation SMS skipped: no mobile');
return; return;
} }
if (!this.sms.isConfigured()) { const result = await this.sms.send({
this.logger.warn('Automation SMS skipped: SMS not configured'); organizationId,
return; phone: mobile,
} text,
const result = await this.sms.sendSmsDetailed(mobile, text); source: 'automation',
});
if (!result.ok) { if (!result.ok) {
this.logger.warn(`Automation SMS failed: ${result.reason ?? 'unknown'}`); 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 = { export type SmsSendResult = {
ok: boolean; ok: boolean;
transport: 'trez' | 'soap' | 'link'; blocked?: boolean;
transport: 'trez' | 'soap' | 'link' | 'none';
reason?: string; 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() @Injectable()
@@ -50,17 +61,29 @@ export class SmsService {
return result.ok; return result.ok;
} }
async sendSmsDetailed(phone: string, text: string): Promise<SmsSendResult> { buildOtpText(code: string): string {
const username = this.config.get<string>('SMS_USERNAME'); const template =
const password = this.getPassword(); this.config.get<string>('SMS_OTP_TEMPLATE')?.trim() ||
const sender = this.config.get<string>('SMS_SENDER'); `کد ورود منتکت: {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) { 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 to = this.normalizePhoneForSms(phone);
const provider = this.resolveProvider(); const provider =
override?.provider ?? this.resolveProvider();
if (provider === 'trez') { if (provider === 'trez') {
return this.sendViaTrez(username, password, sender, to, text); 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 { PeopleController } from './people.controller';
import { PeopleService } from './people.service'; import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service'; import { ContactsExcelService } from './contacts-excel.service';
import { SmsService } from '../common/sms.service';
import { BehaviorModule } from '../behavior/behavior.module'; import { BehaviorModule } from '../behavior/behavior.module';
@Module({ @Module({
@@ -20,7 +19,7 @@ import { BehaviorModule } from '../behavior/behavior.module';
BehaviorModule, BehaviorModule,
], ],
controllers: [PeopleController, CompaniesController], controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService, SmsService], providers: [PeopleService, CompaniesService, ContactsExcelService],
exports: [PeopleService, CompaniesService], exports: [PeopleService, CompaniesService],
}) })
export class ContactsModule {} 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 { WebhooksService } from '../public-api/webhooks.service';
import { GamificationService } from '../gamification/gamification.service'; import { GamificationService } from '../gamification/gamification.service';
import { parseBirthDate } from '../common/excel'; 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 { selectMonthBirthdays } from './birthday-month';
import { renderSmsText } from '../common/sms-text'; import { renderSmsText } from '../common/sms-text';
import { normalizeTags } from '../common/tags'; import { normalizeTags } from '../common/tags';
@@ -131,7 +131,7 @@ export class PeopleService implements OnModuleInit {
private readonly messageAutomations: MessageAutomationsService, private readonly messageAutomations: MessageAutomationsService,
private readonly webhooks: WebhooksService, private readonly webhooks: WebhooksService,
private readonly game: GamificationService, private readonly game: GamificationService,
private readonly sms: SmsService, private readonly sms: SmsGatewayService,
private readonly outboundSms: OutboundSmsLogService, private readonly outboundSms: OutboundSmsLogService,
private readonly behaviorAccess: BehaviorAccessService, private readonly behaviorAccess: BehaviorAccessService,
) {} ) {}
@@ -662,7 +662,12 @@ export class PeopleService implements OnModuleInit {
company: person.company?.name ?? '', company: person.company?.name ?? '',
}); });
if (!message) throw new BadRequestException('متن پیامک خالی است.'); 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({ await this.outboundSms.record({
organizationId: actor.organizationId, organizationId: actor.organizationId,
personId: person.id, personId: person.id,
@@ -675,9 +680,11 @@ export class PeopleService implements OnModuleInit {
}); });
if (!result.ok) { if (!result.ok) {
throw new ServiceUnavailableException( throw new ServiceUnavailableException(
result.reason === 'SMS not configured' result.blocked
? 'ارسال پیامک روی سرور فعال نیست.' ? 'ارسال پیامک به‌خاطر محدودیت اعتبار مسدود است.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.', : result.reason === 'SMS not configured'
? 'ارسال پیامک روی سرور فعال نیست.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
); );
} }
return { ok: true }; return { ok: true };
@@ -698,7 +705,12 @@ export class PeopleService implements OnModuleInit {
company: person.company?.name ?? '', company: person.company?.name ?? '',
}); });
if (!message) throw new BadRequestException('متن پیامک خالی است.'); 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({ await this.outboundSms.record({
organizationId: actor.organizationId, organizationId: actor.organizationId,
personId: person.id, personId: person.id,
@@ -711,9 +723,11 @@ export class PeopleService implements OnModuleInit {
}); });
if (!result.ok) { if (!result.ok) {
throw new ServiceUnavailableException( throw new ServiceUnavailableException(
result.reason === 'SMS not configured' result.blocked
? 'ارسال پیامک روی سرور فعال نیست.' ? 'ارسال پیامک به‌خاطر محدودیت اعتبار مسدود است.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.', : result.reason === 'SMS not configured'
? 'ارسال پیامک روی سرور فعال نیست.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
); );
} }
return { ok: true }; return { ok: true };
@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule'; import { Cron } from '@nestjs/schedule';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.service'; import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { import {
CHEQUE_REMINDER_DAYS, CHEQUE_REMINDER_DAYS,
isDueInDays, isDueInDays,
@@ -18,7 +18,7 @@ export class ChequeRemindersService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly sms: SmsService, private readonly sms: SmsGatewayService,
) {} ) {}
@Cron('0 8 * * *', { timeZone: 'Asia/Tehran' }) @Cron('0 8 * * *', { timeZone: 'Asia/Tehran' })
@@ -134,20 +134,27 @@ export class ChequeRemindersService {
dedupe: true, dedupe: true,
}); });
if (user.mobile) { 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) { if (incoming && row.person?.mobile) {
await this.sms.sendSmsDetailed( await this.sms.send({
row.person.mobile, organizationId: row.organizationId,
personIncomingSms({ phone: row.person.mobile,
text: personIncomingSms({
number: row.number, number: row.number,
amount, amount,
currency: row.currency, currency: row.currency,
dueDate: row.dueDate, 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 { AuthModule } from '../auth/auth.module';
import { TreasuryModule } from '../treasury/treasury.module'; import { TreasuryModule } from '../treasury/treasury.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { SmsService } from '../common/sms.service';
import { ChequesService } from './cheques.service'; import { ChequesService } from './cheques.service';
import { ChequeRemindersService } from './cheque-reminders.service'; import { ChequeRemindersService } from './cheque-reminders.service';
import { CommissionsService } from './commissions.service'; import { CommissionsService } from './commissions.service';
@@ -11,7 +10,7 @@ import { FinanceController } from './finance.controller';
@Module({ @Module({
imports: [AuthModule, NotificationsModule, TreasuryModule], imports: [AuthModule, NotificationsModule, TreasuryModule],
controllers: [FinanceController], controllers: [FinanceController],
providers: [ChequesService, ChequeRemindersService, CommissionsService, SmsService], providers: [ChequesService, ChequeRemindersService, CommissionsService],
exports: [ChequesService, CommissionsService], exports: [ChequesService, CommissionsService],
}) })
export class FinanceModule {} export class FinanceModule {}
+1 -2
View File
@@ -5,12 +5,11 @@ import { LeadStagesService } from './lead-stages.service';
import { LeadsController } from './leads.controller'; import { LeadsController } from './leads.controller';
import { LeadsImportService } from './leads-import.service'; import { LeadsImportService } from './leads-import.service';
import { LeadsService } from './leads.service'; import { LeadsService } from './leads.service';
import { SmsService } from '../common/sms.service';
@Module({ @Module({
imports: [AuthModule, BillingModule], imports: [AuthModule, BillingModule],
controllers: [LeadsController], controllers: [LeadsController],
providers: [LeadsService, LeadStagesService, LeadsImportService, SmsService], providers: [LeadsService, LeadStagesService, LeadsImportService],
exports: [LeadsService, LeadStagesService], exports: [LeadsService, LeadStagesService],
}) })
export class LeadsModule {} export class LeadsModule {}
+13 -6
View File
@@ -21,7 +21,7 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
import { FILTERED_IDS_MAX } from '@mento/shared'; import { FILTERED_IDS_MAX } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; 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 { renderSmsText } from '../common/sms-text';
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'; import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
import { import {
@@ -87,7 +87,7 @@ export class LeadsService {
private readonly stages: LeadStagesService, private readonly stages: LeadStagesService,
private readonly files: LocalFileStorage, private readonly files: LocalFileStorage,
private readonly game: GamificationService, private readonly game: GamificationService,
private readonly sms: SmsService, private readonly sms: SmsGatewayService,
private readonly outboundSms: OutboundSmsLogService, private readonly outboundSms: OutboundSmsLogService,
) {} ) {}
@@ -1291,7 +1291,12 @@ export class LeadsService {
company: lead.contactName ? lead.title : '', company: lead.contactName ? lead.title : '',
}); });
if (!message) throw new BadRequestException('متن پیامک خالی است.'); 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({ await this.outboundSms.record({
organizationId: actor.organizationId, organizationId: actor.organizationId,
leadId: id, leadId: id,
@@ -1304,9 +1309,11 @@ export class LeadsService {
}); });
if (!result.ok) { if (!result.ok) {
throw new ServiceUnavailableException( throw new ServiceUnavailableException(
result.reason === 'SMS not configured' result.blocked
? 'ارسال پیامک روی سرور فعال نیست.' ? 'ارسال پیامک به‌خاطر محدودیت اعتبار مسدود است.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.', : result.reason === 'SMS not configured'
? 'ارسال پیامک روی سرور فعال نیست.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
); );
} }
await this.addEvent(actor, id, 'note', 'پیامک ارسال شد', message); await this.addEvent(actor, id, 'note', 'پیامک ارسال شد', message);
+1 -2
View File
@@ -1,13 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { SmsService } from '../common/sms.service';
import { LoyaltyController } from './loyalty.controller'; import { LoyaltyController } from './loyalty.controller';
import { LoyaltyService } from './loyalty.service'; import { LoyaltyService } from './loyalty.service';
@Module({ @Module({
imports: [AuthModule], imports: [AuthModule],
controllers: [LoyaltyController], controllers: [LoyaltyController],
providers: [LoyaltyService, SmsService], providers: [LoyaltyService],
exports: [LoyaltyService], exports: [LoyaltyService],
}) })
export class LoyaltyModule {} export class LoyaltyModule {}
+14 -7
View File
@@ -22,7 +22,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search'; import { buildSearchWhere, ci } from '../common/search';
import { type Actor, ownerWhere } from '../common/record-scope'; 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 = [ const DEFAULT_TIERS = [
{ name: 'برنزی', minPoints: 0, color: '#c9843a' }, { name: 'برنزی', minPoints: 0, color: '#c9843a' },
@@ -57,7 +57,7 @@ const DAY_MS = 86_400_000;
export class LoyaltyService { export class LoyaltyService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly sms: SmsService, private readonly sms: SmsGatewayService,
) {} ) {}
async ensureDefaults(organizationId: string): Promise<void> { async ensureDefaults(organizationId: string): Promise<void> {
@@ -447,10 +447,12 @@ export class LoyaltyService {
const settings = await this.ensureSettings(actor.organizationId); const settings = await this.ensureSettings(actor.organizationId);
if (settings.notifyOnAdjust && updated.mobile) { if (settings.notifyOnAdjust && updated.mobile) {
const sign = delta > 0 ? '+' : ''; const sign = delta > 0 ? '+' : '';
void this.sms.sendSmsDetailed( void this.sms.send({
updated.mobile, organizationId: actor.organizationId,
`باشگاه مشتریان: ${sign}${Math.trunc(delta)} امتیاز. موجودی: ${updated.loyaltyPoints}`, phone: updated.mobile,
); text: `باشگاه مشتریان: ${sign}${Math.trunc(delta)} امتیاز. موجودی: ${updated.loyaltyPoints}`,
source: 'loyalty',
});
} }
return updated; return updated;
} }
@@ -504,7 +506,12 @@ export class LoyaltyService {
skipped += 1; skipped += 1;
continue; 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; if (result.ok) sent += 1;
else failed += 1; else failed += 1;
} }
@@ -1,14 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { SmsService } from '../common/sms.service';
import { MessageAutomationsController } from './message-automations.controller'; import { MessageAutomationsController } from './message-automations.controller';
import { MessageAutomationsService } from './message-automations.service'; import { MessageAutomationsService } from './message-automations.service';
@Module({ @Module({
imports: [AuthModule, NotificationsModule], imports: [AuthModule, NotificationsModule],
controllers: [MessageAutomationsController], controllers: [MessageAutomationsController],
providers: [MessageAutomationsService, SmsService], providers: [MessageAutomationsService],
exports: [MessageAutomationsService], exports: [MessageAutomationsService],
}) })
export class MessageAutomationsModule {} export class MessageAutomationsModule {}
@@ -13,7 +13,7 @@ import type {
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.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 { dateToJalali, jalaliMonthName } from '../common/jalali';
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'; import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
import { import {
@@ -47,7 +47,7 @@ export class MessageAutomationsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly sms: SmsService, private readonly sms: SmsGatewayService,
private readonly outboundSms: OutboundSmsLogService, private readonly outboundSms: OutboundSmsLogService,
) {} ) {}
@@ -397,7 +397,10 @@ export class MessageAutomationsService {
}, },
select: { mobile: true }, 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( private async sendSms(
mobile: string | null | undefined, mobile: string | null | undefined,
text: string, text: string,
log?: { log: {
organizationId: string; organizationId: string;
personId?: string | null; personId?: string | null;
ruleId: string; ruleId: string;
@@ -423,12 +426,13 @@ export class MessageAutomationsService {
this.logger.warn('Message automation SMS skipped: no mobile'); this.logger.warn('Message automation SMS skipped: no mobile');
return; return;
} }
if (!this.sms.isConfigured()) { const result = await this.sms.send({
this.logger.warn('Message automation SMS skipped: SMS not configured'); organizationId: log.organizationId,
return; phone: mobile,
} text,
const result = await this.sms.sendSmsDetailed(mobile, text); source: 'automation',
if (log?.personId) { });
if (log.personId) {
await this.outboundSms.record({ await this.outboundSms.record({
organizationId: log.organizationId, organizationId: log.organizationId,
personId: log.personId, personId: log.personId,
@@ -1,11 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { NotificationsController } from './notifications.controller'; import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service'; import { NotificationsService } from './notifications.service';
import { SmsService } from '../common/sms.service';
@Module({ @Module({
controllers: [NotificationsController], controllers: [NotificationsController],
providers: [NotificationsService, SmsService], providers: [NotificationsService],
exports: [NotificationsService], exports: [NotificationsService],
}) })
export class NotificationsModule {} export class NotificationsModule {}
@@ -3,7 +3,7 @@ import { Cron } from '@nestjs/schedule';
import { brand } from '@mento/shared'; import { brand } from '@mento/shared';
import type { NotificationDto, NotificationsListDto } from '@mento/shared'; import type { NotificationDto, NotificationsListDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; 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; const REMINDER_SMS_MAX_DELAY_MS = 6 * 60 * 60 * 1000;
@@ -41,7 +41,7 @@ export class NotificationsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly sms: SmsService, private readonly sms: SmsGatewayService,
) {} ) {}
async list( async list(
@@ -249,7 +249,7 @@ export class NotificationsService {
}); });
} }
if (activity.reminderNotifySms && !stale) { if (activity.reminderNotifySms && !stale) {
await this.sendActivityReminderSms(mobile, { await this.sendActivityReminderSms(activity.organizationId, mobile, {
title: activity.title, title: activity.title,
related, related,
relatedMobile: activity.person?.mobile ?? null, relatedMobile: activity.person?.mobile ?? null,
@@ -295,6 +295,7 @@ export class NotificationsService {
} }
private async sendActivityReminderSms( private async sendActivityReminderSms(
organizationId: string,
mobile: string | null | undefined, mobile: string | null | undefined,
info: { title: string; related: string; relatedMobile: string | null; dueAt: Date | null }, 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'); this.logger.warn('Activity reminder SMS skipped: assignee has no mobile');
return; return;
} }
if (!this.sms.isConfigured()) {
this.logger.warn('Activity reminder SMS skipped: SMS not configured');
return;
}
const lines = [`${brand.reminderPrefixFa} ${info.title}`]; const lines = [`${brand.reminderPrefixFa} ${info.title}`];
if (info.related) { if (info.related) {
lines.push( lines.push(
@@ -314,7 +311,12 @@ export class NotificationsService {
} }
if (info.dueAt) lines.push(`زمان: ${formatTehranDateTime(info.dueAt)}`); if (info.dueAt) lines.push(`زمان: ${formatTehranDateTime(info.dueAt)}`);
const text = lines.join('\n').slice(0, 300); 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) { if (!result.ok) {
this.logger.warn(`Activity reminder SMS failed: ${result.reason ?? 'unknown'}`); this.logger.warn(`Activity reminder SMS failed: ${result.reason ?? 'unknown'}`);
} }
+1 -2
View File
@@ -1,6 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { SmsService } from '../common/sms.service';
import { PortalController } from './portal.controller'; import { PortalController } from './portal.controller';
import { PortalService } from './portal.service'; import { PortalService } from './portal.service';
import { PortalAuthGuard } from './portal-auth'; import { PortalAuthGuard } from './portal-auth';
@@ -8,6 +7,6 @@ import { PortalAuthGuard } from './portal-auth';
@Module({ @Module({
imports: [AuthModule], imports: [AuthModule],
controllers: [PortalController], controllers: [PortalController],
providers: [PortalService, PortalAuthGuard, SmsService], providers: [PortalService, PortalAuthGuard],
}) })
export class PortalModule {} export class PortalModule {}
+9 -12
View File
@@ -19,7 +19,7 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
import { TICKET_SLA_HOURS } from '@mento/shared'; import { TICKET_SLA_HOURS } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; 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'; import type { PortalActor } from './portal-auth';
const OTP_TTL_SECONDS = 5 * 60; const OTP_TTL_SECONDS = 5 * 60;
@@ -31,7 +31,7 @@ export class PortalService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly jwt: JwtService, private readonly jwt: JwtService,
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly sms: SmsService, private readonly sms: SmsGatewayService,
) {} ) {}
async getOrgInfo(slug: string): Promise<PortalOrgInfoDto> { async getOrgInfo(slug: string): Promise<PortalOrgInfoDto> {
@@ -99,16 +99,13 @@ export class PortalService {
(this.config.get<string>('NODE_ENV') !== 'production' && (this.config.get<string>('NODE_ENV') !== 'production' &&
this.config.get<string>('PORTAL_SHOW_OTP') !== 'false'); this.config.get<string>('PORTAL_SHOW_OTP') !== 'false');
if (this.sms.isConfigured()) { const sent = await this.sms.sendOtp(org.id, mobile, code);
const sent = await this.sms.sendOtpSms(mobile, code); if (!sent && !returnOtp) {
if (!sent && !returnOtp) { throw new BadRequestException(
throw new BadRequestException( 'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید',
'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید', );
); }
} if (!sent) {
} else if (!returnOtp) {
throw new BadRequestException('سامانه پیامک پیکربندی نشده است');
} else {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`[portal-otp] ${org.slug} ${mobile} => ${code}`); 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 { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module'; import { BillingModule } from '../billing/billing.module';
import { SmsService } from '../common/sms.service';
import { TrackingModule } from '../tracking/tracking.module'; import { TrackingModule } from '../tracking/tracking.module';
import { SmsCampaignsController } from './sms-campaigns.controller'; import { SmsCampaignsController } from './sms-campaigns.controller';
import { SmsCampaignsService } from './sms-campaigns.service'; import { SmsCampaignsService } from './sms-campaigns.service';
@@ -9,7 +8,7 @@ import { SmsCampaignsService } from './sms-campaigns.service';
@Module({ @Module({
imports: [AuthModule, BillingModule, TrackingModule], imports: [AuthModule, BillingModule, TrackingModule],
controllers: [SmsCampaignsController], controllers: [SmsCampaignsController],
providers: [SmsCampaignsService, SmsService], providers: [SmsCampaignsService],
exports: [SmsCampaignsService], exports: [SmsCampaignsService],
}) })
export class SmsCampaignsModule {} export class SmsCampaignsModule {}
@@ -15,7 +15,7 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
import { SMS_CAMPAIGN_CHUNK_SIZE } from '@mento/shared'; import { SMS_CAMPAIGN_CHUNK_SIZE } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; 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 { renderSmsText } from '../common/sms-text';
import { import {
type Actor, type Actor,
@@ -45,7 +45,7 @@ function leadAccessWhere(baseActor: Actor): Prisma.LeadWhereInput {
export class SmsCampaignsService { export class SmsCampaignsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly sms: SmsService, private readonly sms: SmsGatewayService,
private readonly tracking: TrackingService, private readonly tracking: TrackingService,
private readonly outboundSms: OutboundSmsLogService, private readonly outboundSms: OutboundSmsLogService,
) {} ) {}
@@ -583,10 +583,12 @@ export class SmsCampaignsService {
body: withTokens, body: withTokens,
}); });
const result = await this.sms.sendSmsDetailed( const result = await this.sms.send({
recipient.mobile, organizationId: campaign.organizationId,
personalized.body, phone: recipient.mobile,
); text: personalized.body,
source: 'campaign',
});
await this.outboundSms.record({ await this.outboundSms.record({
organizationId: campaign.organizationId, organizationId: campaign.organizationId,
personId: recipient.personId, personId: recipient.personId,
@@ -13,7 +13,7 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.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 ExcelJS from 'exceljs';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
@@ -47,7 +47,7 @@ export class TreasuryReceivablesService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly sms: SmsService, private readonly sms: SmsGatewayService,
) {} ) {}
async list(actor: Actor): Promise<FinancialReceivableDto[]> { async list(actor: Actor): Promise<FinancialReceivableDto[]> {
@@ -306,8 +306,15 @@ export class TreasuryReceivablesService {
where: { id: admin.userId }, where: { id: admin.userId },
select: { mobile: true }, select: { mobile: true },
}); });
if (user?.mobile && this.sms.isConfigured()) { if (user?.mobile) {
await this.sms.sendSmsDetailed(user.mobile, `${title}: ${body}`).catch(() => undefined); await this.sms
.send({
organizationId: row.receivable.organizationId,
phone: user.mobile,
text: `${title}: ${body}`,
source: 'treasury_reminder',
})
.catch(() => undefined);
} }
} }
await this.prisma.financialInstallment.update({ await this.prisma.financialInstallment.update({
-2
View File
@@ -1,7 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { SmsService } from '../common/sms.service';
import { TreasuryController } from './treasury.controller'; import { TreasuryController } from './treasury.controller';
import { TreasuryAccessGuard } from './treasury-access.guard'; import { TreasuryAccessGuard } from './treasury-access.guard';
import { TreasuryBridgeService } from './treasury-bridge.service'; import { TreasuryBridgeService } from './treasury-bridge.service';
@@ -18,7 +17,6 @@ import { TreasuryService } from './treasury.service';
TreasuryOpsService, TreasuryOpsService,
TreasuryBridgeService, TreasuryBridgeService,
TreasuryAccessGuard, TreasuryAccessGuard,
SmsService,
], ],
exports: [TreasuryService, TreasuryBridgeService], exports: [TreasuryService, TreasuryBridgeService],
}) })
+2 -2
View File
@@ -2,7 +2,7 @@
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import axios from 'axios'; import axios from 'axios';
import type { OutboundSmsHistoryResponse, OutboundSmsLogDto } from '@mento/shared'; 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 { api } from '@/lib/api';
import { formatJalaliDay, formatJalaliNumeric, toFaDigits } from '@/lib/jalali'; import { formatJalaliDay, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { UiButton, UiModal } from '@/components/ui'; import { UiButton, UiModal } from '@/components/ui';
@@ -46,7 +46,7 @@ const historyLoading = ref(false);
const preview = computed(() => fill(text.value)); const preview = computed(() => fill(text.value));
const count = computed(() => text.value.length); 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( watch(
() => props.open, () => 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 'کاربران'; return 'کاربران';
case 'adm-admins': case 'adm-admins':
return 'ادمین‌ها'; return 'ادمین‌ها';
case 'adm-sms':
return 'پیامک';
default: default:
return 'سوپرادمین'; return 'سوپرادمین';
} }
@@ -31,6 +33,7 @@ const tabs = [
{ name: 'adm-dashboard', label: 'خانه', icon: 'home' as const }, { name: 'adm-dashboard', label: 'خانه', icon: 'home' as const },
{ name: 'adm-organizations', label: 'محیط‌ها', icon: 'building' as const }, { name: 'adm-organizations', label: 'محیط‌ها', icon: 'building' as const },
{ name: 'adm-users', label: 'کاربران', icon: 'users' 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 }, { 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 OrgSwitcher from '@/components/OrgSwitcher.vue';
import NavHubPanel from '@/components/nav/NavHubPanel.vue'; import NavHubPanel from '@/components/nav/NavHubPanel.vue';
import GamificationToaster from '@/components/gamification/GamificationToaster.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 { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs';
import type { import type {
ActivitySummaryDto, ActivitySummaryDto,
@@ -769,6 +770,7 @@ onUnmounted(() => {
<BrandName lang="en" /> <BrandName lang="en" />
</div> </div>
<div class="top-tools"> <div class="top-tools">
<SmsHeaderWallet />
<button class="round-btn" type="button" aria-label="اعلان‌ها" @click="toggleNotif"> <button class="round-btn" type="button" aria-label="اعلان‌ها" @click="toggleNotif">
<UiIcon name="bell" :size="18" /> <UiIcon name="bell" :size="18" />
<span v-if="hasUnread" class="badge">{{ toFaDigits(unreadCount) }}</span> <span v-if="hasUnread" class="badge">{{ toFaDigits(unreadCount) }}</span>
@@ -805,6 +807,7 @@ onUnmounted(() => {
</form> </form>
<div class="topbar-actions"> <div class="topbar-actions">
<SmsHeaderWallet />
<RouterLink class="round-btn accent" :to="{ name: 'notes' }" title="هوش مصنوعی"> <RouterLink class="round-btn accent" :to="{ name: 'notes' }" title="هوش مصنوعی">
<UiIcon name="sparkle" :size="18" /> <UiIcon name="sparkle" :size="18" />
</RouterLink> </RouterLink>
+5
View File
@@ -61,6 +61,11 @@ export const router = createRouter({
name: 'adm-admins', name: 'adm-admins',
component: () => import('@/views/adm/AdmAdminsView.vue'), 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: "نام شرکت" }, { token: "{شرکت}", label: "نام شرکت" },
] as const; ] as const;
/** First-part Persian SMS limit (legacy UI estimate). Prefer countSmsParts(). */
export const MESSAGE_AUTOMATION_SMS_SEGMENT = 70; 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< export const MESSAGE_AUTOMATION_CHANNEL_LABELS: Record<
MessageAutomationChannel, MessageAutomationChannel,
string string
@@ -5734,6 +5749,94 @@ export interface TreasuryDashboardDto {
upcomingPayables: FinancialDocumentDto[]; 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"; export * from "./gamification";