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
@@ -0,0 +1,34 @@
CREATE TABLE "SmsWallet" (
"id" TEXT NOT NULL DEFAULT 'default',
"balanceToman" INTEGER NOT NULL DEFAULT 0,
"pricePerPart" INTEGER NOT NULL DEFAULT 250,
"blocked" BOOLEAN NOT NULL DEFAULT false,
"cardNumber" TEXT NOT NULL DEFAULT '6219861941033817',
"sheba" TEXT NOT NULL DEFAULT 'IR805606118280056043604401',
"accountName" TEXT NOT NULL DEFAULT 'حسین منتظر',
"yellowThreshold" INTEGER NOT NULL DEFAULT 10000,
"redThreshold" INTEGER NOT NULL DEFAULT 1000,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsWallet_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SmsUsage" (
"id" TEXT NOT NULL,
"parts" INTEGER NOT NULL,
"costToman" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsUsage_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "SmsUsage_createdAt_idx" ON "SmsUsage"("createdAt");
CREATE TABLE "SmsTopUp" (
"id" TEXT NOT NULL,
"amountToman" INTEGER NOT NULL,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsTopUp_pkey" PRIMARY KEY ("id")
);
INSERT INTO "SmsWallet" ("id", "updatedAt")
VALUES ('default', CURRENT_TIMESTAMP);
+30
View File
@@ -757,3 +757,33 @@ model ProductSuggestion {
@@index([customerId, isActive]) @@index([customerId, isActive])
@@index([productId]) @@index([productId])
} }
/// یک ردیف برای تعرفه، موجودی و اطلاعات واریز پیامک
model SmsWallet {
id String @id @default("default")
balanceToman Int @default(0)
pricePerPart Int @default(250)
blocked Boolean @default(false)
cardNumber String @default("6219861941033817")
sheba String @default("IR805606118280056043604401")
accountName String @default("حسین منتظر")
yellowThreshold Int @default(10000)
redThreshold Int @default(1000)
updatedAt DateTime @updatedAt
}
model SmsUsage {
id String @id @default(cuid())
parts Int
costToman Int
createdAt DateTime @default(now())
@@index([createdAt])
}
model SmsTopUp {
id String @id @default(cuid())
amountToman Int
note String?
createdAt DateTime @default(now())
}
@@ -0,0 +1,34 @@
CREATE TABLE "SmsWallet" (
"id" TEXT NOT NULL DEFAULT 'default',
"balanceToman" INTEGER NOT NULL DEFAULT 0,
"pricePerPart" INTEGER NOT NULL DEFAULT 250,
"blocked" BOOLEAN NOT NULL DEFAULT false,
"cardNumber" TEXT NOT NULL DEFAULT '6219861941033817',
"sheba" TEXT NOT NULL DEFAULT 'IR805606118280056043604401',
"accountName" TEXT NOT NULL DEFAULT 'حسین منتظر',
"yellowThreshold" INTEGER NOT NULL DEFAULT 10000,
"redThreshold" INTEGER NOT NULL DEFAULT 1000,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsWallet_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SmsUsage" (
"id" TEXT NOT NULL,
"parts" INTEGER NOT NULL,
"costToman" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsUsage_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "SmsUsage_createdAt_idx" ON "SmsUsage"("createdAt");
CREATE TABLE "SmsTopUp" (
"id" TEXT NOT NULL,
"amountToman" INTEGER NOT NULL,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsTopUp_pkey" PRIMARY KEY ("id")
);
INSERT INTO "SmsWallet" ("id", "updatedAt")
VALUES ('default', CURRENT_TIMESTAMP);
+3 -1
View File
@@ -91,7 +91,9 @@ export class AuthService {
where: { id: challenge.id }, where: { id: challenge.id },
}); });
throw new ServiceUnavailableException( 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 { Module } from '@nestjs/common';
import { SmsService } from './sms.service'; import { SmsService } from './sms.service';
import { SmsWalletController } from './sms-wallet.controller';
import { SmsWalletService } from './sms-wallet.service';
@Module({ @Module({
providers: [SmsService], controllers: [SmsWalletController],
providers: [SmsService, SmsWalletService],
exports: [SmsService], exports: [SmsService],
}) })
export class SmsModule {} export class SmsModule {}
+27 -5
View File
@@ -1,5 +1,7 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { smsPartCount } from './sms-parts';
import { SmsWalletService } from './sms-wallet.service';
export type SmsSendResult = { export type SmsSendResult = {
success: boolean; success: boolean;
@@ -16,7 +18,10 @@ function maskPhone(phone: string): string {
export class SmsService { export class SmsService {
private readonly logger = new Logger(SmsService.name); private readonly logger = new Logger(SmsService.name);
constructor(private readonly config: ConfigService) {} constructor(
private readonly config: ConfigService,
private readonly wallet: SmsWalletService,
) {}
isEnabled(): boolean { isEnabled(): boolean {
const raw = ( const raw = (
@@ -39,6 +44,14 @@ export class SmsService {
} }
async send(to: string, message: string): Promise<SmsSendResult> { 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>( const url = this.config.get<string>(
'SMS_API_URL', 'SMS_API_URL',
'http://185.231.112.101/api/createsms', 'http://185.231.112.101/api/createsms',
@@ -61,16 +74,16 @@ export class SmsService {
body: JSON.stringify({ body: JSON.stringify({
phone: to, phone: to,
host, host,
text: message.trim(), text,
platform, platform,
}), }),
signal: AbortSignal.timeout(15_000), signal: AbortSignal.timeout(15_000),
}); });
const text = await response.text(); const rawText = await response.text();
let body: unknown = text; let body: unknown = rawText;
try { try {
body = text ? JSON.parse(text) : null; body = rawText ? JSON.parse(rawText) : null;
} catch { } catch {
/* keep raw text */ /* keep raw text */
} }
@@ -92,6 +105,15 @@ export class SmsService {
this.logger.warn( this.logger.warn(
`SMS failed to ${maskPhone(to)}: ${JSON.stringify(body)}`, `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 }; return { success, raw: body };