Files
CRM_Montzar/apps/api/src/treasury/treasury-ops.service.ts
T
MahdiandCursor 5ef693990c feat(finance): edit payroll with month ranges, reasons, and toman
Let salary and payments be corrected after they are saved, pick a Jalali period as a month range, and show toman on finance amounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 16:53:45 +03:30

691 lines
26 KiB
TypeScript

import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { invoiceSalesInPeriod } from './invoice-ledger';
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 reasonText(value?: string | null) {
const text = value?.trim() ?? '';
return text ? text.slice(0, 500) : null;
}
function parseDate(iso?: string | null) {
if (!iso) return null;
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d;
}
function payrollStatus(payable: Prisma.Decimal, paid: Prisma.Decimal) {
if (paid.lessThanOrEqualTo(0)) return 'open';
if (paid.greaterThanOrEqualTo(payable)) return 'settled';
return 'partial';
}
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: {
where: { voidedAt: null },
include: { account: { select: { name: true } } },
orderBy: { occurredAt: 'desc' },
},
},
orderBy: { dueDate: 'desc' },
take: 500,
});
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;
benefitsReason?: string | null;
bonus?: string | number;
bonusReason?: string | null;
deductions?: string | number;
deductionsReason?: string | null;
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,
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
bonus,
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
deductions,
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
payable,
dueDate: parseDate(input.dueDate) ?? new Date(),
notes: input.notes?.trim() || null,
createdById: actor.userId,
},
include: {
movements: {
where: { voidedAt: null },
include: { account: { select: { name: true } } },
orderBy: { occurredAt: 'desc' },
},
},
});
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 = payrollStatus(row.payable, paid.plus(amount));
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 updatePayroll(
actor: Actor,
id: string,
input: {
userId: string;
period: string;
baseAmount: string | number;
benefits?: string | number;
benefitsReason?: string | null;
bonus?: string | number;
bonusReason?: string | null;
deductions?: string | number;
deductionsReason?: string | null;
},
) {
const row = await this.prisma.financialPayroll.findFirst({
where: { id, organizationId: actor.organizationId },
include: { movements: true },
});
if (!row || row.voidedAt || row.status === 'void') {
throw new NotFoundException('رکورد حقوق برای ویرایش پیدا نشد.');
}
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 paid = this.paidOf(row.movements);
if (payable.lessThan(paid)) {
throw new BadRequestException('مبلغ قابل پرداخت از پرداخت‌های ثبت‌شده کمتر است.');
}
const member = await this.prisma.membership.findFirst({
where: { organizationId: actor.organizationId, userId: input.userId, isActive: true },
});
if (!member) throw new BadRequestException('این کاربر عضو محیط نیست.');
try {
await this.prisma.financialPayroll.update({
where: { id: row.id },
data: {
userId: input.userId,
period: input.period.trim(),
baseAmount: base,
benefits,
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
bonus,
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
deductions,
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
payable,
status: payrollStatus(payable, paid),
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
throw new BadRequestException('برای این پرسنل در این دوره حقوق ثبت شده است.');
}
throw error;
}
return this.listPayroll(actor);
}
async updatePayrollPayment(
actor: Actor,
payrollId: string,
movementId: string,
input: { amount: string | number; accountId: string; occurredAt?: string },
) {
const movement = await this.prisma.financialMovement.findFirst({
where: {
id: movementId,
payrollId,
organizationId: actor.organizationId,
voidedAt: null,
},
});
const payroll = await this.prisma.financialPayroll.findFirst({
where: { id: payrollId, organizationId: actor.organizationId },
include: { movements: true },
});
if (!movement || !payroll || payroll.voidedAt || payroll.status === 'void') {
throw new NotFoundException('پرداخت حقوق برای ویرایش پیدا نشد.');
}
const amount = dec(input.amount);
const otherPaid = this.paidOf(payroll.movements.filter((item) => item.id !== movement.id));
if (!amount.greaterThan(0) || otherPaid.plus(amount).greaterThan(payroll.payable)) {
throw new BadRequestException('مبلغ پرداخت حقوق نامعتبر است.');
}
const account = await this.prisma.financialAccount.findFirst({
where: { id: input.accountId, organizationId: actor.organizationId },
});
if (!account || (!account.isActive && account.id !== movement.accountId)) {
throw new BadRequestException('حساب پرداخت پیدا نشد.');
}
const available = await this.balance(account.id);
const room = account.id === movement.accountId ? available.plus(movement.amount) : available;
if (room.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
await this.prisma.$transaction([
this.prisma.financialMovement.update({
where: { id: movement.id },
data: {
accountId: account.id,
amount,
occurredAt: parseDate(input.occurredAt) ?? movement.occurredAt,
},
}),
this.prisma.financialPayroll.update({
where: { id: payroll.id },
data: { status: payrollStatus(payroll.payable, otherPaid.plus(amount)) },
}),
]);
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,
parties?: { debtors: TreasuryPartyBalanceDto[]; creditors: TreasuryPartyBalanceDto[] },
) {
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.invoiceSales, data.previous.invoiceSales],
['فاکتور برگشتی', data.current.invoiceCredits, data.previous.invoiceCredits],
['هزینه ثبت‌شده', 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]));
if (parties) {
this.addPartySheet(book, 'بدهکاران', parties.debtors, 'دریافت‌شده');
this.addPartySheet(book, 'بستانکاران', parties.creditors, 'پرداخت‌شده');
}
const buffer = await book.xlsx.writeBuffer();
return Buffer.from(buffer);
}
private addPartySheet(
book: ExcelJS.Workbook,
title: string,
rows: TreasuryPartyBalanceDto[],
settledLabel: string,
) {
const sheet = book.addWorksheet(title);
const kindLabel: Record<string, string> = { person: 'مشتری', company: 'شرکت', user: 'کاربر' };
sheet.addRow(['طرف حساب', 'نوع', 'مبلغ کل', settledLabel, 'مانده']);
let amount = new Prisma.Decimal(0);
let settled = new Prisma.Decimal(0);
let remaining = new Prisma.Decimal(0);
for (const row of rows) {
sheet.addRow([
row.partyName,
(row.partyKind && kindLabel[row.partyKind]) || 'ثبت قبلی',
row.amount,
row.settledAmount,
row.remaining,
]);
amount = amount.plus(row.amount);
settled = settled.plus(row.settledAmount);
remaining = remaining.plus(row.remaining);
}
sheet.addRow(['جمع', '', money(amount), money(settled), money(remaining)]);
}
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),
}));
const invoiceSales = await invoiceSalesInPeriod(this.prisma, organizationId, from, to);
return {
bookedIncome: money(bookedIncome),
invoiceSales: invoiceSales.sales,
invoiceCredits: invoiceSales.credits,
bookedExpense: money(bookedExpense),
operatingProfit: money(
bookedIncome.plus(invoiceSales.net).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;
benefitsReason?: string | null;
bonus: Prisma.Decimal;
bonusReason?: string | null;
deductions: Prisma.Decimal;
deductionsReason?: string | null;
payable: Prisma.Decimal;
dueDate: Date;
status: string;
movements: Array<{
id: string;
amount: Prisma.Decimal;
voidedAt: Date | null;
accountId: string;
occurredAt: Date;
account?: { name: string } | 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),
benefitsReason: row.benefitsReason?.trim() || null,
bonus: money(row.bonus),
bonusReason: row.bonusReason?.trim() || null,
deductions: money(row.deductions),
deductionsReason: row.deductionsReason?.trim() || null,
payable: money(row.payable),
paidAmount: money(paid),
remaining: money(left.isNegative() ? 0 : left),
dueDate: row.dueDate.toISOString(),
status: row.status,
payments: row.movements
.filter((item) => !item.voidedAt)
.map((item) => ({
id: item.id,
amount: money(item.amount),
occurredAt: item.occurredAt.toISOString(),
accountId: item.accountId,
accountName: item.account?.name ?? '',
})),
};
}
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),
);
}
}