Files
nademitex_server/src/sms/sms-wallet.service.ts
T

204 lines
6.1 KiB
TypeScript

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();
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;
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,
};
}