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(),
};
}
}
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { useRoute } from 'vue-router';
const route = useRoute();
const links = [
{ to: '/finance', label: 'داشبورد', exact: true },
{ to: '/finance/incomes', label: 'دریافت‌ها و درآمدها' },
{ to: '/finance/expenses', label: 'پرداخت‌ها و هزینه‌ها' },
{ to: '/finance/receivables', label: 'اقساط و مطالبات' },
{ to: '/finance/accounts', label: 'صندوق و حساب‌ها' },
{ to: '/finance/payroll', label: 'حقوق' },
{ to: '/finance/recurring', label: 'تکرارشونده' },
{ to: '/finance/reports', label: 'گزارش' },
{ to: '/finance/categories', label: 'دسته‌بندی‌ها' },
];
function on(path: string, exact?: boolean) {
if (exact) return route.path === path;
return route.path === path || route.path.startsWith(`${path}/`);
}
</script>
<template>
<nav class="treasury-nav" aria-label="مدیریت مالی">
<RouterLink
v-for="link in links"
:key="link.to"
:to="link.to"
:class="{ on: on(link.to, link.exact) }"
>
{{ link.label }}
</RouterLink>
</nav>
</template>
<style scoped>
.treasury-nav {
display: flex;
gap: 0.35rem;
overflow: auto;
padding-bottom: 0.25rem;
}
.treasury-nav a {
flex: 0 0 auto;
border-radius: 999px;
padding: 0.4rem 0.8rem;
color: #475569;
text-decoration: none;
font-weight: 700;
font-size: 0.86rem;
background: #f8fafc;
}
.treasury-nav a.on {
background: #0f766e;
color: #fff;
}
</style>
+8
View File
@@ -354,6 +354,14 @@ export const NAV_HUBS: NavHubDef[] = [
tint: 'sky',
module: 'cheques',
},
{
to: '/finance',
title: 'مدیریت مالی',
desc: 'صندوق، درآمد و هزینه داخلی',
icon: 'dollar',
tint: 'mint',
admin: true,
},
{
to: '/reservations',
title: 'رزرو و نوبت‌دهی',
+60
View File
@@ -228,6 +228,66 @@ export const router = createRouter({
name: 'cheques',
component: () => import('@/views/ChequesView.vue'),
},
{
path: 'finance',
name: 'finance',
component: () => import('@/views/treasury/TreasuryHomeView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/accounts',
name: 'finance-accounts',
component: () => import('@/views/treasury/TreasuryAccountsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/accounts/:id',
name: 'finance-account',
component: () => import('@/views/treasury/TreasuryAccountView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/incomes',
name: 'finance-incomes',
component: () => import('@/views/treasury/TreasuryIncomesView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/expenses',
name: 'finance-expenses',
component: () => import('@/views/treasury/TreasuryExpensesView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/receivables',
name: 'finance-receivables',
component: () => import('@/views/treasury/TreasuryReceivablesView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/payroll',
name: 'finance-payroll',
component: () => import('@/views/treasury/TreasuryPayrollView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/recurring',
name: 'finance-recurring',
component: () => import('@/views/treasury/TreasuryRecurringView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/reports',
name: 'finance-reports',
component: () => import('@/views/treasury/TreasuryReportsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/categories',
name: 'finance-categories',
component: () => import('@/views/treasury/TreasuryCategoriesView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'reservations',
name: 'reservations',
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialMovementDto } from '@mento/shared';
import { UiButton, UiField, UiInput, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
import { formatJalaliNumeric } from '@/lib/jalali';
const route = useRoute();
const account = ref<FinancialAccountDto | null>(null);
const items = ref<FinancialMovementDto[]>([]);
const error = ref('');
const stated = ref('');
const reason = ref('');
const note = ref('');
async function reconcile() {
error.value = '';
note.value = '';
try {
const { data } = await api.post<{ difference: string; systemBalance: string }>('/treasury/reconciliations', {
accountId: route.params.id,
statedBalance: stated.value,
});
note.value = `اختلاف ${toman(data.difference)} نسبت به موجودی سیستم ${toman(data.systemBalance)}. موجودی خودکار عوض نشد.`;
} catch {
error.value = 'ثبت مغایرت ناموفق بود.';
}
}
async function adjust(direction: 'in' | 'out') {
error.value = '';
try {
await api.post('/treasury/adjustments', {
accountId: route.params.id,
amount: stated.value,
direction,
reason: reason.value,
});
window.location.reload();
} catch {
error.value = 'اصلاح موجودی ناموفق بود.';
}
}
onMounted(async () => {
try {
const { data } = await api.get<{ account: FinancialAccountDto; items: FinancialMovementDto[] }>(
`/treasury/accounts/${route.params.id}/ledger`,
);
account.value = data.account;
items.value = data.items;
} catch {
error.value = 'گردش حساب بارگذاری نشد.';
}
});
</script>
<template>
<section class="page">
<UiPageHeader :title="account?.name || 'حساب'" description="گردش قطعی این حساب" />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<div v-if="account" class="stats">
<article><span>موجودی فعلی</span><strong>{{ toman(account.balance) }}</strong></article>
<article><span>مانده افتتاحیه</span><strong>{{ toman(account.openingBalance) }}</strong></article>
<article><span>ورودی</span><strong>{{ toman(account.periodIn) }}</strong></article>
<article><span>خروجی</span><strong>{{ toman(account.periodOut) }}</strong></article>
</div>
<div v-if="account" class="reconcile">
<UiField label="موجودی واقعی"><UiInput v-model="stated" /></UiField>
<UiField label="علت اصلاح"><UiInput v-model="reason" /></UiField>
<UiButton variant="secondary" @click="reconcile">ثبت مغایرت</UiButton>
<UiButton variant="secondary" @click="adjust('in')">اصلاح ورود</UiButton>
<UiButton variant="secondary" @click="adjust('out')">اصلاح خروج</UiButton>
<p v-if="note" class="note">{{ note }}</p>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>تاریخ</th>
<th>شرح</th>
<th>نوع</th>
<th>ورودی</th>
<th>خروجی</th>
<th>مانده</th>
</tr>
</thead>
<tbody>
<tr v-for="row in items" :key="row.id">
<td>{{ formatJalaliNumeric(new Date(row.occurredAt)) }}</td>
<td>{{ row.memo || '—' }}</td>
<td>{{ row.kindLabel }}</td>
<td>{{ row.direction === 'in' ? toman(row.amount) : '—' }}</td>
<td>{{ row.direction === 'out' ? toman(row.amount) : '—' }}</td>
<td>{{ row.balanceAfter ? toman(row.balanceAfter) : '—' }}</td>
</tr>
<tr v-if="!items.length"><td colspan="6">گردشی ثبت نشده است.</td></tr>
</tbody>
</table>
</div>
</section>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.7rem; }
.stats article { background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem; }
.stats span { color: #64748b; display: block; }
.table-wrap { overflow: auto; background: #fff; border: 1px solid #e7eef2; border-radius: 16px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.7rem; text-align: right; border-bottom: 1px solid #f1f5f9; white-space: nowrap; }
.reconcile { display: grid; grid-template-columns: 1fr 1fr auto auto auto; gap: 0.6rem; align-items: end; }
.note { grid-column: 1 / -1; color: #0f766e; font-weight: 700; }
.err { color: #b91c1c; font-weight: 700; }
@media (max-width: 800px) { .reconcile { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialAccountType } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
const ok = ref('');
const openForm = ref(false);
const saving = ref(false);
const transferOpen = ref(false);
const form = ref({
name: '',
type: 'cash' as FinancialAccountType,
bankName: '',
accountRef: '',
responsibleName: '',
openingBalance: '',
openingDate: '',
notes: '',
});
const transfer = ref({
fromAccountId: '',
toAccountId: '',
amount: '',
memo: '',
});
const active = computed(() => accounts.value.filter((row) => row.isActive));
const total = computed(() =>
active.value.reduce((sum, row) => sum + Number(row.balance), 0),
);
const periodIn = computed(() =>
active.value.reduce((sum, row) => sum + Number(row.periodIn), 0),
);
const periodOut = computed(() =>
active.value.reduce((sum, row) => sum + Number(row.periodOut), 0),
);
const typeLabel: Record<string, string> = {
bank: 'حساب بانکی',
cash: 'صندوق نقدی',
petty: 'تنخواه',
};
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : msg || 'عملیات ناموفق بود.';
}
async function load() {
const { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
accounts.value = data;
}
async function createAccount() {
saving.value = true;
error.value = '';
ok.value = '';
try {
await api.post('/treasury/accounts', {
...form.value,
openingBalance: form.value.openingBalance || 0,
openingDate: form.value.openingDate || undefined,
});
openForm.value = false;
form.value = {
name: '',
type: 'cash',
bankName: '',
accountRef: '',
responsibleName: '',
openingBalance: '',
openingDate: '',
notes: '',
};
ok.value = 'حساب ثبت شد. موجودی اولیه درآمد دوره نیست.';
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
async function sendTransfer() {
saving.value = true;
error.value = '';
ok.value = '';
try {
await api.post('/treasury/transfers', {
...transfer.value,
clientKey: crypto.randomUUID(),
});
transferOpen.value = false;
ok.value = 'انتقال ثبت شد و در درآمد یا هزینه نیامده است.';
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری حساب‌ها ناموفق بود.';
});
});
</script>
<template>
<section class="page">
<UiPageHeader title="صندوق و حساب‌ها" description="موجودی هر حساب از مانده افتتاحیه و گردش قطعی محاسبه می‌شود." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<div class="stats">
<article>
<span>مجموع موجودی حساب‌ها</span>
<strong>{{ toman(total) }}</strong>
</article>
<article>
<span>ورودی این فهرست</span>
<strong class="in">{{ toman(periodIn) }}</strong>
</article>
<article>
<span>خروجی این فهرست</span>
<strong class="out">{{ toman(periodOut) }}</strong>
</article>
<article>
<span>حساب‌های فعال</span>
<strong>{{ active.length }} حساب</strong>
</article>
</div>
<div class="list">
<article v-for="account in accounts" :key="account.id" class="account">
<div>
<strong>{{ account.name }}</strong>
<small>{{ typeLabel[account.type] }}{{ account.isActive ? '' : ' · غیرفعال' }}</small>
<p>مشاهده گردش حساب و جزئیات</p>
</div>
<div class="side">
<b>{{ toman(account.balance) }}</b>
<RouterLink class="link" :to="`/finance/accounts/${account.id}`">جزئیات حساب</RouterLink>
</div>
</article>
</div>
<div class="actions">
<UiButton @click="openForm = !openForm">افزودن حساب جدید</UiButton>
<UiButton variant="secondary" @click="transferOpen = !transferOpen">انتقال وجه</UiButton>
</div>
<UiCard v-if="openForm" title="حساب جدید">
<div class="grid">
<UiField label="نام حساب"><UiInput v-model="form.name" /></UiField>
<UiField label="نوع">
<select v-model="form.type" class="select">
<option value="cash">صندوق نقدی</option>
<option value="bank">حساب بانکی</option>
<option value="petty">تنخواه</option>
</select>
</UiField>
<UiField v-if="form.type === 'bank'" label="نام بانک"><UiInput v-model="form.bankName" /></UiField>
<UiField label="شماره یا شناسه"><UiInput v-model="form.accountRef" /></UiField>
<UiField label="مسئول"><UiInput v-model="form.responsibleName" /></UiField>
<UiField label="موجودی اولیه"><UiInput v-model="form.openingBalance" /></UiField>
<UiField label="تاریخ شروع"><UiInput v-model="form.openingDate" type="date" /></UiField>
<UiField label="توضیحات"><UiInput v-model="form.notes" /></UiField>
</div>
<UiButton :loading="saving" @click="createAccount">ثبت حساب</UiButton>
</UiCard>
<UiCard v-if="transferOpen" title="انتقال بین حساب‌ها">
<div class="grid">
<UiField label="از حساب">
<select v-model="transfer.fromAccountId" class="select">
<option value="">انتخاب</option>
<option v-for="account in active" :key="account.id" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
<UiField label="به حساب">
<select v-model="transfer.toAccountId" class="select">
<option value="">انتخاب</option>
<option v-for="account in active" :key="`to-${account.id}`" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
<UiField label="مبلغ"><UiInput v-model="transfer.amount" /></UiField>
<UiField label="شرح"><UiInput v-model="transfer.memo" /></UiField>
</div>
<p class="muted">این انتقال درآمد یا هزینه نیست. اگر موجودی مبدأ کافی نباشد ثبت نمی‌شود.</p>
<UiButton :loading="saving" @click="sendTransfer">تأیید انتقال</UiButton>
</UiCard>
</section>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.stats, .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; }
.stats article, .account {
background: #fff;
border: 1px solid #e7eef2;
border-radius: 16px;
padding: 0.9rem 1rem;
}
.stats span, .account small, .account p, .muted { color: #64748b; margin: 0.15rem 0 0; }
.in { color: #15803d; }
.out { color: #b91c1c; }
.list { display: grid; gap: 0.65rem; }
.account { display: flex; justify-content: space-between; gap: 1rem; align-items: center; }
.side { display: grid; justify-items: end; gap: 0.4rem; }
.link { color: #0f766e; font-weight: 700; text-decoration: none; }
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
.err { color: #b91c1c; font-weight: 700; }
.ok { color: #0f766e; font-weight: 700; }
</style>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type { FinancialCategoryDto } from '@mento/shared';
import { UiButton, UiField, UiInput, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
const rows = ref<FinancialCategoryDto[]>([]);
const name = ref('');
const kind = ref<'income' | 'expense'>('expense');
const error = ref('');
async function load() {
const { data } = await api.get<FinancialCategoryDto[]>('/treasury/categories');
rows.value = data;
}
async function add() {
error.value = '';
try {
await api.post('/treasury/categories', { name: name.value, kind: kind.value });
name.value = '';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ثبت ناموفق بود.')
: 'ثبت ناموفق بود.';
}
}
onMounted(() => {
void load();
});
</script>
<template>
<section class="page">
<UiPageHeader title="دسته‌بندی‌های مالی" description="دسته‌های پیش‌فرض با اولین بازدید ساخته می‌شوند." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<div class="add">
<UiField label="نام"><UiInput v-model="name" /></UiField>
<UiField label="نوع">
<select v-model="kind" class="select">
<option value="income">درآمد</option>
<option value="expense">هزینه</option>
</select>
</UiField>
<UiButton @click="add">افزودن</UiButton>
</div>
<ul>
<li v-for="row in rows" :key="row.id">
<strong>{{ row.name }}</strong>
<span>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }}</span>
</li>
</ul>
</section>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.add { display: grid; grid-template-columns: 1fr 160px auto; gap: 0.7rem; align-items: end; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
li { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 12px; padding: 0.7rem 0.9rem; }
li span { color: #64748b; }
.err { color: #b91c1c; font-weight: 700; }
@media (max-width: 700px) { .add { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const props = defineProps<{ kind: 'income' | 'expense' }>();
const rows = ref<FinancialDocumentDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
const ok = ref('');
const saving = ref(false);
const q = ref('');
const form = ref({
title: '',
amount: '',
categoryId: '',
partyName: '',
accountId: '',
settleAmount: '',
method: 'transfer',
reference: '',
notes: '',
});
const title = computed(() => (props.kind === 'income' ? 'دریافت‌ها و درآمدها' : 'پرداخت‌ها و هزینه‌ها'));
const settleLabel = computed(() => (props.kind === 'income' ? 'مبلغ دریافت‌شده' : 'مبلغ پرداخت‌شده'));
const statusLabel: Record<string, string> = {
open: props.kind === 'income' ? 'دریافت‌نشده' : 'پرداخت‌نشده',
partial: 'جزئی',
settled: props.kind === 'income' ? 'دریافت‌شده' : 'پرداخت‌شده',
void: 'باطل‌شده',
};
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'ذخیره ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
}
async function load() {
const [docs, cats, acc] = await Promise.all([
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind: props.kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind: props.kind } }),
api.get<FinancialAccountDto[]>('/treasury/accounts'),
]);
rows.value = docs.data;
categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive);
}
async function createDoc() {
saving.value = true;
error.value = '';
ok.value = '';
try {
await api.post('/treasury/documents', {
kind: props.kind,
title: form.value.title,
amount: form.value.amount,
categoryId: form.value.categoryId || null,
partyName: form.value.partyName || null,
accountId: form.value.accountId || null,
settleAmount: form.value.settleAmount || 0,
method: form.value.method,
reference: form.value.reference || null,
notes: form.value.notes || null,
});
form.value = {
title: '',
amount: '',
categoryId: '',
partyName: '',
accountId: '',
settleAmount: '',
method: 'transfer',
reference: '',
notes: '',
};
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمی‌کند.';
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
async function voidDoc(id: string) {
if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return;
try {
await api.post(`/treasury/documents/${id}/void`, { reason: 'ابطال توسط مدیر' });
await load();
} catch (e) {
error.value = apiMessage(e);
}
}
watch(() => props.kind, () => {
void load();
});
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری ناموفق بود.';
});
});
</script>
<template>
<section class="page">
<UiPageHeader :title="title" description="ثبت سند، موجودی را عوض نمی‌کند مگر مبلغ دریافت یا پرداخت واقعی وارد شود." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard title="سند جدید">
<div class="grid">
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
<UiField label="مبلغ کل"><UiInput v-model="form.amount" /></UiField>
<UiField label="دسته‌بندی">
<select v-model="form.categoryId" class="select">
<option value="">بدون دسته</option>
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
</select>
</UiField>
<UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField>
<UiField :label="settleLabel"><UiInput v-model="form.settleAmount" /></UiField>
<UiField label="حساب">
<select v-model="form.accountId" class="select">
<option value="">انتخاب حساب</option>
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
<UiField label="روش">
<select v-model="form.method" class="select">
<option value="cash">نقدی</option>
<option value="card_to_card">کارت‌به‌کارت</option>
<option value="pos">کارت‌خوان</option>
<option value="transfer">حواله</option>
<option value="other">سایر</option>
</select>
</UiField>
<UiField label="پیگیری"><UiInput v-model="form.reference" /></UiField>
</div>
<UiButton :loading="saving" @click="createDoc">ثبت</UiButton>
</UiCard>
<UiField label="جستجو"><UiInput v-model="q" @change="load" /></UiField>
<div class="list">
<article v-for="row in rows" :key="row.id">
<div>
<strong>{{ row.title }}</strong>
<small>
{{ statusLabel[row.status] }}
· مانده {{ toman(row.remaining) }}
<template v-if="row.categoryName"> · {{ row.categoryName }}</template>
</small>
</div>
<div class="side">
<b>{{ toman(row.amount) }}</b>
<button v-if="row.status !== 'void'" type="button" @click="voidDoc(row.id)">ابطال</button>
</div>
</article>
<p v-if="!rows.length" class="muted">سندی نیست.</p>
</div>
</section>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
.list { display: grid; gap: 0.55rem; }
.list article { display: flex; justify-content: space-between; gap: 0.75rem; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
.list small, .muted { color: #64748b; }
.side { display: grid; justify-items: end; gap: 0.3rem; }
.side button { border: 0; background: transparent; color: #b91c1c; cursor: pointer; font: inherit; }
.err { color: #b91c1c; font-weight: 700; }
.ok { color: #0f766e; font-weight: 700; }
</style>
@@ -0,0 +1,6 @@
<script setup lang="ts">
import TreasuryDocsView from './TreasuryDocsView.vue';
</script>
<template>
<TreasuryDocsView kind="expense" />
</template>
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { api } from '@/lib/api';
import type { TreasuryDashboardDto } from '@mento/shared';
import { UiCard, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const data = ref<TreasuryDashboardDto | null>(null);
const error = ref('');
const cards = [
{ key: 'bookedIncome', label: 'درآمد ثبت‌شده', hint: 'تعهدی' },
{ key: 'actualReceipts', label: 'دریافت واقعی', hint: 'ورود به حساب' },
{ key: 'bookedExpense', label: 'هزینه ثبت‌شده', hint: 'تعهدی' },
{ key: 'actualPayments', label: 'پرداخت واقعی', hint: 'خروج از حساب' },
{ key: 'operatingProfit', label: 'سود یا زیان عملیاتی', hint: 'درآمد ثبت‌شده − هزینه ثبت‌شده' },
{ key: 'receivables', label: 'مطالبات وصول‌نشده', hint: 'درآمد بدون دریافت کامل' },
{ key: 'payables', label: 'تعهدات پرداخت', hint: 'هزینه بدون پرداخت کامل' },
{ key: 'totalBalance', label: 'موجودی حساب‌ها', hint: 'صندوق و بانک' },
] as const;
onMounted(async () => {
try {
const res = await api.get<TreasuryDashboardDto>('/treasury/dashboard');
data.value = res.data;
} catch {
error.value = 'بارگذاری داشبورد مالی ناموفق بود.';
}
});
</script>
<template>
<section class="page">
<UiPageHeader
title="مدیریت مالی"
description="دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست."
/>
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<div v-if="data" class="stats">
<article v-for="card in cards" :key="card.key">
<span>{{ card.label }}</span>
<strong>{{ toman(data[card.key]) }}</strong>
<small>{{ card.hint }}</small>
</article>
</div>
<div v-if="data" class="split">
<UiCard title="اقساط نزدیک یا معوق">
<p v-if="!data.upcomingInstallments.length" class="muted">قسط بازی نیست.</p>
<ul v-else class="list">
<li v-for="row in data.upcomingInstallments" :key="row.id">
<div>
<strong>قسط {{ row.position }}</strong>
<small>{{ row.status === 'overdue' ? 'معوق' : 'باز' }} · مانده {{ toman(row.remaining) }}</small>
</div>
<b>{{ toman(row.amount) }}</b>
</li>
</ul>
</UiCard>
<UiCard title="پرداخت‌های آینده">
<p v-if="!data.upcomingPayables.length" class="muted">تعهد پرداخت بازی نیست.</p>
<ul v-else class="list">
<li v-for="row in data.upcomingPayables" :key="row.id">
<div>
<strong>{{ row.title }}</strong>
<small>مانده {{ toman(row.remaining) }}</small>
</div>
<b>{{ toman(row.amount) }}</b>
</li>
</ul>
</UiCard>
</div>
<UiCard v-if="data" title="آخرین اسناد">
<p v-if="!data.recent.length" class="muted">هنوز سندی ثبت نشده است.</p>
<ul v-else class="list">
<li v-for="row in data.recent" :key="row.id">
<div>
<strong>{{ row.title }}</strong>
<small>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ row.status }}</small>
</div>
<b>{{ toman(row.amount) }}</b>
</li>
</ul>
</UiCard>
</section>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.stats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.stats article {
background: #fff;
border: 1px solid #e7eef2;
border-radius: 16px;
padding: 0.9rem 1rem;
display: grid;
gap: 0.25rem;
}
.stats span, .stats small, .muted { color: #64748b; }
.stats strong { font-size: 1.05rem; }
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.55rem; }
@media (max-width: 800px) { .split { grid-template-columns: 1fr; } }
.list li { display: flex; justify-content: space-between; gap: 0.75rem; }
.list small { display: block; color: #64748b; }
.err { color: #b91c1c; font-weight: 700; }
</style>
@@ -0,0 +1,6 @@
<script setup lang="ts">
import TreasuryDocsView from './TreasuryDocsView.vue';
</script>
<template>
<TreasuryDocsView kind="income" />
</template>
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
type PayrollRow = {
id: string;
fullName: string;
period: string;
payable: string;
remaining: string;
status: string;
};
const rows = ref<PayrollRow[]>([]);
const users = ref<OrgUserDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
const ok = ref('');
const form = ref({ userId: '', period: '', baseAmount: '', benefits: '', bonus: '', deductions: '' });
const pay = ref({ id: '', amount: '', accountId: '' });
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : msg || 'عملیات ناموفق بود.';
}
async function load() {
const [payroll, people, acc] = await Promise.all([
api.get<PayrollRow[]>('/treasury/payroll'),
api.get<OrgUserDto[]>('/users'),
api.get<FinancialAccountDto[]>('/treasury/accounts'),
]);
rows.value = payroll.data;
users.value = people.data.filter((row) => row.isActive !== false);
accounts.value = acc.data.filter((row) => row.isActive);
}
async function createRow() {
error.value = '';
try {
await api.post('/treasury/payroll', form.value);
ok.value = 'حقوق ثبت شد و تا پرداخت، موجودی حساب تغییر نمی‌کند.';
await load();
} catch (e) {
error.value = apiMessage(e);
}
}
async function payRow() {
error.value = '';
try {
await api.post(`/treasury/payroll/${pay.value.id}/pay`, {
amount: pay.value.amount,
accountId: pay.value.accountId,
clientKey: crypto.randomUUID(),
});
ok.value = 'پرداخت حقوق از حساب ثبت شد.';
pay.value = { id: '', amount: '', accountId: '' };
await load();
} catch (e) {
error.value = apiMessage(e);
}
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری حقوق ناموفق بود.';
});
});
</script>
<template>
<section class="page">
<UiPageHeader title="حقوق و دستمزد" description="فقط مالک و مدیر این صفحه را می‌بینند." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard title="ثبت دوره">
<div class="grid">
<UiField label="پرسنل">
<select v-model="form.userId" class="select">
<option value="">انتخاب</option>
<option v-for="user in users" :key="user.id" :value="user.id">{{ user.fullName }}</option>
</select>
</UiField>
<UiField label="دوره"><UiInput v-model="form.period" placeholder="۱۴۰۵-۰۷" /></UiField>
<UiField label="پایه"><UiInput v-model="form.baseAmount" /></UiField>
<UiField label="مزایا"><UiInput v-model="form.benefits" /></UiField>
<UiField label="پاداش"><UiInput v-model="form.bonus" /></UiField>
<UiField label="کسورات"><UiInput v-model="form.deductions" /></UiField>
</div>
<UiButton @click="createRow">ثبت حقوق</UiButton>
</UiCard>
<article v-for="row in rows" :key="row.id" class="row">
<div>
<strong>{{ row.fullName }}</strong>
<small>{{ row.period }} · مانده {{ toman(row.remaining) }} · {{ row.status }}</small>
</div>
<div class="side">
<b>{{ toman(row.payable) }}</b>
<button v-if="row.status !== 'settled' && row.status !== 'void'" type="button" @click="pay = { id: row.id, amount: row.remaining, accountId: accounts[0]?.id || '' }">پرداخت</button>
</div>
</article>
<UiCard v-if="pay.id" title="پرداخت حقوق">
<div class="grid">
<UiField label="مبلغ"><UiInput v-model="pay.amount" /></UiField>
<UiField label="حساب">
<select v-model="pay.accountId" class="select">
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
</div>
<UiButton @click="payRow">ثبت پرداخت</UiButton>
</UiCard>
</section>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
.row { display: flex; justify-content: space-between; gap: 0.75rem; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
.row small { display: block; color: #64748b; }
.side { display: grid; justify-items: end; }
.side button { border: 0; background: transparent; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
.err { color: #b91c1c; font-weight: 700; }
.ok { color: #0f766e; font-weight: 700; }
</style>
@@ -0,0 +1,220 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialReceivableDto } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
import { formatJalaliNumeric } from '@/lib/jalali';
const rows = ref<FinancialReceivableDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
const ok = ref('');
const saving = ref(false);
const form = ref({
title: '',
partyName: '',
totalAmount: '',
prepaidAmount: '',
accountId: '',
count: '3',
interval: 'monthly',
startDate: '',
});
const collect = ref({ installmentId: '', receivableId: '', amount: '', accountId: '' });
const custom = ref(false);
const customLines = ref([{ amount: '', dueDate: '' }]);
const statusLabel: Record<string, string> = {
open: 'باز',
partial: 'جزئی',
settled: 'تسویه‌شده',
overdue: 'معوق',
void: 'باطل',
};
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : msg || 'عملیات ناموفق بود.';
}
async function load() {
const [list, acc] = await Promise.all([
api.get<FinancialReceivableDto[]>('/treasury/receivables'),
api.get<FinancialAccountDto[]>('/treasury/accounts'),
]);
rows.value = list.data;
accounts.value = acc.data.filter((row) => row.isActive);
}
async function createContract() {
saving.value = true;
error.value = '';
ok.value = '';
try {
await api.post('/treasury/receivables', {
title: form.value.title,
partyName: form.value.partyName || null,
totalAmount: form.value.totalAmount,
prepaidAmount: form.value.prepaidAmount || 0,
accountId: form.value.accountId || null,
...(custom.value
? { installments: customLines.value }
: {
count: Number(form.value.count),
interval: form.value.interval,
startDate: form.value.startDate || undefined,
}),
});
ok.value = 'قرارداد و برنامه اقساط ثبت شد.';
form.value.title = '';
form.value.totalAmount = '';
form.value.prepaidAmount = '';
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
async function collectInstallment() {
if (!collect.value.installmentId) return;
saving.value = true;
error.value = '';
try {
await api.post(`/treasury/installments/${collect.value.installmentId}/collect`, {
amount: collect.value.amount,
accountId: collect.value.accountId,
clientKey: crypto.randomUUID(),
});
ok.value = 'دریافت قسط در همان حساب ثبت شد.';
collect.value = { installmentId: '', receivableId: '', amount: '', accountId: '' };
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری مطالبات ناموفق بود.';
});
});
</script>
<template>
<section class="page">
<UiPageHeader
title="اقساط و مطالبات"
description="دریافت هر قسط فقط یک بار وارد حساب می‌شود و مانده همان قسط را کم می‌کند."
/>
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard title="قرارداد جدید">
<div class="grid">
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
<UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField>
<UiField label="مبلغ کل"><UiInput v-model="form.totalAmount" /></UiField>
<UiField label="پیش‌پرداخت"><UiInput v-model="form.prepaidAmount" /></UiField>
<UiField label="حساب پیش‌پرداخت">
<select v-model="form.accountId" class="select">
<option value="">انتخاب</option>
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
<UiField label="تعداد قسط"><UiInput v-model="form.count" :disabled="custom" /></UiField>
<UiField label="دوره">
<select v-model="form.interval" class="select">
<option value="monthly">ماهانه</option>
<option value="weekly">هفتگی</option>
</select>
</UiField>
<UiField label="شروع"><UiInput v-model="form.startDate" type="date" /></UiField>
</div>
<label class="check"><input v-model="custom" type="checkbox" /> اقساط نامساوی</label>
<div v-if="custom" class="custom">
<div v-for="(line, index) in customLines" :key="index" class="grid">
<UiField label="مبلغ قسط"><UiInput v-model="line.amount" /></UiField>
<UiField label="سررسید"><UiInput v-model="line.dueDate" type="date" /></UiField>
</div>
<UiButton variant="secondary" @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</UiButton>
</div>
<UiButton :loading="saving" @click="createContract">ساخت اقساط</UiButton>
</UiCard>
<article v-for="row in rows" :key="row.id" class="contract">
<header>
<div>
<strong>{{ row.title }}</strong>
<small>{{ row.partyName || 'بدون طرف حساب' }} · مانده {{ toman(row.remaining) }}</small>
</div>
<b>{{ toman(row.totalAmount) }}</b>
</header>
<table>
<thead>
<tr>
<th>قسط</th>
<th>سررسید</th>
<th>مبلغ</th>
<th>مانده</th>
<th>وضعیت</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="item in row.installments" :key="item.id">
<td>{{ item.position }}</td>
<td>{{ formatJalaliNumeric(new Date(item.dueDate)) }}</td>
<td>{{ toman(item.amount) }}</td>
<td>{{ toman(item.remaining) }}</td>
<td>{{ statusLabel[item.status] }}</td>
<td>
<button
v-if="item.status !== 'settled'"
type="button"
@click="collect = { installmentId: item.id, receivableId: row.id, amount: item.remaining, accountId: accounts[0]?.id || '' }"
>
دریافت
</button>
</td>
</tr>
</tbody>
</table>
</article>
<UiCard v-if="collect.installmentId" title="دریافت قسط">
<div class="grid">
<UiField label="مبلغ"><UiInput v-model="collect.amount" /></UiField>
<UiField label="حساب مقصد">
<select v-model="collect.accountId" class="select">
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
</div>
<UiButton :loading="saving" @click="collectInstallment">ثبت دریافت</UiButton>
</UiCard>
</section>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
.contract { background: #fff; border: 1px solid #e7eef2; border-radius: 16px; padding: 0.8rem 1rem; overflow: auto; }
.contract header, .contract header div { display: flex; justify-content: space-between; gap: 0.75rem; }
.contract small { display: block; color: #64748b; }
table { width: 100%; border-collapse: collapse; margin-top: 0.6rem; }
th, td { text-align: right; padding: 0.45rem; border-top: 1px solid #f1f5f9; white-space: nowrap; }
button { border: 0; background: transparent; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
.check { display: flex; gap: 0.4rem; align-items: center; margin: 0.4rem 0 0.8rem; font-weight: 700; }
.custom { display: grid; gap: 0.5rem; margin-bottom: 0.8rem; }
.err { color: #b91c1c; font-weight: 700; }
.ok { color: #0f766e; font-weight: 700; }
</style>
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
import { formatJalaliNumeric } from '@/lib/jalali';
type Row = { id: string; title: string; kind: string; amount: string; interval: string; nextDue: string; isActive: boolean };
const rows = ref<Row[]>([]);
const error = ref('');
const ok = ref('');
const form = ref({ title: '', kind: 'expense', amount: '', interval: 'monthly', startDate: '' });
async function load() {
const { data } = await api.get<Row[]>('/treasury/recurring');
rows.value = data;
}
async function createItem() {
error.value = '';
try {
await api.post('/treasury/recurring', form.value);
ok.value = 'الگو ثبت شد. در سررسید فقط سند باز ساخته می‌شود.';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ثبت ناموفق بود.')
: 'ثبت ناموفق بود.';
}
}
onMounted(() => {
void load();
});
</script>
<template>
<section class="page">
<UiPageHeader title="درآمد و هزینه تکرارشونده" description="تا تأیید دریافت یا پرداخت، موجودی حساب عوض نمی‌شود." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard title="الگوی جدید">
<div class="grid">
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
<UiField label="نوع">
<select v-model="form.kind" class="select">
<option value="expense">هزینه</option>
<option value="income">درآمد</option>
</select>
</UiField>
<UiField label="مبلغ"><UiInput v-model="form.amount" /></UiField>
<UiField label="دوره">
<select v-model="form.interval" class="select">
<option value="monthly">ماهانه</option>
<option value="weekly">هفتگی</option>
<option value="yearly">سالانه</option>
</select>
</UiField>
<UiField label="شروع"><UiInput v-model="form.startDate" type="date" /></UiField>
</div>
<UiButton @click="createItem">ثبت الگو</UiButton>
</UiCard>
<article v-for="row in rows" :key="row.id" class="row">
<div>
<strong>{{ row.title }}</strong>
<small>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ row.interval }} · بعدی {{ formatJalaliNumeric(new Date(row.nextDue)) }}</small>
</div>
<b>{{ toman(row.amount) }}</b>
</article>
</section>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
.row { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
.row small { display: block; color: #64748b; }
.err { color: #b91c1c; font-weight: 700; }
.ok { color: #0f766e; font-weight: 700; }
</style>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { api } from '@/lib/api';
import { UiButton, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
type Period = {
bookedIncome: string;
bookedExpense: string;
operatingProfit: string;
cashIn: string;
cashOut: string;
cashNet: string;
expenseCategories: Array<{ name: string; amount: string; share: string }>;
};
const current = ref<Period | null>(null);
const previous = ref<Period | null>(null);
const payablesByMonth = ref<Array<{ month: string; amount: string }>>([]);
const receivablesByParty = ref<Array<{ name: string; amount: string }>>([]);
const error = ref('');
const lines = [
['درآمد ثبت‌شده', 'bookedIncome'],
['هزینه ثبت‌شده', 'bookedExpense'],
['سود عملیاتی', 'operatingProfit'],
['ورود وجه', 'cashIn'],
['خروج وجه', 'cashOut'],
['خالص جریان نقدی', 'cashNet'],
] as const;
async function load() {
const { data } = await api.get<{
current: Period;
previous: Period;
payablesByMonth: Array<{ month: string; amount: string }>;
receivablesByParty: Array<{ name: string; amount: string }>;
}>('/treasury/reports');
current.value = data.current;
previous.value = data.previous;
payablesByMonth.value = data.payablesByMonth;
receivablesByParty.value = data.receivablesByParty;
}
async function download() {
const { data } = await api.get<Blob>('/treasury/reports/export', { responseType: 'blob' });
const url = URL.createObjectURL(data);
const link = document.createElement('a');
link.href = url;
link.download = 'treasury-report.xlsx';
link.click();
URL.revokeObjectURL(url);
}
onMounted(() => {
void load().catch(() => {
error.value = 'گزارش بارگذاری نشد.';
});
});
</script>
<template>
<section class="page">
<UiPageHeader title="گزارش مالی" description="سود عملیاتی از درآمد و هزینه ثبت‌شده است، نه از خالص وجه." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<UiButton variant="secondary" @click="download">خروجی اکسل</UiButton>
<table v-if="current && previous">
<thead>
<tr><th>شاخص</th><th>این دوره</th><th>دوره قبل</th></tr>
</thead>
<tbody>
<tr v-for="line in lines" :key="line[1]">
<td>{{ line[0] }}</td>
<td>{{ toman(current[line[1]]) }}</td>
<td>{{ toman(previous[line[1]]) }}</td>
</tr>
</tbody>
</table>
<h2>مطالبات به تفکیک طرف حساب</h2>
<ul>
<li v-for="row in receivablesByParty" :key="row.name"><span>{{ row.name }}</span><b>{{ toman(row.amount) }}</b></li>
<li v-if="!receivablesByParty.length">مورد بازی نیست.</li>
</ul>
<h2>تعهدات به تفکیک ماه</h2>
<ul>
<li v-for="row in payablesByMonth" :key="row.month"><span>{{ row.month }}</span><b>{{ toman(row.amount) }}</b></li>
<li v-if="!payablesByMonth.length">تعهد بازی نیست.</li>
</ul>
<h2 v-if="current">سهم هزینه‌ها</h2>
<ul v-if="current">
<li v-for="row in current.expenseCategories" :key="row.name">
<span>{{ row.name }}</span>
<b>{{ toman(row.amount) }} · {{ row.share }}٪</b>
</li>
</ul>
</section>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 16px; overflow: hidden; }
th, td { text-align: right; padding: 0.7rem; border-bottom: 1px solid #f1f5f9; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
li { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 12px; padding: 0.7rem 0.9rem; }
h2 { margin: 0; font-size: 1rem; }
.err { color: #b91c1c; font-weight: 700; }
</style>
@@ -0,0 +1,5 @@
export function toman(value: string | number | null | undefined) {
const n = Number(value ?? 0);
if (!Number.isFinite(n)) return '۰';
return `${new Intl.NumberFormat('fa-IR').format(Math.round(n))} تومان`;
}
+109
View File
@@ -5598,6 +5598,115 @@ export interface RoutineUserSettingsDto {
summaryHour: number;
}
/* ───────── دفتر مالی داخلی ───────── */
export type FinancialAccountType = "bank" | "cash" | "petty";
export type FinancialDocKind = "income" | "expense";
export type FinancialDocStatus = "open" | "partial" | "settled" | "void";
export type FinancialPayMethod = "cash" | "card_to_card" | "pos" | "transfer" | "other";
export interface FinancialAccountDto {
id: string;
name: string;
type: FinancialAccountType;
bankName: string | null;
accountRef: string | null;
responsibleName: string | null;
openingBalance: string;
openingDate: string;
notes: string | null;
isActive: boolean;
currency: string;
balance: string;
periodIn: string;
periodOut: string;
lastMovementAt: string | null;
}
export interface FinancialCategoryDto {
id: string;
name: string;
kind: FinancialDocKind;
isActive: boolean;
}
export interface FinancialDocumentDto {
id: string;
kind: FinancialDocKind;
title: string;
amount: string;
settledAmount: string;
remaining: string;
bookedAt: string;
dueAt: string | null;
categoryId: string | null;
categoryName: string | null;
personId: string | null;
partyName: string | null;
accountId: string | null;
accountName: string | null;
method: string | null;
status: FinancialDocStatus;
reference: string | null;
notes: string | null;
createdAt: string;
}
export interface FinancialMovementDto {
id: string;
accountId: string;
direction: "in" | "out";
amount: string;
kind: string;
kindLabel: string;
transferGroupId: string | null;
documentId: string | null;
occurredAt: string;
memo: string | null;
reference: string | null;
balanceAfter: string | null;
}
export type FinancialInstallmentStatus = "open" | "partial" | "settled" | "overdue";
export interface FinancialInstallmentDto {
id: string;
position: number;
dueDate: string;
amount: string;
paidAmount: string;
remaining: string;
status: FinancialInstallmentStatus;
}
export interface FinancialReceivableDto {
id: string;
title: string;
personId: string | null;
partyName: string | null;
totalAmount: string;
paidAmount: string;
remaining: string;
status: "open" | "settled" | "void";
notes: string | null;
installments: FinancialInstallmentDto[];
createdAt: string;
}
export interface TreasuryDashboardDto {
bookedIncome: string;
actualReceipts: string;
bookedExpense: string;
actualPayments: string;
operatingProfit: string;
receivables: string;
payables: string;
totalBalance: string;
recent: FinancialDocumentDto[];
upcomingInstallments: FinancialInstallmentDto[];
upcomingPayables: FinancialDocumentDto[];
}
/* ───────── باشگاه امتیازات (گیمیفیکیشن کارکنان) ───────── */
export * from "./gamification";