Bill all platform SMS (including OTP), allow negative balance with manual/auto cutoffs, and support own-panel bypass without wallet debit.
425 lines
14 KiB
TypeScript
425 lines
14 KiB
TypeScript
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<SmsAccount> {
|
|
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);
|
|
}
|
|
}
|