Files
api/src/sms/sms.service.ts
T

723 lines
22 KiB
TypeScript
Raw Normal View History

2026-07-23 19:41:21 +03:30
import {
BadRequestException,
2026-08-05 14:13:44 +03:30
ForbiddenException,
HttpException,
HttpStatus,
2026-07-23 19:41:21 +03:30
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma, SmsAccount, SmsLedgerType } from '@prisma/client';
2026-07-23 19:41:21 +03:30
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,
2026-08-05 14:13:44 +03:30
RemoteSmsConsumeDto,
SmsTableQueryDto,
UpdateSmsAccountDto,
UpdateSmsChargeInfoDto,
} from './dto/sms-account.dto';
2026-07-23 19:41:21 +03:30
type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown';
@Injectable()
export class SmsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
private readonly notify: AdminNotifyService,
2026-07-23 19:41:21 +03:30
) {}
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),
2026-07-23 19:41:21 +03:30
};
});
}
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),
2026-07-23 19:41:21 +03:30
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);
}
2026-07-23 19:41:21 +03:30
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}» برداشته شد.`,
);
}
2026-07-23 19:41:21 +03:30
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';
2026-07-23 19:41:21 +03:30
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,
2026-07-23 19:41:21 +03:30
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')}`,
);
2026-07-23 19:41:21 +03:30
return this.getAccount(tenantId);
}
async setBlocked(tenantId: string, isBlocked: boolean, userId?: string) {
return this.updateAccount(tenantId, { isBlocked }, userId);
}
2026-08-05 14:13:44 +03:30
/** 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;
if (account.isBlocked && !isAlert) {
allowed = false;
reason = 'blocked';
} else if (!isAlert && account.balanceRials < estimatedCostRials) {
allowed = false;
reason = 'insufficient_balance';
}
return {
allowed,
blocked: account.isBlocked,
reason,
balanceRials: account.balanceRials,
pricePerPartRials: account.pricePerPartRials,
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',
});
}
const costRials = parts * account.pricePerPartRials;
if (!isAlert && account.balanceRials < costRials) {
throw new HttpException(
{
message: 'موجودی کیف پول پیامک کافی نیست',
reason: 'insufficient_balance',
balanceRials: account.balanceRials,
costRials,
},
HttpStatus.PAYMENT_REQUIRED,
);
}
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;
}
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,
})),
};
}
2026-07-23 19:41:21 +03:30
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) {
2026-07-23 19:41:21 +03:30
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) };
}
2026-07-23 19:41:21 +03:30
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 || '',
};
}
2026-07-23 19:41:21 +03:30
private publicAccount(account: SmsAccount) {
return {
id: account.id,
2026-07-23 19:41:21 +03:30
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`),
2026-07-23 19:41:21 +03:30
};
}
private async requireTenant(tenantId: string) {
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
if (!tenant) throw new NotFoundException('نسخه یافت نشد');
return tenant;
}
}