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:
2026-10-18 18:49:49 +03:30
co-authored by Cursor
parent 61d17c5cea
commit ab538b8304
26 changed files with 3836 additions and 0 deletions
+2
View File
@@ -33,6 +33,7 @@ import { MemoryModule } from './memory/memory.module';
import { TahesabModule } from './integrations/tahesab/tahesab.module';
import { InventoryModule } from './inventory/inventory.module';
import { FinanceModule } from './finance/finance.module';
import { TreasuryModule } from './treasury/treasury.module';
import { PlatformModule } from './platform/platform.module';
import { BillingModule } from './billing/billing.module';
import { AuditLogsModule } from './audit-logs/audit-logs.module';
@@ -83,6 +84,7 @@ import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
InvoicesModule,
InventoryModule,
FinanceModule,
TreasuryModule,
SalesModule,
TicketsModule,
FaqModule,
@@ -0,0 +1,479 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
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 parseDate(iso?: string | null) {
if (!iso) return null;
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d;
}
function periodKey(date: Date) {
return date.toISOString().slice(0, 10);
}
function addInterval(date: Date, interval: string) {
const next = new Date(date);
if (interval === 'weekly') next.setDate(next.getDate() + 7);
else if (interval === 'yearly') next.setFullYear(next.getFullYear() + 1);
else next.setMonth(next.getMonth() + 1);
return next;
}
@Injectable()
export class TreasuryOpsService {
private readonly logger = new Logger(TreasuryOpsService.name);
constructor(private readonly prisma: PrismaService) {}
async listPayroll(actor: Actor) {
const rows = await this.prisma.financialPayroll.findMany({
where: { organizationId: actor.organizationId },
include: { movements: true },
orderBy: { dueDate: 'desc' },
take: 100,
});
const users = await this.prisma.user.findMany({
where: { id: { in: rows.map((row) => row.userId) } },
select: { id: true, fullName: true },
});
const names = new Map(users.map((user) => [user.id, user.fullName]));
return rows.map((row) => this.payrollDto(row, names.get(row.userId) ?? ''));
}
async createPayroll(
actor: Actor,
input: {
userId: string;
period: string;
baseAmount: string | number;
benefits?: string | number;
bonus?: string | number;
deductions?: string | number;
dueDate?: string;
notes?: string | null;
},
) {
const base = dec(input.baseAmount);
const benefits = dec(input.benefits ?? 0);
const bonus = dec(input.bonus ?? 0);
const deductions = dec(input.deductions ?? 0);
const payable = base.plus(benefits).plus(bonus).minus(deductions);
if (!input.userId || !input.period?.trim()) {
throw new BadRequestException('پرسنل و دوره حقوق لازم است.');
}
if (!payable.greaterThan(0)) throw new BadRequestException('مبلغ قابل پرداخت باید بزرگ‌تر از صفر باشد.');
const member = await this.prisma.membership.findFirst({
where: { organizationId: actor.organizationId, userId: input.userId, isActive: true },
});
if (!member) throw new BadRequestException('این کاربر عضو محیط نیست.');
const row = await this.prisma.financialPayroll.create({
data: {
organizationId: actor.organizationId,
userId: input.userId,
period: input.period.trim(),
baseAmount: base,
benefits,
bonus,
deductions,
payable,
dueDate: parseDate(input.dueDate) ?? new Date(),
notes: input.notes?.trim() || null,
createdById: actor.userId,
},
include: { movements: true },
});
const user = await this.prisma.user.findUnique({ where: { id: row.userId }, select: { fullName: true } });
return this.payrollDto(row, user?.fullName ?? '');
}
async payPayroll(
actor: Actor,
id: string,
input: { amount: string | number; accountId: string; occurredAt?: string; clientKey?: string | null },
) {
const row = await this.prisma.financialPayroll.findFirst({
where: { id, organizationId: actor.organizationId },
include: { movements: true },
});
if (!row || row.voidedAt || row.status === 'void' || row.status === 'settled') {
throw new NotFoundException('رکورد حقوق قابل پرداخت نیست.');
}
const amount = dec(input.amount);
const paid = this.paidOf(row.movements);
const remaining = row.payable.minus(paid);
if (!amount.greaterThan(0) || amount.greaterThan(remaining)) {
throw new BadRequestException('مبلغ پرداخت حقوق نامعتبر است.');
}
if (input.clientKey) {
const dup = await this.prisma.financialMovement.findFirst({
where: { payrollId: id, reference: input.clientKey, voidedAt: null },
});
if (dup) throw new BadRequestException('این پرداخت قبلاً ثبت شده است.');
}
const account = await this.prisma.financialAccount.findFirst({
where: { id: input.accountId, organizationId: actor.organizationId, isActive: true },
});
if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.');
const balance = await this.balance(account.id);
if (balance.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
const status = paid.plus(amount).equals(row.payable) ? 'settled' : 'partial';
await this.prisma.$transaction([
this.prisma.financialMovement.create({
data: {
organizationId: actor.organizationId,
accountId: account.id,
payrollId: row.id,
direction: 'out',
amount,
kind: 'payment',
occurredAt: parseDate(input.occurredAt) ?? new Date(),
memo: `حقوق ${row.period}`,
reference: input.clientKey || null,
createdById: actor.userId,
},
}),
this.prisma.financialPayroll.update({ where: { id: row.id }, data: { status } }),
]);
return this.listPayroll(actor);
}
async listRecurring(actor: Actor) {
const rows = await this.prisma.financialRecurringItem.findMany({
where: { organizationId: actor.organizationId },
orderBy: { nextDue: 'asc' },
});
return rows.map((row) => ({
id: row.id,
title: row.title,
kind: row.kind,
amount: money(row.amount),
interval: row.interval,
nextDue: row.nextDue.toISOString(),
isActive: row.isActive,
}));
}
async createRecurring(
actor: Actor,
input: {
title: string;
kind: 'income' | 'expense';
amount: string | number;
interval: 'weekly' | 'monthly' | 'yearly';
startDate?: string;
endDate?: string | null;
accountId?: string | null;
categoryId?: string | null;
},
) {
const amount = dec(input.amount);
if (!input.title?.trim() || !amount.greaterThan(0)) {
throw new BadRequestException('عنوان و مبلغ تکرارشونده لازم است.');
}
const start = parseDate(input.startDate) ?? new Date();
const row = await this.prisma.financialRecurringItem.create({
data: {
organizationId: actor.organizationId,
title: input.title.trim(),
kind: input.kind,
amount,
interval: input.interval,
startDate: start,
endDate: parseDate(input.endDate),
nextDue: start,
accountId: input.accountId || null,
categoryId: input.categoryId || null,
createdById: actor.userId,
},
});
return { id: row.id };
}
@Cron('30 8 * * *')
async spawnRecurring() {
const now = new Date();
const items = await this.prisma.financialRecurringItem.findMany({
where: { isActive: true, nextDue: { lte: now } },
take: 100,
});
for (const item of items) {
if (item.endDate && item.nextDue > item.endDate) continue;
const key = periodKey(item.nextDue);
const exists = await this.prisma.financialRecurringOccurrence.findUnique({
where: { itemId_periodKey: { itemId: item.id, periodKey: key } },
});
if (!exists) {
const doc = await this.prisma.financialDocument.create({
data: {
organizationId: item.organizationId,
kind: item.kind,
title: item.title,
amount: item.amount,
bookedAt: item.nextDue,
dueAt: item.nextDue,
categoryId: item.categoryId,
accountId: item.accountId,
status: 'open',
notes: 'ساخته‌شده از الگوی تکرارشونده؛ تا تأیید، موجودی حساب تغییر نمی‌کند.',
},
});
await this.prisma.financialRecurringOccurrence.create({
data: {
itemId: item.id,
periodKey: key,
dueDate: item.nextDue,
documentId: doc.id,
},
});
}
await this.prisma.financialRecurringItem.update({
where: { id: item.id },
data: { nextDue: addInterval(item.nextDue, item.interval) },
});
}
if (items.length) this.logger.log(`recurring finance items: ${items.length}`);
}
async reconcile(
actor: Actor,
input: { accountId: string; statedBalance: string | number; reconciledAt?: string; notes?: string | null },
) {
const account = await this.prisma.financialAccount.findFirst({
where: { id: input.accountId, organizationId: actor.organizationId },
});
if (!account) throw new NotFoundException('حساب پیدا نشد.');
const systemBalance = await this.balance(account.id);
const stated = dec(input.statedBalance);
const row = await this.prisma.financialReconciliation.create({
data: {
organizationId: actor.organizationId,
accountId: account.id,
reconciledAt: parseDate(input.reconciledAt) ?? new Date(),
statedBalance: stated,
systemBalance,
difference: stated.minus(systemBalance),
notes: input.notes?.trim() || null,
createdById: actor.userId,
},
});
return {
id: row.id,
statedBalance: money(row.statedBalance),
systemBalance: money(row.systemBalance),
difference: money(row.difference),
};
}
async adjust(
actor: Actor,
input: { accountId: string; amount: string | number; direction: 'in' | 'out'; reason: string },
) {
const reason = input.reason?.trim();
if (!reason || reason.length < 3) throw new BadRequestException('علت اصلاح موجودی لازم است.');
const amount = dec(input.amount);
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ اصلاح باید بزرگ‌تر از صفر باشد.');
const account = await this.prisma.financialAccount.findFirst({
where: { id: input.accountId, organizationId: actor.organizationId, isActive: true },
});
if (!account) throw new NotFoundException('حساب پیدا نشد.');
if (input.direction === 'out') {
const balance = await this.balance(account.id);
if (balance.lessThan(amount)) throw new BadRequestException('موجودی برای این اصلاح کافی نیست.');
}
await this.prisma.financialMovement.create({
data: {
organizationId: actor.organizationId,
accountId: account.id,
direction: input.direction,
amount,
kind: 'adjustment',
occurredAt: new Date(),
memo: `اصلاحیه: ${reason}`,
createdById: actor.userId,
},
});
return { ok: true };
}
async report(actor: Actor, fromIso?: string, toIso?: string) {
const to = parseDate(toIso) ?? new Date();
const from = parseDate(fromIso) ?? new Date(to.getFullYear(), to.getMonth(), 1);
const span = to.getTime() - from.getTime();
const prevTo = new Date(from.getTime() - 1);
const prevFrom = new Date(prevTo.getTime() - span);
const current = await this.period(actor.organizationId, from, to);
const previous = await this.period(actor.organizationId, prevFrom, prevTo);
const breakdown = await this.breakdown(actor.organizationId);
return { from: from.toISOString(), to: to.toISOString(), current, previous, ...breakdown };
}
async reportExcel(actor: Actor, from?: string, to?: string) {
const data = await this.report(actor, from, to);
const book = new ExcelJS.Workbook();
const sheet = book.addWorksheet('گزارش مالی');
sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']);
const rows: Array<[string, string, string]> = [
['درآمد ثبت‌شده', data.current.bookedIncome, data.previous.bookedIncome],
['هزینه ثبت‌شده', data.current.bookedExpense, data.previous.bookedExpense],
['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit],
['ورود وجه', data.current.cashIn, data.previous.cashIn],
['خروج وجه', data.current.cashOut, data.previous.cashOut],
['خالص جریان نقدی', data.current.cashNet, data.previous.cashNet],
];
rows.forEach((row) => sheet.addRow(row));
sheet.addRow([]);
sheet.addRow(['دسته هزینه', 'مبلغ', 'سهم']);
data.current.expenseCategories.forEach((row) => sheet.addRow([row.name, row.amount, row.share]));
const buffer = await book.xlsx.writeBuffer();
return Buffer.from(buffer);
}
private async period(organizationId: string, from: Date, to: Date) {
const docs = await this.prisma.financialDocument.findMany({
where: {
organizationId,
status: { not: 'void' },
bookedAt: { gte: from, lte: to },
},
include: { category: true },
});
const cash = await this.prisma.financialMovement.findMany({
where: {
organizationId,
voidedAt: null,
occurredAt: { gte: from, lte: to },
kind: { in: ['receipt', 'payment'] },
},
});
let bookedIncome = new Prisma.Decimal(0);
let bookedExpense = new Prisma.Decimal(0);
const byCat = new Map<string, Prisma.Decimal>();
for (const doc of docs) {
if (doc.kind === 'income') bookedIncome = bookedIncome.plus(doc.amount);
else {
bookedExpense = bookedExpense.plus(doc.amount);
const name = doc.category?.name ?? 'بدون دسته';
byCat.set(name, (byCat.get(name) ?? new Prisma.Decimal(0)).plus(doc.amount));
}
}
let cashIn = new Prisma.Decimal(0);
let cashOut = new Prisma.Decimal(0);
for (const row of cash) {
if (row.direction === 'in') cashIn = cashIn.plus(row.amount);
else cashOut = cashOut.plus(row.amount);
}
const expenseCategories = [...byCat.entries()].map(([name, amount]) => ({
name,
amount: money(amount),
share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1),
}));
return {
bookedIncome: money(bookedIncome),
bookedExpense: money(bookedExpense),
operatingProfit: money(bookedIncome.minus(bookedExpense)),
cashIn: money(cashIn),
cashOut: money(cashOut),
cashNet: money(cashIn.minus(cashOut)),
expenseCategories,
};
}
private async breakdown(organizationId: string) {
const [docs, payroll, contracts] = await Promise.all([
this.prisma.financialDocument.findMany({
where: { organizationId, kind: 'expense', status: { in: ['open', 'partial'] } },
include: { movements: true },
}),
this.prisma.financialPayroll.findMany({
where: { organizationId, status: { in: ['open', 'partial'] } },
include: { movements: true },
}),
this.prisma.financialReceivable.findMany({
where: { organizationId, status: 'open' },
include: { movements: true },
}),
]);
const payablesByMonth = new Map<string, Prisma.Decimal>();
const addMonth = (date: Date, amount: Prisma.Decimal) => {
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
payablesByMonth.set(key, (payablesByMonth.get(key) ?? new Prisma.Decimal(0)).plus(amount));
};
for (const doc of docs) {
const left = doc.amount.minus(this.paidOf(doc.movements));
if (left.greaterThan(0)) addMonth(doc.dueAt ?? doc.bookedAt, left);
}
for (const row of payroll) {
const left = row.payable.minus(this.paidOf(row.movements));
if (left.greaterThan(0)) addMonth(row.dueDate, left);
}
const receivablesByParty = new Map<string, Prisma.Decimal>();
for (const row of contracts) {
const paid = this.paidOf(row.movements);
const left = row.totalAmount.minus(paid);
if (!left.greaterThan(0)) continue;
const name = row.partyName?.trim() || 'بدون نام';
receivablesByParty.set(name, (receivablesByParty.get(name) ?? new Prisma.Decimal(0)).plus(left));
}
return {
payablesByMonth: [...payablesByMonth.entries()].map(([month, amount]) => ({ month, amount: money(amount) })),
receivablesByParty: [...receivablesByParty.entries()].map(([name, amount]) => ({ name, amount: money(amount) })),
};
}
private paidOf(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 payrollDto(
row: {
id: string;
userId: string;
period: string;
baseAmount: Prisma.Decimal;
benefits: Prisma.Decimal;
bonus: Prisma.Decimal;
deductions: Prisma.Decimal;
payable: Prisma.Decimal;
dueDate: Date;
status: string;
movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>;
},
fullName: string,
) {
const paid = this.paidOf(row.movements);
const left = row.payable.minus(paid);
return {
id: row.id,
userId: row.userId,
fullName,
period: row.period,
baseAmount: money(row.baseAmount),
benefits: money(row.benefits),
bonus: money(row.bonus),
deductions: money(row.deductions),
payable: money(row.payable),
paidAmount: money(paid),
remaining: money(left.isNegative() ? 0 : left),
dueDate: row.dueDate.toISOString(),
status: row.status,
};
}
private async balance(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),
);
}
}
@@ -0,0 +1,407 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import type {
FinancialInstallmentDto,
FinancialInstallmentStatus,
FinancialReceivableDto,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import type { Actor } from '../common/record-scope';
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 parseDate(iso?: string | null) {
if (!iso) return null;
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d;
}
function addPeriod(base: Date, interval: 'weekly' | 'monthly', step: number) {
const next = new Date(base);
if (interval === 'weekly') next.setDate(next.getDate() + step * 7);
else next.setMonth(next.getMonth() + step);
return next;
}
type MovementSlice = { amount: Prisma.Decimal; voidedAt: Date | null; installmentId?: string | null };
@Injectable()
export class TreasuryReceivablesService {
private readonly logger = new Logger(TreasuryReceivablesService.name);
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
) {}
async list(actor: Actor): Promise<FinancialReceivableDto[]> {
const rows = await this.prisma.financialReceivable.findMany({
where: { organizationId: actor.organizationId },
include: {
installments: { include: { movements: true }, orderBy: { position: 'asc' } },
movements: true,
},
orderBy: { createdAt: 'desc' },
take: 100,
});
return rows.map((row) => this.toDto(row));
}
async create(
actor: Actor,
input: {
title: string;
personId?: string | null;
partyName?: string | null;
totalAmount: string | number;
prepaidAmount?: string | number | null;
accountId?: string | null;
startDate?: string;
count?: number;
interval?: 'weekly' | 'monthly';
notes?: string | null;
installments?: Array<{ amount: string | number; dueDate: string }>;
},
): Promise<FinancialReceivableDto> {
const title = input.title.trim();
const total = dec(input.totalAmount);
const prepaid = dec(input.prepaidAmount ?? 0);
if (title.length < 2) throw new BadRequestException('عنوان قرارداد را وارد کنید.');
if (!total.greaterThan(0)) throw new BadRequestException('مبلغ قرارداد باید بزرگ‌تر از صفر باشد.');
if (prepaid.isNegative() || prepaid.greaterThan(total)) {
throw new BadRequestException('پیش‌پرداخت نامعتبر است.');
}
const financed = total.minus(prepaid);
const lines = this.buildSchedule(financed, input);
if (prepaid.greaterThan(0) && !input.accountId) {
throw new BadRequestException('برای پیش‌پرداخت، حساب مقصد را انتخاب کنید.');
}
if (input.accountId) {
const account = await this.prisma.financialAccount.findFirst({
where: { id: input.accountId, organizationId: actor.organizationId, isActive: true },
});
if (!account) throw new BadRequestException('حساب مقصد پیدا نشد.');
}
const start = parseDate(input.startDate) ?? new Date();
const created = await this.prisma.$transaction(async (tx) => {
const receivable = await tx.financialReceivable.create({
data: {
organizationId: actor.organizationId,
title,
personId: input.personId || null,
partyName: input.partyName?.trim() || null,
totalAmount: total,
notes: input.notes?.trim() || null,
createdById: actor.userId,
status: financed.equals(0) ? 'settled' : 'open',
installments: {
create: lines.map((line, index) => ({
position: index + 1,
dueDate: line.dueDate,
amount: line.amount,
})),
},
},
include: { installments: true },
});
if (prepaid.greaterThan(0) && input.accountId) {
await tx.financialMovement.create({
data: {
organizationId: actor.organizationId,
accountId: input.accountId,
receivableId: receivable.id,
direction: 'in',
amount: prepaid,
kind: 'receipt',
occurredAt: start,
memo: `پیش‌پرداخت ${title}`,
createdById: actor.userId,
},
});
}
return receivable.id;
});
return this.get(actor, created);
}
async collect(
actor: Actor,
installmentId: string,
input: {
amount: string | number;
accountId: string;
occurredAt?: string;
notes?: string | null;
clientKey?: string | null;
},
): Promise<FinancialReceivableDto> {
const installment = await this.prisma.financialInstallment.findFirst({
where: { id: installmentId, receivable: { organizationId: actor.organizationId } },
include: { movements: true, receivable: true },
});
if (!installment || installment.receivable.voidedAt) {
throw new NotFoundException('قسط پیدا نشد.');
}
const amount = dec(input.amount);
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ دریافت باید بزرگ‌تر از صفر باشد.');
const paid = this.paidOf(installment.movements);
const remaining = installment.amount.minus(paid);
if (!remaining.greaterThan(0)) {
throw new BadRequestException('این قسط قبلاً تسویه شده است.');
}
if (amount.greaterThan(remaining)) {
throw new BadRequestException('مبلغ از مانده قسط بیشتر است.');
}
const clientKey = input.clientKey?.trim();
if (clientKey) {
const dup = await this.prisma.financialMovement.findFirst({
where: { installmentId, reference: clientKey, voidedAt: null },
});
if (dup) throw new BadRequestException('این دریافت قبلاً ثبت شده است.');
}
const account = await this.prisma.financialAccount.findFirst({
where: { id: input.accountId, organizationId: actor.organizationId, isActive: true },
});
if (!account) throw new BadRequestException('حساب مقصد پیدا نشد.');
await this.prisma.$transaction(async (tx) => {
await tx.financialMovement.create({
data: {
organizationId: actor.organizationId,
accountId: account.id,
receivableId: installment.receivableId,
installmentId: installment.id,
direction: 'in',
amount,
kind: 'receipt',
occurredAt: parseDate(input.occurredAt) ?? new Date(),
memo: input.notes?.trim() || `قسط ${installment.position} ${installment.receivable.title}`,
reference: clientKey || null,
createdById: actor.userId,
},
});
const nextPaid = paid.plus(amount);
const all = await tx.financialInstallment.findMany({
where: { receivableId: installment.receivableId },
include: { movements: true },
});
const settled = all.every((row) => {
const rowPaid = row.id === installment.id ? nextPaid : this.paidOf(row.movements);
return rowPaid.greaterThanOrEqualTo(row.amount);
});
if (settled) {
await tx.financialReceivable.update({
where: { id: installment.receivableId },
data: { status: 'settled' },
});
}
});
return this.get(actor, installment.receivableId);
}
async upcoming(actor: Actor) {
const rows = await this.prisma.financialInstallment.findMany({
where: {
receivable: { organizationId: actor.organizationId, status: { not: 'void' }, voidedAt: null },
},
include: { movements: true, receivable: true },
orderBy: { dueDate: 'asc' },
take: 200,
});
return rows
.map((row) => this.toInstallment(row))
.filter((row) => row.status === 'open' || row.status === 'partial' || row.status === 'overdue')
.slice(0, 12);
}
async payables(actor: Actor) {
return this.prisma.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
kind: 'expense',
status: { in: ['open', 'partial'] },
},
include: { category: true, account: true, movements: true },
orderBy: [{ dueAt: 'asc' }, { bookedAt: 'asc' }],
take: 12,
});
}
@Cron('15 8 * * *')
async remindDue() {
const now = new Date();
const horizon = new Date(now);
horizon.setDate(horizon.getDate() + 3);
const rows = await this.prisma.financialInstallment.findMany({
where: {
dueDate: { lte: horizon },
reminderNotifiedAt: null,
receivable: { status: 'open', voidedAt: null },
},
include: { movements: true, receivable: true },
take: 80,
});
for (const row of rows) {
const dto = this.toInstallment(row);
if (dto.status === 'settled') continue;
const admins = await this.prisma.membership.findMany({
where: {
organizationId: row.receivable.organizationId,
isActive: true,
role: { in: ['owner', 'admin'] },
},
select: { userId: true },
});
for (const admin of admins) {
await this.notifications.create({
organizationId: row.receivable.organizationId,
userId: admin.userId,
type: 'treasury_installment',
title: dto.status === 'overdue' ? 'قسط معوق' : 'قسط نزدیک سررسید',
body: `${row.receivable.title} · قسط ${row.position}`,
link: '/finance/receivables',
entityType: 'financial_installment',
entityId: row.id,
dedupe: true,
});
}
await this.prisma.financialInstallment.update({
where: { id: row.id },
data: { reminderNotifiedAt: now },
});
}
if (rows.length) this.logger.log(`treasury installment reminders: ${rows.length}`);
}
private async get(actor: Actor, id: string): Promise<FinancialReceivableDto> {
const row = await this.prisma.financialReceivable.findFirst({
where: { id, organizationId: actor.organizationId },
include: {
installments: { include: { movements: true }, orderBy: { position: 'asc' } },
movements: true,
},
});
if (!row) throw new NotFoundException('قرارداد پیدا نشد.');
return this.toDto(row);
}
private buildSchedule(
financed: Prisma.Decimal,
input: {
startDate?: string;
count?: number;
interval?: 'weekly' | 'monthly';
installments?: Array<{ amount: string | number; dueDate: string }>;
},
) {
if (financed.equals(0)) return [];
if (input.installments?.length) {
const lines = input.installments.map((line) => {
const dueDate = parseDate(line.dueDate);
const amount = dec(line.amount);
if (!dueDate || !amount.greaterThan(0)) {
throw new BadRequestException('مبلغ و سررسید هر قسط لازم است.');
}
return { amount, dueDate };
});
const sum = lines.reduce((total, line) => total.plus(line.amount), new Prisma.Decimal(0));
if (!sum.equals(financed)) {
throw new BadRequestException('جمع اقساط باید برابر مانده پس از پیش‌پرداخت باشد.');
}
return lines;
}
const count = Math.floor(input.count ?? 0);
if (count < 1 || count > 60) {
throw new BadRequestException('تعداد اقساط باید بین ۱ و ۶۰ باشد.');
}
const interval = input.interval === 'weekly' ? 'weekly' : 'monthly';
const start = parseDate(input.startDate) ?? new Date();
const base = financed.dividedBy(count).toDecimalPlaces(2, Prisma.Decimal.ROUND_DOWN);
const lines = Array.from({ length: count }, (_, index) => ({
amount: base,
dueDate: addPeriod(start, interval, index),
}));
const assigned = base.mul(count);
lines[lines.length - 1]!.amount = lines[lines.length - 1]!.amount.plus(financed.minus(assigned));
return lines;
}
private paidOf(movements: MovementSlice[]) {
return movements
.filter((row) => !row.voidedAt)
.reduce((sum, row) => sum.plus(row.amount), new Prisma.Decimal(0));
}
private toInstallment(row: {
id: string;
position: number;
dueDate: Date;
amount: Prisma.Decimal;
movements: MovementSlice[];
}): FinancialInstallmentDto {
const paid = this.paidOf(row.movements);
const remainingRaw = row.amount.minus(paid);
const remaining = remainingRaw.isNegative() ? new Prisma.Decimal(0) : remainingRaw;
let status: FinancialInstallmentStatus = 'open';
if (remaining.equals(0)) status = 'settled';
else if (row.dueDate.getTime() < Date.now()) status = 'overdue';
else if (paid.greaterThan(0)) status = 'partial';
return {
id: row.id,
position: row.position,
dueDate: row.dueDate.toISOString(),
amount: money(row.amount),
paidAmount: money(paid),
remaining: money(remaining),
status,
};
}
private toDto(row: {
id: string;
title: string;
personId: string | null;
partyName: string | null;
totalAmount: Prisma.Decimal;
notes: string | null;
status: string;
createdAt: Date;
movements: MovementSlice[];
installments: Array<{
id: string;
position: number;
dueDate: Date;
amount: Prisma.Decimal;
movements: MovementSlice[];
}>;
}): FinancialReceivableDto {
const paid = this.paidOf(row.movements);
const remainingRaw = row.totalAmount.minus(paid);
const remaining = remainingRaw.isNegative() ? new Prisma.Decimal(0) : remainingRaw;
return {
id: row.id,
title: row.title,
personId: row.personId,
partyName: row.partyName,
totalAmount: money(row.totalAmount),
paidAmount: money(paid),
remaining: money(remaining),
status: row.status as FinancialReceivableDto['status'],
notes: row.notes,
installments: row.installments.map((item) => this.toInstallment(item)),
createdAt: row.createdAt.toISOString(),
};
}
}
@@ -0,0 +1,198 @@
import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { TreasuryOpsService } from './treasury-ops.service';
import { TreasuryReceivablesService } from './treasury-receivables.service';
import { TreasuryService } from './treasury.service';
@Controller('treasury')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('owner', 'admin')
export class TreasuryController {
constructor(
private readonly treasury: TreasuryService,
private readonly receivables: TreasuryReceivablesService,
private readonly ops: TreasuryOpsService,
) {}
@Get('dashboard')
dashboard(
@CurrentUser() user: Actor,
@Query('from') from?: string,
@Query('to') to?: string,
) {
return this.treasury.dashboard(user, { from, to });
}
@Get('categories')
categories(@CurrentUser() user: Actor, @Query('kind') kind?: string) {
return this.treasury.listCategories(user, kind);
}
@Post('categories')
createCategory(
@CurrentUser() user: Actor,
@Body() body: { name: string; kind: 'income' | 'expense' },
) {
return this.treasury.createCategory(user, body);
}
@Get('accounts')
accounts(
@CurrentUser() user: Actor,
@Query('from') from?: string,
@Query('to') to?: string,
) {
return this.treasury.listAccounts(user, { from, to });
}
@Post('accounts')
createAccount(@CurrentUser() user: Actor, @Body() body: Parameters<TreasuryService['createAccount']>[1]) {
return this.treasury.createAccount(user, body);
}
@Patch('accounts/:id')
updateAccount(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() body: { name?: string; notes?: string | null; isActive?: boolean; responsibleName?: string | null },
) {
return this.treasury.updateAccount(user, id, body);
}
@Get('accounts/:id/ledger')
ledger(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Query('from') from?: string,
@Query('to') to?: string,
) {
return this.treasury.ledger(user, id, { from, to });
}
@Post('transfers')
transfer(@CurrentUser() user: Actor, @Body() body: Parameters<TreasuryService['transfer']>[1]) {
return this.treasury.transfer(user, body);
}
@Get('documents')
documents(
@CurrentUser() user: Actor,
@Query('kind') kind: 'income' | 'expense',
@Query('q') q?: string,
) {
return this.treasury.listDocuments(user, kind === 'expense' ? 'expense' : 'income', q);
}
@Post('documents')
createDocument(@CurrentUser() user: Actor, @Body() body: Parameters<TreasuryService['createDocument']>[1]) {
return this.treasury.createDocument(user, body);
}
@Post('documents/:id/settle')
settle(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() body: { amount: string | number; accountId: string; occurredAt?: string; reference?: string | null },
) {
return this.treasury.settleDocument(user, id, body);
}
@Get('receivables')
receivablesList(@CurrentUser() user: Actor) {
return this.receivables.list(user);
}
@Post('receivables')
createReceivable(
@CurrentUser() user: Actor,
@Body() body: Parameters<TreasuryReceivablesService['create']>[1],
) {
return this.receivables.create(user, body);
}
@Post('installments/:id/collect')
collectInstallment(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() body: Parameters<TreasuryReceivablesService['collect']>[2],
) {
return this.receivables.collect(user, id, body);
}
@Get('payroll')
payroll(@CurrentUser() user: Actor) {
return this.ops.listPayroll(user);
}
@Post('payroll')
createPayroll(@CurrentUser() user: Actor, @Body() body: Parameters<TreasuryOpsService['createPayroll']>[1]) {
return this.ops.createPayroll(user, body);
}
@Post('payroll/:id/pay')
payPayroll(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() body: Parameters<TreasuryOpsService['payPayroll']>[2],
) {
return this.ops.payPayroll(user, id, body);
}
@Get('recurring')
recurring(@CurrentUser() user: Actor) {
return this.ops.listRecurring(user);
}
@Post('recurring')
createRecurring(
@CurrentUser() user: Actor,
@Body() body: Parameters<TreasuryOpsService['createRecurring']>[1],
) {
return this.ops.createRecurring(user, body);
}
@Get('reports')
report(
@CurrentUser() user: Actor,
@Query('from') from?: string,
@Query('to') to?: string,
) {
return this.ops.report(user, from, to);
}
@Get('reports/export')
async exportReport(
@CurrentUser() user: Actor,
@Query('from') from?: string,
@Query('to') to?: string,
) {
const buffer = await this.ops.reportExcel(user, from, to);
return new StreamableFile(buffer, {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: 'attachment; filename="treasury-report.xlsx"',
});
}
@Post('reconciliations')
reconcile(@CurrentUser() user: Actor, @Body() body: Parameters<TreasuryOpsService['reconcile']>[1]) {
return this.ops.reconcile(user, body);
}
@Post('adjustments')
adjust(@CurrentUser() user: Actor, @Body() body: Parameters<TreasuryOpsService['adjust']>[1]) {
return this.ops.adjust(user, body);
}
@Post('documents/:id/void')
voidDoc(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() body: { reason?: string },
) {
return this.treasury.voidDocument(user, id, body.reason);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { TreasuryController } from './treasury.controller';
import { TreasuryOpsService } from './treasury-ops.service';
import { TreasuryReceivablesService } from './treasury-receivables.service';
import { TreasuryService } from './treasury.service';
@Module({
imports: [AuthModule, NotificationsModule],
controllers: [TreasuryController],
providers: [TreasuryService, TreasuryReceivablesService, TreasuryOpsService],
exports: [TreasuryService],
})
export class TreasuryModule {}
+769
View File
@@ -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(),
};
}
}