Files
CRM_Montzar/apps/api/src/finance/cheque-reminders.service.ts
T
Mahdi f5d68d83fc feat: add per-org SMS wallet with adm panel and header credit
Bill all platform SMS (including OTP), allow negative balance with manual/auto cutoffs, and support own-panel bypass without wallet debit.
2026-10-19 15:55:48 +03:30

161 lines
4.5 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { NotificationsService } from '../notifications/notifications.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import {
CHEQUE_REMINDER_DAYS,
isDueInDays,
personIncomingSms,
staffIncomingSms,
staffOutgoingSms,
} from './cheque-reminder.util';
@Injectable()
export class ChequeRemindersService {
private readonly logger = new Logger(ChequeRemindersService.name);
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly sms: SmsGatewayService,
) {}
@Cron('0 8 * * *', { timeZone: 'Asia/Tehran' })
async handleDaily(): Promise<void> {
await this.notifyDueSoon();
}
async notifyDueSoon(now: Date = new Date()): Promise<number> {
const from = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const to = new Date(now.getTime() + 6 * 24 * 60 * 60 * 1000);
const rows = await this.prisma.cheque.findMany({
where: {
status: 'pending',
reminderNotifiedAt: null,
dueDate: { gte: from, lte: to },
},
include: {
person: { select: { firstName: true, lastName: true, mobile: true } },
},
take: 500,
});
let sent = 0;
for (const row of rows) {
if (!isDueInDays(row.dueDate, CHEQUE_REMINDER_DAYS, now)) continue;
try {
await this.notifyOne(row);
sent += 1;
} catch (err) {
this.logger.warn(
`cheque reminder failed ${row.id}: ${err instanceof Error ? err.message : 'unknown'}`,
);
} finally {
await this.prisma.cheque.update({
where: { id: row.id },
data: { reminderNotifiedAt: new Date() },
});
}
}
if (sent) this.logger.log(`cheque reminders sent: ${sent}`);
return sent;
}
private async notifyOne(row: {
id: string;
organizationId: string;
ownerId: string | null;
direction: string;
number: string;
amount: { toString(): string } | number;
currency: string;
dueDate: Date;
person: { firstName: string; lastName: string; mobile: string | null } | null;
}) {
const personName = row.person
? `${row.person.firstName} ${row.person.lastName}`.trim()
: '';
const amount = Number(row.amount);
const incoming = row.direction !== 'out';
const staffText = incoming
? staffIncomingSms({
number: row.number,
personName,
amount,
currency: row.currency,
dueDate: row.dueDate,
})
: staffOutgoingSms({
number: row.number,
personName,
amount,
currency: row.currency,
dueDate: row.dueDate,
});
const staff = await this.prisma.user.findMany({
where: {
OR: [
{
memberships: {
some: {
organizationId: row.organizationId,
role: { in: ['owner', 'admin'] },
isActive: true,
},
},
},
...(row.ownerId
? [
{
id: row.ownerId,
memberships: { some: { organizationId: row.organizationId } },
},
]
: []),
],
},
select: { id: true, mobile: true },
});
const seen = new Set<string>();
for (const user of staff) {
if (seen.has(user.id)) continue;
seen.add(user.id);
await this.notifications.create({
organizationId: row.organizationId,
userId: user.id,
type: 'cheque_due',
title: 'سررسید چک',
body: staffText.replace(/^یادآوری منتکت:\s*/, ''),
link: '/cheques',
entityType: 'cheque',
entityId: row.id,
dedupe: true,
});
if (user.mobile) {
await this.sms.send({
organizationId: row.organizationId,
phone: user.mobile,
text: staffText,
source: 'cheque_reminder',
});
}
}
if (incoming && row.person?.mobile) {
await this.sms.send({
organizationId: row.organizationId,
phone: row.person.mobile,
text: personIncomingSms({
number: row.number,
amount,
currency: row.currency,
dueDate: row.dueDate,
}),
source: 'cheque_reminder',
});
}
}
}