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(); 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; const crossedToEmpty = wallet.balanceToman > 0 && balanceToman <= 0; await tx.smsWallet.update({ where: { id: WALLET_ID }, data: { balanceToman, blocked: crossedToEmpty ? 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, 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) 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 && 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 }, }); 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, }; }