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
+1
View File
@@ -20,6 +20,7 @@
"@nestjs/jwt": "^11.0.0", "@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.12", "@nestjs/platform-express": "^11.0.12",
"@nestjs/schedule": "^6.0.1",
"@prisma/client": "^6.5.0", "@prisma/client": "^6.5.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
@@ -0,0 +1,31 @@
-- AlterTable
ALTER TABLE "Cheque" ALTER COLUMN "paymentId" DROP NOT NULL;
ALTER TABLE "Cheque" ALTER COLUMN "invoiceId" DROP NOT NULL;
ALTER TABLE "Cheque" ADD COLUMN "amount" DECIMAL(18,2);
ALTER TABLE "Cheque" ADD COLUMN "currency" TEXT NOT NULL DEFAULT 'IRR';
ALTER TABLE "Cheque" ADD COLUMN "personId" TEXT;
ALTER TABLE "Cheque" ADD COLUMN "ownerId" TEXT;
UPDATE "Cheque" AS c
SET
"amount" = p."amount",
"currency" = p."currency"
FROM "Payment" AS p
WHERE c."paymentId" = p."id";
UPDATE "Cheque" AS c
SET
"personId" = i."personId",
"ownerId" = i."ownerId"
FROM "Invoice" AS i
WHERE c."invoiceId" = i."id";
UPDATE "Cheque" SET "amount" = 0 WHERE "amount" IS NULL;
ALTER TABLE "Cheque" ALTER COLUMN "amount" SET NOT NULL;
CREATE INDEX "Cheque_personId_idx" ON "Cheque"("personId");
CREATE INDEX "Cheque_ownerId_idx" ON "Cheque"("ownerId");
ALTER TABLE "Cheque" ADD CONSTRAINT "Cheque_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Cheque" ADD CONSTRAINT "Cheque_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Cheque" ADD COLUMN "direction" TEXT NOT NULL DEFAULT 'in';
ALTER TABLE "Cheque" ADD COLUMN "reminderNotifiedAt" TIMESTAMP(3);
CREATE INDEX "Cheque_organizationId_direction_dueDate_idx" ON "Cheque"("organizationId", "direction", "dueDate");
+19 -5
View File
@@ -213,6 +213,7 @@ model User {
salesGoals SalesGoal[] @relation("SalesGoalUser") salesGoals SalesGoal[] @relation("SalesGoalUser")
commissionPercent Decimal @default(0) @db.Decimal(8, 2) commissionPercent Decimal @default(0) @db.Decimal(8, 2)
commissionEntries CommissionEntry[] commissionEntries CommissionEntry[]
receivedCheques Cheque[] @relation("ChequeOwner")
assignedTickets Ticket[] @relation("TicketAssignee") assignedTickets Ticket[] @relation("TicketAssignee")
reportedTickets Ticket[] @relation("TicketReporter") reportedTickets Ticket[] @relation("TicketReporter")
ticketMessages TicketMessage[] @relation("TicketMessageAuthor") ticketMessages TicketMessage[] @relation("TicketMessageAuthor")
@@ -291,6 +292,7 @@ model Person {
accountingLinks PersonAccountingLink[] accountingLinks PersonAccountingLink[]
accountingDocuments AccountingDocumentLog[] accountingDocuments AccountingDocumentLog[]
openingBalances PartyOpeningBalance[] openingBalances PartyOpeningBalance[]
cheques Cheque[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -1284,26 +1286,38 @@ model CommissionEntry {
@@index([status]) @@index([status])
} }
/// چک دریافتی وصل به پرداخت فاکتور /// چک دریافتی؛ مستقل یا وصل به پرداخت فاکتور
model Cheque { model Cheque {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
paymentId String @unique paymentId String? @unique
payment Payment @relation(fields: [paymentId], references: [id], onDelete: Cascade) payment Payment? @relation(fields: [paymentId], references: [id], onDelete: Cascade)
invoiceId String invoiceId String?
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
ownerId String?
owner User? @relation("ChequeOwner", fields: [ownerId], references: [id], onDelete: SetNull)
amount Decimal @db.Decimal(18, 2)
currency String @default("IRR")
number String number String
bank String bank String
dueDate DateTime dueDate DateTime
/// pending | cleared | bounced /// pending | cleared | bounced
status String @default("pending") status String @default("pending")
/// in = دریافتی، out = پرداختی
direction String @default("in")
reminderNotifiedAt DateTime?
notes String? notes String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([organizationId, status, dueDate]) @@index([organizationId, status, dueDate])
@@index([organizationId, direction, dueDate])
@@index([invoiceId]) @@index([invoiceId])
@@index([personId])
@@index([ownerId])
} }
/// مانده اول دوره طرف‌حساب (مثبت = بدهکار ما) /// مانده اول دوره طرف‌حساب (مثبت = بدهکار ما)
+2
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
@@ -36,6 +37,7 @@ import { FinanceModule } from './finance/finance.module';
isGlobal: true, isGlobal: true,
envFilePath: ['.env', '../../.env'], envFilePath: ['.env', '../../.env'],
}), }),
ScheduleModule.forRoot(),
AiModule, AiModule,
PrismaModule, PrismaModule,
StorageModule, StorageModule,
@@ -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, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { ChequeDto, UpdateChequeRequest } from '@mento/shared'; import type {
ChequeDto,
CreateChequeRequest,
UpdateChequeRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; 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; const CHEQUE_STATUSES = ['pending', 'cleared', 'bounced'] as const;
@@ -17,13 +21,16 @@ export class ChequesService {
async list( async list(
organizationId: string, organizationId: string,
actor: Pick<Actor, 'userId' | 'role'>, actor: Pick<Actor, 'userId' | 'role'>,
opts?: { status?: string; q?: string }, opts?: { status?: string; q?: string; direction?: string },
): Promise<ChequeDto[]> { ): Promise<ChequeDto[]> {
const rows = await this.prisma.cheque.findMany({ const rows = await this.prisma.cheque.findMany({
where: { where: {
organizationId, organizationId,
invoice: ownerWhere(actor), ...this.accessWhere(actor),
...(opts?.status ? { status: opts.status } : {}), ...(opts?.status ? { status: opts.status } : {}),
...(opts?.direction === 'in' || opts?.direction === 'out'
? { direction: opts.direction }
: {}),
...(opts?.q?.trim() ...(opts?.q?.trim()
? { ? {
OR: [ OR: [
@@ -34,6 +41,24 @@ export class ChequesService {
number: { contains: opts.q.trim(), mode: 'insensitive' }, 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)); 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( async update(
organizationId: string, organizationId: string,
id: string, id: string,
@@ -52,7 +116,7 @@ export class ChequesService {
dto: UpdateChequeRequest, dto: UpdateChequeRequest,
): Promise<ChequeDto> { ): Promise<ChequeDto> {
const existing = await this.prisma.cheque.findFirst({ const existing = await this.prisma.cheque.findFirst({
where: { id, organizationId, invoice: ownerWhere(actor) }, where: { id, organizationId, ...this.accessWhere(actor) },
select: { id: true }, select: { id: true },
}); });
if (!existing) throw new NotFoundException('چک پیدا نشد'); if (!existing) throw new NotFoundException('چک پیدا نشد');
@@ -79,8 +143,18 @@ export class ChequesService {
return this.toDto(row); 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 = { private include = {
payment: { select: { id: true, amount: true, currency: true, paidAt: true } }, payment: { select: { id: true, amount: true, currency: true, paidAt: true } },
person: { select: { firstName: true, lastName: true } },
invoice: { invoice: {
select: { select: {
id: true, id: true,
@@ -95,24 +169,28 @@ export class ChequesService {
private toDto( private toDto(
row: Prisma.ChequeGetPayload<{ include: ChequesService['include'] }>, row: Prisma.ChequeGetPayload<{ include: ChequesService['include'] }>,
): ChequeDto { ): ChequeDto {
const personName = row.invoice.person const personName = row.person
? `${row.invoice.person.firstName} ${row.invoice.person.lastName}`.trim() ? `${row.person.firstName} ${row.person.lastName}`.trim()
: row.invoice.company?.name ?? null; : row.invoice?.person
? `${row.invoice.person.firstName} ${row.invoice.person.lastName}`.trim()
: row.invoice?.company?.name ?? null;
return { return {
id: row.id, id: row.id,
paymentId: row.paymentId, paymentId: row.paymentId,
invoiceId: row.invoiceId, invoiceId: row.invoiceId,
invoiceNumber: row.invoice.number, invoiceNumber: row.invoice?.number ?? null,
invoiceTitle: row.invoice.title, invoiceTitle: row.invoice?.title ?? null,
personId: row.personId,
personName, personName,
amount: Number(row.payment.amount), amount: Number(row.amount ?? row.payment?.amount ?? 0),
currency: row.payment.currency, currency: row.currency || row.payment?.currency || 'IRR',
number: row.number, number: row.number,
bank: row.bank, bank: row.bank,
dueDate: row.dueDate.toISOString(), dueDate: row.dueDate.toISOString(),
status: row.status, status: row.status,
direction: row.direction === 'out' ? 'out' : 'in',
notes: row.notes, notes: row.notes,
paidAt: row.payment.paidAt.toISOString(), paidAt: (row.payment?.paidAt ?? row.createdAt).toISOString(),
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.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_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 { export class UpdateChequeDto {
@IsOptional() @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 { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator'; import { CurrentUser } from '../auth/current-user.decorator';
import { UpdateChequeDto } from './dto'; import { CreateChequeDto, UpdateChequeDto } from './dto';
import { ChequesService } from './cheques.service'; import { ChequesService } from './cheques.service';
import { CommissionsService } from './commissions.service'; import { CommissionsService } from './commissions.service';
@@ -20,8 +20,17 @@ export class FinanceController {
@CurrentUser() user: AuthUser, @CurrentUser() user: AuthUser,
@Query('status') status?: string, @Query('status') status?: string,
@Query('q') q?: 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') @Patch('cheques/:id')
+5 -2
View File
@@ -1,13 +1,16 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { SmsService } from '../common/sms.service';
import { ChequesService } from './cheques.service'; import { ChequesService } from './cheques.service';
import { ChequeRemindersService } from './cheque-reminders.service';
import { CommissionsService } from './commissions.service'; import { CommissionsService } from './commissions.service';
import { FinanceController } from './finance.controller'; import { FinanceController } from './finance.controller';
@Module({ @Module({
imports: [AuthModule], imports: [AuthModule, NotificationsModule],
controllers: [FinanceController], controllers: [FinanceController],
providers: [ChequesService, CommissionsService], providers: [ChequesService, ChequeRemindersService, CommissionsService, SmsService],
exports: [ChequesService, CommissionsService], exports: [ChequesService, CommissionsService],
}) })
export class FinanceModule {} export class FinanceModule {}
+17 -3
View File
@@ -446,7 +446,14 @@ export class InvoicesService {
): Promise<InvoiceDto> { ): Promise<InvoiceDto> {
const invoice = await this.prisma.invoice.findFirst({ const invoice = await this.prisma.invoice.findFirst({
where: { id: invoiceId, organizationId, ...ownerWhere(actor) }, where: { id: invoiceId, organizationId, ...ownerWhere(actor) },
select: { id: true, status: true, currency: true, organizationId: true }, select: {
id: true,
status: true,
currency: true,
organizationId: true,
personId: true,
ownerId: true,
},
}); });
if (!invoice) throw new NotFoundException('فاکتور پیدا نشد'); if (!invoice) throw new NotFoundException('فاکتور پیدا نشد');
if (invoice.status === 'canceled') { if (invoice.status === 'canceled') {
@@ -493,9 +500,14 @@ export class InvoicesService {
organizationId: invoice.organizationId, organizationId: invoice.organizationId,
paymentId: payment.id, paymentId: payment.id,
invoiceId, invoiceId,
personId: invoice.personId,
ownerId: invoice.ownerId,
amount: new Prisma.Decimal(dto.amount),
currency: (dto.currency ?? invoice.currency).trim() || 'IRR',
number: dto.cheque.number.trim(), number: dto.cheque.number.trim(),
bank: dto.cheque.bank.trim(), bank: dto.cheque.bank.trim(),
dueDate: new Date(dto.cheque.dueDate), dueDate: new Date(dto.cheque.dueDate),
direction: 'in',
status: dto.cheque.status === 'cleared' || dto.cheque.status === 'bounced' status: dto.cheque.status === 'cleared' || dto.cheque.status === 'bounced'
? dto.cheque.status ? dto.cheque.status
: 'pending', : 'pending',
@@ -765,15 +777,17 @@ export class InvoicesService {
invoiceId: row.id, invoiceId: row.id,
invoiceNumber: row.number, invoiceNumber: row.number,
invoiceTitle: row.title, invoiceTitle: row.title,
personId: row.person?.id ?? p.cheque.personId ?? null,
personName: row.person personName: row.person
? `${row.person.firstName} ${row.person.lastName}`.trim() ? `${row.person.firstName} ${row.person.lastName}`.trim()
: row.company?.name ?? null, : row.company?.name ?? null,
amount: Number(p.amount), amount: Number(p.cheque.amount ?? p.amount),
currency: p.currency, currency: p.cheque.currency || p.currency,
number: p.cheque.number, number: p.cheque.number,
bank: p.cheque.bank, bank: p.cheque.bank,
dueDate: p.cheque.dueDate.toISOString(), dueDate: p.cheque.dueDate.toISOString(),
status: p.cheque.status, status: p.cheque.status,
direction: p.cheque.direction === 'out' ? 'out' : 'in',
notes: p.cheque.notes, notes: p.cheque.notes,
paidAt: p.paidAt.toISOString(), paidAt: p.paidAt.toISOString(),
createdAt: p.cheque.createdAt.toISOString(), createdAt: p.cheque.createdAt.toISOString(),
+298 -30
View File
@@ -1,12 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router'; import { RouterLink } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money'; import { CURRENCY_OPTIONS, formatMoney, parseMoneyNumber } from '@/lib/money';
import { formatJalaliNumeric } from '@/lib/jalali'; import { formatJalaliNumeric, formatLocalDateInput } from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import UiButton from '@/components/ui/UiButton.vue'; import UiButton from '@/components/ui/UiButton.vue';
import type { ChequeDto, ChequeStatus } from '@mento/shared'; import UiInput from '@/components/ui/UiInput.vue';
import UiModal from '@/components/ui/UiModal.vue';
import type {
ChequeDirection,
ChequeDto,
ChequeStatus,
CreateChequeRequest,
OrganizationDto,
PaginatedResponse,
PersonDto,
} from '@mento/shared';
const loading = ref(false); const loading = ref(false);
const error = ref(''); const error = ref('');
@@ -14,27 +26,69 @@ const items = ref<ChequeDto[]>([]);
const status = ref(''); const status = ref('');
const q = ref(''); const q = ref('');
const savingId = ref(''); const savingId = ref('');
const tab = ref<ChequeDirection>('in');
const statusLabel: Record<ChequeStatus, string> = { const formOpen = ref(false);
pending: 'در جریان', const formDirection = ref<ChequeDirection>('in');
cleared: 'وصول شد', const saving = ref(false);
bounced: 'برگشت', const formError = ref('');
}; const people = ref<PersonDto[]>([]);
const enabledCurrencies = ref<string[]>(['IRR']);
const personId = ref('');
const amount = ref('');
const currency = ref('IRR');
const chequeNumber = ref('');
const chequeBank = ref('');
const dueDate = ref(formatLocalDateInput(new Date()));
const formStatus = ref<ChequeStatus>('pending');
const notes = ref('');
const filtered = computed(() => items.value); function statusLabelFor(direction: string): Record<ChequeStatus, string> {
return {
pending: 'در جریان',
cleared: direction === 'out' ? 'پرداخت شد' : 'وصول شد',
bounced: 'برگشت',
};
}
const formStatusLabel = computed(() => statusLabelFor(formDirection.value));
const isIncomingForm = computed(() => formDirection.value !== 'out');
const currencyOptions = computed(() => {
const codes = enabledCurrencies.value.length ? [...enabledCurrencies.value] : [currency.value];
if (currency.value && !codes.includes(currency.value)) codes.push(currency.value);
return codes.map((code) => {
const opt = CURRENCY_OPTIONS.find((c) => c.code === code);
return { code, label: opt ? `${opt.suffix} - ${opt.code}` : code };
});
});
function axiosMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
function personLabel(p: PersonDto) {
return p.company?.name ? `${p.fullName} — ${p.company.name}` : p.fullName;
}
async function load() { async function load() {
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
try { try {
const { data } = await api.get<ChequeDto[]>('/cheques', { const { data } = await api.get<ChequeDto[]>('/cheques', {
params: { status: status.value || undefined, q: q.value || undefined }, params: {
status: status.value || undefined,
q: q.value || undefined,
direction: tab.value,
},
}); });
items.value = data; items.value = data;
} catch (e) { } catch (e) {
error.value = axios.isAxiosError(e) error.value = axiosMessage(e, 'بارگذاری چک‌ها ناموفق بود.');
? 'بارگذاری چک‌ها ناموفق بود.'
: 'بارگذاری چک‌ها ناموفق بود.';
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -55,6 +109,81 @@ async function setStatus(row: ChequeDto, next: ChequeStatus) {
} }
} }
async function loadFormOptions() {
const [peopleRes, orgRes] = await Promise.all([
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
api.get<OrganizationDto>('/organizations/current'),
]);
people.value = peopleRes.data.items;
enabledCurrencies.value = orgRes.data.enabledCurrencies?.length
? orgRes.data.enabledCurrencies
: [orgRes.data.baseCurrency || 'IRR'];
if (!currency.value || !enabledCurrencies.value.includes(currency.value)) {
currency.value = orgRes.data.baseCurrency || 'IRR';
}
}
async function openForm(direction: ChequeDirection) {
formDirection.value = direction;
formError.value = '';
personId.value = '';
amount.value = '';
chequeNumber.value = '';
chequeBank.value = '';
dueDate.value = formatLocalDateInput(new Date());
formStatus.value = 'pending';
notes.value = '';
formOpen.value = true;
try {
await loadFormOptions();
} catch {
formError.value = 'بارگذاری اشخاص ناموفق بود.';
}
}
async function submitForm() {
const parsedAmount = parseMoneyNumber(String(amount.value ?? ''), 0);
if (!personId.value) {
formError.value = 'شخص را انتخاب کنید.';
return;
}
if (parsedAmount == null || parsedAmount <= 0) {
formError.value = 'مبلغ چک را وارد کنید.';
return;
}
if (!chequeNumber.value.trim() || !chequeBank.value.trim() || !dueDate.value) {
formError.value = 'شماره، بانک و سررسید چک لازم است.';
return;
}
saving.value = true;
formError.value = '';
try {
const body: CreateChequeRequest = {
personId: personId.value,
amount: parsedAmount,
currency: currency.value,
number: chequeNumber.value.trim(),
bank: chequeBank.value.trim(),
dueDate: new Date(dueDate.value).toISOString(),
status: formStatus.value,
direction: formDirection.value,
notes: notes.value.trim() || undefined,
};
await api.post<ChequeDto>('/cheques', body);
formOpen.value = false;
tab.value = formDirection.value;
await load();
} catch (e) {
formError.value = axiosMessage(e, 'ثبت چک ناموفق بود.');
} finally {
saving.value = false;
}
}
watch(tab, () => {
void load();
});
onMounted(load); onMounted(load);
</script> </script>
@@ -63,19 +192,33 @@ onMounted(load);
<header class="head"> <header class="head">
<div> <div>
<h1>چک‌ها</h1> <h1>چک‌ها</h1>
<p class="sub">دفتر چک‌های دریافتی وصل به فاکتور؛ وضعیت فقط برای پیگیری است</p> <p class="sub">دفتر چک‌های دریافتی و پرداختی. وضعیت فقط برای پیگیری است</p>
</div>
<div class="head-actions">
<UiButton type="button" @click="openForm('in')">دریافت چک</UiButton>
<UiButton variant="ghost" type="button" @click="openForm('out')">پرداخت چک</UiButton>
<UiButton variant="ghost" type="button" :disabled="loading" @click="load">
بروزرسانی
</UiButton>
</div> </div>
<UiButton variant="ghost" type="button" :disabled="loading" @click="load">
بروزرسانی
</UiButton>
</header> </header>
<div class="tabs">
<button type="button" :class="{ on: tab === 'in' }" @click="tab = 'in'">دریافتی</button>
<button type="button" :class="{ on: tab === 'out' }" @click="tab = 'out'">پرداختی</button>
</div>
<div class="toolbar"> <div class="toolbar">
<input v-model="q" type="search" placeholder="شماره چک، بانک یا فاکتور" @keyup.enter="load" /> <input
v-model="q"
type="search"
placeholder="شماره چک، بانک، فاکتور یا شخص"
@keyup.enter="load"
/>
<select v-model="status" @change="load"> <select v-model="status" @change="load">
<option value="">همه وضعیت‌ها</option> <option value="">همه وضعیت‌ها</option>
<option value="pending">در جریان</option> <option value="pending">در جریان</option>
<option value="cleared">وصول شد</option> <option value="cleared">{{ tab === 'out' ? 'پرداخت شد' : 'وصول شد' }}</option>
<option value="bounced">برگشت</option> <option value="bounced">برگشت</option>
</select> </select>
<UiButton type="button" @click="load">اعمال</UiButton> <UiButton type="button" @click="load">اعمال</UiButton>
@@ -83,7 +226,13 @@ onMounted(load);
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading && !items.length" class="muted">در حال بارگذاری…</p> <p v-else-if="loading && !items.length" class="muted">در حال بارگذاری…</p>
<p v-else-if="!filtered.length" class="muted">چکی ثبت نشده.</p> <p v-else-if="!items.length" class="muted">
{{
tab === 'out'
? 'چک پرداختی ثبت نشده. از «پرداخت چک» می‌توانید به شخص دیگری چک بدهید.'
: 'چک دریافتی ثبت نشده. از «دریافت چک» می‌توانید بدون فاکتور ثبت کنید.'
}}
</p>
<div v-else class="table-wrap"> <div v-else class="table-wrap">
<table> <table>
<thead> <thead>
@@ -92,47 +241,145 @@ onMounted(load);
<th>بانک</th> <th>بانک</th>
<th>سررسید</th> <th>سررسید</th>
<th>مبلغ</th> <th>مبلغ</th>
<th>فاکتور</th> <th v-if="tab === 'in'">فاکتور</th>
<th>طرف‌حساب</th> <th>طرف‌حساب</th>
<th>وضعیت</th> <th>وضعیت</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="row in filtered" :key="row.id"> <tr v-for="row in items" :key="row.id">
<td>{{ row.number }}</td> <td>{{ row.number }}</td>
<td>{{ row.bank }}</td> <td>{{ row.bank }}</td>
<td>{{ formatJalaliNumeric(new Date(row.dueDate)) }}</td> <td>{{ formatJalaliNumeric(new Date(row.dueDate)) }}</td>
<td>{{ formatMoney(row.amount, row.currency) }}</td> <td>{{ formatMoney(row.amount, row.currency) }}</td>
<td> <td v-if="tab === 'in'">
<RouterLink :to="{ name: 'invoice-detail', params: { id: row.invoiceId } }"> <RouterLink
v-if="row.invoiceId"
:to="{ name: 'invoice-detail', params: { id: row.invoiceId } }"
>
{{ row.invoiceNumber }} {{ row.invoiceNumber }}
</RouterLink> </RouterLink>
<span v-else class="muted">بدون فاکتور</span>
</td>
<td>
<RouterLink
v-if="row.personId"
:to="{ name: 'person-detail', params: { id: row.personId } }"
>
{{ row.personName || '—' }}
</RouterLink>
<span v-else>{{ row.personName || '—' }}</span>
</td> </td>
<td>{{ row.personName || '—' }}</td>
<td> <td>
<select <select
:value="row.status" :value="row.status"
:disabled="savingId === row.id" :disabled="savingId === row.id"
@change="setStatus(row, ($event.target as HTMLSelectElement).value as ChequeStatus)" @change="setStatus(row, ($event.target as HTMLSelectElement).value as ChequeStatus)"
> >
<option value="pending">{{ statusLabel.pending }}</option> <option value="pending">{{ statusLabelFor(row.direction).pending }}</option>
<option value="cleared">{{ statusLabel.cleared }}</option> <option value="cleared">{{ statusLabelFor(row.direction).cleared }}</option>
<option value="bounced">{{ statusLabel.bounced }}</option> <option value="bounced">{{ statusLabelFor(row.direction).bounced }}</option>
</select> </select>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<UiModal
:open="formOpen"
:title="isIncomingForm ? 'دریافت چک' : 'پرداخت چک'"
@close="formOpen = false"
>
<form class="receive-form" @submit.prevent="submitForm">
<p class="hint">
{{
isIncomingForm
? 'چک را از یک شخص بگیرید؛ لازم نیست به فاکتور وصل باشد.'
: 'چکی که به شخص دیگری می‌پردازید را ثبت کنید.'
}}
</p>
<p v-if="formError" class="error">{{ formError }}</p>
<label class="field">
<span>{{ isIncomingForm ? 'شخص' : 'گیرنده' }} <em>*</em></span>
<select v-model="personId" required>
<option value="">انتخاب شخص</option>
<option v-for="p in people" :key="p.id" :value="p.id">
{{ personLabel(p) }}
</option>
</select>
</label>
<div class="row-2">
<label class="field">
<span>مبلغ <em>*</em></span>
<MoneyInput v-model="amount" :decimals="0" placeholder="مبلغ چک" />
</label>
<label v-if="currencyOptions.length > 1" class="field">
<span>ارز</span>
<select v-model="currency">
<option v-for="opt in currencyOptions" :key="opt.code" :value="opt.code">
{{ opt.label }}
</option>
</select>
</label>
</div>
<div class="row-2">
<label class="field">
<span>شماره چک <em>*</em></span>
<UiInput v-model="chequeNumber" placeholder="شماره چک" maxlength="80" required />
</label>
<label class="field">
<span>بانک <em>*</em></span>
<UiInput v-model="chequeBank" placeholder="نام بانک" maxlength="120" required />
</label>
</div>
<div class="row-2">
<label class="field">
<span>سررسید <em>*</em></span>
<JalaliDateInput v-model="dueDate" required placeholder="سررسید چک" />
</label>
<label class="field">
<span>وضعیت</span>
<select v-model="formStatus">
<option value="pending">{{ formStatusLabel.pending }}</option>
<option value="cleared">{{ formStatusLabel.cleared }}</option>
<option value="bounced">{{ formStatusLabel.bounced }}</option>
</select>
</label>
</div>
<label class="field">
<span>یادداشت</span>
<textarea v-model="notes" rows="3" maxlength="2000" placeholder="اختیاری" />
</label>
</form>
<template #footer>
<UiButton type="button" :disabled="saving" @click="submitForm">
{{ saving ? 'در حال ثبت…' : isIncomingForm ? 'ثبت دریافت' : 'ثبت پرداخت' }}
</UiButton>
<UiButton variant="ghost" type="button" :disabled="saving" @click="formOpen = false">
انصراف
</UiButton>
</template>
</UiModal>
</section> </section>
</template> </template>
<style scoped> <style scoped>
.page { display: grid; gap: 1rem; } .page { display: grid; gap: 1rem; }
.head { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; } .head { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; flex-wrap: wrap; }
.head-actions { display: flex; gap: 0.45rem; flex-wrap: wrap; }
h1 { margin: 0; font-size: 1.25rem; } h1 { margin: 0; font-size: 1.25rem; }
.sub, .muted { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.88rem; } .sub, .muted { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.88rem; }
.error { color: var(--danger, #dc2626); font-weight: 600; } .error { color: var(--danger, #dc2626); font-weight: 600; }
.hint { margin: 0 0 0.75rem; color: var(--muted); font-size: 0.88rem; }
.tabs {
display: flex; gap: 0.35rem; overflow-x: auto; border-bottom: 1px solid var(--line);
}
.tabs button {
border: 0; background: transparent; padding: 0.65rem 0.85rem; cursor: pointer;
color: #64748b; font-weight: 700; border-bottom: 2px solid transparent; white-space: nowrap;
}
.tabs button.on { color: var(--teal); border-bottom-color: var(--teal); }
.toolbar { .toolbar {
display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
} }
@@ -149,4 +396,25 @@ select {
padding: 0 0.55rem; background: #fff; padding: 0 0.55rem; background: #fff;
} }
a { color: #0f766e; font-weight: 700; } a { color: #0f766e; font-weight: 700; }
.receive-form { display: grid; gap: 0.85rem; }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.field { display: grid; gap: 0.35rem; }
.field > span { color: var(--muted); font-size: 0.86rem; font-weight: 600; }
.field em { color: #ef4444; font-style: normal; }
.field select,
.field textarea,
.field :deep(.money-input) {
width: 100%;
min-height: var(--control-h);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.55rem 0.75rem;
background: #fff;
font: inherit;
box-sizing: border-box;
}
.field textarea { min-height: 5rem; resize: vertical; }
@media (max-width: 640px) {
.row-2 { grid-template-columns: 1fr; }
}
</style> </style>
@@ -0,0 +1,38 @@
# چک خروجی و یادآوری سررسید — برنامه پیاده‌سازی
> اجرا در همین نشست؛ کامیت فقط اگر کاربر بخواهد.
**Goal:** دفتر چک دریافتی/پرداختی + یادآوری یک‌بار سه روز قبل از سررسید.
**Architecture:** فیلد `direction` و `reminderNotifiedAt` روی `Cheque`. زمان‌بند `@nestjs/schedule` ساعت ۸ تهران. متن و تشخیص روز در `cheque-reminder.util.ts`.
**Tech Stack:** Prisma, NestJS, Vue 3, `@nestjs/schedule`, SMS موجود، اعلان موجود.
## Global Constraints
- npm workspaces؛ Prisma فقط از `apps/api`
- متن پیامک با `یادآوری منتکت:` و حداکثر ۱۶۰ نویسه
- پرداختی به فاکتور وصل نمی‌شود؛ چک فاکتور همیشه `in`
- بدون سند حسابداری
## Files
- `apps/api/prisma/schema.prisma` + migration `20260911120000_cheque_direction_reminder`
- `packages/shared/src/index.ts`
- `apps/api/src/finance/cheque-reminder.util.ts`
- `apps/api/src/finance/__tests__/cheque-reminder.check.ts`
- `apps/api/src/finance/cheque-reminders.service.ts`
- `apps/api/src/finance/{cheques.service,dto,finance.controller,finance.module}.ts`
- `apps/api/src/invoices/invoices.service.ts`
- `apps/api/src/app.module.ts`
- `apps/api/package.json` (`@nestjs/schedule`)
- `apps/web/src/views/ChequesView.vue`
## Tasks
1. util + unit check (روز تهران، متن پیامک)
2. schema/migration + shared types
3. API create/list direction؛ فاکتور `direction: in`
4. ChequeRemindersService + cron
5. UI تب و مودال پرداخت
6. `npm run build -w @mento/shared`، migrate، generate، unit check
@@ -0,0 +1,54 @@
# چک خروجی و یادآوری سررسید
تاریخ: ۱۴۰۵/۶/۲۰
وضعیت: تأییدشده برای پیاده‌سازی
## هدف
دفتر چک هم دریافتی و هم پرداختی باشد. سه روز قبل از سررسید، یک‌بار به افراد توافق‌شده اطلاع داده شود.
## صفحه
مسیر همان `/cheques` است؛ لینک سایدبار عوض نمی‌شود.
دو تب: **دریافتی** و **پرداختی**. دو دکمه: **دریافت چک** و **پرداخت چک**. هر کدام مودال جدا با فیلدهای شخص، مبلغ، ارز (اگر بیش از یکی فعال باشد)، شماره، بانک، سررسید، وضعیت، یادداشت. در پرداختی شخص یعنی گیرنده.
ستون‌های جدول مثل الان. وضعیت:
- دریافتی: در جریان / وصول شد / برگشت
- پرداختی: در جریان / پرداخت شد / برگشت
چک وصل به فاکتور فقط در تب دریافتی دیده می‌شود. پرداختی به فاکتور وصل نمی‌شود.
## داده
همان مدل `Cheque` با:
- `direction`: `in` | `out` (چک فاکتور همیشه `in`)
- `reminderNotifiedAt`: تا هر چک فقط یک‌بار یادآوری شود
`POST /cheques` جهت را می‌گیرد. `GET /cheques?direction=in|out` فیلتر می‌کند. `PATCH` وضعیت مثل قبل.
## یادآوری
زمان‌بند روزانه حدود ساعت ۸ به وقت تهران. فقط چک‌های `pending` که روز سررسیدشان دقیقاً ۳ روز بعد است (تقویم تهران).
گیرندگان داخلی: همهٔ کاربران `owner`/`admin` سازمان + ثبت‌کنندهٔ چک (`ownerId`). اگر یک نفر دو نقش دارد، یک‌بار.
کانال:
- همهٔ گیرندگان داخلی: اعلان داخل برنامه (عنوان «سررسید چک»، لینک `/cheques`) + پیامک اگر موبایل داشته باشند
- دریافتی: پیامک به موبایل شخص طرف‌حساب
- پرداختی: به شخص پیامک نمی‌رود
بعد از تلاش برای آن چک، `reminderNotifiedAt` ست می‌شود. خطای یک چک بقیه را متوقف نمی‌کند. اگر پیامک پیکربندی نباشد یا موبایل خالی باشد، همان کانال رد می‌شود؛ اعلان داخل برنامه برای کاربران CRM می‌ماند.
متن‌ها با پیشوند `یادآوری منتکت:` و حداکثر حدود ۱۶۰ نویسه:
- شخص (فقط دریافتی): `یادآوری منتکت: چک شماره {شماره} به مبلغ {مبلغ} سه روز دیگر ({سررسید شمسی}) سررسید می‌شود.`
- داخلی دریافتی: `یادآوری منتکت: چک دریافتی {شماره} از {نام شخص}، مبلغ {مبلغ}، سررسید {تاریخ} (۳ روز دیگر).`
- داخلی پرداختی: `یادآوری منتکت: چک پرداختی {شماره} به {نام شخص}، مبلغ {مبلغ}، سررسید {تاریخ} (۳ روز دیگر).`
## خارج از این نسخه
سند حسابداری برای چک خروجی، وصل پرداختی به فاکتور، یادآوری روز سررسید یا برگشت، تنظیم فاصلهٔ روز از داخل تنظیمات.
+46
View File
@@ -29,6 +29,7 @@
"@nestjs/jwt": "^11.0.0", "@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.12", "@nestjs/platform-express": "^11.0.12",
"@nestjs/schedule": "^6.0.1",
"@prisma/client": "^6.5.0", "@prisma/client": "^6.5.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
@@ -1393,6 +1394,19 @@
"@nestjs/core": "^11.0.0" "@nestjs/core": "^11.0.0"
} }
}, },
"node_modules/@nestjs/schedule": {
"version": "6.1.3",
"resolved": "https://registry.npmmirror.com/@nestjs/schedule/-/schedule-6.1.3.tgz",
"integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==",
"license": "MIT",
"dependencies": {
"cron": "4.4.0"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0"
}
},
"node_modules/@nestjs/schematics": { "node_modules/@nestjs/schematics": {
"version": "11.1.0", "version": "11.1.0",
"resolved": "https://registry.npmmirror.com/@nestjs/schematics/-/schematics-11.1.0.tgz", "resolved": "https://registry.npmmirror.com/@nestjs/schematics/-/schematics-11.1.0.tgz",
@@ -2043,6 +2057,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/luxon": {
"version": "3.7.5",
"resolved": "https://registry.npmmirror.com/@types/luxon/-/luxon-3.7.5.tgz",
"integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==",
"license": "MIT"
},
"node_modules/@types/ms": { "node_modules/@types/ms": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz",
@@ -3595,6 +3615,23 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/cron": {
"version": "4.4.0",
"resolved": "https://registry.npmmirror.com/cron/-/cron-4.4.0.tgz",
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
"license": "MIT",
"dependencies": {
"@types/luxon": "~3.7.0",
"luxon": "~3.7.0"
},
"engines": {
"node": ">=18.x"
},
"funding": {
"type": "ko-fi",
"url": "https://ko-fi.com/intcreator"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
@@ -5310,6 +5347,15 @@
"node": "20 || >=22" "node": "20 || >=22"
} }
}, },
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.17", "version": "0.30.17",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz",
+20 -4
View File
@@ -873,6 +873,7 @@ export type NotificationType =
| "activity_reminder" | "activity_reminder"
| "deal_moved" | "deal_moved"
| "note_reminder" | "note_reminder"
| "cheque_due"
| "ticket_assigned" | "ticket_assigned"
| "automation" | "automation"
| "system"; | "system";
@@ -1137,13 +1138,15 @@ export interface InvoiceLineItemDto {
} }
export type ChequeStatus = "pending" | "cleared" | "bounced"; export type ChequeStatus = "pending" | "cleared" | "bounced";
export type ChequeDirection = "in" | "out";
export interface ChequeDto { export interface ChequeDto {
id: string; id: string;
paymentId: string; paymentId: string | null;
invoiceId: string; invoiceId: string | null;
invoiceNumber: string; invoiceNumber: string | null;
invoiceTitle: string; invoiceTitle: string | null;
personId: string | null;
personName: string | null; personName: string | null;
amount: number; amount: number;
currency: string; currency: string;
@@ -1151,6 +1154,7 @@ export interface ChequeDto {
bank: string; bank: string;
dueDate: string; dueDate: string;
status: ChequeStatus | string; status: ChequeStatus | string;
direction: ChequeDirection | string;
notes: string | null; notes: string | null;
paidAt: string; paidAt: string;
createdAt: string; createdAt: string;
@@ -1165,6 +1169,18 @@ export interface ChequeInput {
notes?: string; notes?: string;
} }
export interface CreateChequeRequest {
personId: string;
amount: number;
currency?: string;
number: string;
bank: string;
dueDate: string;
status?: ChequeStatus | string;
direction?: ChequeDirection | string;
notes?: string;
}
export interface UpdateChequeRequest { export interface UpdateChequeRequest {
number?: string; number?: string;
bank?: string; bank?: string;