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:
@@ -20,6 +20,7 @@
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.12",
|
||||
"@nestjs/schedule": "^6.0.1",
|
||||
"@prisma/client": "^6.5.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"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");
|
||||
@@ -213,6 +213,7 @@ model User {
|
||||
salesGoals SalesGoal[] @relation("SalesGoalUser")
|
||||
commissionPercent Decimal @default(0) @db.Decimal(8, 2)
|
||||
commissionEntries CommissionEntry[]
|
||||
receivedCheques Cheque[] @relation("ChequeOwner")
|
||||
assignedTickets Ticket[] @relation("TicketAssignee")
|
||||
reportedTickets Ticket[] @relation("TicketReporter")
|
||||
ticketMessages TicketMessage[] @relation("TicketMessageAuthor")
|
||||
@@ -291,6 +292,7 @@ model Person {
|
||||
accountingLinks PersonAccountingLink[]
|
||||
accountingDocuments AccountingDocumentLog[]
|
||||
openingBalances PartyOpeningBalance[]
|
||||
cheques Cheque[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -1284,26 +1286,38 @@ model CommissionEntry {
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
/// چک دریافتی وصل به پرداخت فاکتور
|
||||
/// چک دریافتی؛ مستقل یا وصل به پرداخت فاکتور
|
||||
model Cheque {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
paymentId String @unique
|
||||
payment Payment @relation(fields: [paymentId], references: [id], onDelete: Cascade)
|
||||
invoiceId String
|
||||
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
|
||||
paymentId String? @unique
|
||||
payment Payment? @relation(fields: [paymentId], references: [id], onDelete: Cascade)
|
||||
invoiceId String?
|
||||
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
|
||||
bank String
|
||||
dueDate DateTime
|
||||
/// pending | cleared | bounced
|
||||
status String @default("pending")
|
||||
/// in = دریافتی، out = پرداختی
|
||||
direction String @default("in")
|
||||
reminderNotifiedAt DateTime?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId, status, dueDate])
|
||||
@@index([organizationId, direction, dueDate])
|
||||
@@index([invoiceId])
|
||||
@@index([personId])
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
/// مانده اول دوره طرفحساب (مثبت = بدهکار ما)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
@@ -36,6 +37,7 @@ import { FinanceModule } from './finance/finance.module';
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env', '../../.env'],
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
AiModule,
|
||||
PrismaModule,
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -446,7 +446,14 @@ export class InvoicesService {
|
||||
): Promise<InvoiceDto> {
|
||||
const invoice = await this.prisma.invoice.findFirst({
|
||||
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.status === 'canceled') {
|
||||
@@ -493,9 +500,14 @@ export class InvoicesService {
|
||||
organizationId: invoice.organizationId,
|
||||
paymentId: payment.id,
|
||||
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(),
|
||||
bank: dto.cheque.bank.trim(),
|
||||
dueDate: new Date(dto.cheque.dueDate),
|
||||
direction: 'in',
|
||||
status: dto.cheque.status === 'cleared' || dto.cheque.status === 'bounced'
|
||||
? dto.cheque.status
|
||||
: 'pending',
|
||||
@@ -765,15 +777,17 @@ export class InvoicesService {
|
||||
invoiceId: row.id,
|
||||
invoiceNumber: row.number,
|
||||
invoiceTitle: row.title,
|
||||
personId: row.person?.id ?? p.cheque.personId ?? null,
|
||||
personName: row.person
|
||||
? `${row.person.firstName} ${row.person.lastName}`.trim()
|
||||
: row.company?.name ?? null,
|
||||
amount: Number(p.amount),
|
||||
currency: p.currency,
|
||||
amount: Number(p.cheque.amount ?? p.amount),
|
||||
currency: p.cheque.currency || p.currency,
|
||||
number: p.cheque.number,
|
||||
bank: p.cheque.bank,
|
||||
dueDate: p.cheque.dueDate.toISOString(),
|
||||
status: p.cheque.status,
|
||||
direction: p.cheque.direction === 'out' ? 'out' : 'in',
|
||||
notes: p.cheque.notes,
|
||||
paidAt: p.paidAt.toISOString(),
|
||||
createdAt: p.cheque.createdAt.toISOString(),
|
||||
|
||||
Reference in New Issue
Block a user