import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { Prisma, SmsAccount } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { ChargeSmsWalletDto, UpdateSmsAccountDto } from './dto/sms-account.dto'; type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown'; @Injectable() export class SmsService { constructor( private readonly prisma: PrismaService, private readonly audit: AuditService, ) {} 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.toSummary(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: { id: account.id, balanceRials: account.balanceRials, pricePerPartRials: account.pricePerPartRials, isBlocked: account.isBlocked, alertCriticalRials: account.alertCriticalRials, alertWarningRials: account.alertWarningRials, gatewayProvider: account.gatewayProvider, melipayamakUser: account.melipayamakUser, melipayamakPassword: account.melipayamakPassword, melipayamakFrom: account.melipayamakFrom, panelUsername: account.panelUsername, panelPassword: account.panelPassword, panelFrom: account.panelFrom, extraSettings: (account.extraSettings as Record) || {}, todayMessages: account.todayMessages, todayParts: account.todayParts, todayCostRials: account.todayCostRials, monthMessages: account.monthMessages, monthParts: account.monthParts, monthCostRials: account.monthCostRials, }, summary: this.toSummary(account), ledgers, safeNote: 'این پنل متعلق به سوپرادمین است و فعلاً پنل پیامکی داخل همکار/خانگی را تغییر نمی‌دهد.', }; } 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, }); 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 = 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, 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, }); return this.getAccount(tenantId); } async setBlocked(tenantId: string, isBlocked: boolean, userId?: string) { return this.updateAccount(tenantId, { isBlocked }, userId); } 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; // Seed varied demo balances so color cards are visible until real charges exist 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 toSummary(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 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 publicAccount(account: SmsAccount) { return { balanceRials: account.balanceRials, pricePerPartRials: account.pricePerPartRials, isBlocked: account.isBlocked, alertCriticalRials: account.alertCriticalRials, alertWarningRials: account.alertWarningRials, gatewayProvider: account.gatewayProvider, melipayamakUser: account.melipayamakUser, melipayamakFrom: account.melipayamakFrom, panelUsername: account.panelUsername, panelFrom: account.panelFrom, }; } private async requireTenant(tenantId: string) { const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } }); if (!tenant) throw new NotFoundException('نسخه یافت نشد'); return tenant; } }