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.
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user