Files
CRM_Montzar/apps/api/src/sms-billing/sms-wallet.service.ts
T
Mahdi f5d68d83fc feat: add per-org SMS wallet with adm panel and header credit
Bill all platform SMS (including OTP), allow negative balance with manual/auto cutoffs, and support own-panel bypass without wallet debit.
2026-10-19 15:55:48 +03:30

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