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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user