Files
CRM_Montzar/apps/api/src/treasury/treasury.service.ts
T
MahdiandCursor 08a278e1c8 feat(treasury): number documents and manage categories on their tab
Save a system tracking number on each income and expense, reuse the full contact dialogs for a new party, and create, rename, or delete categories only from the categories tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 13:11:09 +03:30

1286 lines
46 KiB
TypeScript

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 { TreasuryReceivablesService } from './treasury-receivables.service';
const INCOME_CATEGORIES = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها'];
const EXPENSE_CATEGORIES = [
'اجاره',
'آب، برق، گاز و ارتباطات',
'حقوق و مزایا',
'اشتراک نرم‌افزار',
'تجهیزات و ملزومات',
'تبلیغات',
'ایاب و ذهاب',
'پشتیبانی و خدمات',
'مالیات و عوارض',
'سایر هزینه‌ها',
];
const KIND_LABEL: Record<string, string> = {
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<FinancialCategoryDto[]> {
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<FinancialCategoryDto> {
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<FinancialCategoryDto> {
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<FinancialAccountDto[]> {
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<FinancialAccountDto> {
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<FinancialAccountDto> {
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,
...(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,
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<FinancialDocumentDto[]> {
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;
},
): Promise<FinancialDocumentDto> {
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 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,
},
});
}
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<TreasuryPartySearchDto> {
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<TreasuryPartyBalanceDto[]> {
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<string, {
key: string;
partyKind: FinancialPartyKind | null;
partyName: string;
amount: Prisma.Decimal;
settled: Prisma.Decimal;
remaining: Prisma.Decimal;
}>();
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),
};
current.amount = current.amount.plus(row.amount);
current.settled = current.settled.plus(settled);
current.remaining = current.remaining.plus(rawRemaining);
buckets.set(key, current);
}
return [...buckets.values()]
.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),
}));
}
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<FinancialDocumentDto> {
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<FinancialDocumentDto> {
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([
this.prisma.financialMovement.updateMany({
where: { documentId: doc.id, voidedAt: null },
data: { voidedAt: now },
}),
this.prisma.financialDocument.update({
where: { id: doc.id },
data: { status: 'void', voidedAt: now, voidReason: reason?.trim() || 'ابطال' },
}),
]);
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<TreasuryDashboardDto> {
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 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.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 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 },
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<string[] | null> {
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<FinancialAccountDto> {
const from = startOfDay(range?.from);
const to = startOfDay(range?.to);
const movements = await this.prisma.financialMovement.findMany({
where: { accountId: account.id, voidedAt: null },
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 ?? [],
};
}
}