From ab538b83046a02c7a41d40ce56c69630dbdea5e4 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sun, 18 Oct 2026 18:49:49 +0330 Subject: [PATCH] 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 --- .../migration.sql | 96 +++ .../migration.sql | 42 + .../migration.sql | 79 ++ apps/api/prisma/schema.prisma | 243 ++++++ apps/api/src/app.module.ts | 2 + apps/api/src/treasury/treasury-ops.service.ts | 479 +++++++++++ .../treasury/treasury-receivables.service.ts | 407 +++++++++ apps/api/src/treasury/treasury.controller.ts | 198 +++++ apps/api/src/treasury/treasury.module.ts | 15 + apps/api/src/treasury/treasury.service.ts | 769 ++++++++++++++++++ .../src/components/treasury/TreasuryNav.vue | 57 ++ apps/web/src/lib/nav-hubs.ts | 8 + apps/web/src/router/index.ts | 60 ++ .../views/treasury/TreasuryAccountView.vue | 120 +++ .../views/treasury/TreasuryAccountsView.vue | 224 +++++ .../views/treasury/TreasuryCategoriesView.vue | 70 ++ .../src/views/treasury/TreasuryDocsView.vue | 183 +++++ .../views/treasury/TreasuryExpensesView.vue | 6 + .../src/views/treasury/TreasuryHomeView.vue | 112 +++ .../views/treasury/TreasuryIncomesView.vue | 6 + .../views/treasury/TreasuryPayrollView.vue | 134 +++ .../treasury/TreasuryReceivablesView.vue | 220 +++++ .../views/treasury/TreasuryRecurringView.vue | 84 ++ .../views/treasury/TreasuryReportsView.vue | 108 +++ .../web/src/views/treasury/treasury-format.ts | 5 + packages/shared/src/index.ts | 109 +++ 26 files changed, 3836 insertions(+) create mode 100644 apps/api/prisma/migrations/20261018120000_internal_finance_ledger/migration.sql create mode 100644 apps/api/prisma/migrations/20261018180000_treasury_receivables/migration.sql create mode 100644 apps/api/prisma/migrations/20261018200000_treasury_phase3/migration.sql create mode 100644 apps/api/src/treasury/treasury-ops.service.ts create mode 100644 apps/api/src/treasury/treasury-receivables.service.ts create mode 100644 apps/api/src/treasury/treasury.controller.ts create mode 100644 apps/api/src/treasury/treasury.module.ts create mode 100644 apps/api/src/treasury/treasury.service.ts create mode 100644 apps/web/src/components/treasury/TreasuryNav.vue create mode 100644 apps/web/src/views/treasury/TreasuryAccountView.vue create mode 100644 apps/web/src/views/treasury/TreasuryAccountsView.vue create mode 100644 apps/web/src/views/treasury/TreasuryCategoriesView.vue create mode 100644 apps/web/src/views/treasury/TreasuryDocsView.vue create mode 100644 apps/web/src/views/treasury/TreasuryExpensesView.vue create mode 100644 apps/web/src/views/treasury/TreasuryHomeView.vue create mode 100644 apps/web/src/views/treasury/TreasuryIncomesView.vue create mode 100644 apps/web/src/views/treasury/TreasuryPayrollView.vue create mode 100644 apps/web/src/views/treasury/TreasuryReceivablesView.vue create mode 100644 apps/web/src/views/treasury/TreasuryRecurringView.vue create mode 100644 apps/web/src/views/treasury/TreasuryReportsView.vue create mode 100644 apps/web/src/views/treasury/treasury-format.ts diff --git a/apps/api/prisma/migrations/20261018120000_internal_finance_ledger/migration.sql b/apps/api/prisma/migrations/20261018120000_internal_finance_ledger/migration.sql new file mode 100644 index 0000000..30c76fd --- /dev/null +++ b/apps/api/prisma/migrations/20261018120000_internal_finance_ledger/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20261018180000_treasury_receivables/migration.sql b/apps/api/prisma/migrations/20261018180000_treasury_receivables/migration.sql new file mode 100644 index 0000000..bcd2dde --- /dev/null +++ b/apps/api/prisma/migrations/20261018180000_treasury_receivables/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20261018200000_treasury_phase3/migration.sql b/apps/api/prisma/migrations/20261018200000_treasury_phase3/migration.sql new file mode 100644 index 0000000..835163e --- /dev/null +++ b/apps/api/prisma/migrations/20261018200000_treasury_phase3/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2fa27e4..69c1911 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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()) diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 3991eb7..cd4d9e9 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/treasury/treasury-ops.service.ts b/apps/api/src/treasury/treasury-ops.service.ts new file mode 100644 index 0000000..1a7b497 --- /dev/null +++ b/apps/api/src/treasury/treasury-ops.service.ts @@ -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(); + 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(); + 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(); + 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), + ); + } +} diff --git a/apps/api/src/treasury/treasury-receivables.service.ts b/apps/api/src/treasury/treasury-receivables.service.ts new file mode 100644 index 0000000..785a3b4 --- /dev/null +++ b/apps/api/src/treasury/treasury-receivables.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/apps/api/src/treasury/treasury.controller.ts b/apps/api/src/treasury/treasury.controller.ts new file mode 100644 index 0000000..4c408b6 --- /dev/null +++ b/apps/api/src/treasury/treasury.controller.ts @@ -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[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[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[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[1], + ) { + return this.receivables.create(user, body); + } + + @Post('installments/:id/collect') + collectInstallment( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Body() body: Parameters[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[1]) { + return this.ops.createPayroll(user, body); + } + + @Post('payroll/:id/pay') + payPayroll( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Body() body: Parameters[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[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[1]) { + return this.ops.reconcile(user, body); + } + + @Post('adjustments') + adjust(@CurrentUser() user: Actor, @Body() body: Parameters[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); + } +} diff --git a/apps/api/src/treasury/treasury.module.ts b/apps/api/src/treasury/treasury.module.ts new file mode 100644 index 0000000..2c7ce70 --- /dev/null +++ b/apps/api/src/treasury/treasury.module.ts @@ -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 {} diff --git a/apps/api/src/treasury/treasury.service.ts b/apps/api/src/treasury/treasury.service.ts new file mode 100644 index 0000000..4fe0ac0 --- /dev/null +++ b/apps/api/src/treasury/treasury.service.ts @@ -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 = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/apps/web/src/components/treasury/TreasuryNav.vue b/apps/web/src/components/treasury/TreasuryNav.vue new file mode 100644 index 0000000..c7e46e6 --- /dev/null +++ b/apps/web/src/components/treasury/TreasuryNav.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/apps/web/src/lib/nav-hubs.ts b/apps/web/src/lib/nav-hubs.ts index c7015b3..787b02b 100644 --- a/apps/web/src/lib/nav-hubs.ts +++ b/apps/web/src/lib/nav-hubs.ts @@ -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: 'رزرو و نوبت‌دهی', diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 7112b37..604f765 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -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', diff --git a/apps/web/src/views/treasury/TreasuryAccountView.vue b/apps/web/src/views/treasury/TreasuryAccountView.vue new file mode 100644 index 0000000..6c4851c --- /dev/null +++ b/apps/web/src/views/treasury/TreasuryAccountView.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/apps/web/src/views/treasury/TreasuryAccountsView.vue b/apps/web/src/views/treasury/TreasuryAccountsView.vue new file mode 100644 index 0000000..564c8aa --- /dev/null +++ b/apps/web/src/views/treasury/TreasuryAccountsView.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/apps/web/src/views/treasury/TreasuryCategoriesView.vue b/apps/web/src/views/treasury/TreasuryCategoriesView.vue new file mode 100644 index 0000000..2e201ab --- /dev/null +++ b/apps/web/src/views/treasury/TreasuryCategoriesView.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/apps/web/src/views/treasury/TreasuryDocsView.vue b/apps/web/src/views/treasury/TreasuryDocsView.vue new file mode 100644 index 0000000..0fe2372 --- /dev/null +++ b/apps/web/src/views/treasury/TreasuryDocsView.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/apps/web/src/views/treasury/TreasuryExpensesView.vue b/apps/web/src/views/treasury/TreasuryExpensesView.vue new file mode 100644 index 0000000..4e3b2c4 --- /dev/null +++ b/apps/web/src/views/treasury/TreasuryExpensesView.vue @@ -0,0 +1,6 @@ + + diff --git a/apps/web/src/views/treasury/TreasuryHomeView.vue b/apps/web/src/views/treasury/TreasuryHomeView.vue new file mode 100644 index 0000000..b24a506 --- /dev/null +++ b/apps/web/src/views/treasury/TreasuryHomeView.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/apps/web/src/views/treasury/TreasuryIncomesView.vue b/apps/web/src/views/treasury/TreasuryIncomesView.vue new file mode 100644 index 0000000..5c5b766 --- /dev/null +++ b/apps/web/src/views/treasury/TreasuryIncomesView.vue @@ -0,0 +1,6 @@ + + diff --git a/apps/web/src/views/treasury/TreasuryPayrollView.vue b/apps/web/src/views/treasury/TreasuryPayrollView.vue new file mode 100644 index 0000000..8721d51 --- /dev/null +++ b/apps/web/src/views/treasury/TreasuryPayrollView.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/apps/web/src/views/treasury/TreasuryReceivablesView.vue b/apps/web/src/views/treasury/TreasuryReceivablesView.vue new file mode 100644 index 0000000..28b7773 --- /dev/null +++ b/apps/web/src/views/treasury/TreasuryReceivablesView.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/apps/web/src/views/treasury/TreasuryRecurringView.vue b/apps/web/src/views/treasury/TreasuryRecurringView.vue new file mode 100644 index 0000000..a4ac3d1 --- /dev/null +++ b/apps/web/src/views/treasury/TreasuryRecurringView.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/apps/web/src/views/treasury/TreasuryReportsView.vue b/apps/web/src/views/treasury/TreasuryReportsView.vue new file mode 100644 index 0000000..86b60fa --- /dev/null +++ b/apps/web/src/views/treasury/TreasuryReportsView.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/apps/web/src/views/treasury/treasury-format.ts b/apps/web/src/views/treasury/treasury-format.ts new file mode 100644 index 0000000..8ed8d11 --- /dev/null +++ b/apps/web/src/views/treasury/treasury-format.ts @@ -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))} تومان`; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 43c8e4c..bab31a1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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";