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:
2026-10-19 15:55:48 +03:30
parent f15ebb72ea
commit f5d68d83fc
42 changed files with 2073 additions and 121 deletions
+111
View File
@@ -0,0 +1,111 @@
import {
IsBoolean,
IsIn,
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
ValidateIf,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
export class PlatformSmsChargeDto {
@Type(() => Number)
@IsInt()
amountRials!: number;
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}
export class PlatformSmsUpdateAccountDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
pricePerPartRials?: number;
@IsOptional()
@IsBoolean()
isBlocked?: boolean;
/** null = غیرفعال کردن آستانه خودکار */
@IsOptional()
@Transform(({ value }) =>
value === null || value === '' || value === undefined ? null : Number(value),
)
@ValidateIf((_, v) => v !== null && v !== undefined)
@IsInt()
autoBlockBelowRials?: number | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
alertCriticalRials?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
alertWarningRials?: number;
@IsOptional()
@IsIn(['platform', 'own'])
panelMode?: 'platform' | 'own';
@IsOptional()
@IsString()
@MaxLength(32)
ownPanelType?: string;
@IsOptional()
@IsString()
@MaxLength(120)
ownPanelUsername?: string | null;
@IsOptional()
@IsString()
@MaxLength(120)
ownPanelPassword?: string | null;
@IsOptional()
@IsString()
@MaxLength(32)
ownPanelFrom?: string | null;
@IsOptional()
@IsString()
@MaxLength(64)
chargeCard?: string | null;
@IsOptional()
@IsString()
@MaxLength(64)
chargeIban?: string | null;
@IsOptional()
@IsString()
@MaxLength(120)
chargeHolder?: string | null;
@IsOptional()
@IsString()
@MaxLength(2000)
chargeInstruction?: string | null;
}
export class PlatformSmsBlockDto {
@IsBoolean()
isBlocked!: boolean;
}
export class PlatformSmsPriceDto {
@Type(() => Number)
@IsInt()
@Min(0)
pricePerPartRials!: number;
}
@@ -0,0 +1,27 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { SmsWalletService } from './sms-wallet.service';
@Controller('sms-wallet')
@UseGuards(JwtAuthGuard)
export class OrgSmsController {
constructor(private readonly wallet: SmsWalletService) {}
/** خلاصه برای نشان اعتبار در هدر CRM */
@Get('summary')
async summary(@CurrentUser() user: { organizationId: string }) {
const account = await this.wallet.ensureAccount(user.organizationId);
const billingEnabled = this.wallet.isBillingEnabled(account);
return {
billingEnabled,
balanceRials: account.balanceRials,
pricePerPartRials: account.pricePerPartRials,
isBlocked: account.isBlocked,
autoBlocked: this.wallet.isAutoBlocked(account),
balanceLevel: this.wallet.balanceLevel(account),
panelMode: account.panelMode,
chargeInfo: billingEnabled ? this.wallet.chargeInfoOf(account) : null,
};
}
}
@@ -0,0 +1,83 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import { PlatformAuthGuard } from '../platform/platform-auth.guard';
import type { PlatformActor } from '../platform/platform-jwt.strategy';
import {
PlatformSmsBlockDto,
PlatformSmsChargeDto,
PlatformSmsPriceDto,
PlatformSmsUpdateAccountDto,
} from './dto';
import { SmsWalletService } from './sms-wallet.service';
@Controller('platform/sms')
@UseGuards(PlatformAuthGuard)
export class PlatformSmsController {
constructor(private readonly wallet: SmsWalletService) {}
@Get('tenants')
listTenants(@Query('q') q?: string) {
return this.wallet.listTenantSummaries(q);
}
@Get('organizations/:organizationId')
overview(@Param('organizationId') organizationId: string) {
return this.wallet.getOverview(organizationId);
}
@Post('organizations/:organizationId/charge')
charge(
@Param('organizationId') organizationId: string,
@Body() dto: PlatformSmsChargeDto,
@CurrentUser() actor: PlatformActor,
) {
return this.wallet
.charge(organizationId, dto.amountRials, actor.platformAdminId, dto.note)
.then((account) => this.wallet.getOverview(organizationId).then((o) => ({
...o,
account: this.wallet.publicAccount(account),
})));
}
@Put('organizations/:organizationId/price')
updatePrice(
@Param('organizationId') organizationId: string,
@Body() dto: PlatformSmsPriceDto,
) {
return this.wallet
.updateAccount(organizationId, {
pricePerPartRials: dto.pricePerPartRials,
})
.then(() => this.wallet.getOverview(organizationId));
}
@Put('organizations/:organizationId/block')
setBlocked(
@Param('organizationId') organizationId: string,
@Body() dto: PlatformSmsBlockDto,
) {
return this.wallet
.updateAccount(organizationId, { isBlocked: dto.isBlocked })
.then(() => this.wallet.getOverview(organizationId));
}
@Patch('organizations/:organizationId')
updateAccount(
@Param('organizationId') organizationId: string,
@Body() dto: PlatformSmsUpdateAccountDto,
) {
return this.wallet
.updateAccount(organizationId, dto)
.then(() => this.wallet.getOverview(organizationId));
}
}
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { SmsService } from '../common/sms.service';
import { OrgSmsController } from './org-sms.controller';
import { PlatformSmsController } from './platform-sms.controller';
import { SmsGatewayService } from './sms-gateway.service';
import { SmsWalletService } from './sms-wallet.service';
@Global()
@Module({
controllers: [PlatformSmsController, OrgSmsController],
providers: [SmsService, SmsWalletService, SmsGatewayService],
exports: [SmsService, SmsWalletService, SmsGatewayService],
})
export class SmsBillingModule {}
@@ -0,0 +1,145 @@
import { Injectable, Logger } from '@nestjs/common';
import {
SmsService,
type SmsSendResult,
type SmsTransportOverride,
} from '../common/sms.service';
import { countSmsParts } from './sms-segment.util';
import { SmsWalletService } from './sms-wallet.service';
export type SmsGatewaySendInput = {
/** اگر نباشد (مثلاً ثبت‌نام قبل از ساخت محیط) فقط ارسال پلتفرم بدون کسر */
organizationId?: string | null;
phone: string;
text: string;
source?: string | null;
isAlert?: boolean;
};
@Injectable()
export class SmsGatewayService {
private readonly logger = new Logger(SmsGatewayService.name);
constructor(
private readonly transport: SmsService,
private readonly wallet: SmsWalletService,
) {}
isConfigured(): boolean {
return this.transport.isConfigured();
}
normalizePhoneForSms(mobile: string): string {
return this.transport.normalizePhoneForSms(mobile);
}
async sendOtp(
organizationId: string | null | undefined,
mobile: string,
code: string,
): Promise<boolean> {
const text = this.transport.buildOtpText(code);
const result = await this.send({
organizationId,
phone: mobile,
text,
source: 'otp',
});
return result.ok;
}
async send(input: SmsGatewaySendInput): Promise<SmsSendResult> {
const text = String(input.text ?? '').trim();
if (!text) {
return { ok: false, transport: 'none', reason: 'empty body' };
}
const partsEstimate = Math.max(1, countSmsParts(text));
let override: SmsTransportOverride | undefined;
let billable = false;
let providerLabel = 'platform';
if (input.organizationId) {
const account = await this.wallet.ensureAccount(input.organizationId);
const billingEnabled = this.wallet.isBillingEnabled(account);
if (billingEnabled) {
if (!this.wallet.canSend(account, Boolean(input.isAlert))) {
return {
ok: false,
blocked: true,
transport: 'none',
reason: account.isBlocked ? 'blocked' : 'auto_blocked',
parts: partsEstimate,
};
}
billable = true;
} else {
// پنل اختصاصی مشتری: بدون کسر/لاگ کیف
const user = account.ownPanelUsername?.trim();
const pass = account.ownPanelPassword?.trim();
const from = account.ownPanelFrom?.trim();
if (!user || !pass || !from) {
return {
ok: false,
transport: 'none',
reason: 'own SMS panel not configured',
};
}
const type = (account.ownPanelType || '0098').toLowerCase();
override = {
username: user,
password: pass,
sender: from,
provider: type === 'trez' ? 'trez' : '0098',
};
providerLabel = `own:${type || '0098'}`;
}
}
if (!override && !this.transport.isConfigured()) {
return { ok: false, transport: 'none', reason: 'SMS not configured' };
}
const result = await this.transport.sendSmsDetailed(
input.phone,
text,
override,
);
if (!result.ok) {
return result;
}
const parts = Math.max(1, countSmsParts(text));
const to = this.transport.normalizePhoneForSms(input.phone);
if (billable && input.organizationId) {
try {
const debit = await this.wallet.debitSuccessfulSms({
organizationId: input.organizationId,
receiver: to,
body: text,
parts,
provider: result.transport || providerLabel,
source: input.source,
isAlert: input.isAlert,
});
return {
...result,
parts,
costRials: debit.costRials,
};
} catch (e) {
this.logger.error(
`SMS debit failed after send org=${input.organizationId}: ${
e instanceof Error ? e.message : String(e)
}`,
);
return { ...result, parts };
}
}
return { ...result, parts, costRials: 0 };
}
}
@@ -0,0 +1,14 @@
/** Persian SMS parts (same rules as Hamkar SmsSegmentCalculator). */
const PERSIAN_LIMITS = [70, 64, 67] as const;
export function countSmsParts(text: string): number {
const length = [...String(text ?? '').trim()].length;
if (length <= 0) return 0;
const [first, second, rest] = PERSIAN_LIMITS;
if (length <= first) return 1;
if (length <= first + second) return 2;
const thirdThreshold = first + second + rest;
if (length <= thirdThreshold) return 3;
return 3 + Math.ceil((length - thirdThreshold) / rest);
}
@@ -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);
}
}