import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { parseUserTreasuryAccess } from '@mento/shared'; import { randomUUID } from 'node:crypto'; import { Prisma } from '@prisma/client'; import ExcelJS from 'exceljs'; import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto, FinancialMovementDto, FinancialPartyKind, TreasuryDashboardDto, TreasuryPartyBalanceDto, TreasuryPartySearchDto, } from '@mento/shared'; import { CompaniesService } from '../contacts/companies.service'; import { PeopleService } from '../contacts/people.service'; import { dateToJalali } from '../common/jalali'; import { PrismaService } from '../prisma/prisma.service'; import { LocalFileStorage } from '../storage/local-file.storage'; import { StorageQuotaService } from '../billing/storage-quota.service'; import type { Actor } from '../common/record-scope'; import { loadInvoicePartyPositions, invoiceSalesInPeriod } from './invoice-ledger'; import { TreasuryReceivablesService } from './treasury-receivables.service'; const INCOME_CATEGORIES = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها']; const EXPENSE_CATEGORIES = [ 'اجاره', 'آب، برق، گاز و ارتباطات', 'حقوق و مزایا', 'اشتراک نرم‌افزار', 'تجهیزات و ملزومات', 'تبلیغات', 'ایاب و ذهاب', 'پشتیبانی و خدمات', 'مالیات و عوارض', 'سایر هزینه‌ها', ]; const KIND_LABEL: Record = { opening: 'مانده افتتاحیه', receipt: 'دریافت', payment: 'پرداخت', transfer_in: 'انتقال ورودی', transfer_out: 'انتقال خروجی', }; 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 startOfDay(iso?: string) { if (!iso) return null; const d = new Date(iso); if (Number.isNaN(d.getTime())) return null; return d; } @Injectable() export class TreasuryService { constructor( private readonly prisma: PrismaService, private readonly receivables: TreasuryReceivablesService, private readonly files: LocalFileStorage, private readonly storage: StorageQuotaService, private readonly people: PeopleService, private readonly companies: CompaniesService, ) {} async ensureCategories(organizationId: string) { const count = await this.prisma.financialCategory.count({ where: { organizationId }, }); if (count > 0) return; await this.prisma.financialCategory.createMany({ data: [ ...INCOME_CATEGORIES.map((name) => ({ organizationId, name, kind: 'income', })), ...EXPENSE_CATEGORIES.map((name) => ({ organizationId, name, kind: 'expense', })), ], skipDuplicates: true, }); } async listCategories(actor: Actor, kind?: string): Promise { await this.ensureCategories(actor.organizationId); const rows = await this.prisma.financialCategory.findMany({ where: { organizationId: actor.organizationId, ...(kind === 'income' || kind === 'expense' ? { kind } : {}), }, orderBy: [{ kind: 'asc' }, { name: 'asc' }], }); return rows.map((row) => ({ id: row.id, name: row.name, kind: row.kind as FinancialCategoryDto['kind'], isActive: row.isActive, })); } async createCategory( actor: Actor, input: { name: string; kind: 'income' | 'expense' }, ): Promise { const name = input.name.trim(); if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.'); try { const row = await this.prisma.financialCategory.create({ data: { organizationId: actor.organizationId, name, kind: input.kind, }, }); return { id: row.id, name: row.name, kind: row.kind as FinancialCategoryDto['kind'], isActive: row.isActive, }; } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { throw new BadRequestException('این نام برای همین نوع دسته قبلاً ثبت شده است.'); } throw e; } } async updateCategory( actor: Actor, id: string, input: { name?: string; isActive?: boolean }, ): Promise { const current = await this.prisma.financialCategory.findFirst({ where: { id, organizationId: actor.organizationId }, }); if (!current) throw new NotFoundException('دسته‌بندی پیدا نشد.'); const name = input.name == null ? current.name : input.name.trim(); if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.'); try { const row = await this.prisma.financialCategory.update({ where: { id: current.id }, data: { name, ...(input.isActive == null ? {} : { isActive: input.isActive }), }, }); return { id: row.id, name: row.name, kind: row.kind as FinancialCategoryDto['kind'], isActive: row.isActive, }; } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { throw new BadRequestException('این نام برای همین نوع دسته قبلاً ثبت شده است.'); } throw e; } } async deleteCategory(actor: Actor, id: string): Promise<{ ok: true }> { const current = await this.prisma.financialCategory.findFirst({ where: { id, organizationId: actor.organizationId }, }); if (!current) throw new NotFoundException('دسته‌بندی پیدا نشد.'); await this.prisma.financialCategory.delete({ where: { id: current.id } }); return { ok: true }; } async listAccounts( actor: Actor, range?: { from?: string; to?: string }, ): Promise { const scope = await this.accountScope(actor); const accounts = await this.prisma.financialAccount.findMany({ where: { organizationId: actor.organizationId, ...(scope ? { id: { in: scope } } : {}), }, orderBy: [{ isActive: 'desc' }, { name: 'asc' }], }); return Promise.all(accounts.map((account) => this.toAccountDto(account, range))); } async createAccount( actor: Actor, input: { name: string; type: 'bank' | 'cash' | 'petty'; bankName?: string | null; accountRef?: string | null; responsibleName?: string | null; openingBalance?: string | number; openingDate?: string; notes?: string | null; }, ): Promise { const name = input.name.trim(); if (name.length < 2) throw new BadRequestException('نام حساب را وارد کنید.'); if (!['bank', 'cash', 'petty'].includes(input.type)) { throw new BadRequestException('نوع حساب نامعتبر است.'); } const opening = dec(input.openingBalance ?? 0); if (opening.isNegative()) { throw new BadRequestException('موجودی اولیه نمی‌تواند منفی باشد.'); } const openingDate = startOfDay(input.openingDate) ?? new Date(); const account = await this.prisma.$transaction(async (tx) => { const created = await tx.financialAccount.create({ data: { organizationId: actor.organizationId, name, type: input.type, bankName: input.bankName?.trim() || null, accountRef: input.accountRef?.trim() || null, responsibleName: input.responsibleName?.trim() || null, openingBalance: opening, openingDate, notes: input.notes?.trim() || null, createdById: actor.userId, }, }); if (opening.greaterThan(0)) { await tx.financialMovement.create({ data: { organizationId: actor.organizationId, accountId: created.id, direction: 'in', amount: opening, kind: 'opening', occurredAt: openingDate, memo: 'مانده افتتاحیه', createdById: actor.userId, }, }); } return created; }); return this.toAccountDto(account); } async updateAccount( actor: Actor, id: string, input: { name?: string; type?: 'bank' | 'cash' | 'petty'; bankName?: string | null; accountRef?: string | null; notes?: string | null; isActive?: boolean; responsibleName?: string | null; openingBalance?: string | number; openingDate?: string; }, ): Promise { const existing = await this.requireAccount(actor.organizationId, id, actor); if (input.type && !['bank', 'cash', 'petty'].includes(input.type)) { throw new BadRequestException('نوع حساب نامعتبر است.'); } const opening = input.openingBalance !== undefined ? dec(input.openingBalance) : null; if (opening?.isNegative()) { throw new BadRequestException('موجودی اولیه نمی‌تواند منفی باشد.'); } const openingDate = input.openingDate !== undefined ? (startOfDay(input.openingDate) ?? existing.openingDate) : null; const row = await this.prisma.$transaction(async (tx) => { const updated = await tx.financialAccount.update({ where: { id: existing.id }, data: { ...(input.name?.trim() ? { name: input.name.trim() } : {}), ...(input.type ? { type: input.type } : {}), ...(input.bankName !== undefined ? { bankName: input.bankName?.trim() || null } : {}), ...(input.accountRef !== undefined ? { accountRef: input.accountRef?.trim() || null } : {}), ...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}), ...(input.responsibleName !== undefined ? { responsibleName: input.responsibleName?.trim() || null } : {}), ...(input.isActive !== undefined ? { isActive: input.isActive } : {}), ...(opening ? { openingBalance: opening } : {}), ...(openingDate ? { openingDate } : {}), }, }); if (opening || openingDate) { const nextAmount = opening ?? existing.openingBalance; const nextDate = openingDate ?? existing.openingDate; const openings = await tx.financialMovement.findMany({ where: { accountId: existing.id, kind: 'opening', voidedAt: null }, orderBy: { createdAt: 'asc' }, }); if (nextAmount.isZero()) { if (openings.length) { await tx.financialMovement.updateMany({ where: { id: { in: openings.map((row) => row.id) } }, data: { voidedAt: new Date() }, }); } } else if (openings[0]) { await tx.financialMovement.update({ where: { id: openings[0].id }, data: { amount: nextAmount, occurredAt: nextDate, direction: 'in' }, }); const extra = openings.slice(1).map((row) => row.id); if (extra.length) { await tx.financialMovement.updateMany({ where: { id: { in: extra } }, data: { voidedAt: new Date() }, }); } } else { await tx.financialMovement.create({ data: { organizationId: actor.organizationId, accountId: existing.id, direction: 'in', amount: nextAmount, kind: 'opening', occurredAt: nextDate, memo: 'مانده افتتاحیه', createdById: actor.userId, }, }); } } return updated; }); return this.toAccountDto(row); } async ledger( actor: Actor, accountId: string, range?: { from?: string; to?: string }, ): Promise<{ account: FinancialAccountDto; items: FinancialMovementDto[] }> { const account = await this.requireAccount(actor.organizationId, accountId, actor); const from = startOfDay(range?.from); const to = startOfDay(range?.to); const rows = await this.prisma.financialMovement.findMany({ where: { organizationId: actor.organizationId, accountId, voidedAt: null, kind: { not: 'allocation' }, ...(from || to ? { occurredAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}), }, } : {}), }, orderBy: [{ occurredAt: 'asc' }, { createdAt: 'asc' }], }); let running = new Prisma.Decimal(0); if (from) { const prior = await this.prisma.financialMovement.findMany({ where: { organizationId: actor.organizationId, accountId, voidedAt: null, kind: { not: 'allocation' }, occurredAt: { lt: from }, }, select: { direction: true, amount: true }, }); for (const row of prior) { running = row.direction === 'in' ? running.plus(row.amount) : running.minus(row.amount); } } const items: FinancialMovementDto[] = rows.map((row) => { running = row.direction === 'in' ? running.plus(row.amount) : running.minus(row.amount); return { id: row.id, accountId: row.accountId, direction: row.direction as 'in' | 'out', amount: money(row.amount), kind: row.kind, kindLabel: KIND_LABEL[row.kind] ?? row.kind, transferGroupId: row.transferGroupId, documentId: row.documentId, occurredAt: row.occurredAt.toISOString(), memo: row.memo, reference: row.reference, balanceAfter: money(running), }; }); return { account: await this.toAccountDto(account, range), items }; } async transfer( actor: Actor, input: { fromAccountId: string; toAccountId: string; amount: string | number; occurredAt?: string; reference?: string | null; memo?: string | null; clientKey?: string | null; }, ) { if (input.fromAccountId === input.toAccountId) { throw new BadRequestException('حساب مبدأ و مقصد نباید یکسان باشند.'); } const amount = dec(input.amount); if (!amount.greaterThan(0)) { throw new BadRequestException('مبلغ انتقال باید بزرگ‌تر از صفر باشد.'); } const clientKey = input.clientKey?.trim(); if (clientKey) { const dup = await this.prisma.financialMovement.findFirst({ where: { organizationId: actor.organizationId, transferGroupId: clientKey, voidedAt: null, }, }); if (dup) throw new BadRequestException('این انتقال قبلاً ثبت شده است.'); } const [source, target] = await Promise.all([ this.requireAccount(actor.organizationId, input.fromAccountId, actor), this.requireAccount(actor.organizationId, input.toAccountId, actor), ]); if (!source.isActive || !target.isActive) { throw new BadRequestException('حساب غیرفعال در انتقال قابل استفاده نیست.'); } const balance = await this.accountBalance(source.id); if (balance.lessThan(amount)) { throw new BadRequestException('موجودی حساب مبدأ برای این انتقال کافی نیست.'); } const groupId = clientKey || randomUUID(); const occurredAt = startOfDay(input.occurredAt) ?? new Date(); const memo = input.memo?.trim() || `انتقال از ${source.name} به ${target.name}`; await this.prisma.$transaction([ this.prisma.financialMovement.create({ data: { organizationId: actor.organizationId, accountId: source.id, direction: 'out', amount, kind: 'transfer_out', transferGroupId: groupId, occurredAt, memo, reference: input.reference?.trim() || null, createdById: actor.userId, }, }), this.prisma.financialMovement.create({ data: { organizationId: actor.organizationId, accountId: target.id, direction: 'in', amount, kind: 'transfer_in', transferGroupId: groupId, occurredAt, memo, reference: input.reference?.trim() || null, createdById: actor.userId, }, }), ]); return { ok: true, transferGroupId: groupId }; } async listDocuments( actor: Actor, kind: 'income' | 'expense', q?: string, ): Promise { const scope = await this.accountScope(actor); const rows = await this.prisma.financialDocument.findMany({ where: { organizationId: actor.organizationId, kind, ...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}), ...(q?.trim() ? { OR: [ { title: { contains: q.trim(), mode: 'insensitive' } }, { partyName: { contains: q.trim(), mode: 'insensitive' } }, { reference: { contains: q.trim(), mode: 'insensitive' } }, ], } : {}), }, include: { category: true, account: true, movements: true, attachments: { select: { id: true, fileName: true } } }, orderBy: { bookedAt: 'desc' }, take: 200, }); return rows.map((row) => this.toDocumentDto(row)); } async createDocument( actor: Actor, input: { kind: 'income' | 'expense'; title: string; amount: string | number; bookedAt?: string; dueAt?: string | null; categoryId?: string | null; partyKind?: FinancialPartyKind | null; personId?: string | null; companyId?: string | null; userId?: string | null; partyName?: string | null; method?: string | null; reference?: string | null; notes?: string | null; settleAmount?: string | number | null; accountId?: string | null; settledAt?: string | null; /** دریافت یا پرداخت روی مانده؛ سند تعهدی جدید نمی‌سازد. */ settlement?: boolean; }, ): Promise { const title = input.title.trim(); const amount = dec(input.amount); if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.'); if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.'); const party = await this.resolveParty(actor, input); const settle = input.settleAmount == null || input.settleAmount === '' ? new Prisma.Decimal(0) : dec(input.settleAmount); if (party.partyKind === 'other' && !settle.equals(amount)) { throw new BadRequestException('برای سایر، مبلغ دریافت یا پرداخت باید با مبلغ کل یکی باشد.'); } if (settle.isNegative() || settle.greaterThan(amount)) { throw new BadRequestException('مبلغ دریافت/پرداخت از مبلغ سند بیشتر است.'); } if (settle.greaterThan(0) && !input.accountId) { throw new BadRequestException('برای ثبت وجه قطعی، حساب را انتخاب کنید.'); } if (input.accountId) { const account = await this.requireAccount(actor.organizationId, input.accountId, actor); if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.'); if (input.kind === 'expense' && settle.greaterThan(0)) { const balance = await this.accountBalance(account.id); if (balance.lessThan(settle)) { throw new BadRequestException('موجودی حساب برای این پرداخت کافی نیست.'); } } } const bookedAt = startOfDay(input.bookedAt) ?? new Date(); const settlement = input.settlement === true; if (settlement) { if (party.partyKind === 'other') { throw new BadRequestException('برای دریافت یا پرداخت، طرف حساب واقعی لازم است.'); } if (!settle.equals(amount) || !settle.greaterThan(0)) { throw new BadRequestException('مبلغ دریافت یا پرداخت را وارد کنید.'); } if (!input.accountId) throw new BadRequestException('حساب را انتخاب کنید.'); } const payExisting = settlement; const doc = await this.prisma.$transaction(async (tx) => { const reference = await this.nextDocumentReference( tx, actor.organizationId, input.kind, input.bookedAt, bookedAt, ); const created = await tx.financialDocument.create({ data: { organizationId: actor.organizationId, kind: input.kind, title, amount, bookedAt, dueAt: input.dueAt ? startOfDay(input.dueAt) : null, categoryId: input.categoryId || null, personId: party.personId, companyId: party.companyId, userId: party.userId, partyKind: party.partyKind, partyName: party.partyName, accountId: input.accountId || null, method: input.method || null, status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial', reference, notes: input.notes?.trim() || null, createdById: actor.userId, }, }); if (settle.greaterThan(0) && input.accountId) { await tx.financialMovement.create({ data: { organizationId: actor.organizationId, accountId: input.accountId, documentId: created.id, direction: input.kind === 'income' ? 'in' : 'out', amount: settle, kind: input.kind === 'income' ? 'receipt' : 'payment', occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt, memo: title, reference, createdById: actor.userId, }, }); } if (payExisting && input.accountId) { const leftover = await this.allocatePartyBalance(tx, actor, { party, sourceId: created.id, kind: input.kind, amount: settle, accountId: input.accountId, occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt, memo: title, }); if (leftover.greaterThan(0)) { const opposite = input.kind === 'income' ? 'expense' : 'income'; const oppositeRef = await this.nextDocumentReference( tx, actor.organizationId, opposite, input.bookedAt, bookedAt, ); await tx.financialDocument.create({ data: { organizationId: actor.organizationId, kind: opposite, title, amount: leftover, bookedAt, personId: party.personId, companyId: party.companyId, userId: party.userId, partyKind: party.partyKind, partyName: party.partyName, status: 'open', reference: oppositeRef, notes: `source:${created.id}`, createdById: actor.userId, }, }); } } return tx.financialDocument.findUniqueOrThrow({ where: { id: created.id }, include: { category: true, account: true, movements: true }, }); }); return this.toDocumentDto(doc); } /** شماره پیگیری روزانه: DR-14050813-0001 برای درآمد و PD-… برای هزینه. */ private async nextDocumentReference( tx: Prisma.TransactionClient, organizationId: string, kind: 'income' | 'expense', bookedAtInput: string | undefined, bookedAt: Date, ) { const match = bookedAtInput?.match(/^(\d{4})-(\d{2})-(\d{2})/); const when = match ? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])) : bookedAt; const { jy, jm, jd } = dateToJalali(when); const ymd = `${jy}${String(jm).padStart(2, '0')}${String(jd).padStart(2, '0')}`; const prefix = `${kind === 'income' ? 'DR' : 'PD'}-${ymd}-`; await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`finref:${organizationId}:${prefix}`})::bigint)`; const latest = await tx.financialDocument.findFirst({ where: { organizationId, reference: { startsWith: prefix } }, orderBy: { reference: 'desc' }, select: { reference: true }, }); const current = Number(latest?.reference?.slice(prefix.length)); const next = Number.isFinite(current) && current > 0 ? current + 1 : 1; return `${prefix}${String(next).padStart(4, '0')}`; } async searchParties(actor: Actor, q?: string): Promise { const term = q?.trim() ?? ''; const contains = term ? { contains: term, mode: 'insensitive' as const } : undefined; const [people, companies, users] = await Promise.all([ this.prisma.person.findMany({ where: { organizationId: actor.organizationId, ...(contains ? { OR: [{ firstName: contains }, { lastName: contains }] } : {}), }, select: { id: true, firstName: true, lastName: true }, orderBy: { updatedAt: 'desc' }, take: 20, }), this.prisma.company.findMany({ where: { organizationId: actor.organizationId, ...(contains ? { name: contains } : {}), }, select: { id: true, name: true }, orderBy: { updatedAt: 'desc' }, take: 20, }), this.prisma.user.findMany({ where: { organizationId: actor.organizationId, isActive: true, ...(contains ? { fullName: contains } : {}), }, select: { id: true, fullName: true }, orderBy: { fullName: 'asc' }, take: 20, }), ]); return { people: people.map((row) => ({ id: row.id, name: `${row.firstName} ${row.lastName}`.trim() || 'مشتری', })), companies, users: users.map((row) => ({ id: row.id, name: row.fullName })), }; } async createPartyPerson(actor: Actor, input: { firstName?: string; lastName?: string }) { const firstName = input.firstName?.trim() ?? ''; const lastName = input.lastName?.trim() ?? ''; if (`${firstName} ${lastName}`.trim().length < 2) { throw new BadRequestException('نام شخص را وارد کنید.'); } const person = await this.people.create(actor, { firstName, lastName }); return { id: person.id, name: person.fullName, kind: 'person' as const }; } async createPartyCompany(actor: Actor, input: { name?: string }) { const name = input.name?.trim() ?? ''; if (name.length < 2) throw new BadRequestException('نام شرکت را وارد کنید.'); const company = await this.companies.create(actor, { name }); return { id: company.id, name: company.name, kind: 'company' as const }; } async partyBalances(actor: Actor, kind: 'income' | 'expense'): Promise { const scope = await this.accountScope(actor); const rows = await this.prisma.financialDocument.findMany({ where: { organizationId: actor.organizationId, kind, voidedAt: null, status: { in: ['open', 'partial'] }, ...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}), }, select: { amount: true, partyKind: true, personId: true, companyId: true, userId: true, partyName: true, movements: { select: { amount: true, voidedAt: true } }, }, }); const buckets = new Map(); for (const row of rows) { if (row.partyKind === 'other') continue; const settled = this.settledOf(row.movements); const rawRemaining = row.amount.minus(settled); if (!rawRemaining.greaterThan(0)) continue; const linkedId = row.personId || row.companyId || row.userId; const partyKind = row.partyKind === 'person' || row.partyKind === 'company' || row.partyKind === 'user' ? row.partyKind : null; const key = partyKind && linkedId ? `${partyKind}:${linkedId}` : `legacy:${(row.partyName || '').trim() || 'بدون طرف حساب'}`; const current = buckets.get(key) ?? { key, partyKind, partyName: row.partyName?.trim() || 'بدون طرف حساب', amount: new Prisma.Decimal(0), settled: new Prisma.Decimal(0), remaining: new Prisma.Decimal(0), documentRemaining: new Prisma.Decimal(0), }; current.amount = current.amount.plus(row.amount); current.settled = current.settled.plus(settled); current.remaining = current.remaining.plus(rawRemaining); current.documentRemaining = current.documentRemaining.plus(rawRemaining); buckets.set(key, current); } const invoiceParties = await loadInvoicePartyPositions(this.prisma, actor.organizationId); for (const party of invoiceParties) { const onDebtorSide = party.net.greaterThan(0); if (kind === 'income' && !onDebtorSide) continue; if (kind === 'expense' && onDebtorSide) continue; const remaining = party.net.abs(); const amount = onDebtorSide ? party.billed : Prisma.Decimal.max(party.billed, remaining); const settled = Prisma.Decimal.max(amount.minus(remaining), 0); const current = buckets.get(party.key) ?? { key: party.key, partyKind: party.partyKind, partyName: party.partyName, amount: new Prisma.Decimal(0), settled: new Prisma.Decimal(0), remaining: new Prisma.Decimal(0), documentRemaining: new Prisma.Decimal(0), }; current.amount = current.amount.plus(amount); current.settled = current.settled.plus(settled); current.remaining = current.remaining.plus(remaining); if (!current.partyName || current.partyName === 'بدون طرف حساب') { current.partyName = party.partyName; } buckets.set(party.key, current); } return [...buckets.values()] .filter((row) => row.remaining.greaterThan(0)) .sort((a, b) => b.remaining.comparedTo(a.remaining)) .map((row) => ({ key: row.key, partyKind: row.partyKind, partyName: row.partyName, amount: money(row.amount), settledAmount: money(row.settled), remaining: money(row.remaining), documentRemaining: money(row.documentRemaining), })); } /** دریافت یا پرداختِ بخشی از ماندهٔ سندهای باز یک طرف حساب، بدون ساخت سند جدید. */ async collectPartyBalance( actor: Actor, input: { kind: 'income' | 'expense'; partyKind: 'person' | 'company' | 'user'; partyId: string; amount: string | number; accountId: string; occurredAt?: string; }, ): Promise<{ applied: string; documentCount: number }> { const amount = dec(input.amount); if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.'); const partyId = input.partyId.trim(); if (!partyId) throw new BadRequestException('طرف حساب را انتخاب کنید.'); if (input.partyKind !== 'person' && input.partyKind !== 'company' && input.partyKind !== 'user') { throw new BadRequestException('این مانده به سند بازی وصل نیست.'); } if (!input.accountId.trim()) throw new BadRequestException('حساب را انتخاب کنید.'); const account = await this.requireAccount(actor.organizationId, input.accountId, actor); if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.'); const scope = await this.accountScope(actor); const docs = await this.prisma.financialDocument.findMany({ where: { organizationId: actor.organizationId, kind: input.kind, voidedAt: null, status: { in: ['open', 'partial'] }, partyKind: input.partyKind, ...(input.partyKind === 'person' ? { personId: partyId } : {}), ...(input.partyKind === 'company' ? { companyId: partyId } : {}), ...(input.partyKind === 'user' ? { userId: partyId } : {}), ...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}), }, include: { movements: { select: { amount: true, voidedAt: true } } }, orderBy: [{ bookedAt: 'asc' }, { createdAt: 'asc' }], }); const open = docs .map((doc) => { const remaining = doc.amount.minus(this.settledOf(doc.movements)); return { doc, remaining }; }) .filter((row) => row.remaining.greaterThan(0)); const available = open.reduce((sum, row) => sum.plus(row.remaining), new Prisma.Decimal(0)); if (!available.greaterThan(0)) { throw new BadRequestException( 'سند بازی برای کم کردن این مانده نیست. اگر مانده از فاکتور است، دریافت را روی خود فاکتور ثبت کنید.', ); } if (amount.greaterThan(available)) { throw new BadRequestException('مبلغ از ماندهٔ سندهای باز این طرف حساب بیشتر است.'); } if (input.kind === 'expense') { const balance = await this.accountBalance(account.id); if (balance.lessThan(amount)) { throw new BadRequestException('موجودی حساب برای این پرداخت کافی نیست.'); } } const occurredAt = startOfDay(input.occurredAt) ?? new Date(); let left = amount; const touched: string[] = []; await this.prisma.$transaction(async (tx) => { for (const row of open) { if (!left.greaterThan(0)) break; const slice = left.lessThan(row.remaining) ? left : row.remaining; const status = slice.equals(row.remaining) ? 'settled' : 'partial'; await tx.financialMovement.create({ data: { organizationId: actor.organizationId, accountId: account.id, documentId: row.doc.id, direction: input.kind === 'income' ? 'in' : 'out', amount: slice, kind: input.kind === 'income' ? 'receipt' : 'payment', occurredAt, memo: input.kind === 'income' ? 'دریافت از مانده' : 'پرداخت از مانده', createdById: actor.userId, }, }); await tx.financialDocument.update({ where: { id: row.doc.id }, data: { status, accountId: account.id }, }); if (status === 'settled') { await tx.financialRecurringOccurrence.updateMany({ where: { documentId: row.doc.id, status: 'pending' }, data: { status: 'confirmed' }, }); } touched.push(row.doc.id); left = left.minus(slice); } }); await this.prisma.financialAuditLog.create({ data: { organizationId: actor.organizationId, entityType: 'document', entityId: touched[0] ?? partyId, action: 'settle', after: { amount: amount.toFixed(2), accountId: account.id, documentIds: touched, partyId, }, actorUserId: actor.userId, }, }).catch(() => undefined); return { applied: money(amount), documentCount: touched.length }; } private async resolveParty( actor: Actor, input: { partyKind?: FinancialPartyKind | null; personId?: string | null; companyId?: string | null; userId?: string | null; }, ) { const kind = input.partyKind; if (kind === 'other') { return { partyKind: 'other' as const, personId: null, companyId: null, userId: null, partyName: 'سایر' }; } if (kind === 'person') { const id = input.personId?.trim(); if (!id) throw new BadRequestException('مشتری را انتخاب کنید.'); const person = await this.prisma.person.findFirst({ where: { id, organizationId: actor.organizationId }, select: { id: true, firstName: true, lastName: true }, }); if (!person) throw new BadRequestException('مشتری پیدا نشد.'); return { partyKind: 'person' as const, personId: person.id, companyId: null, userId: null, partyName: `${person.firstName} ${person.lastName}`.trim() || 'مشتری', }; } if (kind === 'company') { const id = input.companyId?.trim(); if (!id) throw new BadRequestException('شرکت را انتخاب کنید.'); const company = await this.prisma.company.findFirst({ where: { id, organizationId: actor.organizationId }, select: { id: true, name: true }, }); if (!company) throw new BadRequestException('شرکت پیدا نشد.'); return { partyKind: 'company' as const, personId: null, companyId: company.id, userId: null, partyName: company.name, }; } if (kind === 'user') { const id = input.userId?.trim(); if (!id) throw new BadRequestException('کاربر را انتخاب کنید.'); const user = await this.prisma.user.findFirst({ where: { id, organizationId: actor.organizationId }, select: { id: true, fullName: true }, }); if (!user) throw new BadRequestException('کاربر پیدا نشد.'); return { partyKind: 'user' as const, personId: null, companyId: null, userId: user.id, partyName: user.fullName, }; } throw new BadRequestException('طرف حساب را انتخاب کنید.'); } async settleDocument( actor: Actor, id: string, input: { amount: string | number; accountId: string; occurredAt?: string; reference?: string | null }, ): Promise { const doc = await this.prisma.financialDocument.findFirst({ where: { id, organizationId: actor.organizationId }, include: { movements: true, category: true, account: true }, }); if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.'); if (doc.status === 'settled' || doc.status === 'void') { throw new BadRequestException('این سند دیگر قابل تسویه نیست.'); } const amount = dec(input.amount); if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.'); const settled = this.settledOf(doc.movements); const remaining = doc.amount.minus(settled); if (amount.greaterThan(remaining)) { throw new BadRequestException('مبلغ از مانده سند بیشتر است.'); } const account = await this.requireAccount(actor.organizationId, input.accountId, actor); if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.'); if (doc.kind === 'expense') { const balance = await this.accountBalance(account.id); if (balance.lessThan(amount)) { throw new BadRequestException('موجودی حساب برای این پرداخت کافی نیست.'); } } const nextSettled = settled.plus(amount); const status = nextSettled.equals(doc.amount) ? 'settled' : 'partial'; await this.prisma.$transaction([ this.prisma.financialMovement.create({ data: { organizationId: actor.organizationId, accountId: account.id, documentId: doc.id, direction: doc.kind === 'income' ? 'in' : 'out', amount, kind: doc.kind === 'income' ? 'receipt' : 'payment', occurredAt: startOfDay(input.occurredAt) ?? new Date(), memo: doc.title, reference: input.reference?.trim() || null, createdById: actor.userId, }, }), this.prisma.financialDocument.update({ where: { id: doc.id }, data: { status, accountId: account.id }, }), ]); await this.prisma.financialAuditLog.create({ data: { organizationId: actor.organizationId, entityType: 'document', entityId: doc.id, action: 'settle', after: { amount: amount.toFixed(2), accountId: account.id, status }, actorUserId: actor.userId, }, }).catch(() => undefined); if (status === 'settled') { await this.prisma.financialRecurringOccurrence.updateMany({ where: { documentId: doc.id, status: 'pending' }, data: { status: 'confirmed' }, }); } const fresh = await this.prisma.financialDocument.findUniqueOrThrow({ where: { id: doc.id }, include: { category: true, account: true, movements: true }, }); return this.toDocumentDto(fresh); } async voidDocument(actor: Actor, id: string, reason?: string): Promise { const doc = await this.prisma.financialDocument.findFirst({ where: { id, organizationId: actor.organizationId }, }); if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.'); const now = new Date(); await this.prisma.$transaction(async (tx) => { const allocations = await tx.financialMovement.findMany({ where: { organizationId: actor.organizationId, reference: `alloc:${doc.id}`, voidedAt: null }, select: { documentId: true }, }); await tx.financialMovement.updateMany({ where: { documentId: doc.id, voidedAt: null }, data: { voidedAt: now }, }); await tx.financialMovement.updateMany({ where: { organizationId: actor.organizationId, reference: `alloc:${doc.id}`, voidedAt: null }, data: { voidedAt: now }, }); await tx.financialDocument.update({ where: { id: doc.id }, data: { status: 'void', voidedAt: now, voidReason: reason?.trim() || 'ابطال' }, }); const excess = await tx.financialDocument.findMany({ where: { organizationId: actor.organizationId, notes: `source:${doc.id}`, voidedAt: null, status: { in: ['open', 'partial'] }, }, include: { movements: { select: { voidedAt: true } } }, }); for (const child of excess) { if (child.movements.some((row) => !row.voidedAt)) continue; await tx.financialDocument.update({ where: { id: child.id }, data: { status: 'void', voidedAt: now, voidReason: 'ابطال سند مبدأ' }, }); } const affected = [...new Set(allocations.map((row) => row.documentId).filter((id): id is string => Boolean(id)))]; for (const documentId of affected) { const row = await tx.financialDocument.findUnique({ where: { id: documentId }, include: { movements: { select: { amount: true, voidedAt: true } } }, }); if (!row || row.voidedAt || row.status === 'void') continue; const settled = this.settledOf(row.movements); const status = settled.equals(0) ? 'open' : settled.greaterThanOrEqualTo(row.amount) ? 'settled' : 'partial'; await tx.financialDocument.update({ where: { id: row.id }, data: { status } }); } }); const fresh = await this.prisma.financialDocument.findUniqueOrThrow({ where: { id: doc.id }, include: { category: true, account: true, movements: true }, }); return this.toDocumentDto(fresh); } async listAudit(actor: Actor) { const rows = await this.prisma.financialAuditLog.findMany({ where: { organizationId: actor.organizationId }, orderBy: { createdAt: 'desc' }, take: 200, }); return rows.map((row) => ({ id: row.id, entityType: row.entityType, entityId: row.entityId, action: row.action, before: row.before, after: row.after, createdAt: row.createdAt.toISOString(), })); } async openAttachment(actor: Actor, id: string) { const row = await this.prisma.financialAttachment.findFirst({ where: { id, organizationId: actor.organizationId }, }); if (!row) throw new NotFoundException('پیوست پیدا نشد.'); return { stream: this.files.openReadStream(row.storageKey), mimeType: row.mimeType, fileName: row.fileName, }; } async documentsExcel(actor: Actor, kind: 'income' | 'expense') { const rows = await this.listDocuments(actor, kind); const book = new ExcelJS.Workbook(); const sheet = book.addWorksheet(kind === 'income' ? 'درآمدها' : 'هزینه‌ها'); sheet.addRow(['عنوان', 'مبلغ', 'تسویه', 'مانده', 'وضعیت', 'دسته', 'طرف حساب', 'تاریخ']); for (const row of rows) { sheet.addRow([ row.title, row.amount, row.settledAmount, row.remaining, row.status, row.categoryName ?? '', row.partyName ?? '', row.bookedAt.slice(0, 10), ]); } return Buffer.from(await book.xlsx.writeBuffer()); } async addAttachment(actor: Actor, documentId: string, file: { originalname: string; mimetype: string; buffer: Buffer }) { const doc = await this.prisma.financialDocument.findFirst({ where: { id: documentId, organizationId: actor.organizationId }, select: { id: true }, }); if (!doc) throw new NotFoundException('سند پیدا نشد.'); const safeName = file.originalname.replace(/[^\w.\-\u0600-\u06FF]+/g, '_').slice(0, 80); const storageKey = `treasury/${actor.organizationId}/${doc.id}/${randomUUID()}-${safeName || 'file'}`; const bytes = file.buffer.length; await this.storage.assertCanAdd(actor.organizationId, bytes); await this.files.save(storageKey, file.buffer); try { const row = await this.prisma.financialAttachment.create({ data: { organizationId: actor.organizationId, documentId: doc.id, fileName: file.originalname, mimeType: file.mimetype || 'application/octet-stream', storageKey, createdById: actor.userId, }, select: { id: true, fileName: true, createdAt: true }, }); await this.storage.addLocal(actor.organizationId, bytes); return row; } catch (err) { await this.files.remove(storageKey); throw err; } } async dashboard( actor: Actor, range?: { from?: string; to?: string }, ): Promise { const from = startOfDay(range?.from); const to = startOfDay(range?.to); const scope = await this.accountScope(actor); const accountFilter = scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}; const bookedWhere = { organizationId: actor.organizationId, status: { not: 'void' }, ...accountFilter, ...(from || to ? { bookedAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } } : {}), }; const cashWhere = { organizationId: actor.organizationId, voidedAt: null, ...(scope ? { accountId: { in: scope } } : {}), ...(from || to ? { occurredAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } } : {}), }; const [income, expense, receipts, payments, openDocs, accounts, recent, upcomingInstallments, payableRows] = await Promise.all([ this.prisma.financialDocument.aggregate({ where: { ...bookedWhere, kind: 'income' }, _sum: { amount: true }, }), this.prisma.financialDocument.aggregate({ where: { ...bookedWhere, kind: 'expense' }, _sum: { amount: true }, }), this.prisma.financialMovement.aggregate({ where: { ...cashWhere, kind: 'receipt' }, _sum: { amount: true }, }), this.prisma.financialMovement.aggregate({ where: { ...cashWhere, kind: 'payment' }, _sum: { amount: true }, }), this.prisma.financialDocument.findMany({ where: { organizationId: actor.organizationId, status: { in: ['open', 'partial'] }, ...accountFilter, }, include: { movements: true }, }), this.listAccounts(actor), this.prisma.financialDocument.findMany({ where: { organizationId: actor.organizationId, ...accountFilter }, include: { category: true, account: true, movements: true }, orderBy: { createdAt: 'desc' }, take: 8, }), this.receivables.upcoming(actor), this.receivables.payables(actor), ]); let receivables = new Prisma.Decimal(0); let payables = new Prisma.Decimal(0); for (const doc of openDocs) { const left = doc.amount.minus(this.settledOf(doc.movements)); if (left.lessThanOrEqualTo(0)) continue; if (doc.kind === 'income') receivables = receivables.plus(left); else payables = payables.plus(left); } const contracts = await this.receivables.list(actor); for (const contract of contracts) { if (contract.status === 'open') receivables = receivables.plus(contract.remaining); } const openPayroll = await this.prisma.financialPayroll.findMany({ where: { organizationId: actor.organizationId, status: { in: ['open', 'partial'] } }, include: { movements: true }, }); for (const row of openPayroll) { const paid = row.movements .filter((item) => !item.voidedAt) .reduce((sum, item) => sum.plus(item.amount), new Prisma.Decimal(0)); const left = row.payable.minus(paid); if (left.greaterThan(0)) payables = payables.plus(left); } const bookedIncome = dec(income._sum.amount); const bookedExpense = dec(expense._sum.amount); const invoiceSales = await invoiceSalesInPeriod( this.prisma, actor.organizationId, from, to, ); const invoiceParties = await loadInvoicePartyPositions(this.prisma, actor.organizationId); for (const party of invoiceParties) { if (party.net.greaterThan(0)) receivables = receivables.plus(party.net); else payables = payables.plus(party.net.abs()); } const totalBalance = accounts.reduce( (sum, account) => sum.plus(account.balance), new Prisma.Decimal(0), ); return { bookedIncome: money(bookedIncome), actualReceipts: money(dec(receipts._sum.amount)), bookedExpense: money(bookedExpense), actualPayments: money(dec(payments._sum.amount)), operatingProfit: money(bookedIncome.plus(invoiceSales.net).minus(bookedExpense)), receivables: money(receivables), payables: money(payables), totalBalance: money(totalBalance), recent: recent.map((row) => this.toDocumentDto(row)), upcomingInstallments, upcomingPayables: payableRows.map((row) => this.toDocumentDto(row)), }; } /** مبلغ قطعی را از ماندهٔ باز همان طرف کم می‌کند؛ اول سندهای هم‌نوع، بعد طرف مقابل. روی موجودی حساب اثر ندارد. */ private async allocatePartyBalance( tx: Prisma.TransactionClient, actor: Actor, input: { party: { partyKind: string; personId: string | null; companyId: string | null; userId: string | null }; sourceId: string; kind: 'income' | 'expense'; amount: Prisma.Decimal; accountId: string; occurredAt: Date; memo: string; }, ): Promise { const scope = await this.accountScope(actor); const openDocs = await tx.financialDocument.findMany({ where: { organizationId: actor.organizationId, id: { not: input.sourceId }, voidedAt: null, status: { in: ['open', 'partial'] }, partyKind: input.party.partyKind, ...(input.party.personId ? { personId: input.party.personId } : {}), ...(input.party.companyId ? { companyId: input.party.companyId } : {}), ...(input.party.userId ? { userId: input.party.userId } : {}), ...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}), }, include: { movements: { select: { amount: true, voidedAt: true } } }, orderBy: [{ bookedAt: 'asc' }, { createdAt: 'asc' }], }); const ordered = [ ...openDocs.filter((row) => row.kind === input.kind), ...openDocs.filter((row) => row.kind !== input.kind), ]; let left = input.amount; for (const row of ordered) { if (!left.greaterThan(0)) break; const remaining = row.amount.minus(this.settledOf(row.movements)); if (!remaining.greaterThan(0)) continue; const slice = left.lessThan(remaining) ? left : remaining; const status = slice.equals(remaining) ? 'settled' : 'partial'; await tx.financialMovement.create({ data: { organizationId: actor.organizationId, accountId: input.accountId, documentId: row.id, direction: row.kind === 'income' ? 'in' : 'out', amount: slice, kind: 'allocation', occurredAt: input.occurredAt, memo: input.memo, reference: `alloc:${input.sourceId}`, createdById: actor.userId, }, }); await tx.financialDocument.update({ where: { id: row.id }, data: { status }, }); if (status === 'settled') { await tx.financialRecurringOccurrence.updateMany({ where: { documentId: row.id, status: 'pending' }, data: { status: 'confirmed' }, }); } left = left.minus(slice); } return left; } private settledOf(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 async accountBalance(accountId: string) { const rows = await this.prisma.financialMovement.findMany({ where: { accountId, voidedAt: null, kind: { not: 'allocation' } }, 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), ); } private async accountScope(actor: Actor): Promise { if (actor.role === 'owner' || actor.role === 'admin') return null; const membership = await this.prisma.membership.findFirst({ where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true }, select: { user: { select: { fullName: true } }, orgRole: { select: { treasuryAccountIds: true, treasuryOwnAccounts: true } }, treasuryAccessOverride: true, }, }); const override = parseUserTreasuryAccess(membership?.treasuryAccessOverride); if (override && !override.enabled) return []; const extra = override ? override.accountIds : (membership?.orgRole?.treasuryAccountIds ?? []); const own = override ? override.ownAccounts : Boolean(membership?.orgRole?.treasuryOwnAccounts); if (!own && extra.length === 0) return null; const ids = new Set(extra); const name = membership?.user.fullName?.trim(); if (own && name) { const rows = await this.prisma.financialAccount.findMany({ where: { organizationId: actor.organizationId, responsibleName: { not: null } }, select: { id: true, responsibleName: true }, }); for (const row of rows) { if (row.responsibleName?.trim() === name) ids.add(row.id); } } return [...ids]; } private async requireAccount(organizationId: string, id: string, actor?: Actor) { const row = await this.prisma.financialAccount.findFirst({ where: { id, organizationId }, }); if (!row) throw new NotFoundException('حساب پیدا نشد.'); if (actor) { const scope = await this.accountScope(actor); if (scope && !scope.includes(row.id)) { throw new ForbiddenException('به این حساب دسترسی ندارید.'); } } return row; } private async toAccountDto( account: { id: string; name: string; type: string; bankName: string | null; accountRef: string | null; responsibleName: string | null; openingBalance: Prisma.Decimal; openingDate: Date; notes: string | null; isActive: boolean; currency: string; }, range?: { from?: string; to?: string }, ): Promise { const from = startOfDay(range?.from); const to = startOfDay(range?.to); const movements = await this.prisma.financialMovement.findMany({ where: { accountId: account.id, voidedAt: null, kind: { not: 'allocation' } }, select: { direction: true, amount: true, kind: true, occurredAt: true }, }); let balance = new Prisma.Decimal(0); let periodIn = new Prisma.Decimal(0); let periodOut = new Prisma.Decimal(0); let last: Date | null = null; for (const row of movements) { balance = row.direction === 'in' ? balance.plus(row.amount) : balance.minus(row.amount); if (!last || row.occurredAt > last) last = row.occurredAt; const inRange = (!from || row.occurredAt >= from) && (!to || row.occurredAt <= to); if (!inRange || row.kind === 'opening') continue; if (row.direction === 'in') periodIn = periodIn.plus(row.amount); else periodOut = periodOut.plus(row.amount); } return { id: account.id, name: account.name, type: account.type as FinancialAccountDto['type'], bankName: account.bankName, accountRef: account.accountRef, responsibleName: account.responsibleName, openingBalance: money(account.openingBalance), openingDate: account.openingDate.toISOString(), notes: account.notes, isActive: account.isActive, currency: account.currency, balance: money(balance), periodIn: money(periodIn), periodOut: money(periodOut), lastMovementAt: last ? last.toISOString() : null, }; } private toDocumentDto(row: { id: string; kind: string; title: string; amount: Prisma.Decimal; bookedAt: Date; dueAt: Date | null; categoryId: string | null; partyKind?: string | null; personId: string | null; companyId?: string | null; userId?: string | null; partyName: string | null; accountId: string | null; method: string | null; status: string; reference: string | null; notes: string | null; createdAt: Date; category?: { name: string } | null; account?: { name: string } | null; movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>; attachments?: Array<{ id: string; fileName: string }>; }): FinancialDocumentDto { const settled = this.settledOf(row.movements); const rawRemaining = row.amount.minus(settled); const remaining = rawRemaining.isNegative() ? new Prisma.Decimal(0) : rawRemaining; return { id: row.id, kind: row.kind as FinancialDocumentDto['kind'], title: row.title, amount: money(row.amount), settledAmount: money(settled), remaining: money(remaining), bookedAt: row.bookedAt.toISOString(), dueAt: row.dueAt ? row.dueAt.toISOString() : null, categoryId: row.categoryId, categoryName: row.category?.name ?? null, partyKind: (row.partyKind as FinancialDocumentDto['partyKind']) ?? null, personId: row.personId, companyId: row.companyId ?? null, userId: row.userId ?? null, partyName: row.partyName, accountId: row.accountId, accountName: row.account?.name ?? null, method: row.method, status: row.status as FinancialDocumentDto['status'], reference: row.reference, notes: row.notes, createdAt: row.createdAt.toISOString(), attachments: row.attachments ?? [], }; } }