964 lines
29 KiB
TypeScript
964 lines
29 KiB
TypeScript
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<string, unknown> = {},
|
|
) {
|
|
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<string, unknown> = {},
|
|
) {
|
|
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<string, unknown>[],
|
|
rawBody: Record<string, unknown>,
|
|
) {
|
|
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;
|
|
}
|
|
}
|