import { BadRequestException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { Prisma } from '@prisma/client'; import type { FinancialInstallmentDto, FinancialInstallmentStatus, FinancialReceivableDto, } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { SmsGatewayService } from '../sms-billing/sms-gateway.service'; import ExcelJS from 'exceljs'; import type { Actor } from '../common/record-scope'; function dec(value: Prisma.Decimal | number | string | null | undefined) { return new Prisma.Decimal(value ?? 0); } function money(value: Prisma.Decimal | number | string) { return new Prisma.Decimal(value).toFixed(2); } function parseDate(iso?: string | null) { if (!iso) return null; const d = new Date(iso); return Number.isNaN(d.getTime()) ? null : d; } function addPeriod(base: Date, interval: 'weekly' | 'monthly', step: number) { const next = new Date(base); if (interval === 'weekly') next.setDate(next.getDate() + step * 7); else next.setMonth(next.getMonth() + step); return next; } type MovementSlice = { amount: Prisma.Decimal; voidedAt: Date | null; installmentId?: string | null }; @Injectable() export class TreasuryReceivablesService { private readonly logger = new Logger(TreasuryReceivablesService.name); constructor( private readonly prisma: PrismaService, private readonly notifications: NotificationsService, private readonly sms: SmsGatewayService, ) {} async list(actor: Actor): Promise { const rows = await this.prisma.financialReceivable.findMany({ where: { organizationId: actor.organizationId }, include: { installments: { include: { movements: true }, orderBy: { position: 'asc' } }, movements: true, }, orderBy: { createdAt: 'desc' }, take: 100, }); return rows.map((row) => this.toDto(row)); } async create( actor: Actor, input: { title: string; personId?: string | null; partyName?: string | null; totalAmount: string | number; prepaidAmount?: string | number | null; accountId?: string | null; startDate?: string; count?: number; interval?: 'weekly' | 'monthly'; notes?: string | null; installments?: Array<{ amount: string | number; dueDate: string }>; }, ): Promise { const title = input.title.trim(); const total = dec(input.totalAmount); const prepaid = dec(input.prepaidAmount ?? 0); if (title.length < 2) throw new BadRequestException('عنوان قرارداد را وارد کنید.'); if (!total.greaterThan(0)) throw new BadRequestException('مبلغ قرارداد باید بزرگ‌تر از صفر باشد.'); if (prepaid.isNegative() || prepaid.greaterThan(total)) { throw new BadRequestException('پیش‌پرداخت نامعتبر است.'); } const financed = total.minus(prepaid); const lines = this.buildSchedule(financed, input); if (prepaid.greaterThan(0) && !input.accountId) { throw new BadRequestException('برای پیش‌پرداخت، حساب مقصد را انتخاب کنید.'); } if (input.accountId) { const account = await this.prisma.financialAccount.findFirst({ where: { id: input.accountId, organizationId: actor.organizationId, isActive: true }, }); if (!account) throw new BadRequestException('حساب مقصد پیدا نشد.'); } const start = parseDate(input.startDate) ?? new Date(); const created = await this.prisma.$transaction(async (tx) => { const receivable = await tx.financialReceivable.create({ data: { organizationId: actor.organizationId, title, personId: input.personId || null, partyName: input.partyName?.trim() || null, totalAmount: total, notes: input.notes?.trim() || null, createdById: actor.userId, status: financed.equals(0) ? 'settled' : 'open', installments: { create: lines.map((line, index) => ({ position: index + 1, dueDate: line.dueDate, amount: line.amount, })), }, }, include: { installments: true }, }); if (prepaid.greaterThan(0) && input.accountId) { await tx.financialMovement.create({ data: { organizationId: actor.organizationId, accountId: input.accountId, receivableId: receivable.id, direction: 'in', amount: prepaid, kind: 'receipt', occurredAt: start, memo: `پیش‌پرداخت ${title}`, createdById: actor.userId, }, }); } return receivable.id; }); return this.get(actor, created); } async collect( actor: Actor, installmentId: string, input: { amount: string | number; accountId: string; occurredAt?: string; notes?: string | null; clientKey?: string | null; }, ): Promise { const installment = await this.prisma.financialInstallment.findFirst({ where: { id: installmentId, receivable: { organizationId: actor.organizationId } }, include: { movements: true, receivable: true }, }); if (!installment || installment.receivable.voidedAt) { throw new NotFoundException('قسط پیدا نشد.'); } const amount = dec(input.amount); if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ دریافت باید بزرگ‌تر از صفر باشد.'); const paid = this.paidOf(installment.movements); const remaining = installment.amount.minus(paid); if (!remaining.greaterThan(0)) { throw new BadRequestException('این قسط قبلاً تسویه شده است.'); } if (amount.greaterThan(remaining)) { throw new BadRequestException('مبلغ از مانده قسط بیشتر است.'); } const clientKey = input.clientKey?.trim(); if (clientKey) { const dup = await this.prisma.financialMovement.findFirst({ where: { installmentId, reference: clientKey, voidedAt: null }, }); if (dup) throw new BadRequestException('این دریافت قبلاً ثبت شده است.'); } const account = await this.prisma.financialAccount.findFirst({ where: { id: input.accountId, organizationId: actor.organizationId, isActive: true }, }); if (!account) throw new BadRequestException('حساب مقصد پیدا نشد.'); await this.prisma.$transaction(async (tx) => { await tx.financialMovement.create({ data: { organizationId: actor.organizationId, accountId: account.id, receivableId: installment.receivableId, installmentId: installment.id, direction: 'in', amount, kind: 'receipt', occurredAt: parseDate(input.occurredAt) ?? new Date(), memo: input.notes?.trim() || `قسط ${installment.position} ${installment.receivable.title}`, reference: clientKey || null, createdById: actor.userId, }, }); const nextPaid = paid.plus(amount); const all = await tx.financialInstallment.findMany({ where: { receivableId: installment.receivableId }, include: { movements: true }, }); const settled = all.every((row) => { const rowPaid = row.id === installment.id ? nextPaid : this.paidOf(row.movements); return rowPaid.greaterThanOrEqualTo(row.amount); }); if (settled) { await tx.financialReceivable.update({ where: { id: installment.receivableId }, data: { status: 'settled' }, }); } }); return this.get(actor, installment.receivableId); } async excel(actor: Actor) { const rows = await this.list(actor); const book = new ExcelJS.Workbook(); const sheet = book.addWorksheet('اقساط'); sheet.addRow(['قرارداد', 'طرف حساب', 'قسط', 'سررسید', 'مبلغ', 'مانده', 'وضعیت']); for (const row of rows) { for (const item of row.installments) { sheet.addRow([ row.title, row.partyName ?? '', item.position, item.dueDate.slice(0, 10), item.amount, item.remaining, item.status, ]); } } return Buffer.from(await book.xlsx.writeBuffer()); } async upcoming(actor: Actor) { const rows = await this.prisma.financialInstallment.findMany({ where: { receivable: { organizationId: actor.organizationId, status: { not: 'void' }, voidedAt: null }, }, include: { movements: true, receivable: true }, orderBy: { dueDate: 'asc' }, take: 200, }); return rows .map((row) => this.toInstallment(row)) .filter((row) => row.status === 'open' || row.status === 'partial' || row.status === 'overdue') .slice(0, 12); } async payables(actor: Actor) { return this.prisma.financialDocument.findMany({ where: { organizationId: actor.organizationId, kind: 'expense', status: { in: ['open', 'partial'] }, }, include: { category: true, account: true, movements: true }, orderBy: [{ dueAt: 'asc' }, { bookedAt: 'asc' }], take: 12, }); } @Cron('15 8 * * *') async remindDue() { const now = new Date(); const horizon = new Date(now); horizon.setDate(horizon.getDate() + 3); const rows = await this.prisma.financialInstallment.findMany({ where: { dueDate: { lte: horizon }, reminderNotifiedAt: null, receivable: { status: 'open', voidedAt: null }, }, include: { movements: true, receivable: true }, take: 80, }); for (const row of rows) { const dto = this.toInstallment(row); if (dto.status === 'settled') continue; const admins = await this.prisma.membership.findMany({ where: { organizationId: row.receivable.organizationId, isActive: true, role: { in: ['owner', 'admin'] }, }, select: { userId: true }, }); const title = dto.status === 'overdue' ? 'قسط معوق' : 'قسط نزدیک سررسید'; const body = `${row.receivable.title} · قسط ${row.position} · مانده ${dto.remaining}`; for (const admin of admins) { await this.notifications.create({ organizationId: row.receivable.organizationId, userId: admin.userId, type: 'treasury_installment', title, body, link: '/finance/receivables', entityType: 'financial_installment', entityId: row.id, dedupe: true, }); const user = await this.prisma.user.findUnique({ where: { id: admin.userId }, select: { mobile: true }, }); if (user?.mobile) { await this.sms .send({ organizationId: row.receivable.organizationId, phone: user.mobile, text: `${title}: ${body}`, source: 'treasury_reminder', }) .catch(() => undefined); } } await this.prisma.financialInstallment.update({ where: { id: row.id }, data: { reminderNotifiedAt: now }, }); } if (rows.length) this.logger.log(`treasury installment reminders: ${rows.length}`); } private async get(actor: Actor, id: string): Promise { const row = await this.prisma.financialReceivable.findFirst({ where: { id, organizationId: actor.organizationId }, include: { installments: { include: { movements: true }, orderBy: { position: 'asc' } }, movements: true, }, }); if (!row) throw new NotFoundException('قرارداد پیدا نشد.'); return this.toDto(row); } private buildSchedule( financed: Prisma.Decimal, input: { startDate?: string; count?: number; interval?: 'weekly' | 'monthly'; installments?: Array<{ amount: string | number; dueDate: string }>; }, ) { if (financed.equals(0)) return []; if (input.installments?.length) { const lines = input.installments.map((line) => { const dueDate = parseDate(line.dueDate); const amount = dec(line.amount); if (!dueDate || !amount.greaterThan(0)) { throw new BadRequestException('مبلغ و سررسید هر قسط لازم است.'); } return { amount, dueDate }; }); const sum = lines.reduce((total, line) => total.plus(line.amount), new Prisma.Decimal(0)); if (!sum.equals(financed)) { throw new BadRequestException('جمع اقساط باید برابر مانده پس از پیش‌پرداخت باشد.'); } return lines; } const count = Math.floor(input.count ?? 0); if (count < 1 || count > 60) { throw new BadRequestException('تعداد اقساط باید بین ۱ و ۶۰ باشد.'); } const interval = input.interval === 'weekly' ? 'weekly' : 'monthly'; const start = parseDate(input.startDate) ?? new Date(); const base = financed.dividedBy(count).toDecimalPlaces(2, Prisma.Decimal.ROUND_DOWN); const lines = Array.from({ length: count }, (_, index) => ({ amount: base, dueDate: addPeriod(start, interval, index), })); const assigned = base.mul(count); lines[lines.length - 1]!.amount = lines[lines.length - 1]!.amount.plus(financed.minus(assigned)); return lines; } private paidOf(movements: MovementSlice[]) { return movements .filter((row) => !row.voidedAt) .reduce((sum, row) => sum.plus(row.amount), new Prisma.Decimal(0)); } private toInstallment(row: { id: string; position: number; dueDate: Date; amount: Prisma.Decimal; movements: MovementSlice[]; }): FinancialInstallmentDto { const paid = this.paidOf(row.movements); const remainingRaw = row.amount.minus(paid); const remaining = remainingRaw.isNegative() ? new Prisma.Decimal(0) : remainingRaw; let status: FinancialInstallmentStatus = 'open'; if (remaining.equals(0)) status = 'settled'; else if (row.dueDate.getTime() < Date.now()) status = 'overdue'; else if (paid.greaterThan(0)) status = 'partial'; return { id: row.id, position: row.position, dueDate: row.dueDate.toISOString(), amount: money(row.amount), paidAmount: money(paid), remaining: money(remaining), status, }; } private toDto(row: { id: string; title: string; personId: string | null; partyName: string | null; totalAmount: Prisma.Decimal; notes: string | null; status: string; createdAt: Date; movements: MovementSlice[]; installments: Array<{ id: string; position: number; dueDate: Date; amount: Prisma.Decimal; movements: MovementSlice[]; }>; }): FinancialReceivableDto { const paid = this.paidOf(row.movements); const remainingRaw = row.totalAmount.minus(paid); const remaining = remainingRaw.isNegative() ? new Prisma.Decimal(0) : remainingRaw; return { id: row.id, title: row.title, personId: row.personId, partyName: row.partyName, totalAmount: money(row.totalAmount), paidAmount: money(paid), remaining: money(remaining), status: row.status as FinancialReceivableDto['status'], notes: row.notes, installments: row.installments.map((item) => this.toInstallment(item)), createdAt: row.createdAt.toISOString(), }; } }