import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { Prisma } from '@prisma/client'; import ExcelJS from 'exceljs'; import type { TreasuryPartyBalanceDto } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import type { Actor } from '../common/record-scope'; import { invoiceSalesInPeriod } from './invoice-ledger'; 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 reasonText(value?: string | null) { const text = value?.trim() ?? ''; return text ? text.slice(0, 500) : null; } function parseDate(iso?: string | null) { if (!iso) return null; const d = new Date(iso); return Number.isNaN(d.getTime()) ? null : d; } function payrollStatus(payable: Prisma.Decimal, paid: Prisma.Decimal) { if (paid.lessThanOrEqualTo(0)) return 'open'; if (paid.greaterThanOrEqualTo(payable)) return 'settled'; return 'partial'; } function periodKey(date: Date) { return date.toISOString().slice(0, 10); } function addInterval(date: Date, interval: string) { const next = new Date(date); if (interval === 'weekly') next.setDate(next.getDate() + 7); else if (interval === 'yearly') next.setFullYear(next.getFullYear() + 1); else next.setMonth(next.getMonth() + 1); return next; } @Injectable() export class TreasuryOpsService { private readonly logger = new Logger(TreasuryOpsService.name); constructor(private readonly prisma: PrismaService) {} async listPayroll(actor: Actor) { const rows = await this.prisma.financialPayroll.findMany({ where: { organizationId: actor.organizationId }, include: { movements: { where: { voidedAt: null }, include: { account: { select: { name: true } } }, orderBy: { occurredAt: 'desc' }, }, }, orderBy: { dueDate: 'desc' }, take: 500, }); const users = await this.prisma.user.findMany({ where: { id: { in: rows.map((row) => row.userId) } }, select: { id: true, fullName: true }, }); const names = new Map(users.map((user) => [user.id, user.fullName])); return rows.map((row) => this.payrollDto(row, names.get(row.userId) ?? '')); } async createPayroll( actor: Actor, input: { userId: string; period: string; baseAmount: string | number; benefits?: string | number; benefitsReason?: string | null; bonus?: string | number; bonusReason?: string | null; deductions?: string | number; deductionsReason?: string | null; dueDate?: string; notes?: string | null; }, ) { const base = dec(input.baseAmount); const benefits = dec(input.benefits ?? 0); const bonus = dec(input.bonus ?? 0); const deductions = dec(input.deductions ?? 0); const payable = base.plus(benefits).plus(bonus).minus(deductions); if (!input.userId || !input.period?.trim()) { throw new BadRequestException('پرسنل و دوره حقوق لازم است.'); } if (!payable.greaterThan(0)) throw new BadRequestException('مبلغ قابل پرداخت باید بزرگ‌تر از صفر باشد.'); const member = await this.prisma.membership.findFirst({ where: { organizationId: actor.organizationId, userId: input.userId, isActive: true }, }); if (!member) throw new BadRequestException('این کاربر عضو محیط نیست.'); const row = await this.prisma.financialPayroll.create({ data: { organizationId: actor.organizationId, userId: input.userId, period: input.period.trim(), baseAmount: base, benefits, benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null, bonus, bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null, deductions, deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null, payable, dueDate: parseDate(input.dueDate) ?? new Date(), notes: input.notes?.trim() || null, createdById: actor.userId, }, include: { movements: { where: { voidedAt: null }, include: { account: { select: { name: true } } }, orderBy: { occurredAt: 'desc' }, }, }, }); const user = await this.prisma.user.findUnique({ where: { id: row.userId }, select: { fullName: true } }); return this.payrollDto(row, user?.fullName ?? ''); } async payPayroll( actor: Actor, id: string, input: { amount: string | number; accountId: string; occurredAt?: string; clientKey?: string | null }, ) { const row = await this.prisma.financialPayroll.findFirst({ where: { id, organizationId: actor.organizationId }, include: { movements: true }, }); if (!row || row.voidedAt || row.status === 'void' || row.status === 'settled') { throw new NotFoundException('رکورد حقوق قابل پرداخت نیست.'); } const amount = dec(input.amount); const paid = this.paidOf(row.movements); const remaining = row.payable.minus(paid); if (!amount.greaterThan(0) || amount.greaterThan(remaining)) { throw new BadRequestException('مبلغ پرداخت حقوق نامعتبر است.'); } if (input.clientKey) { const dup = await this.prisma.financialMovement.findFirst({ where: { payrollId: id, reference: input.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('حساب پرداخت پیدا نشد.'); const balance = await this.balance(account.id); if (balance.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.'); const status = payrollStatus(row.payable, paid.plus(amount)); await this.prisma.$transaction([ this.prisma.financialMovement.create({ data: { organizationId: actor.organizationId, accountId: account.id, payrollId: row.id, direction: 'out', amount, kind: 'payment', occurredAt: parseDate(input.occurredAt) ?? new Date(), memo: `حقوق ${row.period}`, reference: input.clientKey || null, createdById: actor.userId, }, }), this.prisma.financialPayroll.update({ where: { id: row.id }, data: { status } }), ]); return this.listPayroll(actor); } async updatePayroll( actor: Actor, id: string, input: { userId: string; period: string; baseAmount: string | number; benefits?: string | number; benefitsReason?: string | null; bonus?: string | number; bonusReason?: string | null; deductions?: string | number; deductionsReason?: string | null; }, ) { const row = await this.prisma.financialPayroll.findFirst({ where: { id, organizationId: actor.organizationId }, include: { movements: true }, }); if (!row || row.voidedAt || row.status === 'void') { throw new NotFoundException('رکورد حقوق برای ویرایش پیدا نشد.'); } const base = dec(input.baseAmount); const benefits = dec(input.benefits ?? 0); const bonus = dec(input.bonus ?? 0); const deductions = dec(input.deductions ?? 0); const payable = base.plus(benefits).plus(bonus).minus(deductions); if (!input.userId || !input.period?.trim()) { throw new BadRequestException('پرسنل و دوره حقوق لازم است.'); } if (!payable.greaterThan(0)) throw new BadRequestException('مبلغ قابل پرداخت باید بزرگ‌تر از صفر باشد.'); const paid = this.paidOf(row.movements); if (payable.lessThan(paid)) { throw new BadRequestException('مبلغ قابل پرداخت از پرداخت‌های ثبت‌شده کمتر است.'); } const member = await this.prisma.membership.findFirst({ where: { organizationId: actor.organizationId, userId: input.userId, isActive: true }, }); if (!member) throw new BadRequestException('این کاربر عضو محیط نیست.'); try { await this.prisma.financialPayroll.update({ where: { id: row.id }, data: { userId: input.userId, period: input.period.trim(), baseAmount: base, benefits, benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null, bonus, bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null, deductions, deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null, payable, status: payrollStatus(payable, paid), }, }); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { throw new BadRequestException('برای این پرسنل در این دوره حقوق ثبت شده است.'); } throw error; } return this.listPayroll(actor); } async updatePayrollPayment( actor: Actor, payrollId: string, movementId: string, input: { amount: string | number; accountId: string; occurredAt?: string }, ) { const movement = await this.prisma.financialMovement.findFirst({ where: { id: movementId, payrollId, organizationId: actor.organizationId, voidedAt: null, }, }); const payroll = await this.prisma.financialPayroll.findFirst({ where: { id: payrollId, organizationId: actor.organizationId }, include: { movements: true }, }); if (!movement || !payroll || payroll.voidedAt || payroll.status === 'void') { throw new NotFoundException('پرداخت حقوق برای ویرایش پیدا نشد.'); } const amount = dec(input.amount); const otherPaid = this.paidOf(payroll.movements.filter((item) => item.id !== movement.id)); if (!amount.greaterThan(0) || otherPaid.plus(amount).greaterThan(payroll.payable)) { throw new BadRequestException('مبلغ پرداخت حقوق نامعتبر است.'); } const account = await this.prisma.financialAccount.findFirst({ where: { id: input.accountId, organizationId: actor.organizationId }, }); if (!account || (!account.isActive && account.id !== movement.accountId)) { throw new BadRequestException('حساب پرداخت پیدا نشد.'); } const available = await this.balance(account.id); const room = account.id === movement.accountId ? available.plus(movement.amount) : available; if (room.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.'); await this.prisma.$transaction([ this.prisma.financialMovement.update({ where: { id: movement.id }, data: { accountId: account.id, amount, occurredAt: parseDate(input.occurredAt) ?? movement.occurredAt, }, }), this.prisma.financialPayroll.update({ where: { id: payroll.id }, data: { status: payrollStatus(payroll.payable, otherPaid.plus(amount)) }, }), ]); return this.listPayroll(actor); } async listRecurring(actor: Actor) { const rows = await this.prisma.financialRecurringItem.findMany({ where: { organizationId: actor.organizationId }, orderBy: { nextDue: 'asc' }, }); return rows.map((row) => ({ id: row.id, title: row.title, kind: row.kind, amount: money(row.amount), interval: row.interval, nextDue: row.nextDue.toISOString(), isActive: row.isActive, })); } async createRecurring( actor: Actor, input: { title: string; kind: 'income' | 'expense'; amount: string | number; interval: 'weekly' | 'monthly' | 'yearly'; startDate?: string; endDate?: string | null; accountId?: string | null; categoryId?: string | null; }, ) { const amount = dec(input.amount); if (!input.title?.trim() || !amount.greaterThan(0)) { throw new BadRequestException('عنوان و مبلغ تکرارشونده لازم است.'); } const start = parseDate(input.startDate) ?? new Date(); const row = await this.prisma.financialRecurringItem.create({ data: { organizationId: actor.organizationId, title: input.title.trim(), kind: input.kind, amount, interval: input.interval, startDate: start, endDate: parseDate(input.endDate), nextDue: start, accountId: input.accountId || null, categoryId: input.categoryId || null, createdById: actor.userId, }, }); return { id: row.id }; } @Cron('30 8 * * *') async spawnRecurring() { const now = new Date(); const items = await this.prisma.financialRecurringItem.findMany({ where: { isActive: true, nextDue: { lte: now } }, take: 100, }); for (const item of items) { if (item.endDate && item.nextDue > item.endDate) continue; const key = periodKey(item.nextDue); const exists = await this.prisma.financialRecurringOccurrence.findUnique({ where: { itemId_periodKey: { itemId: item.id, periodKey: key } }, }); if (!exists) { const doc = await this.prisma.financialDocument.create({ data: { organizationId: item.organizationId, kind: item.kind, title: item.title, amount: item.amount, bookedAt: item.nextDue, dueAt: item.nextDue, categoryId: item.categoryId, accountId: item.accountId, status: 'open', notes: 'ساخته‌شده از الگوی تکرارشونده؛ تا تأیید، موجودی حساب تغییر نمی‌کند.', }, }); await this.prisma.financialRecurringOccurrence.create({ data: { itemId: item.id, periodKey: key, dueDate: item.nextDue, documentId: doc.id, }, }); } await this.prisma.financialRecurringItem.update({ where: { id: item.id }, data: { nextDue: addInterval(item.nextDue, item.interval) }, }); } if (items.length) this.logger.log(`recurring finance items: ${items.length}`); } async reconcile( actor: Actor, input: { accountId: string; statedBalance: string | number; reconciledAt?: string; notes?: string | null }, ) { const account = await this.prisma.financialAccount.findFirst({ where: { id: input.accountId, organizationId: actor.organizationId }, }); if (!account) throw new NotFoundException('حساب پیدا نشد.'); const systemBalance = await this.balance(account.id); const stated = dec(input.statedBalance); const row = await this.prisma.financialReconciliation.create({ data: { organizationId: actor.organizationId, accountId: account.id, reconciledAt: parseDate(input.reconciledAt) ?? new Date(), statedBalance: stated, systemBalance, difference: stated.minus(systemBalance), notes: input.notes?.trim() || null, createdById: actor.userId, }, }); return { id: row.id, statedBalance: money(row.statedBalance), systemBalance: money(row.systemBalance), difference: money(row.difference), }; } async adjust( actor: Actor, input: { accountId: string; amount: string | number; direction: 'in' | 'out'; reason: string }, ) { const reason = input.reason?.trim(); if (!reason || reason.length < 3) throw new BadRequestException('علت اصلاح موجودی لازم است.'); const amount = dec(input.amount); if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ اصلاح باید بزرگ‌تر از صفر باشد.'); const account = await this.prisma.financialAccount.findFirst({ where: { id: input.accountId, organizationId: actor.organizationId, isActive: true }, }); if (!account) throw new NotFoundException('حساب پیدا نشد.'); if (input.direction === 'out') { const balance = await this.balance(account.id); if (balance.lessThan(amount)) throw new BadRequestException('موجودی برای این اصلاح کافی نیست.'); } await this.prisma.financialMovement.create({ data: { organizationId: actor.organizationId, accountId: account.id, direction: input.direction, amount, kind: 'adjustment', occurredAt: new Date(), memo: `اصلاحیه: ${reason}`, createdById: actor.userId, }, }); return { ok: true }; } async report(actor: Actor, fromIso?: string, toIso?: string) { const to = parseDate(toIso) ?? new Date(); const from = parseDate(fromIso) ?? new Date(to.getFullYear(), to.getMonth(), 1); const span = to.getTime() - from.getTime(); const prevTo = new Date(from.getTime() - 1); const prevFrom = new Date(prevTo.getTime() - span); const current = await this.period(actor.organizationId, from, to); const previous = await this.period(actor.organizationId, prevFrom, prevTo); const breakdown = await this.breakdown(actor.organizationId); return { from: from.toISOString(), to: to.toISOString(), current, previous, ...breakdown }; } async reportExcel( actor: Actor, from?: string, to?: string, parties?: { debtors: TreasuryPartyBalanceDto[]; creditors: TreasuryPartyBalanceDto[] }, ) { const data = await this.report(actor, from, to); const book = new ExcelJS.Workbook(); const sheet = book.addWorksheet('گزارش مالی'); sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']); const rows: Array<[string, string, string]> = [ ['درآمد ثبت‌شده', data.current.bookedIncome, data.previous.bookedIncome], ['فروش فاکتور', data.current.invoiceSales, data.previous.invoiceSales], ['فاکتور برگشتی', data.current.invoiceCredits, data.previous.invoiceCredits], ['هزینه ثبت‌شده', data.current.bookedExpense, data.previous.bookedExpense], ['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit], ['ورود وجه', data.current.cashIn, data.previous.cashIn], ['خروج وجه', data.current.cashOut, data.previous.cashOut], ['خالص جریان نقدی', data.current.cashNet, data.previous.cashNet], ]; rows.forEach((row) => sheet.addRow(row)); sheet.addRow([]); sheet.addRow(['دسته هزینه', 'مبلغ', 'سهم']); data.current.expenseCategories.forEach((row) => sheet.addRow([row.name, row.amount, row.share])); if (parties) { this.addPartySheet(book, 'بدهکاران', parties.debtors, 'دریافت‌شده'); this.addPartySheet(book, 'بستانکاران', parties.creditors, 'پرداخت‌شده'); } const buffer = await book.xlsx.writeBuffer(); return Buffer.from(buffer); } private addPartySheet( book: ExcelJS.Workbook, title: string, rows: TreasuryPartyBalanceDto[], settledLabel: string, ) { const sheet = book.addWorksheet(title); const kindLabel: Record = { person: 'مشتری', company: 'شرکت', user: 'کاربر' }; sheet.addRow(['طرف حساب', 'نوع', 'مبلغ کل', settledLabel, 'مانده']); let amount = new Prisma.Decimal(0); let settled = new Prisma.Decimal(0); let remaining = new Prisma.Decimal(0); for (const row of rows) { sheet.addRow([ row.partyName, (row.partyKind && kindLabel[row.partyKind]) || 'ثبت قبلی', row.amount, row.settledAmount, row.remaining, ]); amount = amount.plus(row.amount); settled = settled.plus(row.settledAmount); remaining = remaining.plus(row.remaining); } sheet.addRow(['جمع', '', money(amount), money(settled), money(remaining)]); } private async period(organizationId: string, from: Date, to: Date) { const docs = await this.prisma.financialDocument.findMany({ where: { organizationId, status: { not: 'void' }, bookedAt: { gte: from, lte: to }, }, include: { category: true }, }); const cash = await this.prisma.financialMovement.findMany({ where: { organizationId, voidedAt: null, occurredAt: { gte: from, lte: to }, kind: { in: ['receipt', 'payment'] }, }, }); let bookedIncome = new Prisma.Decimal(0); let bookedExpense = new Prisma.Decimal(0); const byCat = new Map(); for (const doc of docs) { if (doc.kind === 'income') bookedIncome = bookedIncome.plus(doc.amount); else { bookedExpense = bookedExpense.plus(doc.amount); const name = doc.category?.name ?? 'بدون دسته'; byCat.set(name, (byCat.get(name) ?? new Prisma.Decimal(0)).plus(doc.amount)); } } let cashIn = new Prisma.Decimal(0); let cashOut = new Prisma.Decimal(0); for (const row of cash) { if (row.direction === 'in') cashIn = cashIn.plus(row.amount); else cashOut = cashOut.plus(row.amount); } const expenseCategories = [...byCat.entries()].map(([name, amount]) => ({ name, amount: money(amount), share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1), })); const invoiceSales = await invoiceSalesInPeriod(this.prisma, organizationId, from, to); return { bookedIncome: money(bookedIncome), invoiceSales: invoiceSales.sales, invoiceCredits: invoiceSales.credits, bookedExpense: money(bookedExpense), operatingProfit: money( bookedIncome.plus(invoiceSales.net).minus(bookedExpense), ), cashIn: money(cashIn), cashOut: money(cashOut), cashNet: money(cashIn.minus(cashOut)), expenseCategories, }; } private async breakdown(organizationId: string) { const [docs, payroll, contracts] = await Promise.all([ this.prisma.financialDocument.findMany({ where: { organizationId, kind: 'expense', status: { in: ['open', 'partial'] } }, include: { movements: true }, }), this.prisma.financialPayroll.findMany({ where: { organizationId, status: { in: ['open', 'partial'] } }, include: { movements: true }, }), this.prisma.financialReceivable.findMany({ where: { organizationId, status: 'open' }, include: { movements: true }, }), ]); const payablesByMonth = new Map(); const addMonth = (date: Date, amount: Prisma.Decimal) => { const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; payablesByMonth.set(key, (payablesByMonth.get(key) ?? new Prisma.Decimal(0)).plus(amount)); }; for (const doc of docs) { const left = doc.amount.minus(this.paidOf(doc.movements)); if (left.greaterThan(0)) addMonth(doc.dueAt ?? doc.bookedAt, left); } for (const row of payroll) { const left = row.payable.minus(this.paidOf(row.movements)); if (left.greaterThan(0)) addMonth(row.dueDate, left); } const receivablesByParty = new Map(); for (const row of contracts) { const paid = this.paidOf(row.movements); const left = row.totalAmount.minus(paid); if (!left.greaterThan(0)) continue; const name = row.partyName?.trim() || 'بدون نام'; receivablesByParty.set(name, (receivablesByParty.get(name) ?? new Prisma.Decimal(0)).plus(left)); } return { payablesByMonth: [...payablesByMonth.entries()].map(([month, amount]) => ({ month, amount: money(amount) })), receivablesByParty: [...receivablesByParty.entries()].map(([name, amount]) => ({ name, amount: money(amount) })), }; } private paidOf(movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>) { return movements.filter((row) => !row.voidedAt).reduce((sum, row) => sum.plus(row.amount), new Prisma.Decimal(0)); } private payrollDto( row: { id: string; userId: string; period: string; baseAmount: Prisma.Decimal; benefits: Prisma.Decimal; benefitsReason?: string | null; bonus: Prisma.Decimal; bonusReason?: string | null; deductions: Prisma.Decimal; deductionsReason?: string | null; payable: Prisma.Decimal; dueDate: Date; status: string; movements: Array<{ id: string; amount: Prisma.Decimal; voidedAt: Date | null; accountId: string; occurredAt: Date; account?: { name: string } | null; }>; }, fullName: string, ) { const paid = this.paidOf(row.movements); const left = row.payable.minus(paid); return { id: row.id, userId: row.userId, fullName, period: row.period, baseAmount: money(row.baseAmount), benefits: money(row.benefits), benefitsReason: row.benefitsReason?.trim() || null, bonus: money(row.bonus), bonusReason: row.bonusReason?.trim() || null, deductions: money(row.deductions), deductionsReason: row.deductionsReason?.trim() || null, payable: money(row.payable), paidAmount: money(paid), remaining: money(left.isNegative() ? 0 : left), dueDate: row.dueDate.toISOString(), status: row.status, payments: row.movements .filter((item) => !item.voidedAt) .map((item) => ({ id: item.id, amount: money(item.amount), occurredAt: item.occurredAt.toISOString(), accountId: item.accountId, accountName: item.account?.name ?? '', })), }; } private async balance(accountId: string) { const rows = await this.prisma.financialMovement.findMany({ where: { accountId, voidedAt: null }, select: { direction: true, amount: true }, }); return rows.reduce( (sum, row) => (row.direction === 'in' ? sum.plus(row.amount) : sum.minus(row.amount)), new Prisma.Decimal(0), ); } }