feat: complete application updates
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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);
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -91,7 +91,9 @@ export class AuthService {
|
||||
where: { id: challenge.id },
|
||||
});
|
||||
throw new ServiceUnavailableException(
|
||||
'ارسال پیامک با خطا مواجه شد؛ دوباره تلاش کنید',
|
||||
sent.raw === 'sms wallet blocked'
|
||||
? 'ارسال پیامک بهخاطر اتمام اعتبار یا مسدود بودن کیف پول متوقف است'
|
||||
: 'ارسال پیامک با خطا مواجه شد؛ دوباره تلاش کنید',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user