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 { 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); } }