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>
1286 lines
46 KiB
TypeScript
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 ?? [],
|
|
};
|
|
}
|
|
}
|