import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { Prisma, SmsAccount, SmsLedgerType } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { AdminNotifyService } from '../notifications/admin-notify.service'; import { iranYmd } from '../common/iran-time.util'; import { ChargeSmsWalletDto, SmsTableQueryDto, UpdateSmsAccountDto, UpdateSmsChargeInfoDto, } from './dto/sms-account.dto'; type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown'; @Injectable() export class SmsService { constructor( private readonly prisma: PrismaService, private readonly audit: AuditService, private readonly notify: AdminNotifyService, ) {} async listTenants() { await this.ensureAccountsForAllTenants(); const rows = await this.prisma.tenant.findMany({ where: { isActive: true }, orderBy: [{ product: 'asc' }, { nameFa: 'asc' }], include: { smsAccount: true }, }); return rows.map((tenant) => { const account = tenant.smsAccount!; return { id: tenant.id, product: tenant.product, slug: tenant.slug, nameFa: tenant.nameFa, nameEn: tenant.nameEn, domain: tenant.domain, avatarUrl: tenant.avatarUrl, isActive: tenant.isActive, isBlocked: account.isBlocked, sms: this.toSummaryQuick(account), }; }); } async getLowestBalances(limit = 5) { const rows = await this.listTenants(); return [...rows] .sort((a, b) => a.sms.balanceRials - b.sms.balanceRials) .slice(0, limit); } async getAccount(tenantId: string) { const tenant = await this.requireTenant(tenantId); const account = await this.ensureAccount(tenant.id); const ledgers = await this.prisma.smsLedger.findMany({ where: { smsAccountId: account.id }, orderBy: { createdAt: 'desc' }, take: 20, }); return { tenant: { id: tenant.id, product: tenant.product, slug: tenant.slug, nameFa: tenant.nameFa, nameEn: tenant.nameEn, domain: tenant.domain, avatarUrl: tenant.avatarUrl, }, account: this.publicAccount(account), chargeInfo: this.chargeInfoOf(account), summary: await this.toSummaryFromLogs(account), ledgers, safeNote: 'این پنل متعلق به سوپرادمین است و فعلاً پنل پیامکی داخل همکار/خانگی را تغییر نمی‌دهد.', }; } async getChargeInfo(tenantId: string) { const account = await this.ensureAccount(tenantId); return this.chargeInfoOf(account); } async updateChargeInfo( tenantId: string, dto: UpdateSmsChargeInfoDto, userId?: string, ) { const account = await this.ensureAccount(tenantId); const before = this.chargeInfoOf(account); const updated = await this.prisma.smsAccount.update({ where: { id: account.id }, data: { chargeCard: dto.chargeCard === undefined ? undefined : dto.chargeCard || null, chargeIban: dto.chargeIban === undefined ? undefined : dto.chargeIban || null, chargeHolder: dto.chargeHolder === undefined ? undefined : dto.chargeHolder || null, chargeInstruction: dto.chargeInstruction === undefined ? undefined : dto.chargeInstruction || null, }, }); await this.audit.log({ action: 'sms.charge_info.update', entityType: 'sms_account', entityId: updated.id, tenantId, userId, before: before as unknown as Prisma.InputJsonValue, after: this.chargeInfoOf(updated) as unknown as Prisma.InputJsonValue, }); return this.chargeInfoOf(updated); } async updateAccount(tenantId: string, dto: UpdateSmsAccountDto, userId?: string) { const account = await this.ensureAccount(tenantId); const before = { ...account }; if ( dto.alertCriticalRials != null && dto.alertWarningRials != null && dto.alertCriticalRials > dto.alertWarningRials ) { throw new BadRequestException('آستانه قرمز نباید از آستانه زرد بزرگ‌تر باشد'); } const updated = await this.prisma.smsAccount.update({ where: { id: account.id }, data: { pricePerPartRials: dto.pricePerPartRials ?? undefined, isBlocked: dto.isBlocked ?? undefined, alertCriticalRials: dto.alertCriticalRials ?? undefined, alertWarningRials: dto.alertWarningRials ?? undefined, gatewayProvider: dto.gatewayProvider ?? undefined, melipayamakUser: dto.melipayamakUser ?? undefined, melipayamakPassword: dto.melipayamakPassword ?? undefined, melipayamakFrom: dto.melipayamakFrom ?? undefined, panelUsername: dto.panelUsername ?? undefined, panelPassword: dto.panelPassword ?? undefined, panelFrom: dto.panelFrom ?? undefined, extraSettings: dto.extraSettings === undefined ? undefined : (dto.extraSettings as Prisma.InputJsonValue), }, }); await this.audit.log({ action: 'sms.account.update', entityType: 'sms_account', entityId: updated.id, tenantId, userId, before: this.publicAccount(before) as unknown as Prisma.InputJsonValue, after: this.publicAccount(updated) as unknown as Prisma.InputJsonValue, }); if (before.isBlocked !== updated.isBlocked) { const tenant = await this.requireTenant(tenantId); this.notify.notifyAdmins( updated.isBlocked ? `سوپرادمین: ارسال SMS نسخه «${tenant.nameFa}» مسدود شد.` : `سوپرادمین: مسدودسازی SMS نسخه «${tenant.nameFa}» برداشته شد.`, ); } return this.getAccount(tenantId); } async chargeWallet(tenantId: string, dto: ChargeSmsWalletDto, userId?: string) { if (!dto.amountRials || dto.amountRials === 0) { throw new BadRequestException('مبلغ شارژ نامعتبر است'); } const account = await this.ensureAccount(tenantId); const nextBalance = account.balanceRials + dto.amountRials; if (nextBalance < 0) { throw new BadRequestException('موجودی نمی‌تواند منفی شود'); } const type: SmsLedgerType = dto.amountRials > 0 ? 'charge' : 'adjust'; const [updated] = await this.prisma.$transaction([ this.prisma.smsAccount.update({ where: { id: account.id }, data: { balanceRials: nextBalance }, }), this.prisma.smsLedger.create({ data: { smsAccountId: account.id, type, amountRials: dto.amountRials, balanceAfterRials: nextBalance, note: dto.note || (dto.amountRials > 0 ? 'شارژ کیف پول' : 'اصلاح موجودی'), createdById: userId, }, }), ]); await this.audit.log({ action: 'sms.wallet.charge', entityType: 'sms_account', entityId: account.id, tenantId, userId, after: { amountRials: dto.amountRials, balanceRials: updated.balanceRials, note: dto.note || null, } as unknown as Prisma.InputJsonValue, }); const tenant = await this.requireTenant(tenantId); const amountLabel = dto.amountRials.toLocaleString('fa-IR'); this.notify.notifyAdmins( `سوپرادمین: کیف پول SMS «${tenant.nameFa}» ${dto.amountRials > 0 ? 'شارژ' : 'اصلاح'} شد (${amountLabel} ریال). موجودی: ${updated.balanceRials.toLocaleString('fa-IR')}`, ); return this.getAccount(tenantId); } async setBlocked(tenantId: string, isBlocked: boolean, userId?: string) { return this.updateAccount(tenantId, { isBlocked }, userId); } async getPeriodReport(tenantId: string, fromDate?: string, toDate?: string) { const account = await this.ensureAccount(tenantId); const range = this.resolveDateRange(fromDate, toDate, true); const logs = await this.prisma.smsSendLog.findMany({ where: { smsAccountId: account.id, createdAt: { gte: range.from, lte: range.to }, }, select: { parts: true, totalAmountRials: true, provider: true, }, }); const byProviderMap = new Map< string, { provider: string; messages: number; parts: number; amount: number } >(); let messages = 0; let parts = 0; let amount = 0; for (const row of logs) { messages += 1; parts += row.parts; amount += row.totalAmountRials; const key = row.provider || 'نامشخص'; const cur = byProviderMap.get(key) || { provider: key, messages: 0, parts: 0, amount: 0, }; cur.messages += 1; cur.parts += row.parts; cur.amount += row.totalAmountRials; byProviderMap.set(key, cur); } return { fromDate: iranYmd(range.from), toDate: iranYmd(range.to), balance: account.balanceRials, summary: { messages, parts, amount }, byProvider: [...byProviderMap.values()].sort((a, b) => b.amount - a.amount), }; } async getChargeReport(tenantId: string, fromDate?: string, toDate?: string) { const account = await this.ensureAccount(tenantId); const range = this.resolveDateRange(fromDate, toDate, false); const where: Prisma.SmsLedgerWhereInput = { smsAccountId: account.id, type: 'charge', }; if (range) { where.createdAt = { gte: range.from, lte: range.to }; } const items = await this.prisma.smsLedger.findMany({ where, orderBy: { createdAt: 'desc' }, take: 200, }); return { fromDate: range ? iranYmd(range.from) : null, toDate: range ? iranYmd(range.to) : null, summary: { charges: items.length, amount: items.reduce((sum, row) => sum + row.amountRials, 0), }, items: items.map((row) => ({ id: row.id, amount: row.amountRials, balanceAfter: row.balanceAfterRials, note: row.note, createdBy: row.createdById, createdAt: row.createdAt, })), }; } async getLogsTable(tenantId: string, dto: SmsTableQueryDto) { const account = await this.ensureAccount(tenantId); const page = dto.page || 1; const pageSize = dto.pageSize || 20; const range = this.resolveDateRange(dto.fromDate, dto.toDate, false); const where: Prisma.SmsSendLogWhereInput = { smsAccountId: account.id, }; if (range) { where.createdAt = { gte: range.from, lte: range.to }; } if (dto.search?.trim()) { const q = dto.search.trim(); where.OR = [ { receiver: { contains: q } }, { body: { contains: q } }, { provider: { contains: q } }, { source: { contains: q } }, ]; } const [total, rows] = await this.prisma.$transaction([ this.prisma.smsSendLog.count({ where }), this.prisma.smsSendLog.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), ]); return { page, pageSize, total, rows: rows.map((row) => ({ id: row.id, receiver: row.receiver, body: row.body, parts: row.parts, pricePerPart: row.pricePerPartRials, totalAmount: row.totalAmountRials, provider: row.provider, source: row.source, isAlert: row.isAlert, createdAt: row.createdAt, })), }; } async getLedgerTable(tenantId: string, dto: SmsTableQueryDto) { const account = await this.ensureAccount(tenantId); const page = dto.page || 1; const pageSize = dto.pageSize || 20; const range = this.resolveDateRange(dto.fromDate, dto.toDate, false); const where: Prisma.SmsLedgerWhereInput = { smsAccountId: account.id, }; if (range) { where.createdAt = { gte: range.from, lte: range.to }; } if (dto.search?.trim()) { where.note = { contains: dto.search.trim() }; } const [total, rows] = await this.prisma.$transaction([ this.prisma.smsLedger.count({ where }), this.prisma.smsLedger.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), ]); return { page, pageSize, total, rows: rows.map((row) => ({ id: row.id, type: row.type, amount: row.amountRials, balanceAfter: row.balanceAfterRials, pricePerPart: null as number | null, parts: row.parts, note: row.note, createdBy: row.createdById, createdAt: row.createdAt, })), }; } private async ensureAccountsForAllTenants() { const tenants = await this.prisma.tenant.findMany({ where: { smsAccount: null }, select: { id: true }, }); for (const tenant of tenants) { await this.ensureAccount(tenant.id); } } private async ensureAccount(tenantId: string) { await this.requireTenant(tenantId); const existing = await this.prisma.smsAccount.findUnique({ where: { tenantId } }); if (existing) return existing; const demoBalances = [250000, 85000, 7500, 150000, 12000, 3000, 500000, 45000]; const hash = [...tenantId].reduce((sum, ch) => sum + ch.charCodeAt(0), 0); const balanceRials = demoBalances[hash % demoBalances.length]; return this.prisma.smsAccount.create({ data: { tenantId, balanceRials, pricePerPartRials: 2500, alertCriticalRials: 10000, alertWarningRials: 100000, }, }); } private toSummaryQuick(account: SmsAccount) { const level = this.balanceLevel( account.balanceRials, account.alertCriticalRials, account.alertWarningRials, ); return { balanceRials: account.balanceRials, balanceLabel: this.formatMoney(account.balanceRials), balanceLevel: level, perPartRials: account.pricePerPartRials, todayMessages: account.todayMessages, todayParts: account.todayParts, todayCostRials: account.todayCostRials, monthMessages: account.monthMessages, monthParts: account.monthParts, monthCostRials: account.monthCostRials, isBlocked: account.isBlocked, alertCriticalRials: account.alertCriticalRials, alertWarningRials: account.alertWarningRials, }; } private async toSummaryFromLogs(account: SmsAccount) { const usage = await this.computeUsageStats(account.id); const base = this.toSummaryQuick(account); return { ...base, todayMessages: usage.today.messages, todayParts: usage.today.parts, todayCostRials: usage.today.amount, monthMessages: usage.month.messages, monthParts: usage.month.parts, monthCostRials: usage.month.amount, }; } private async computeUsageStats(smsAccountId: string) { const todayYmd = iranYmd(new Date()); const todayStart = new Date(`${todayYmd}T00:00:00+03:30`); const monthStartYmd = `${todayYmd.slice(0, 8)}01`; const monthStart = new Date(`${monthStartYmd}T00:00:00+03:30`); const now = new Date(); const [todayLogs, monthLogs] = await Promise.all([ this.prisma.smsSendLog.findMany({ where: { smsAccountId, createdAt: { gte: todayStart, lte: now } }, select: { parts: true, totalAmountRials: true }, }), this.prisma.smsSendLog.findMany({ where: { smsAccountId, createdAt: { gte: monthStart, lte: now } }, select: { parts: true, totalAmountRials: true }, }), ]); const fold = (rows: { parts: number; totalAmountRials: number }[]) => ({ messages: rows.length, parts: rows.reduce((s, r) => s + r.parts, 0), amount: rows.reduce((s, r) => s + r.totalAmountRials, 0), }); return { today: fold(todayLogs), month: fold(monthLogs) }; } private balanceLevel( balanceRials: number, critical = 10000, warning = 100000, ): BalanceLevel { if (balanceRials < critical) return 'critical'; if (balanceRials <= warning) return 'warning'; return 'healthy'; } private formatMoney(value: number) { return `${new Intl.NumberFormat('fa-IR').format(value)} ریال`; } private chargeInfoOf(account: SmsAccount) { return { card: account.chargeCard || '', iban: account.chargeIban || '', accountHolder: account.chargeHolder || '', instruction: account.chargeInstruction || '', }; } private publicAccount(account: SmsAccount) { return { id: account.id, balanceRials: account.balanceRials, pricePerPartRials: account.pricePerPartRials, isBlocked: account.isBlocked, alertCriticalRials: account.alertCriticalRials, alertWarningRials: account.alertWarningRials, chargeCard: account.chargeCard, chargeIban: account.chargeIban, chargeHolder: account.chargeHolder, chargeInstruction: account.chargeInstruction, }; } /** * @param defaultMonth when true and no dates: from month start to now (always returns a range) * @returns null when optional and no dates provided */ private resolveDateRange( fromDate: string | undefined, toDate: string | undefined, defaultMonth: true, ): { from: Date; to: Date }; private resolveDateRange( fromDate?: string, toDate?: string, defaultMonth?: false, ): { from: Date; to: Date } | null; private resolveDateRange( fromDate?: string, toDate?: string, defaultMonth = false, ): { from: Date; to: Date } | null { const today = iranYmd(new Date()); if (!fromDate && !toDate) { if (!defaultMonth) return null; return { from: new Date(`${today.slice(0, 8)}01T00:00:00+03:30`), to: new Date(`${today}T23:59:59.999+03:30`), }; } const fromYmd = fromDate && /^\d{4}-\d{2}-\d{2}$/.test(fromDate) ? fromDate : today; const toYmd = toDate && /^\d{4}-\d{2}-\d{2}$/.test(toDate) ? toDate : today; return { from: new Date(`${fromYmd}T00:00:00+03:30`), to: new Date(`${toYmd}T23:59:59.999+03:30`), }; } private async requireTenant(tenantId: string) { const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } }); if (!tenant) throw new NotFoundException('نسخه یافت نشد'); return tenant; } }