feat: add internal finance ledger for accounts, claims, and payroll
Keep booked income and expense separate from cash balances, with installments, recurring items, and reports. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,769 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
FinancialAccountDto,
|
||||
FinancialCategoryDto,
|
||||
FinancialDocumentDto,
|
||||
FinancialMovementDto,
|
||||
TreasuryDashboardDto,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.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,
|
||||
) {}
|
||||
|
||||
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' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: { category: true, account: true, movements: 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;
|
||||
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 },
|
||||
}),
|
||||
]);
|
||||
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 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 }>;
|
||||
}): 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user