Let cheques be received or paid without an invoice, and remind staff three days before due date.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-11 11:34:49 +03:30
co-authored by Cursor
parent 9a991fc1f1
commit 9beaed96e8
18 changed files with 982 additions and 61 deletions
@@ -0,0 +1,139 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { NotificationsService } from '../notifications/notifications.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.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: SmsService,
) {}
@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: {
organizationId: row.organizationId,
OR: [
{ role: { in: ['owner', 'admin'] } },
...(row.ownerId ? [{ id: row.ownerId }] : []),
],
},
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.sendSmsDetailed(user.mobile, staffText);
}
}
if (incoming && row.person?.mobile) {
await this.sms.sendSmsDetailed(
row.person.mobile,
personIncomingSms({
number: row.number,
amount,
currency: row.currency,
dueDate: row.dueDate,
}),
);
}
}
}