import { BadRequestException, ForbiddenException, 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, RemoteSmsChargeDto, RemoteSmsChargeInfoDto, RemoteSmsConsumeDto, 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: 'منبع حقیقت کیف پول SMS این پنل است؛ پنل پیامک همکار/خانگی وقتی remote فعال باشد از همین داده همگام می‌شود.', }; } 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, autoBlockBelowRials: dto.autoBlockBelowRials === undefined ? undefined : dto.autoBlockBelowRials, 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 = this.formatMoney(dto.amountRials); this.notify.notifyAdmins( `سوپرادمین: کیف پول SMS «${tenant.nameFa}» ${dto.amountRials > 0 ? 'شارژ' : 'اصلاح'} شد (${amountLabel}). موجودی: ${this.formatMoney(updated.balanceRials)}`, ); return this.getAccount(tenantId); } async setBlocked(tenantId: string, isBlocked: boolean, userId?: string) { return this.updateAccount(tenantId, { isBlocked }, userId); } /** Product apps: pre-send gate */ async remoteStatus(tenantId: string, parts = 1, isAlert = false) { const account = await this.ensureAccount(tenantId); const safeParts = Math.max(1, Math.min(50, Math.floor(parts) || 1)); const estimatedCostRials = safeParts * account.pricePerPartRials; const balanceLevel = this.balanceLevel( account.balanceRials, account.alertCriticalRials, account.alertWarningRials, ); let reason: string | null = null; let allowed = true; // Manual block switch, then optional auto-block threshold (empty = no auto block). // Alert SMS (isAlert) always bypasses both. Negative balance alone does not block. 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 { allowed, blocked: account.isBlocked || this.isAutoBlocked(account), reason, balanceRials: account.balanceRials, pricePerPartRials: account.pricePerPartRials, autoBlockBelowRials: account.autoBlockBelowRials, parts: safeParts, estimatedCostRials, balanceLevel, }; } /** Product apps: post-send debit + send log */ async remoteConsume(tenantId: string, dto: RemoteSmsConsumeDto) { const parts = Math.max(1, Math.min(50, Math.floor(dto.parts) || 1)); const receiver = String(dto.receiver || '').trim().slice(0, 32); if (!receiver) { throw new BadRequestException('شماره گیرنده الزامی است'); } const body = String(dto.body || '') .trim() .slice(0, 1000); const provider = dto.provider ? String(dto.provider).trim().slice(0, 64) : null; const source = dto.source ? String(dto.source).trim().slice(0, 64) : null; const isAlert = Boolean(dto.isAlert); const result = await this.prisma.$transaction(async (tx) => { const account = await tx.smsAccount.findUnique({ where: { tenantId } }); if (!account) { throw new NotFoundException('حساب پیامک یافت نشد'); } if (account.isBlocked && !isAlert) { throw new ForbiddenException({ message: 'ارسال پیامک برای این نسخه مسدود است', reason: 'blocked', }); } if (this.isAutoBlocked(account) && !isAlert) { throw new ForbiddenException({ message: 'ارسال پیامک به‌خاطر کمتر بودن موجودی از آستانه خودکار مسدود است', reason: 'auto_blocked', }); } const costRials = parts * account.pricePerPartRials; // Negative wallet still allowed unless manual/auto block applies. const nextBalance = account.balanceRials - costRials; const updated = await tx.smsAccount.update({ where: { id: account.id }, data: { balanceRials: nextBalance }, }); await tx.smsLedger.create({ data: { smsAccountId: account.id, type: 'debit', amountRials: -costRials, balanceAfterRials: nextBalance, parts, note: source ? `مصرف ارسال (${source})` : 'مصرف ارسال', }, }); await tx.smsSendLog.create({ data: { smsAccountId: account.id, receiver, body: body || '—', parts, pricePerPartRials: account.pricePerPartRials, totalAmountRials: costRials, provider, source, isAlert, }, }); return { ok: true as const, balanceRials: updated.balanceRials, costRials, parts, pricePerPartRials: account.pricePerPartRials, balanceLevel: this.balanceLevel( updated.balanceRials, account.alertCriticalRials, account.alertWarningRials, ), }; }); return result; } /** Product panel: overview in Hamkar/Khanegi shape */ async remoteOverview(tenantId: string) { const account = await this.ensureAccount(tenantId); const usage = await this.computeUsageStats(account.id); const charge = this.chargeInfoOf(account); return { wallet: { balance: account.balanceRials, price_per_part: account.pricePerPartRials, is_blocked: account.isBlocked, alert_days_sent: 0, last_alert_date: null as string | null, }, charge_info: { card: charge.card, iban: charge.iban, account_holder: charge.accountHolder, instruction: charge.instruction, }, stats: { today: { parts: usage.today.parts, amount: usage.today.amount, messages: usage.today.messages, }, month: { parts: usage.month.parts, amount: usage.month.amount, messages: usage.month.messages, }, }, source: 'superadmin', }; } async remoteBalance(tenantId: string) { const account = await this.ensureAccount(tenantId); return { balance: account.balanceRials, is_blocked: account.isBlocked, price_per_part: account.pricePerPartRials, source: 'superadmin', }; } async remoteChargeInfo(tenantId: string) { const charge = await this.getChargeInfo(tenantId); return { card: charge.card, iban: charge.iban, account_holder: charge.accountHolder, instruction: charge.instruction, source: 'superadmin', }; } async remoteUpdateChargeInfo(tenantId: string, dto: RemoteSmsChargeInfoDto) { const updated = await this.updateChargeInfo(tenantId, { chargeCard: dto.card, chargeIban: dto.iban, chargeHolder: dto.account_holder, chargeInstruction: dto.instruction, }); return { message: 'اطلاعات واریز شارژ ذخیره شد.', charge_info: { card: updated.card, iban: updated.iban, account_holder: updated.accountHolder, instruction: updated.instruction, }, source: 'superadmin', }; } async remoteCharge(tenantId: string, dto: RemoteSmsChargeDto) { const amountRials = dto.amountRials ?? dto.amount; if (amountRials == null || !Number.isFinite(amountRials) || amountRials === 0) { throw new BadRequestException('مبلغ شارژ نامعتبر است'); } await this.chargeWallet(tenantId, { amountRials, note: dto.note, }); const account = await this.ensureAccount(tenantId); return { message: 'کیف پول با موفقیت شارژ شد.', balance: account.balanceRials, price_per_part: account.pricePerPartRials, is_blocked: account.isBlocked, source: 'superadmin', }; } async remoteUpdatePrice(tenantId: string, pricePerPart: number) { await this.updateAccount(tenantId, { pricePerPartRials: Math.max(0, Math.floor(pricePerPart) || 0), }); const account = await this.ensureAccount(tenantId); return { message: 'قیمت هر پارت به‌روزرسانی شد.', balance: account.balanceRials, price_per_part: account.pricePerPartRials, is_blocked: account.isBlocked, source: 'superadmin', }; } async remoteSetBlocked(tenantId: string, isBlocked: boolean) { await this.setBlocked(tenantId, isBlocked); const account = await this.ensureAccount(tenantId); return { message: isBlocked ? 'ارسال پیامک مسدود شد.' : 'ارسال پیامک فعال شد.', balance: account.balanceRials, price_per_part: account.pricePerPartRials, is_blocked: account.isBlocked, source: 'superadmin', }; } async remoteLogsTable( tenantId: string, dto: SmsTableQueryDto, rawBody: Record = {}, ) { const result = await this.getLogsTable(tenantId, dto); return this.toProductTable( result, result.rows.map((row) => ({ id: row.id, receiver: row.receiver, body: row.body, parts: row.parts, price_per_part: row.pricePerPart, total_amount: row.totalAmount, provider: row.provider, source: row.source, is_alert: row.isAlert, created_at: row.createdAt, })), rawBody, ); } async remoteLedgerTable( tenantId: string, dto: SmsTableQueryDto, rawBody: Record = {}, ) { const result = await this.getLedgerTable(tenantId, dto); return this.toProductTable( result, result.rows.map((row) => ({ id: row.id, type: row.type, amount: row.amount, balance_after: row.balanceAfter, price_per_part: row.pricePerPart, parts: row.parts, note: row.note, created_by: row.createdBy, created_at: row.createdAt, })), rawBody, ); } async remotePeriodReport(tenantId: string, fromDate?: string, toDate?: string) { const report = await this.getPeriodReport(tenantId, fromDate, toDate); return { from_date: report.fromDate, to_date: report.toDate, balance: report.balance, summary: report.summary, by_provider: report.byProvider.map((row) => ({ provider: row.provider, messages: row.messages, parts: row.parts, amount: row.amount, })), source: 'superadmin', }; } async remoteChargeReport(tenantId: string, fromDate?: string, toDate?: string) { const report = await this.getChargeReport(tenantId, fromDate, toDate); return { from_date: report.fromDate, to_date: report.toDate, summary: report.summary, items: report.items.map((row) => ({ id: row.id, amount: row.amount, balance_after: row.balanceAfter, note: row.note, created_by: row.createdBy, created_at: row.createdAt, })), source: 'superadmin', }; } private toProductTable( result: { page: number; pageSize: number; total: number }, rows: Record[], rawBody: Record, ) { return { query: { limit: result.pageSize, filters: rawBody.filters ?? null, filtersOpt: rawBody.filtersOpt ?? null, search: rawBody.search ?? rawBody.text_search ?? null, key: rawBody.key ?? null, select: rawBody.select ?? null, total: result.total, sort: null, page: result.page, }, rows, source: 'superadmin', }; } 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; // Start at zero — real balances come from product MySQL import // (prisma/import-sms-from-product.ts), not demo numbers. return this.prisma.smsAccount.create({ data: { tenantId, balanceRials: 0, 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'; } /** Human-facing SuperAdmin label: storage is rial, display is toman. */ private formatMoney(value: number) { const toman = Math.trunc(value / 10); return `${new Intl.NumberFormat('fa-IR').format(toman)} تومان`; } private isAutoBlocked(account: { balanceRials: number; autoBlockBelowRials: number | null; }) { return ( account.autoBlockBelowRials != null && account.balanceRials < account.autoBlockBelowRials ); } 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, autoBlockBelowRials: account.autoBlockBelowRials, 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; } }