feat: complete application updates

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-09-24 22:29:32 +03:30
co-authored by Copilot
parent 2198574e4d
commit b9ac917eb6
11 changed files with 462 additions and 7 deletions
+3 -1
View File
@@ -91,7 +91,9 @@ export class AuthService {
where: { id: challenge.id },
});
throw new ServiceUnavailableException(
'ارسال پیامک با خطا مواجه شد؛ دوباره تلاش کنید',
sent.raw === 'sms wallet blocked'
? 'ارسال پیامک به‌خاطر اتمام اعتبار یا مسدود بودن کیف پول متوقف است'
: 'ارسال پیامک با خطا مواجه شد؛ دوباره تلاش کنید',
);
}
}
+49
View File
@@ -0,0 +1,49 @@
import { Type } from 'class-transformer';
import { IsBoolean, IsInt, IsOptional, IsString, Min } from 'class-validator';
export class UpdateSmsWalletDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pricePerPart?: number;
@IsOptional()
@IsBoolean()
blocked?: boolean;
@IsOptional()
@IsString()
cardNumber?: string;
@IsOptional()
@IsString()
sheba?: string;
@IsOptional()
@IsString()
accountName?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
yellowThreshold?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
redThreshold?: number;
}
export class ChargeSmsWalletDto {
@Type(() => Number)
@IsInt()
@Min(1)
amountToman!: number;
@IsOptional()
@IsString()
note?: string;
}
+12
View File
@@ -0,0 +1,12 @@
import { smsPartCount } from './sms-parts';
describe('smsPartCount', () => {
it('bills one part per 67 characters', () => {
expect(smsPartCount('')).toBe(0);
expect(smsPartCount('ا')).toBe(1);
expect(smsPartCount('ا'.repeat(67))).toBe(1);
expect(smsPartCount('ا'.repeat(68))).toBe(2);
expect(smsPartCount('ا'.repeat(134))).toBe(2);
expect(smsPartCount('ا'.repeat(140))).toBe(3);
});
});
+8
View File
@@ -0,0 +1,8 @@
export const SMS_CHARS_PER_PART = 67;
/** Billed parts. 67 chars = 1, 68 = 2, 140 = 3. */
export function smsPartCount(text: string): number {
const len = Array.from(text).length;
if (len <= 0) return 0;
return Math.ceil(len / SMS_CHARS_PER_PART);
}
+37
View File
@@ -0,0 +1,37 @@
import { Body, Controller, Get, Patch, Post } from '@nestjs/common';
import { AuthType } from '../common/decorators/auth.decorators';
import {
CurrentUser,
type AuthPrincipal,
} from '../common/decorators/current-user.decorator';
import { ChargeSmsWalletDto, UpdateSmsWalletDto } from './dto/sms-wallet.dto';
import { SmsWalletService } from './sms-wallet.service';
@Controller('admin/sms-wallet')
@AuthType('admin')
export class SmsWalletController {
constructor(private readonly wallet: SmsWalletService) {}
@Get()
summary() {
return this.wallet.summary();
}
@Get('manage')
manage(@CurrentUser() user: AuthPrincipal) {
this.wallet.assertOwner(user);
return this.wallet.manage();
}
@Patch()
update(@CurrentUser() user: AuthPrincipal, @Body() dto: UpdateSmsWalletDto) {
this.wallet.assertOwner(user);
return this.wallet.update(dto);
}
@Post('charge')
charge(@CurrentUser() user: AuthPrincipal, @Body() dto: ChargeSmsWalletDto) {
this.wallet.assertOwner(user);
return this.wallet.charge(dto.amountToman, dto.note);
}
}
+224
View File
@@ -0,0 +1,224 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AuthPrincipal } from '../common/decorators/current-user.decorator';
import { normalizePhone, toEnglishDigits } from '../common/utils/phone';
import { PrismaService } from '../prisma/prisma.service';
export const SMS_WALLET_OWNER_PHONE = '09128081392';
const WALLET_ID = 'default';
const TOMAN_TO_RIAL = 10;
type Db = PrismaService | Prisma.TransactionClient;
function isOwner(phone?: string | null): boolean {
if (!phone) return false;
return normalizePhone(toEnglishDigits(phone)) === SMS_WALLET_OWNER_PHONE;
}
function normalizeCard(raw: string): string {
const digits = toEnglishDigits(raw).replace(/\D/g, '');
if (!/^\d{16}$/.test(digits)) {
throw new BadRequestException('شماره کارت باید ۱۶ رقم باشد');
}
return digits;
}
function normalizeSheba(raw: string): string {
const compact = toEnglishDigits(raw).replace(/[\s-]/g, '').toUpperCase();
if (!/^IR\d{24}$/.test(compact)) {
throw new BadRequestException('شماره شبا باید با IR و ۲۴ رقم باشد');
}
return compact;
}
function tehranStartOfToday(): Date {
const day = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
return new Date(`${day}T00:00:00+03:30`);
}
@Injectable()
export class SmsWalletService {
constructor(private readonly prisma: PrismaService) {}
assertOwner(user: AuthPrincipal) {
if (!isOwner(user.phone)) {
throw new ForbiddenException('دسترسی به تعرفه پیامک مجاز نیست');
}
}
async canSend(): Promise<boolean> {
const wallet = await this.ensure();
if (wallet.balanceToman <= 0) {
if (!wallet.blocked) {
await this.prisma.smsWallet.update({
where: { id: WALLET_ID },
data: { blocked: true },
});
}
return false;
}
return !wallet.blocked;
}
async recordSend(parts: number): Promise<void> {
if (parts <= 0) return;
// ponytail: no row lock; two overlapping sends can overdraw by one message
await this.prisma.$transaction(async (tx) => {
const wallet = await this.ensure(tx);
const costToman = parts * wallet.pricePerPart;
const balanceToman = wallet.balanceToman - costToman;
await tx.smsWallet.update({
where: { id: WALLET_ID },
data: {
balanceToman,
blocked: balanceToman <= 0 ? true : wallet.blocked,
},
});
await tx.smsUsage.create({ data: { parts, costToman } });
});
}
async summary() {
const wallet = await this.ensure();
return {
balanceToman: wallet.balanceToman,
balanceRial: wallet.balanceToman * TOMAN_TO_RIAL,
blocked: wallet.blocked || wallet.balanceToman <= 0,
yellowThreshold: wallet.yellowThreshold,
redThreshold: wallet.redThreshold,
deposit: {
cardNumber: wallet.cardNumber,
sheba: wallet.sheba,
accountName: wallet.accountName,
},
};
}
async manage() {
const wallet = await this.ensure();
const since = tehranStartOfToday();
const [total, today] = await Promise.all([
this.prisma.smsUsage.aggregate({
_count: { _all: true },
_sum: { parts: true, costToman: true },
}),
this.prisma.smsUsage.aggregate({
where: { createdAt: { gte: since } },
_count: { _all: true },
_sum: { parts: true, costToman: true },
}),
]);
return {
balanceToman: wallet.balanceToman,
pricePerPart: wallet.pricePerPart,
blocked: wallet.blocked,
cardNumber: wallet.cardNumber,
sheba: wallet.sheba,
accountName: wallet.accountName,
yellowThreshold: wallet.yellowThreshold,
redThreshold: wallet.redThreshold,
total: usageOf(total),
today: usageOf(today),
};
}
async update(dto: {
pricePerPart?: number;
blocked?: boolean;
cardNumber?: string;
sheba?: string;
accountName?: string;
yellowThreshold?: number;
redThreshold?: number;
}) {
const wallet = await this.ensure();
const data: Prisma.SmsWalletUpdateInput = {};
if (dto.pricePerPart != null) data.pricePerPart = dto.pricePerPart;
if (dto.cardNumber != null) data.cardNumber = normalizeCard(dto.cardNumber);
if (dto.sheba != null) data.sheba = normalizeSheba(dto.sheba);
if (dto.accountName != null) {
const name = dto.accountName.trim();
if (!name) throw new BadRequestException('نام صاحب حساب الزامی است');
data.accountName = name;
}
if (dto.yellowThreshold != null) data.yellowThreshold = dto.yellowThreshold;
if (dto.redThreshold != null) data.redThreshold = dto.redThreshold;
const yellow = dto.yellowThreshold ?? wallet.yellowThreshold;
const red = dto.redThreshold ?? wallet.redThreshold;
if (yellow < red) {
throw new BadRequestException('آستانه زرد باید بزرگ‌تر یا مساوی آستانه قرمز باشد');
}
if (dto.blocked != null) {
if (dto.blocked === false && wallet.balanceToman <= 0) {
throw new BadRequestException(
'با موجودی صفر یا منفی، مسدودسازی برداشته نمی‌شود',
);
}
data.blocked = dto.blocked;
}
if (Object.keys(data).length) {
await this.prisma.smsWallet.update({ where: { id: WALLET_ID }, data });
}
return this.manage();
}
async charge(amountToman: number, note?: string) {
const wallet = await this.ensure();
const wasBroke = wallet.balanceToman <= 0;
const balanceToman = wallet.balanceToman + amountToman;
await this.prisma.$transaction([
this.prisma.smsWallet.update({
where: { id: WALLET_ID },
data: {
balanceToman,
blocked: balanceToman <= 0 ? true : wasBroke ? false : wallet.blocked,
},
}),
this.prisma.smsTopUp.create({
data: {
amountToman,
note: note?.trim() || null,
},
}),
]);
return this.manage();
}
private async ensure(db: Db = this.prisma) {
const wallet = await db.smsWallet.upsert({
where: { id: WALLET_ID },
update: {},
create: { id: WALLET_ID },
});
if (wallet.balanceToman <= 0 && !wallet.blocked) {
return db.smsWallet.update({
where: { id: WALLET_ID },
data: { blocked: true },
});
}
return wallet;
}
}
function usageOf(row: {
_count: { _all: number };
_sum: { parts: number | null; costToman: number | null };
}) {
return {
messages: row._count._all,
parts: row._sum.parts ?? 0,
costToman: row._sum.costToman ?? 0,
};
}
+4 -1
View File
@@ -1,8 +1,11 @@
import { Module } from '@nestjs/common';
import { SmsService } from './sms.service';
import { SmsWalletController } from './sms-wallet.controller';
import { SmsWalletService } from './sms-wallet.service';
@Module({
providers: [SmsService],
controllers: [SmsWalletController],
providers: [SmsService, SmsWalletService],
exports: [SmsService],
})
export class SmsModule {}
+27 -5
View File
@@ -1,5 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { smsPartCount } from './sms-parts';
import { SmsWalletService } from './sms-wallet.service';
export type SmsSendResult = {
success: boolean;
@@ -16,7 +18,10 @@ function maskPhone(phone: string): string {
export class SmsService {
private readonly logger = new Logger(SmsService.name);
constructor(private readonly config: ConfigService) {}
constructor(
private readonly config: ConfigService,
private readonly wallet: SmsWalletService,
) {}
isEnabled(): boolean {
const raw = (
@@ -39,6 +44,14 @@ export class SmsService {
}
async send(to: string, message: string): Promise<SmsSendResult> {
const text = message.trim();
const parts = smsPartCount(text);
if (parts <= 0) return { success: false, raw: 'empty message' };
if (!(await this.wallet.canSend())) {
this.logger.warn(`SMS blocked for ${maskPhone(to)}`);
return { success: false, raw: 'sms wallet blocked' };
}
const url = this.config.get<string>(
'SMS_API_URL',
'http://185.231.112.101/api/createsms',
@@ -61,16 +74,16 @@ export class SmsService {
body: JSON.stringify({
phone: to,
host,
text: message.trim(),
text,
platform,
}),
signal: AbortSignal.timeout(15_000),
});
const text = await response.text();
let body: unknown = text;
const rawText = await response.text();
let body: unknown = rawText;
try {
body = text ? JSON.parse(text) : null;
body = rawText ? JSON.parse(rawText) : null;
} catch {
/* keep raw text */
}
@@ -92,6 +105,15 @@ export class SmsService {
this.logger.warn(
`SMS failed to ${maskPhone(to)}: ${JSON.stringify(body)}`,
);
} else {
try {
await this.wallet.recordSend(parts);
} catch (err) {
this.logger.error(
`SMS sent but wallet debit failed for ${maskPhone(to)}`,
err instanceof Error ? err.stack : err,
);
}
}
return { success, raw: body };