204 lines
6.1 KiB
TypeScript
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,
|
|
};
|
|
}
|