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,64 @@
import {
addIsoDays,
clipSms,
isDueInDays,
personIncomingSms,
staffIncomingSms,
staffOutgoingSms,
tehranDayKey,
} from '../cheque-reminder.util';
function assert(cond: unknown, msg: string): asserts cond {
if (!cond) throw new Error(msg);
}
function testDayMath() {
assert(addIsoDays('2026-09-11', 3) === '2026-09-14', 'add 3 days');
const now = new Date('2026-09-11T04:30:00.000Z'); // 08:00 Tehran
const due = new Date('2026-09-14T00:00:00.000Z');
assert(isDueInDays(due, 3, now), 'due in exactly 3 Tehran days');
assert(!isDueInDays(due, 2, now), 'not 2 days');
assert(tehranDayKey(now) === '2026-09-11', 'tehran day key');
}
function testSms() {
const due = new Date('2026-09-14T00:00:00.000Z');
const person = personIncomingSms({
number: '۱۲۳۴',
amount: 1_000_000,
currency: 'IRR',
dueDate: due,
});
assert(person.startsWith('یادآوری منتکت:'), 'person prefix');
assert(person.includes('۱۲۳۴'), 'person number');
assert(person.length <= 160, 'person length');
const staffIn = staffIncomingSms({
number: '۱۲۳۴',
personName: 'علی رضایی',
amount: 1_000_000,
currency: 'IRR',
dueDate: due,
});
assert(staffIn.includes('دریافتی'), 'staff in label');
assert(staffIn.includes('علی رضایی'), 'staff in name');
const staffOut = staffOutgoingSms({
number: '۱۲۳۴',
personName: 'علی رضایی',
amount: 1_000_000,
currency: 'IRR',
dueDate: due,
});
assert(staffOut.includes('پرداختی'), 'staff out label');
assert(!staffOut.includes('دریافتی'), 'staff out not incoming');
assert(clipSms('x'.repeat(200)).length === 160, 'clip');
}
function main() {
testDayMath();
testSms();
console.log('cheque reminder checks: OK');
}
main();
@@ -0,0 +1,87 @@
import { brand, formatMoneyAmount } from '@mento/shared';
import { toJalali } from '../common/jalali';
const TEHRAN_TZ = 'Asia/Tehran';
const SMS_MAX = 160;
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
export const CHEQUE_REMINDER_DAYS = 3;
export function toFaDigits(value: string | number): string {
return String(value).replace(/\d/g, (d) => FA_DIGITS[Number(d)] ?? d);
}
export function tehranDayKey(d: Date): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: TEHRAN_TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d);
}
export function addIsoDays(dayKey: string, days: number): string {
const [y, m, d] = dayKey.split('-').map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
dt.setUTCDate(dt.getUTCDate() + days);
return dt.toISOString().slice(0, 10);
}
export function isDueInDays(
dueDate: Date,
days: number,
now: Date = new Date(),
): boolean {
return tehranDayKey(dueDate) === addIsoDays(tehranDayKey(now), days);
}
export function formatTehranJalaliNumeric(d: Date): string {
const [gy, gm, gd] = tehranDayKey(d).split('-').map(Number);
const { jy, jm, jd } = toJalali(gy, gm, gd);
return `${toFaDigits(jy)}/${toFaDigits(String(jm).padStart(2, '0'))}/${toFaDigits(String(jd).padStart(2, '0'))}`;
}
export function clipSms(text: string): string {
return text.slice(0, SMS_MAX);
}
function money(amount: number, currency: string): string {
return formatMoneyAmount(amount, currency);
}
export function personIncomingSms(input: {
number: string;
amount: number;
currency: string;
dueDate: Date;
}): string {
return clipSms(
`${brand.reminderPrefixFa} چک شماره ${input.number} به مبلغ ${money(input.amount, input.currency)} سه روز دیگر (${formatTehranJalaliNumeric(input.dueDate)}) سررسید می‌شود.`,
);
}
export function staffIncomingSms(input: {
number: string;
personName: string;
amount: number;
currency: string;
dueDate: Date;
}): string {
const who = input.personName.trim() || 'طرف‌حساب';
return clipSms(
`${brand.reminderPrefixFa} چک دریافتی ${input.number} از ${who}، مبلغ ${money(input.amount, input.currency)}، سررسید ${formatTehranJalaliNumeric(input.dueDate)} (۳ روز دیگر).`,
);
}
export function staffOutgoingSms(input: {
number: string;
personName: string;
amount: number;
currency: string;
dueDate: Date;
}): string {
const who = input.personName.trim() || 'طرف‌حساب';
return clipSms(
`${brand.reminderPrefixFa} چک پرداختی ${input.number} به ${who}، مبلغ ${money(input.amount, input.currency)}، سررسید ${formatTehranJalaliNumeric(input.dueDate)} (۳ روز دیگر).`,
);
}
@@ -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,
}),
);
}
}
}
+91 -13
View File
@@ -3,10 +3,14 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { ChequeDto, UpdateChequeRequest } from '@mento/shared';
import type {
ChequeDto,
CreateChequeRequest,
UpdateChequeRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { type Actor, ownerWhere } from '../common/record-scope';
import { type Actor, elevated, ownerWhere } from '../common/record-scope';
const CHEQUE_STATUSES = ['pending', 'cleared', 'bounced'] as const;
@@ -17,13 +21,16 @@ export class ChequesService {
async list(
organizationId: string,
actor: Pick<Actor, 'userId' | 'role'>,
opts?: { status?: string; q?: string },
opts?: { status?: string; q?: string; direction?: string },
): Promise<ChequeDto[]> {
const rows = await this.prisma.cheque.findMany({
where: {
organizationId,
invoice: ownerWhere(actor),
...this.accessWhere(actor),
...(opts?.status ? { status: opts.status } : {}),
...(opts?.direction === 'in' || opts?.direction === 'out'
? { direction: opts.direction }
: {}),
...(opts?.q?.trim()
? {
OR: [
@@ -34,6 +41,24 @@ export class ChequesService {
number: { contains: opts.q.trim(), mode: 'insensitive' },
},
},
{
person: {
OR: [
{
firstName: {
contains: opts.q.trim(),
mode: 'insensitive',
},
},
{
lastName: {
contains: opts.q.trim(),
mode: 'insensitive',
},
},
],
},
},
],
}
: {}),
@@ -45,6 +70,45 @@ export class ChequesService {
return rows.map((row) => this.toDto(row));
}
async create(
organizationId: string,
actor: Pick<Actor, 'userId' | 'role'>,
dto: CreateChequeRequest,
): Promise<ChequeDto> {
const person = await this.prisma.person.findFirst({
where: { id: dto.personId, organizationId, ...ownerWhere(actor) },
select: { id: true },
});
if (!person) throw new BadRequestException('شخص معتبر نیست');
if (!(dto.amount > 0)) {
throw new BadRequestException('مبلغ چک لازم است');
}
if (!dto.number?.trim() || !dto.bank?.trim() || !dto.dueDate) {
throw new BadRequestException('شماره، بانک و سررسید چک لازم است');
}
const row = await this.prisma.cheque.create({
data: {
organizationId,
personId: person.id,
ownerId: actor.userId,
amount: new Prisma.Decimal(dto.amount),
currency: (dto.currency ?? 'IRR').trim() || 'IRR',
number: dto.number.trim(),
bank: dto.bank.trim(),
dueDate: new Date(dto.dueDate),
direction: dto.direction === 'out' ? 'out' : 'in',
status:
dto.status === 'cleared' || dto.status === 'bounced'
? dto.status
: 'pending',
notes: dto.notes?.trim() ? dto.notes.trim() : null,
},
include: this.include,
});
return this.toDto(row);
}
async update(
organizationId: string,
id: string,
@@ -52,7 +116,7 @@ export class ChequesService {
dto: UpdateChequeRequest,
): Promise<ChequeDto> {
const existing = await this.prisma.cheque.findFirst({
where: { id, organizationId, invoice: ownerWhere(actor) },
where: { id, organizationId, ...this.accessWhere(actor) },
select: { id: true },
});
if (!existing) throw new NotFoundException('چک پیدا نشد');
@@ -79,8 +143,18 @@ export class ChequesService {
return this.toDto(row);
}
private accessWhere(
actor: Pick<Actor, 'userId' | 'role'>,
): Prisma.ChequeWhereInput {
if (elevated(actor.role)) return {};
return {
OR: [{ ownerId: actor.userId }, { invoice: { ownerId: actor.userId } }],
};
}
private include = {
payment: { select: { id: true, amount: true, currency: true, paidAt: true } },
person: { select: { firstName: true, lastName: true } },
invoice: {
select: {
id: true,
@@ -95,24 +169,28 @@ export class ChequesService {
private toDto(
row: Prisma.ChequeGetPayload<{ include: ChequesService['include'] }>,
): ChequeDto {
const personName = row.invoice.person
? `${row.invoice.person.firstName} ${row.invoice.person.lastName}`.trim()
: row.invoice.company?.name ?? null;
const personName = row.person
? `${row.person.firstName} ${row.person.lastName}`.trim()
: row.invoice?.person
? `${row.invoice.person.firstName} ${row.invoice.person.lastName}`.trim()
: row.invoice?.company?.name ?? null;
return {
id: row.id,
paymentId: row.paymentId,
invoiceId: row.invoiceId,
invoiceNumber: row.invoice.number,
invoiceTitle: row.invoice.title,
invoiceNumber: row.invoice?.number ?? null,
invoiceTitle: row.invoice?.title ?? null,
personId: row.personId,
personName,
amount: Number(row.payment.amount),
currency: row.payment.currency,
amount: Number(row.amount ?? row.payment?.amount ?? 0),
currency: row.currency || row.payment?.currency || 'IRR',
number: row.number,
bank: row.bank,
dueDate: row.dueDate.toISOString(),
status: row.status,
direction: row.direction === 'out' ? 'out' : 'in',
notes: row.notes,
paidAt: row.payment.paidAt.toISOString(),
paidAt: (row.payment?.paidAt ?? row.createdAt).toISOString(),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
+53 -1
View File
@@ -1,6 +1,58 @@
import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
import {
IsIn,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
const CHEQUE_STATUSES = ['pending', 'cleared', 'bounced'] as const;
const CHEQUE_DIRECTIONS = ['in', 'out'] as const;
export class CreateChequeDto {
@IsString()
@MinLength(1)
personId!: string;
@Type(() => Number)
@IsNumber()
@Min(0.01)
amount!: number;
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
@IsString()
@MinLength(1)
@MaxLength(80)
number!: string;
@IsString()
@MinLength(1)
@MaxLength(120)
bank!: string;
@IsString()
dueDate!: string;
@IsOptional()
@IsIn(CHEQUE_STATUSES)
status?: (typeof CHEQUE_STATUSES)[number];
@IsOptional()
@IsIn(CHEQUE_DIRECTIONS)
direction?: (typeof CHEQUE_DIRECTIONS)[number];
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
}
export class UpdateChequeDto {
@IsOptional()
+12 -3
View File
@@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, Patch, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { UpdateChequeDto } from './dto';
import { CreateChequeDto, UpdateChequeDto } from './dto';
import { ChequesService } from './cheques.service';
import { CommissionsService } from './commissions.service';
@@ -20,8 +20,17 @@ export class FinanceController {
@CurrentUser() user: AuthUser,
@Query('status') status?: string,
@Query('q') q?: string,
@Query('direction') direction?: string,
) {
return this.cheques.list(user.organizationId, user, { status, q });
return this.cheques.list(user.organizationId, user, { status, q, direction });
}
@Post('cheques')
createCheque(
@CurrentUser() user: AuthUser,
@Body() dto: CreateChequeDto,
) {
return this.cheques.create(user.organizationId, user, dto);
}
@Patch('cheques/:id')
+5 -2
View File
@@ -1,13 +1,16 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { SmsService } from '../common/sms.service';
import { ChequesService } from './cheques.service';
import { ChequeRemindersService } from './cheque-reminders.service';
import { CommissionsService } from './commissions.service';
import { FinanceController } from './finance.controller';
@Module({
imports: [AuthModule],
imports: [AuthModule, NotificationsModule],
controllers: [FinanceController],
providers: [ChequesService, CommissionsService],
providers: [ChequesService, ChequeRemindersService, CommissionsService, SmsService],
exports: [ChequesService, CommissionsService],
})
export class FinanceModule {}