diff --git a/prisma/migrations/20260924183000_sms_wallet/migration.sql b/prisma/migrations/20260924183000_sms_wallet/migration.sql new file mode 100644 index 0000000..adee1b6 --- /dev/null +++ b/prisma/migrations/20260924183000_sms_wallet/migration.sql @@ -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); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6065aeb..d240119 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -757,3 +757,33 @@ model ProductSuggestion { @@index([customerId, isActive]) @@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()) +} diff --git a/runtime/migrations/20260924183000_sms_wallet/migration.sql b/runtime/migrations/20260924183000_sms_wallet/migration.sql new file mode 100644 index 0000000..adee1b6 --- /dev/null +++ b/runtime/migrations/20260924183000_sms_wallet/migration.sql @@ -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); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 427dd97..9084e81 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -91,7 +91,9 @@ export class AuthService { where: { id: challenge.id }, }); throw new ServiceUnavailableException( - 'ارسال پیامک با خطا مواجه شد؛ دوباره تلاش کنید', + sent.raw === 'sms wallet blocked' + ? 'ارسال پیامک به‌خاطر اتمام اعتبار یا مسدود بودن کیف پول متوقف است' + : 'ارسال پیامک با خطا مواجه شد؛ دوباره تلاش کنید', ); } } diff --git a/src/sms/dto/sms-wallet.dto.ts b/src/sms/dto/sms-wallet.dto.ts new file mode 100644 index 0000000..6136f56 --- /dev/null +++ b/src/sms/dto/sms-wallet.dto.ts @@ -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; +} diff --git a/src/sms/sms-parts.spec.ts b/src/sms/sms-parts.spec.ts new file mode 100644 index 0000000..5c82dd3 --- /dev/null +++ b/src/sms/sms-parts.spec.ts @@ -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); + }); +}); diff --git a/src/sms/sms-parts.ts b/src/sms/sms-parts.ts new file mode 100644 index 0000000..83544cc --- /dev/null +++ b/src/sms/sms-parts.ts @@ -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); +} diff --git a/src/sms/sms-wallet.controller.ts b/src/sms/sms-wallet.controller.ts new file mode 100644 index 0000000..2a215d5 --- /dev/null +++ b/src/sms/sms-wallet.controller.ts @@ -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); + } +} diff --git a/src/sms/sms-wallet.service.ts b/src/sms/sms-wallet.service.ts new file mode 100644 index 0000000..e5a2539 --- /dev/null +++ b/src/sms/sms-wallet.service.ts @@ -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 { + 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 { + 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, + }; +} diff --git a/src/sms/sms.module.ts b/src/sms/sms.module.ts index cc282dd..1051c58 100644 --- a/src/sms/sms.module.ts +++ b/src/sms/sms.module.ts @@ -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 {} diff --git a/src/sms/sms.service.ts b/src/sms/sms.service.ts index 9b8a50e..816a9e2 100644 --- a/src/sms/sms.service.ts +++ b/src/sms/sms.service.ts @@ -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 { + 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( '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 };