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
@@ -0,0 +1,96 @@
-- CreateTable
CREATE TABLE "FinancialAccount" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"bankName" TEXT,
"accountRef" TEXT,
"responsibleName" TEXT,
"openingBalance" DECIMAL(18,2) NOT NULL DEFAULT 0,
"openingDate" TIMESTAMP(3) NOT NULL,
"notes" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"currency" TEXT NOT NULL DEFAULT 'IRR',
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FinancialAccount_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FinancialCategory" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FinancialCategory_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FinancialDocument" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"title" TEXT NOT NULL,
"amount" DECIMAL(18,2) NOT NULL,
"bookedAt" TIMESTAMP(3) NOT NULL,
"dueAt" TIMESTAMP(3),
"categoryId" TEXT,
"personId" TEXT,
"partyName" TEXT,
"accountId" TEXT,
"method" TEXT,
"status" TEXT NOT NULL DEFAULT 'open',
"reference" TEXT,
"notes" TEXT,
"createdById" TEXT,
"voidedAt" TIMESTAMP(3),
"voidReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FinancialDocument_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FinancialMovement" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"documentId" TEXT,
"direction" TEXT NOT NULL,
"amount" DECIMAL(18,2) NOT NULL,
"kind" TEXT NOT NULL,
"transferGroupId" TEXT,
"occurredAt" TIMESTAMP(3) NOT NULL,
"memo" TEXT,
"reference" TEXT,
"createdById" TEXT,
"voidedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FinancialMovement_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "FinancialAccount_organizationId_isActive_idx" ON "FinancialAccount"("organizationId", "isActive");
CREATE UNIQUE INDEX "FinancialCategory_organizationId_kind_name_key" ON "FinancialCategory"("organizationId", "kind", "name");
CREATE INDEX "FinancialCategory_organizationId_kind_idx" ON "FinancialCategory"("organizationId", "kind");
CREATE INDEX "FinancialDocument_organizationId_kind_bookedAt_idx" ON "FinancialDocument"("organizationId", "kind", "bookedAt");
CREATE INDEX "FinancialDocument_organizationId_status_idx" ON "FinancialDocument"("organizationId", "status");
CREATE INDEX "FinancialDocument_personId_idx" ON "FinancialDocument"("personId");
CREATE INDEX "FinancialMovement_organizationId_accountId_occurredAt_idx" ON "FinancialMovement"("organizationId", "accountId", "occurredAt");
CREATE INDEX "FinancialMovement_transferGroupId_idx" ON "FinancialMovement"("transferGroupId");
CREATE INDEX "FinancialMovement_documentId_idx" ON "FinancialMovement"("documentId");
ALTER TABLE "FinancialAccount" ADD CONSTRAINT "FinancialAccount_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialCategory" ADD CONSTRAINT "FinancialCategory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FinancialCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "FinancialAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "FinancialAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "FinancialDocument"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,42 @@
ALTER TABLE "FinancialMovement" ADD COLUMN "receivableId" TEXT;
ALTER TABLE "FinancialMovement" ADD COLUMN "installmentId" TEXT;
CREATE TABLE "FinancialReceivable" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"personId" TEXT,
"partyName" TEXT,
"totalAmount" DECIMAL(18,2) NOT NULL,
"notes" TEXT,
"status" TEXT NOT NULL DEFAULT 'open',
"createdById" TEXT,
"voidedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FinancialReceivable_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FinancialInstallment" (
"id" TEXT NOT NULL,
"receivableId" TEXT NOT NULL,
"position" INTEGER NOT NULL,
"dueDate" TIMESTAMP(3) NOT NULL,
"amount" DECIMAL(18,2) NOT NULL,
"reminderNotifiedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FinancialInstallment_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "FinancialMovement_receivableId_idx" ON "FinancialMovement"("receivableId");
CREATE INDEX "FinancialMovement_installmentId_idx" ON "FinancialMovement"("installmentId");
CREATE INDEX "FinancialReceivable_organizationId_status_idx" ON "FinancialReceivable"("organizationId", "status");
CREATE INDEX "FinancialReceivable_personId_idx" ON "FinancialReceivable"("personId");
CREATE UNIQUE INDEX "FinancialInstallment_receivableId_position_key" ON "FinancialInstallment"("receivableId", "position");
CREATE INDEX "FinancialInstallment_dueDate_idx" ON "FinancialInstallment"("dueDate");
ALTER TABLE "FinancialReceivable" ADD CONSTRAINT "FinancialReceivable_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialReceivable" ADD CONSTRAINT "FinancialReceivable_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "FinancialInstallment" ADD CONSTRAINT "FinancialInstallment_receivableId_fkey" FOREIGN KEY ("receivableId") REFERENCES "FinancialReceivable"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_receivableId_fkey" FOREIGN KEY ("receivableId") REFERENCES "FinancialReceivable"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_installmentId_fkey" FOREIGN KEY ("installmentId") REFERENCES "FinancialInstallment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,79 @@
ALTER TABLE "FinancialMovement" ADD COLUMN "payrollId" TEXT;
CREATE INDEX "FinancialMovement_payrollId_idx" ON "FinancialMovement"("payrollId");
CREATE TABLE "FinancialPayroll" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"period" TEXT NOT NULL,
"baseAmount" DECIMAL(18,2) NOT NULL,
"benefits" DECIMAL(18,2) NOT NULL DEFAULT 0,
"bonus" DECIMAL(18,2) NOT NULL DEFAULT 0,
"deductions" DECIMAL(18,2) NOT NULL DEFAULT 0,
"payable" DECIMAL(18,2) NOT NULL,
"dueDate" TIMESTAMP(3) NOT NULL,
"notes" TEXT,
"status" TEXT NOT NULL DEFAULT 'open',
"createdById" TEXT,
"voidedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FinancialPayroll_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FinancialRecurringItem" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"amount" DECIMAL(18,2) NOT NULL,
"interval" TEXT NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3),
"nextDue" TIMESTAMP(3) NOT NULL,
"accountId" TEXT,
"categoryId" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FinancialRecurringItem_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FinancialRecurringOccurrence" (
"id" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"periodKey" TEXT NOT NULL,
"dueDate" TIMESTAMP(3) NOT NULL,
"documentId" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FinancialRecurringOccurrence_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FinancialReconciliation" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"reconciledAt" TIMESTAMP(3) NOT NULL,
"statedBalance" DECIMAL(18,2) NOT NULL,
"systemBalance" DECIMAL(18,2) NOT NULL,
"difference" DECIMAL(18,2) NOT NULL,
"notes" TEXT,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FinancialReconciliation_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "FinancialPayroll_organizationId_userId_period_key" ON "FinancialPayroll"("organizationId", "userId", "period");
CREATE INDEX "FinancialPayroll_organizationId_status_idx" ON "FinancialPayroll"("organizationId", "status");
CREATE INDEX "FinancialRecurringItem_organizationId_isActive_nextDue_idx" ON "FinancialRecurringItem"("organizationId", "isActive", "nextDue");
CREATE UNIQUE INDEX "FinancialRecurringOccurrence_itemId_periodKey_key" ON "FinancialRecurringOccurrence"("itemId", "periodKey");
CREATE INDEX "FinancialReconciliation_organizationId_accountId_reconciledAt_idx" ON "FinancialReconciliation"("organizationId", "accountId", "reconciledAt");
ALTER TABLE "FinancialPayroll" ADD CONSTRAINT "FinancialPayroll_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_payrollId_fkey" FOREIGN KEY ("payrollId") REFERENCES "FinancialPayroll"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "FinancialRecurringItem" ADD CONSTRAINT "FinancialRecurringItem_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialRecurringOccurrence" ADD CONSTRAINT "FinancialRecurringOccurrence_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "FinancialRecurringItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialReconciliation" ADD CONSTRAINT "FinancialReconciliation_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialReconciliation" ADD CONSTRAINT "FinancialReconciliation_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "FinancialAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+243
View File
@@ -203,6 +203,14 @@ model Organization {
webhookEndpoints WebhookEndpoint[]
smsCampaigns SmsCampaign[]
outboundSmsLogs OutboundSmsLog[]
financialAccounts FinancialAccount[]
financialCategories FinancialCategory[]
financialDocuments FinancialDocument[]
financialMovements FinancialMovement[]
financialReceivables FinancialReceivable[]
financialPayrolls FinancialPayroll[]
financialRecurringItems FinancialRecurringItem[]
financialReconciliations FinancialReconciliation[]
wooCommerceConnections WooCommerceConnection[]
montifyConnections MontifyConnection[]
voipConnections VoipConnection[]
@@ -543,6 +551,8 @@ model Person {
trackingEvents TrackingEvent[]
routineTasks RoutineTask[]
outboundSmsLogs OutboundSmsLog[]
financialDocuments FinancialDocument[]
financialReceivables FinancialReceivable[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1991,6 +2001,239 @@ model WebhookDelivery {
@@index([endpointId, createdAt])
}
/// دفتر مالی داخلی: حساب صندوق/بانک/تنخواه
model FinancialAccount {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
/// bank | cash | petty
type String
bankName String?
accountRef String?
responsibleName String?
openingBalance Decimal @default(0) @db.Decimal(18, 2)
openingDate DateTime
notes String?
isActive Boolean @default(true)
currency String @default("IRR")
createdById String?
movements FinancialMovement[]
documents FinancialDocument[] @relation("DocumentAccount")
reconciliations FinancialReconciliation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, isActive])
}
model FinancialCategory {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
/// income | expense
kind String
isActive Boolean @default(true)
documents FinancialDocument[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, kind, name])
@@index([organizationId, kind])
}
/// درآمد یا هزینهٔ ثبت‌شده (تعهدی). موجودی حساب را خودش عوض نمی‌کند.
model FinancialDocument {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
/// income | expense
kind String
title String
amount Decimal @db.Decimal(18, 2)
bookedAt DateTime
dueAt DateTime?
categoryId String?
category FinancialCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
partyName String?
accountId String?
account FinancialAccount? @relation("DocumentAccount", fields: [accountId], references: [id], onDelete: SetNull)
/// cash | card_to_card | pos | transfer | other
method String?
/// open | partial | settled | void
status String @default("open")
reference String?
notes String?
createdById String?
voidedAt DateTime?
voidReason String?
movements FinancialMovement[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, kind, bookedAt])
@@index([organizationId, status])
@@index([personId])
}
/// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال.
model FinancialMovement {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
accountId String
account FinancialAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
documentId String?
document FinancialDocument? @relation(fields: [documentId], references: [id], onDelete: SetNull)
receivableId String?
receivable FinancialReceivable? @relation(fields: [receivableId], references: [id], onDelete: SetNull)
installmentId String?
installment FinancialInstallment? @relation(fields: [installmentId], references: [id], onDelete: SetNull)
payrollId String?
payroll FinancialPayroll? @relation(fields: [payrollId], references: [id], onDelete: SetNull)
/// in | out
direction String
amount Decimal @db.Decimal(18, 2)
/// opening | receipt | payment | transfer_in | transfer_out
kind String
transferGroupId String?
occurredAt DateTime
memo String?
reference String?
createdById String?
voidedAt DateTime?
createdAt DateTime @default(now())
@@index([organizationId, accountId, occurredAt])
@@index([transferGroupId])
@@index([documentId])
@@index([receivableId])
@@index([installmentId])
@@index([payrollId])
}
model FinancialPayroll {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
userId String
period String
baseAmount Decimal @db.Decimal(18, 2)
benefits Decimal @default(0) @db.Decimal(18, 2)
bonus Decimal @default(0) @db.Decimal(18, 2)
deductions Decimal @default(0) @db.Decimal(18, 2)
payable Decimal @db.Decimal(18, 2)
dueDate DateTime
notes String?
/// open | partial | settled | void
status String @default("open")
createdById String?
voidedAt DateTime?
movements FinancialMovement[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, userId, period])
@@index([organizationId, status])
}
model FinancialRecurringItem {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
title String
/// income | expense
kind String
amount Decimal @db.Decimal(18, 2)
/// weekly | monthly | yearly
interval String
startDate DateTime
endDate DateTime?
nextDue DateTime
accountId String?
categoryId String?
isActive Boolean @default(true)
createdById String?
occurrences FinancialRecurringOccurrence[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, isActive, nextDue])
}
model FinancialRecurringOccurrence {
id String @id @default(cuid())
itemId String
item FinancialRecurringItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
periodKey String
dueDate DateTime
documentId String?
/// pending | confirmed
status String @default("pending")
createdAt DateTime @default(now())
@@unique([itemId, periodKey])
}
model FinancialReconciliation {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
accountId String
account FinancialAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
reconciledAt DateTime
statedBalance Decimal @db.Decimal(18, 2)
systemBalance Decimal @db.Decimal(18, 2)
difference Decimal @db.Decimal(18, 2)
notes String?
createdById String?
createdAt DateTime @default(now())
@@index([organizationId, accountId, reconciledAt])
}
/// قرارداد یا طلب اقساطی دفتر داخلی
model FinancialReceivable {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
title String
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
partyName String?
totalAmount Decimal @db.Decimal(18, 2)
notes String?
/// open | settled | void
status String @default("open")
createdById String?
voidedAt DateTime?
installments FinancialInstallment[]
movements FinancialMovement[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, status])
@@index([personId])
}
model FinancialInstallment {
id String @id @default(cuid())
receivableId String
receivable FinancialReceivable @relation(fields: [receivableId], references: [id], onDelete: Cascade)
position Int
dueDate DateTime
amount Decimal @db.Decimal(18, 2)
reminderNotifiedAt DateTime?
movements FinancialMovement[]
createdAt DateTime @default(now())
@@unique([receivableId, position])
@@index([dueDate])
}
/// لاگ یکپارچهٔ پیامک خروجی (دستی / کمپین / اتوماسیون)
model OutboundSmsLog {
id String @id @default(cuid())
+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(),
};
}
}