2026-10-18 18:49:49 +03:30
|
|
|
import {
|
|
|
|
|
BadRequestException,
|
|
|
|
|
Injectable,
|
|
|
|
|
NotFoundException,
|
|
|
|
|
} from '@nestjs/common';
|
|
|
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
|
import { Prisma } from '@prisma/client';
|
2026-10-18 19:59:24 +03:30
|
|
|
import ExcelJS from 'exceljs';
|
2026-10-18 18:49:49 +03:30
|
|
|
import type {
|
|
|
|
|
FinancialAccountDto,
|
|
|
|
|
FinancialCategoryDto,
|
|
|
|
|
FinancialDocumentDto,
|
|
|
|
|
FinancialMovementDto,
|
|
|
|
|
TreasuryDashboardDto,
|
|
|
|
|
} from '@mento/shared';
|
|
|
|
|
import { PrismaService } from '../prisma/prisma.service';
|
2026-10-18 19:33:09 +03:30
|
|
|
import { LocalFileStorage } from '../storage/local-file.storage';
|
2026-11-02 15:44:36 +03:30
|
|
|
import { StorageQuotaService } from '../billing/storage-quota.service';
|
2026-10-18 18:49:49 +03:30
|
|
|
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,
|
2026-10-18 19:33:09 +03:30
|
|
|
private readonly files: LocalFileStorage,
|
2026-11-02 15:44:36 +03:30
|
|
|
private readonly storage: StorageQuotaService,
|
2026-10-18 18:49:49 +03:30
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
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('نام دستهبندی کوتاه است.');
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async listAccounts(
|
|
|
|
|
actor: Actor,
|
|
|
|
|
range?: { from?: string; to?: string },
|
|
|
|
|
): Promise<FinancialAccountDto[]> {
|
|
|
|
|
const accounts = await this.prisma.financialAccount.findMany({
|
|
|
|
|
where: { organizationId: actor.organizationId },
|
|
|
|
|
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; notes?: string | null; isActive?: boolean; responsibleName?: string | null },
|
|
|
|
|
): Promise<FinancialAccountDto> {
|
|
|
|
|
const existing = await this.requireAccount(actor.organizationId, id);
|
|
|
|
|
const row = await this.prisma.financialAccount.update({
|
|
|
|
|
where: { id: existing.id },
|
|
|
|
|
data: {
|
|
|
|
|
...(input.name?.trim() ? { name: input.name.trim() } : {}),
|
|
|
|
|
...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}),
|
|
|
|
|
...(input.responsibleName !== undefined
|
|
|
|
|
? { responsibleName: input.responsibleName?.trim() || null }
|
|
|
|
|
: {}),
|
|
|
|
|
...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
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);
|
|
|
|
|
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),
|
|
|
|
|
this.requireAccount(actor.organizationId, input.toAccountId),
|
|
|
|
|
]);
|
|
|
|
|
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 rows = await this.prisma.financialDocument.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
organizationId: actor.organizationId,
|
|
|
|
|
kind,
|
|
|
|
|
...(q?.trim()
|
|
|
|
|
? {
|
|
|
|
|
OR: [
|
|
|
|
|
{ title: { contains: q.trim(), mode: 'insensitive' } },
|
|
|
|
|
{ partyName: { contains: q.trim(), mode: 'insensitive' } },
|
|
|
|
|
{ reference: { contains: q.trim(), mode: 'insensitive' } },
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
: {}),
|
|
|
|
|
},
|
2026-10-18 19:59:24 +03:30
|
|
|
include: { category: true, account: true, movements: true, attachments: { select: { id: true, fileName: true } } },
|
2026-10-18 18:49:49 +03:30
|
|
|
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;
|
|
|
|
|
personId?: 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 settle = input.settleAmount == null || input.settleAmount === ''
|
|
|
|
|
? new Prisma.Decimal(0)
|
|
|
|
|
: dec(input.settleAmount);
|
|
|
|
|
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);
|
|
|
|
|
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 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: input.personId || null,
|
|
|
|
|
partyName: input.partyName?.trim() || null,
|
|
|
|
|
accountId: input.accountId || null,
|
|
|
|
|
method: input.method || null,
|
|
|
|
|
status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial',
|
|
|
|
|
reference: input.reference?.trim() || null,
|
|
|
|
|
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: input.reference?.trim() || null,
|
|
|
|
|
createdById: actor.userId,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return tx.financialDocument.findUniqueOrThrow({
|
|
|
|
|
where: { id: created.id },
|
|
|
|
|
include: { category: true, account: true, movements: true },
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
return this.toDocumentDto(doc);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
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 },
|
|
|
|
|
}),
|
|
|
|
|
]);
|
2026-10-18 19:33:09 +03:30
|
|
|
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);
|
2026-10-18 18:49:49 +03:30
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 19:59:24 +03:30
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 19:33:09 +03:30
|
|
|
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'}`;
|
2026-11-02 15:44:36 +03:30
|
|
|
const bytes = file.buffer.length;
|
|
|
|
|
await this.storage.assertCanAdd(actor.organizationId, bytes);
|
2026-10-18 19:33:09 +03:30
|
|
|
await this.files.save(storageKey, file.buffer);
|
2026-11-02 15:44:36 +03:30
|
|
|
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;
|
|
|
|
|
}
|
2026-10-18 19:33:09 +03:30
|
|
|
}
|
|
|
|
|
|
2026-10-18 18:49:49 +03:30
|
|
|
async dashboard(
|
|
|
|
|
actor: Actor,
|
|
|
|
|
range?: { from?: string; to?: string },
|
|
|
|
|
): Promise<TreasuryDashboardDto> {
|
|
|
|
|
const from = startOfDay(range?.from);
|
|
|
|
|
const to = startOfDay(range?.to);
|
|
|
|
|
const bookedWhere = {
|
|
|
|
|
organizationId: actor.organizationId,
|
|
|
|
|
status: { not: 'void' },
|
|
|
|
|
...(from || to
|
|
|
|
|
? { bookedAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
|
|
|
|
|
: {}),
|
|
|
|
|
};
|
|
|
|
|
const cashWhere = {
|
|
|
|
|
organizationId: actor.organizationId,
|
|
|
|
|
voidedAt: null,
|
|
|
|
|
...(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'] },
|
|
|
|
|
},
|
|
|
|
|
include: { movements: true },
|
|
|
|
|
}),
|
|
|
|
|
this.listAccounts(actor),
|
|
|
|
|
this.prisma.financialDocument.findMany({
|
|
|
|
|
where: { organizationId: actor.organizationId },
|
|
|
|
|
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 requireAccount(organizationId: string, id: string) {
|
|
|
|
|
const row = await this.prisma.financialAccount.findFirst({
|
|
|
|
|
where: { id, organizationId },
|
|
|
|
|
});
|
|
|
|
|
if (!row) throw new NotFoundException('حساب پیدا نشد.');
|
|
|
|
|
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;
|
|
|
|
|
personId: 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 }>;
|
2026-10-18 19:59:24 +03:30
|
|
|
attachments?: Array<{ id: string; fileName: string }>;
|
2026-10-18 18:49:49 +03:30
|
|
|
}): 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,
|
|
|
|
|
personId: row.personId,
|
|
|
|
|
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(),
|
2026-10-18 19:59:24 +03:30
|
|
|
attachments: row.attachments ?? [],
|
2026-10-18 18:49:49 +03:30
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|