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([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);
|
||||||
@@ -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'
|
||||||
|
? 'ارسال پیامک بهخاطر اتمام اعتبار یا مسدود بودن کیف پول متوقف است'
|
||||||
|
: 'ارسال پیامک با خطا مواجه شد؛ دوباره تلاش کنید',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user