feat: add internal finance ledger for accounts, claims, and payroll
Keep booked income and expense separate from cash balances, with installments, recurring items, and reports. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "FinancialAccount" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"bankName" TEXT,
|
||||
"accountRef" TEXT,
|
||||
"responsibleName" TEXT,
|
||||
"openingBalance" DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
"openingDate" TIMESTAMP(3) NOT NULL,
|
||||
"notes" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"currency" TEXT NOT NULL DEFAULT 'IRR',
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "FinancialAccount_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialCategory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "FinancialCategory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialDocument" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"amount" DECIMAL(18,2) NOT NULL,
|
||||
"bookedAt" TIMESTAMP(3) NOT NULL,
|
||||
"dueAt" TIMESTAMP(3),
|
||||
"categoryId" TEXT,
|
||||
"personId" TEXT,
|
||||
"partyName" TEXT,
|
||||
"accountId" TEXT,
|
||||
"method" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'open',
|
||||
"reference" TEXT,
|
||||
"notes" TEXT,
|
||||
"createdById" TEXT,
|
||||
"voidedAt" TIMESTAMP(3),
|
||||
"voidReason" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "FinancialDocument_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialMovement" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"documentId" TEXT,
|
||||
"direction" TEXT NOT NULL,
|
||||
"amount" DECIMAL(18,2) NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"transferGroupId" TEXT,
|
||||
"occurredAt" TIMESTAMP(3) NOT NULL,
|
||||
"memo" TEXT,
|
||||
"reference" TEXT,
|
||||
"createdById" TEXT,
|
||||
"voidedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "FinancialMovement_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "FinancialAccount_organizationId_isActive_idx" ON "FinancialAccount"("organizationId", "isActive");
|
||||
CREATE UNIQUE INDEX "FinancialCategory_organizationId_kind_name_key" ON "FinancialCategory"("organizationId", "kind", "name");
|
||||
CREATE INDEX "FinancialCategory_organizationId_kind_idx" ON "FinancialCategory"("organizationId", "kind");
|
||||
CREATE INDEX "FinancialDocument_organizationId_kind_bookedAt_idx" ON "FinancialDocument"("organizationId", "kind", "bookedAt");
|
||||
CREATE INDEX "FinancialDocument_organizationId_status_idx" ON "FinancialDocument"("organizationId", "status");
|
||||
CREATE INDEX "FinancialDocument_personId_idx" ON "FinancialDocument"("personId");
|
||||
CREATE INDEX "FinancialMovement_organizationId_accountId_occurredAt_idx" ON "FinancialMovement"("organizationId", "accountId", "occurredAt");
|
||||
CREATE INDEX "FinancialMovement_transferGroupId_idx" ON "FinancialMovement"("transferGroupId");
|
||||
CREATE INDEX "FinancialMovement_documentId_idx" ON "FinancialMovement"("documentId");
|
||||
|
||||
ALTER TABLE "FinancialAccount" ADD CONSTRAINT "FinancialAccount_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialCategory" ADD CONSTRAINT "FinancialCategory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FinancialCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "FinancialAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "FinancialAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "FinancialDocument"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,42 @@
|
||||
ALTER TABLE "FinancialMovement" ADD COLUMN "receivableId" TEXT;
|
||||
ALTER TABLE "FinancialMovement" ADD COLUMN "installmentId" TEXT;
|
||||
|
||||
CREATE TABLE "FinancialReceivable" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"personId" TEXT,
|
||||
"partyName" TEXT,
|
||||
"totalAmount" DECIMAL(18,2) NOT NULL,
|
||||
"notes" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'open',
|
||||
"createdById" TEXT,
|
||||
"voidedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "FinancialReceivable_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialInstallment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"receivableId" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
"dueDate" TIMESTAMP(3) NOT NULL,
|
||||
"amount" DECIMAL(18,2) NOT NULL,
|
||||
"reminderNotifiedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "FinancialInstallment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "FinancialMovement_receivableId_idx" ON "FinancialMovement"("receivableId");
|
||||
CREATE INDEX "FinancialMovement_installmentId_idx" ON "FinancialMovement"("installmentId");
|
||||
CREATE INDEX "FinancialReceivable_organizationId_status_idx" ON "FinancialReceivable"("organizationId", "status");
|
||||
CREATE INDEX "FinancialReceivable_personId_idx" ON "FinancialReceivable"("personId");
|
||||
CREATE UNIQUE INDEX "FinancialInstallment_receivableId_position_key" ON "FinancialInstallment"("receivableId", "position");
|
||||
CREATE INDEX "FinancialInstallment_dueDate_idx" ON "FinancialInstallment"("dueDate");
|
||||
|
||||
ALTER TABLE "FinancialReceivable" ADD CONSTRAINT "FinancialReceivable_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialReceivable" ADD CONSTRAINT "FinancialReceivable_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialInstallment" ADD CONSTRAINT "FinancialInstallment_receivableId_fkey" FOREIGN KEY ("receivableId") REFERENCES "FinancialReceivable"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_receivableId_fkey" FOREIGN KEY ("receivableId") REFERENCES "FinancialReceivable"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_installmentId_fkey" FOREIGN KEY ("installmentId") REFERENCES "FinancialInstallment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,79 @@
|
||||
ALTER TABLE "FinancialMovement" ADD COLUMN "payrollId" TEXT;
|
||||
CREATE INDEX "FinancialMovement_payrollId_idx" ON "FinancialMovement"("payrollId");
|
||||
|
||||
CREATE TABLE "FinancialPayroll" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"period" TEXT NOT NULL,
|
||||
"baseAmount" DECIMAL(18,2) NOT NULL,
|
||||
"benefits" DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
"bonus" DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
"deductions" DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
"payable" DECIMAL(18,2) NOT NULL,
|
||||
"dueDate" TIMESTAMP(3) NOT NULL,
|
||||
"notes" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'open',
|
||||
"createdById" TEXT,
|
||||
"voidedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "FinancialPayroll_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialRecurringItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"amount" DECIMAL(18,2) NOT NULL,
|
||||
"interval" TEXT NOT NULL,
|
||||
"startDate" TIMESTAMP(3) NOT NULL,
|
||||
"endDate" TIMESTAMP(3),
|
||||
"nextDue" TIMESTAMP(3) NOT NULL,
|
||||
"accountId" TEXT,
|
||||
"categoryId" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "FinancialRecurringItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialRecurringOccurrence" (
|
||||
"id" TEXT NOT NULL,
|
||||
"itemId" TEXT NOT NULL,
|
||||
"periodKey" TEXT NOT NULL,
|
||||
"dueDate" TIMESTAMP(3) NOT NULL,
|
||||
"documentId" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "FinancialRecurringOccurrence_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialReconciliation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"reconciledAt" TIMESTAMP(3) NOT NULL,
|
||||
"statedBalance" DECIMAL(18,2) NOT NULL,
|
||||
"systemBalance" DECIMAL(18,2) NOT NULL,
|
||||
"difference" DECIMAL(18,2) NOT NULL,
|
||||
"notes" TEXT,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "FinancialReconciliation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "FinancialPayroll_organizationId_userId_period_key" ON "FinancialPayroll"("organizationId", "userId", "period");
|
||||
CREATE INDEX "FinancialPayroll_organizationId_status_idx" ON "FinancialPayroll"("organizationId", "status");
|
||||
CREATE INDEX "FinancialRecurringItem_organizationId_isActive_nextDue_idx" ON "FinancialRecurringItem"("organizationId", "isActive", "nextDue");
|
||||
CREATE UNIQUE INDEX "FinancialRecurringOccurrence_itemId_periodKey_key" ON "FinancialRecurringOccurrence"("itemId", "periodKey");
|
||||
CREATE INDEX "FinancialReconciliation_organizationId_accountId_reconciledAt_idx" ON "FinancialReconciliation"("organizationId", "accountId", "reconciledAt");
|
||||
|
||||
ALTER TABLE "FinancialPayroll" ADD CONSTRAINT "FinancialPayroll_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_payrollId_fkey" FOREIGN KEY ("payrollId") REFERENCES "FinancialPayroll"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialRecurringItem" ADD CONSTRAINT "FinancialRecurringItem_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialRecurringOccurrence" ADD CONSTRAINT "FinancialRecurringOccurrence_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "FinancialRecurringItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialReconciliation" ADD CONSTRAINT "FinancialReconciliation_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialReconciliation" ADD CONSTRAINT "FinancialReconciliation_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "FinancialAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -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())
|
||||
|
||||
@@ -33,6 +33,7 @@ import { MemoryModule } from './memory/memory.module';
|
||||
import { TahesabModule } from './integrations/tahesab/tahesab.module';
|
||||
import { InventoryModule } from './inventory/inventory.module';
|
||||
import { FinanceModule } from './finance/finance.module';
|
||||
import { TreasuryModule } from './treasury/treasury.module';
|
||||
import { PlatformModule } from './platform/platform.module';
|
||||
import { BillingModule } from './billing/billing.module';
|
||||
import { AuditLogsModule } from './audit-logs/audit-logs.module';
|
||||
@@ -83,6 +84,7 @@ import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
|
||||
InvoicesModule,
|
||||
InventoryModule,
|
||||
FinanceModule,
|
||||
TreasuryModule,
|
||||
SalesModule,
|
||||
TicketsModule,
|
||||
FaqModule,
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
|
||||
function dec(value: Prisma.Decimal | number | string | null | undefined) {
|
||||
return new Prisma.Decimal(value ?? 0);
|
||||
}
|
||||
function money(value: Prisma.Decimal | number | string) {
|
||||
return new Prisma.Decimal(value).toFixed(2);
|
||||
}
|
||||
function parseDate(iso?: string | null) {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
function periodKey(date: Date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
function addInterval(date: Date, interval: string) {
|
||||
const next = new Date(date);
|
||||
if (interval === 'weekly') next.setDate(next.getDate() + 7);
|
||||
else if (interval === 'yearly') next.setFullYear(next.getFullYear() + 1);
|
||||
else next.setMonth(next.getMonth() + 1);
|
||||
return next;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TreasuryOpsService {
|
||||
private readonly logger = new Logger(TreasuryOpsService.name);
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listPayroll(actor: Actor) {
|
||||
const rows = await this.prisma.financialPayroll.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
include: { movements: true },
|
||||
orderBy: { dueDate: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { id: { in: rows.map((row) => row.userId) } },
|
||||
select: { id: true, fullName: true },
|
||||
});
|
||||
const names = new Map(users.map((user) => [user.id, user.fullName]));
|
||||
return rows.map((row) => this.payrollDto(row, names.get(row.userId) ?? ''));
|
||||
}
|
||||
|
||||
async createPayroll(
|
||||
actor: Actor,
|
||||
input: {
|
||||
userId: string;
|
||||
period: string;
|
||||
baseAmount: string | number;
|
||||
benefits?: string | number;
|
||||
bonus?: string | number;
|
||||
deductions?: string | number;
|
||||
dueDate?: string;
|
||||
notes?: string | null;
|
||||
},
|
||||
) {
|
||||
const base = dec(input.baseAmount);
|
||||
const benefits = dec(input.benefits ?? 0);
|
||||
const bonus = dec(input.bonus ?? 0);
|
||||
const deductions = dec(input.deductions ?? 0);
|
||||
const payable = base.plus(benefits).plus(bonus).minus(deductions);
|
||||
if (!input.userId || !input.period?.trim()) {
|
||||
throw new BadRequestException('پرسنل و دوره حقوق لازم است.');
|
||||
}
|
||||
if (!payable.greaterThan(0)) throw new BadRequestException('مبلغ قابل پرداخت باید بزرگتر از صفر باشد.');
|
||||
const member = await this.prisma.membership.findFirst({
|
||||
where: { organizationId: actor.organizationId, userId: input.userId, isActive: true },
|
||||
});
|
||||
if (!member) throw new BadRequestException('این کاربر عضو محیط نیست.');
|
||||
const row = await this.prisma.financialPayroll.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
userId: input.userId,
|
||||
period: input.period.trim(),
|
||||
baseAmount: base,
|
||||
benefits,
|
||||
bonus,
|
||||
deductions,
|
||||
payable,
|
||||
dueDate: parseDate(input.dueDate) ?? new Date(),
|
||||
notes: input.notes?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
include: { movements: true },
|
||||
});
|
||||
const user = await this.prisma.user.findUnique({ where: { id: row.userId }, select: { fullName: true } });
|
||||
return this.payrollDto(row, user?.fullName ?? '');
|
||||
}
|
||||
|
||||
async payPayroll(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
input: { amount: string | number; accountId: string; occurredAt?: string; clientKey?: string | null },
|
||||
) {
|
||||
const row = await this.prisma.financialPayroll.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
include: { movements: true },
|
||||
});
|
||||
if (!row || row.voidedAt || row.status === 'void' || row.status === 'settled') {
|
||||
throw new NotFoundException('رکورد حقوق قابل پرداخت نیست.');
|
||||
}
|
||||
const amount = dec(input.amount);
|
||||
const paid = this.paidOf(row.movements);
|
||||
const remaining = row.payable.minus(paid);
|
||||
if (!amount.greaterThan(0) || amount.greaterThan(remaining)) {
|
||||
throw new BadRequestException('مبلغ پرداخت حقوق نامعتبر است.');
|
||||
}
|
||||
if (input.clientKey) {
|
||||
const dup = await this.prisma.financialMovement.findFirst({
|
||||
where: { payrollId: id, reference: input.clientKey, voidedAt: null },
|
||||
});
|
||||
if (dup) throw new BadRequestException('این پرداخت قبلاً ثبت شده است.');
|
||||
}
|
||||
const account = await this.prisma.financialAccount.findFirst({
|
||||
where: { id: input.accountId, organizationId: actor.organizationId, isActive: true },
|
||||
});
|
||||
if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.');
|
||||
const balance = await this.balance(account.id);
|
||||
if (balance.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
|
||||
const status = paid.plus(amount).equals(row.payable) ? 'settled' : 'partial';
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: account.id,
|
||||
payrollId: row.id,
|
||||
direction: 'out',
|
||||
amount,
|
||||
kind: 'payment',
|
||||
occurredAt: parseDate(input.occurredAt) ?? new Date(),
|
||||
memo: `حقوق ${row.period}`,
|
||||
reference: input.clientKey || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
}),
|
||||
this.prisma.financialPayroll.update({ where: { id: row.id }, data: { status } }),
|
||||
]);
|
||||
return this.listPayroll(actor);
|
||||
}
|
||||
|
||||
async listRecurring(actor: Actor) {
|
||||
const rows = await this.prisma.financialRecurringItem.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
orderBy: { nextDue: 'asc' },
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
kind: row.kind,
|
||||
amount: money(row.amount),
|
||||
interval: row.interval,
|
||||
nextDue: row.nextDue.toISOString(),
|
||||
isActive: row.isActive,
|
||||
}));
|
||||
}
|
||||
|
||||
async createRecurring(
|
||||
actor: Actor,
|
||||
input: {
|
||||
title: string;
|
||||
kind: 'income' | 'expense';
|
||||
amount: string | number;
|
||||
interval: 'weekly' | 'monthly' | 'yearly';
|
||||
startDate?: string;
|
||||
endDate?: string | null;
|
||||
accountId?: string | null;
|
||||
categoryId?: string | null;
|
||||
},
|
||||
) {
|
||||
const amount = dec(input.amount);
|
||||
if (!input.title?.trim() || !amount.greaterThan(0)) {
|
||||
throw new BadRequestException('عنوان و مبلغ تکرارشونده لازم است.');
|
||||
}
|
||||
const start = parseDate(input.startDate) ?? new Date();
|
||||
const row = await this.prisma.financialRecurringItem.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
title: input.title.trim(),
|
||||
kind: input.kind,
|
||||
amount,
|
||||
interval: input.interval,
|
||||
startDate: start,
|
||||
endDate: parseDate(input.endDate),
|
||||
nextDue: start,
|
||||
accountId: input.accountId || null,
|
||||
categoryId: input.categoryId || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
return { id: row.id };
|
||||
}
|
||||
|
||||
@Cron('30 8 * * *')
|
||||
async spawnRecurring() {
|
||||
const now = new Date();
|
||||
const items = await this.prisma.financialRecurringItem.findMany({
|
||||
where: { isActive: true, nextDue: { lte: now } },
|
||||
take: 100,
|
||||
});
|
||||
for (const item of items) {
|
||||
if (item.endDate && item.nextDue > item.endDate) continue;
|
||||
const key = periodKey(item.nextDue);
|
||||
const exists = await this.prisma.financialRecurringOccurrence.findUnique({
|
||||
where: { itemId_periodKey: { itemId: item.id, periodKey: key } },
|
||||
});
|
||||
if (!exists) {
|
||||
const doc = await this.prisma.financialDocument.create({
|
||||
data: {
|
||||
organizationId: item.organizationId,
|
||||
kind: item.kind,
|
||||
title: item.title,
|
||||
amount: item.amount,
|
||||
bookedAt: item.nextDue,
|
||||
dueAt: item.nextDue,
|
||||
categoryId: item.categoryId,
|
||||
accountId: item.accountId,
|
||||
status: 'open',
|
||||
notes: 'ساختهشده از الگوی تکرارشونده؛ تا تأیید، موجودی حساب تغییر نمیکند.',
|
||||
},
|
||||
});
|
||||
await this.prisma.financialRecurringOccurrence.create({
|
||||
data: {
|
||||
itemId: item.id,
|
||||
periodKey: key,
|
||||
dueDate: item.nextDue,
|
||||
documentId: doc.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.prisma.financialRecurringItem.update({
|
||||
where: { id: item.id },
|
||||
data: { nextDue: addInterval(item.nextDue, item.interval) },
|
||||
});
|
||||
}
|
||||
if (items.length) this.logger.log(`recurring finance items: ${items.length}`);
|
||||
}
|
||||
|
||||
async reconcile(
|
||||
actor: Actor,
|
||||
input: { accountId: string; statedBalance: string | number; reconciledAt?: string; notes?: string | null },
|
||||
) {
|
||||
const account = await this.prisma.financialAccount.findFirst({
|
||||
where: { id: input.accountId, organizationId: actor.organizationId },
|
||||
});
|
||||
if (!account) throw new NotFoundException('حساب پیدا نشد.');
|
||||
const systemBalance = await this.balance(account.id);
|
||||
const stated = dec(input.statedBalance);
|
||||
const row = await this.prisma.financialReconciliation.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: account.id,
|
||||
reconciledAt: parseDate(input.reconciledAt) ?? new Date(),
|
||||
statedBalance: stated,
|
||||
systemBalance,
|
||||
difference: stated.minus(systemBalance),
|
||||
notes: input.notes?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
statedBalance: money(row.statedBalance),
|
||||
systemBalance: money(row.systemBalance),
|
||||
difference: money(row.difference),
|
||||
};
|
||||
}
|
||||
|
||||
async adjust(
|
||||
actor: Actor,
|
||||
input: { accountId: string; amount: string | number; direction: 'in' | 'out'; reason: string },
|
||||
) {
|
||||
const reason = input.reason?.trim();
|
||||
if (!reason || reason.length < 3) throw new BadRequestException('علت اصلاح موجودی لازم است.');
|
||||
const amount = dec(input.amount);
|
||||
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ اصلاح باید بزرگتر از صفر باشد.');
|
||||
const account = await this.prisma.financialAccount.findFirst({
|
||||
where: { id: input.accountId, organizationId: actor.organizationId, isActive: true },
|
||||
});
|
||||
if (!account) throw new NotFoundException('حساب پیدا نشد.');
|
||||
if (input.direction === 'out') {
|
||||
const balance = await this.balance(account.id);
|
||||
if (balance.lessThan(amount)) throw new BadRequestException('موجودی برای این اصلاح کافی نیست.');
|
||||
}
|
||||
await this.prisma.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: account.id,
|
||||
direction: input.direction,
|
||||
amount,
|
||||
kind: 'adjustment',
|
||||
occurredAt: new Date(),
|
||||
memo: `اصلاحیه: ${reason}`,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async report(actor: Actor, fromIso?: string, toIso?: string) {
|
||||
const to = parseDate(toIso) ?? new Date();
|
||||
const from = parseDate(fromIso) ?? new Date(to.getFullYear(), to.getMonth(), 1);
|
||||
const span = to.getTime() - from.getTime();
|
||||
const prevTo = new Date(from.getTime() - 1);
|
||||
const prevFrom = new Date(prevTo.getTime() - span);
|
||||
const current = await this.period(actor.organizationId, from, to);
|
||||
const previous = await this.period(actor.organizationId, prevFrom, prevTo);
|
||||
const breakdown = await this.breakdown(actor.organizationId);
|
||||
return { from: from.toISOString(), to: to.toISOString(), current, previous, ...breakdown };
|
||||
}
|
||||
|
||||
async reportExcel(actor: Actor, from?: string, to?: string) {
|
||||
const data = await this.report(actor, from, to);
|
||||
const book = new ExcelJS.Workbook();
|
||||
const sheet = book.addWorksheet('گزارش مالی');
|
||||
sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']);
|
||||
const rows: Array<[string, string, string]> = [
|
||||
['درآمد ثبتشده', data.current.bookedIncome, data.previous.bookedIncome],
|
||||
['هزینه ثبتشده', data.current.bookedExpense, data.previous.bookedExpense],
|
||||
['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit],
|
||||
['ورود وجه', data.current.cashIn, data.previous.cashIn],
|
||||
['خروج وجه', data.current.cashOut, data.previous.cashOut],
|
||||
['خالص جریان نقدی', data.current.cashNet, data.previous.cashNet],
|
||||
];
|
||||
rows.forEach((row) => sheet.addRow(row));
|
||||
sheet.addRow([]);
|
||||
sheet.addRow(['دسته هزینه', 'مبلغ', 'سهم']);
|
||||
data.current.expenseCategories.forEach((row) => sheet.addRow([row.name, row.amount, row.share]));
|
||||
const buffer = await book.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
private async period(organizationId: string, from: Date, to: Date) {
|
||||
const docs = await this.prisma.financialDocument.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
status: { not: 'void' },
|
||||
bookedAt: { gte: from, lte: to },
|
||||
},
|
||||
include: { category: true },
|
||||
});
|
||||
const cash = await this.prisma.financialMovement.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
voidedAt: null,
|
||||
occurredAt: { gte: from, lte: to },
|
||||
kind: { in: ['receipt', 'payment'] },
|
||||
},
|
||||
});
|
||||
let bookedIncome = new Prisma.Decimal(0);
|
||||
let bookedExpense = new Prisma.Decimal(0);
|
||||
const byCat = new Map<string, Prisma.Decimal>();
|
||||
for (const doc of docs) {
|
||||
if (doc.kind === 'income') bookedIncome = bookedIncome.plus(doc.amount);
|
||||
else {
|
||||
bookedExpense = bookedExpense.plus(doc.amount);
|
||||
const name = doc.category?.name ?? 'بدون دسته';
|
||||
byCat.set(name, (byCat.get(name) ?? new Prisma.Decimal(0)).plus(doc.amount));
|
||||
}
|
||||
}
|
||||
let cashIn = new Prisma.Decimal(0);
|
||||
let cashOut = new Prisma.Decimal(0);
|
||||
for (const row of cash) {
|
||||
if (row.direction === 'in') cashIn = cashIn.plus(row.amount);
|
||||
else cashOut = cashOut.plus(row.amount);
|
||||
}
|
||||
const expenseCategories = [...byCat.entries()].map(([name, amount]) => ({
|
||||
name,
|
||||
amount: money(amount),
|
||||
share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1),
|
||||
}));
|
||||
return {
|
||||
bookedIncome: money(bookedIncome),
|
||||
bookedExpense: money(bookedExpense),
|
||||
operatingProfit: money(bookedIncome.minus(bookedExpense)),
|
||||
cashIn: money(cashIn),
|
||||
cashOut: money(cashOut),
|
||||
cashNet: money(cashIn.minus(cashOut)),
|
||||
expenseCategories,
|
||||
};
|
||||
}
|
||||
|
||||
private async breakdown(organizationId: string) {
|
||||
const [docs, payroll, contracts] = await Promise.all([
|
||||
this.prisma.financialDocument.findMany({
|
||||
where: { organizationId, kind: 'expense', status: { in: ['open', 'partial'] } },
|
||||
include: { movements: true },
|
||||
}),
|
||||
this.prisma.financialPayroll.findMany({
|
||||
where: { organizationId, status: { in: ['open', 'partial'] } },
|
||||
include: { movements: true },
|
||||
}),
|
||||
this.prisma.financialReceivable.findMany({
|
||||
where: { organizationId, status: 'open' },
|
||||
include: { movements: true },
|
||||
}),
|
||||
]);
|
||||
const payablesByMonth = new Map<string, Prisma.Decimal>();
|
||||
const addMonth = (date: Date, amount: Prisma.Decimal) => {
|
||||
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
payablesByMonth.set(key, (payablesByMonth.get(key) ?? new Prisma.Decimal(0)).plus(amount));
|
||||
};
|
||||
for (const doc of docs) {
|
||||
const left = doc.amount.minus(this.paidOf(doc.movements));
|
||||
if (left.greaterThan(0)) addMonth(doc.dueAt ?? doc.bookedAt, left);
|
||||
}
|
||||
for (const row of payroll) {
|
||||
const left = row.payable.minus(this.paidOf(row.movements));
|
||||
if (left.greaterThan(0)) addMonth(row.dueDate, left);
|
||||
}
|
||||
const receivablesByParty = new Map<string, Prisma.Decimal>();
|
||||
for (const row of contracts) {
|
||||
const paid = this.paidOf(row.movements);
|
||||
const left = row.totalAmount.minus(paid);
|
||||
if (!left.greaterThan(0)) continue;
|
||||
const name = row.partyName?.trim() || 'بدون نام';
|
||||
receivablesByParty.set(name, (receivablesByParty.get(name) ?? new Prisma.Decimal(0)).plus(left));
|
||||
}
|
||||
return {
|
||||
payablesByMonth: [...payablesByMonth.entries()].map(([month, amount]) => ({ month, amount: money(amount) })),
|
||||
receivablesByParty: [...receivablesByParty.entries()].map(([name, amount]) => ({ name, amount: money(amount) })),
|
||||
};
|
||||
}
|
||||
|
||||
private paidOf(movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>) {
|
||||
return movements.filter((row) => !row.voidedAt).reduce((sum, row) => sum.plus(row.amount), new Prisma.Decimal(0));
|
||||
}
|
||||
|
||||
private payrollDto(
|
||||
row: {
|
||||
id: string;
|
||||
userId: string;
|
||||
period: string;
|
||||
baseAmount: Prisma.Decimal;
|
||||
benefits: Prisma.Decimal;
|
||||
bonus: Prisma.Decimal;
|
||||
deductions: Prisma.Decimal;
|
||||
payable: Prisma.Decimal;
|
||||
dueDate: Date;
|
||||
status: string;
|
||||
movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>;
|
||||
},
|
||||
fullName: string,
|
||||
) {
|
||||
const paid = this.paidOf(row.movements);
|
||||
const left = row.payable.minus(paid);
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
fullName,
|
||||
period: row.period,
|
||||
baseAmount: money(row.baseAmount),
|
||||
benefits: money(row.benefits),
|
||||
bonus: money(row.bonus),
|
||||
deductions: money(row.deductions),
|
||||
payable: money(row.payable),
|
||||
paidAmount: money(paid),
|
||||
remaining: money(left.isNegative() ? 0 : left),
|
||||
dueDate: row.dueDate.toISOString(),
|
||||
status: row.status,
|
||||
};
|
||||
}
|
||||
|
||||
private async balance(accountId: string) {
|
||||
const rows = await this.prisma.financialMovement.findMany({
|
||||
where: { accountId, voidedAt: null },
|
||||
select: { direction: true, amount: true },
|
||||
});
|
||||
return rows.reduce(
|
||||
(sum, row) => (row.direction === 'in' ? sum.plus(row.amount) : sum.minus(row.amount)),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
FinancialInstallmentDto,
|
||||
FinancialInstallmentStatus,
|
||||
FinancialReceivableDto,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
|
||||
function dec(value: Prisma.Decimal | number | string | null | undefined) {
|
||||
return new Prisma.Decimal(value ?? 0);
|
||||
}
|
||||
|
||||
function money(value: Prisma.Decimal | number | string) {
|
||||
return new Prisma.Decimal(value).toFixed(2);
|
||||
}
|
||||
|
||||
function parseDate(iso?: string | null) {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function addPeriod(base: Date, interval: 'weekly' | 'monthly', step: number) {
|
||||
const next = new Date(base);
|
||||
if (interval === 'weekly') next.setDate(next.getDate() + step * 7);
|
||||
else next.setMonth(next.getMonth() + step);
|
||||
return next;
|
||||
}
|
||||
|
||||
type MovementSlice = { amount: Prisma.Decimal; voidedAt: Date | null; installmentId?: string | null };
|
||||
|
||||
@Injectable()
|
||||
export class TreasuryReceivablesService {
|
||||
private readonly logger = new Logger(TreasuryReceivablesService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
async list(actor: Actor): Promise<FinancialReceivableDto[]> {
|
||||
const rows = await this.prisma.financialReceivable.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
include: {
|
||||
installments: { include: { movements: true }, orderBy: { position: 'asc' } },
|
||||
movements: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async create(
|
||||
actor: Actor,
|
||||
input: {
|
||||
title: string;
|
||||
personId?: string | null;
|
||||
partyName?: string | null;
|
||||
totalAmount: string | number;
|
||||
prepaidAmount?: string | number | null;
|
||||
accountId?: string | null;
|
||||
startDate?: string;
|
||||
count?: number;
|
||||
interval?: 'weekly' | 'monthly';
|
||||
notes?: string | null;
|
||||
installments?: Array<{ amount: string | number; dueDate: string }>;
|
||||
},
|
||||
): Promise<FinancialReceivableDto> {
|
||||
const title = input.title.trim();
|
||||
const total = dec(input.totalAmount);
|
||||
const prepaid = dec(input.prepaidAmount ?? 0);
|
||||
if (title.length < 2) throw new BadRequestException('عنوان قرارداد را وارد کنید.');
|
||||
if (!total.greaterThan(0)) throw new BadRequestException('مبلغ قرارداد باید بزرگتر از صفر باشد.');
|
||||
if (prepaid.isNegative() || prepaid.greaterThan(total)) {
|
||||
throw new BadRequestException('پیشپرداخت نامعتبر است.');
|
||||
}
|
||||
const financed = total.minus(prepaid);
|
||||
const lines = this.buildSchedule(financed, input);
|
||||
if (prepaid.greaterThan(0) && !input.accountId) {
|
||||
throw new BadRequestException('برای پیشپرداخت، حساب مقصد را انتخاب کنید.');
|
||||
}
|
||||
if (input.accountId) {
|
||||
const account = await this.prisma.financialAccount.findFirst({
|
||||
where: { id: input.accountId, organizationId: actor.organizationId, isActive: true },
|
||||
});
|
||||
if (!account) throw new BadRequestException('حساب مقصد پیدا نشد.');
|
||||
}
|
||||
const start = parseDate(input.startDate) ?? new Date();
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const receivable = await tx.financialReceivable.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
title,
|
||||
personId: input.personId || null,
|
||||
partyName: input.partyName?.trim() || null,
|
||||
totalAmount: total,
|
||||
notes: input.notes?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
status: financed.equals(0) ? 'settled' : 'open',
|
||||
installments: {
|
||||
create: lines.map((line, index) => ({
|
||||
position: index + 1,
|
||||
dueDate: line.dueDate,
|
||||
amount: line.amount,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { installments: true },
|
||||
});
|
||||
if (prepaid.greaterThan(0) && input.accountId) {
|
||||
await tx.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: input.accountId,
|
||||
receivableId: receivable.id,
|
||||
direction: 'in',
|
||||
amount: prepaid,
|
||||
kind: 'receipt',
|
||||
occurredAt: start,
|
||||
memo: `پیشپرداخت ${title}`,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
return receivable.id;
|
||||
});
|
||||
return this.get(actor, created);
|
||||
}
|
||||
|
||||
async collect(
|
||||
actor: Actor,
|
||||
installmentId: string,
|
||||
input: {
|
||||
amount: string | number;
|
||||
accountId: string;
|
||||
occurredAt?: string;
|
||||
notes?: string | null;
|
||||
clientKey?: string | null;
|
||||
},
|
||||
): Promise<FinancialReceivableDto> {
|
||||
const installment = await this.prisma.financialInstallment.findFirst({
|
||||
where: { id: installmentId, receivable: { organizationId: actor.organizationId } },
|
||||
include: { movements: true, receivable: true },
|
||||
});
|
||||
if (!installment || installment.receivable.voidedAt) {
|
||||
throw new NotFoundException('قسط پیدا نشد.');
|
||||
}
|
||||
const amount = dec(input.amount);
|
||||
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ دریافت باید بزرگتر از صفر باشد.');
|
||||
const paid = this.paidOf(installment.movements);
|
||||
const remaining = installment.amount.minus(paid);
|
||||
if (!remaining.greaterThan(0)) {
|
||||
throw new BadRequestException('این قسط قبلاً تسویه شده است.');
|
||||
}
|
||||
if (amount.greaterThan(remaining)) {
|
||||
throw new BadRequestException('مبلغ از مانده قسط بیشتر است.');
|
||||
}
|
||||
const clientKey = input.clientKey?.trim();
|
||||
if (clientKey) {
|
||||
const dup = await this.prisma.financialMovement.findFirst({
|
||||
where: { installmentId, reference: clientKey, voidedAt: null },
|
||||
});
|
||||
if (dup) throw new BadRequestException('این دریافت قبلاً ثبت شده است.');
|
||||
}
|
||||
const account = await this.prisma.financialAccount.findFirst({
|
||||
where: { id: input.accountId, organizationId: actor.organizationId, isActive: true },
|
||||
});
|
||||
if (!account) throw new BadRequestException('حساب مقصد پیدا نشد.');
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: account.id,
|
||||
receivableId: installment.receivableId,
|
||||
installmentId: installment.id,
|
||||
direction: 'in',
|
||||
amount,
|
||||
kind: 'receipt',
|
||||
occurredAt: parseDate(input.occurredAt) ?? new Date(),
|
||||
memo: input.notes?.trim() || `قسط ${installment.position} ${installment.receivable.title}`,
|
||||
reference: clientKey || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
const nextPaid = paid.plus(amount);
|
||||
const all = await tx.financialInstallment.findMany({
|
||||
where: { receivableId: installment.receivableId },
|
||||
include: { movements: true },
|
||||
});
|
||||
const settled = all.every((row) => {
|
||||
const rowPaid = row.id === installment.id ? nextPaid : this.paidOf(row.movements);
|
||||
return rowPaid.greaterThanOrEqualTo(row.amount);
|
||||
});
|
||||
if (settled) {
|
||||
await tx.financialReceivable.update({
|
||||
where: { id: installment.receivableId },
|
||||
data: { status: 'settled' },
|
||||
});
|
||||
}
|
||||
});
|
||||
return this.get(actor, installment.receivableId);
|
||||
}
|
||||
|
||||
async upcoming(actor: Actor) {
|
||||
const rows = await this.prisma.financialInstallment.findMany({
|
||||
where: {
|
||||
receivable: { organizationId: actor.organizationId, status: { not: 'void' }, voidedAt: null },
|
||||
},
|
||||
include: { movements: true, receivable: true },
|
||||
orderBy: { dueDate: 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
return rows
|
||||
.map((row) => this.toInstallment(row))
|
||||
.filter((row) => row.status === 'open' || row.status === 'partial' || row.status === 'overdue')
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
async payables(actor: Actor) {
|
||||
return this.prisma.financialDocument.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
kind: 'expense',
|
||||
status: { in: ['open', 'partial'] },
|
||||
},
|
||||
include: { category: true, account: true, movements: true },
|
||||
orderBy: [{ dueAt: 'asc' }, { bookedAt: 'asc' }],
|
||||
take: 12,
|
||||
});
|
||||
}
|
||||
|
||||
@Cron('15 8 * * *')
|
||||
async remindDue() {
|
||||
const now = new Date();
|
||||
const horizon = new Date(now);
|
||||
horizon.setDate(horizon.getDate() + 3);
|
||||
const rows = await this.prisma.financialInstallment.findMany({
|
||||
where: {
|
||||
dueDate: { lte: horizon },
|
||||
reminderNotifiedAt: null,
|
||||
receivable: { status: 'open', voidedAt: null },
|
||||
},
|
||||
include: { movements: true, receivable: true },
|
||||
take: 80,
|
||||
});
|
||||
for (const row of rows) {
|
||||
const dto = this.toInstallment(row);
|
||||
if (dto.status === 'settled') continue;
|
||||
const admins = await this.prisma.membership.findMany({
|
||||
where: {
|
||||
organizationId: row.receivable.organizationId,
|
||||
isActive: true,
|
||||
role: { in: ['owner', 'admin'] },
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
for (const admin of admins) {
|
||||
await this.notifications.create({
|
||||
organizationId: row.receivable.organizationId,
|
||||
userId: admin.userId,
|
||||
type: 'treasury_installment',
|
||||
title: dto.status === 'overdue' ? 'قسط معوق' : 'قسط نزدیک سررسید',
|
||||
body: `${row.receivable.title} · قسط ${row.position}`,
|
||||
link: '/finance/receivables',
|
||||
entityType: 'financial_installment',
|
||||
entityId: row.id,
|
||||
dedupe: true,
|
||||
});
|
||||
}
|
||||
await this.prisma.financialInstallment.update({
|
||||
where: { id: row.id },
|
||||
data: { reminderNotifiedAt: now },
|
||||
});
|
||||
}
|
||||
if (rows.length) this.logger.log(`treasury installment reminders: ${rows.length}`);
|
||||
}
|
||||
|
||||
private async get(actor: Actor, id: string): Promise<FinancialReceivableDto> {
|
||||
const row = await this.prisma.financialReceivable.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
include: {
|
||||
installments: { include: { movements: true }, orderBy: { position: 'asc' } },
|
||||
movements: true,
|
||||
},
|
||||
});
|
||||
if (!row) throw new NotFoundException('قرارداد پیدا نشد.');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
private buildSchedule(
|
||||
financed: Prisma.Decimal,
|
||||
input: {
|
||||
startDate?: string;
|
||||
count?: number;
|
||||
interval?: 'weekly' | 'monthly';
|
||||
installments?: Array<{ amount: string | number; dueDate: string }>;
|
||||
},
|
||||
) {
|
||||
if (financed.equals(0)) return [];
|
||||
if (input.installments?.length) {
|
||||
const lines = input.installments.map((line) => {
|
||||
const dueDate = parseDate(line.dueDate);
|
||||
const amount = dec(line.amount);
|
||||
if (!dueDate || !amount.greaterThan(0)) {
|
||||
throw new BadRequestException('مبلغ و سررسید هر قسط لازم است.');
|
||||
}
|
||||
return { amount, dueDate };
|
||||
});
|
||||
const sum = lines.reduce((total, line) => total.plus(line.amount), new Prisma.Decimal(0));
|
||||
if (!sum.equals(financed)) {
|
||||
throw new BadRequestException('جمع اقساط باید برابر مانده پس از پیشپرداخت باشد.');
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
const count = Math.floor(input.count ?? 0);
|
||||
if (count < 1 || count > 60) {
|
||||
throw new BadRequestException('تعداد اقساط باید بین ۱ و ۶۰ باشد.');
|
||||
}
|
||||
const interval = input.interval === 'weekly' ? 'weekly' : 'monthly';
|
||||
const start = parseDate(input.startDate) ?? new Date();
|
||||
const base = financed.dividedBy(count).toDecimalPlaces(2, Prisma.Decimal.ROUND_DOWN);
|
||||
const lines = Array.from({ length: count }, (_, index) => ({
|
||||
amount: base,
|
||||
dueDate: addPeriod(start, interval, index),
|
||||
}));
|
||||
const assigned = base.mul(count);
|
||||
lines[lines.length - 1]!.amount = lines[lines.length - 1]!.amount.plus(financed.minus(assigned));
|
||||
return lines;
|
||||
}
|
||||
|
||||
private paidOf(movements: MovementSlice[]) {
|
||||
return movements
|
||||
.filter((row) => !row.voidedAt)
|
||||
.reduce((sum, row) => sum.plus(row.amount), new Prisma.Decimal(0));
|
||||
}
|
||||
|
||||
private toInstallment(row: {
|
||||
id: string;
|
||||
position: number;
|
||||
dueDate: Date;
|
||||
amount: Prisma.Decimal;
|
||||
movements: MovementSlice[];
|
||||
}): FinancialInstallmentDto {
|
||||
const paid = this.paidOf(row.movements);
|
||||
const remainingRaw = row.amount.minus(paid);
|
||||
const remaining = remainingRaw.isNegative() ? new Prisma.Decimal(0) : remainingRaw;
|
||||
let status: FinancialInstallmentStatus = 'open';
|
||||
if (remaining.equals(0)) status = 'settled';
|
||||
else if (row.dueDate.getTime() < Date.now()) status = 'overdue';
|
||||
else if (paid.greaterThan(0)) status = 'partial';
|
||||
return {
|
||||
id: row.id,
|
||||
position: row.position,
|
||||
dueDate: row.dueDate.toISOString(),
|
||||
amount: money(row.amount),
|
||||
paidAmount: money(paid),
|
||||
remaining: money(remaining),
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
title: string;
|
||||
personId: string | null;
|
||||
partyName: string | null;
|
||||
totalAmount: Prisma.Decimal;
|
||||
notes: string | null;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
movements: MovementSlice[];
|
||||
installments: Array<{
|
||||
id: string;
|
||||
position: number;
|
||||
dueDate: Date;
|
||||
amount: Prisma.Decimal;
|
||||
movements: MovementSlice[];
|
||||
}>;
|
||||
}): FinancialReceivableDto {
|
||||
const paid = this.paidOf(row.movements);
|
||||
const remainingRaw = row.totalAmount.minus(paid);
|
||||
const remaining = remainingRaw.isNegative() ? new Prisma.Decimal(0) : remainingRaw;
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
personId: row.personId,
|
||||
partyName: row.partyName,
|
||||
totalAmount: money(row.totalAmount),
|
||||
paidAmount: money(paid),
|
||||
remaining: money(remaining),
|
||||
status: row.status as FinancialReceivableDto['status'],
|
||||
notes: row.notes,
|
||||
installments: row.installments.map((item) => this.toInstallment(item)),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { TreasuryOpsService } from './treasury-ops.service';
|
||||
import { TreasuryReceivablesService } from './treasury-receivables.service';
|
||||
import { TreasuryService } from './treasury.service';
|
||||
|
||||
@Controller('treasury')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
export class TreasuryController {
|
||||
constructor(
|
||||
private readonly treasury: TreasuryService,
|
||||
private readonly receivables: TreasuryReceivablesService,
|
||||
private readonly ops: TreasuryOpsService,
|
||||
) {}
|
||||
|
||||
@Get('dashboard')
|
||||
dashboard(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
) {
|
||||
return this.treasury.dashboard(user, { from, to });
|
||||
}
|
||||
|
||||
@Get('categories')
|
||||
categories(@CurrentUser() user: Actor, @Query('kind') kind?: string) {
|
||||
return this.treasury.listCategories(user, kind);
|
||||
}
|
||||
|
||||
@Post('categories')
|
||||
createCategory(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() body: { name: string; kind: 'income' | 'expense' },
|
||||
) {
|
||||
return this.treasury.createCategory(user, body);
|
||||
}
|
||||
|
||||
@Get('accounts')
|
||||
accounts(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
) {
|
||||
return this.treasury.listAccounts(user, { from, to });
|
||||
}
|
||||
|
||||
@Post('accounts')
|
||||
createAccount(@CurrentUser() user: Actor, @Body() body: Parameters<TreasuryService['createAccount']>[1]) {
|
||||
return this.treasury.createAccount(user, body);
|
||||
}
|
||||
|
||||
@Patch('accounts/:id')
|
||||
updateAccount(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { name?: string; notes?: string | null; isActive?: boolean; responsibleName?: string | null },
|
||||
) {
|
||||
return this.treasury.updateAccount(user, id, body);
|
||||
}
|
||||
|
||||
@Get('accounts/:id/ledger')
|
||||
ledger(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
) {
|
||||
return this.treasury.ledger(user, id, { from, to });
|
||||
}
|
||||
|
||||
@Post('transfers')
|
||||
transfer(@CurrentUser() user: Actor, @Body() body: Parameters<TreasuryService['transfer']>[1]) {
|
||||
return this.treasury.transfer(user, body);
|
||||
}
|
||||
|
||||
@Get('documents')
|
||||
documents(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('kind') kind: 'income' | 'expense',
|
||||
@Query('q') q?: string,
|
||||
) {
|
||||
return this.treasury.listDocuments(user, kind === 'expense' ? 'expense' : 'income', q);
|
||||
}
|
||||
|
||||
@Post('documents')
|
||||
createDocument(@CurrentUser() user: Actor, @Body() body: Parameters<TreasuryService['createDocument']>[1]) {
|
||||
return this.treasury.createDocument(user, body);
|
||||
}
|
||||
|
||||
@Post('documents/:id/settle')
|
||||
settle(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { amount: string | number; accountId: string; occurredAt?: string; reference?: string | null },
|
||||
) {
|
||||
return this.treasury.settleDocument(user, id, body);
|
||||
}
|
||||
|
||||
@Get('receivables')
|
||||
receivablesList(@CurrentUser() user: Actor) {
|
||||
return this.receivables.list(user);
|
||||
}
|
||||
|
||||
@Post('receivables')
|
||||
createReceivable(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() body: Parameters<TreasuryReceivablesService['create']>[1],
|
||||
) {
|
||||
return this.receivables.create(user, body);
|
||||
}
|
||||
|
||||
@Post('installments/:id/collect')
|
||||
collectInstallment(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Parameters<TreasuryReceivablesService['collect']>[2],
|
||||
) {
|
||||
return this.receivables.collect(user, id, body);
|
||||
}
|
||||
|
||||
@Get('payroll')
|
||||
payroll(@CurrentUser() user: Actor) {
|
||||
return this.ops.listPayroll(user);
|
||||
}
|
||||
|
||||
@Post('payroll')
|
||||
createPayroll(@CurrentUser() user: Actor, @Body() body: Parameters<TreasuryOpsService['createPayroll']>[1]) {
|
||||
return this.ops.createPayroll(user, body);
|
||||
}
|
||||
|
||||
@Post('payroll/:id/pay')
|
||||
payPayroll(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Parameters<TreasuryOpsService['payPayroll']>[2],
|
||||
) {
|
||||
return this.ops.payPayroll(user, id, body);
|
||||
}
|
||||
|
||||
@Get('recurring')
|
||||
recurring(@CurrentUser() user: Actor) {
|
||||
return this.ops.listRecurring(user);
|
||||
}
|
||||
|
||||
@Post('recurring')
|
||||
createRecurring(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() body: Parameters<TreasuryOpsService['createRecurring']>[1],
|
||||
) {
|
||||
return this.ops.createRecurring(user, body);
|
||||
}
|
||||
|
||||
@Get('reports')
|
||||
report(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
) {
|
||||
return this.ops.report(user, from, to);
|
||||
}
|
||||
|
||||
@Get('reports/export')
|
||||
async exportReport(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
) {
|
||||
const buffer = await this.ops.reportExcel(user, from, to);
|
||||
return new StreamableFile(buffer, {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
disposition: 'attachment; filename="treasury-report.xlsx"',
|
||||
});
|
||||
}
|
||||
|
||||
@Post('reconciliations')
|
||||
reconcile(@CurrentUser() user: Actor, @Body() body: Parameters<TreasuryOpsService['reconcile']>[1]) {
|
||||
return this.ops.reconcile(user, body);
|
||||
}
|
||||
|
||||
@Post('adjustments')
|
||||
adjust(@CurrentUser() user: Actor, @Body() body: Parameters<TreasuryOpsService['adjust']>[1]) {
|
||||
return this.ops.adjust(user, body);
|
||||
}
|
||||
|
||||
@Post('documents/:id/void')
|
||||
voidDoc(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { reason?: string },
|
||||
) {
|
||||
return this.treasury.voidDocument(user, id, body.reason);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,769 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
FinancialAccountDto,
|
||||
FinancialCategoryDto,
|
||||
FinancialDocumentDto,
|
||||
FinancialMovementDto,
|
||||
TreasuryDashboardDto,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { TreasuryReceivablesService } from './treasury-receivables.service';
|
||||
|
||||
const INCOME_CATEGORIES = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها'];
|
||||
const EXPENSE_CATEGORIES = [
|
||||
'اجاره',
|
||||
'آب، برق، گاز و ارتباطات',
|
||||
'حقوق و مزایا',
|
||||
'اشتراک نرمافزار',
|
||||
'تجهیزات و ملزومات',
|
||||
'تبلیغات',
|
||||
'ایاب و ذهاب',
|
||||
'پشتیبانی و خدمات',
|
||||
'مالیات و عوارض',
|
||||
'سایر هزینهها',
|
||||
];
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
opening: 'مانده افتتاحیه',
|
||||
receipt: 'دریافت',
|
||||
payment: 'پرداخت',
|
||||
transfer_in: 'انتقال ورودی',
|
||||
transfer_out: 'انتقال خروجی',
|
||||
};
|
||||
|
||||
function dec(value: Prisma.Decimal | number | string | null | undefined) {
|
||||
return new Prisma.Decimal(value ?? 0);
|
||||
}
|
||||
|
||||
function money(value: Prisma.Decimal | number | string) {
|
||||
return new Prisma.Decimal(value).toFixed(2);
|
||||
}
|
||||
|
||||
function startOfDay(iso?: string) {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TreasuryService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly receivables: TreasuryReceivablesService,
|
||||
) {}
|
||||
|
||||
async ensureCategories(organizationId: string) {
|
||||
const count = await this.prisma.financialCategory.count({
|
||||
where: { organizationId },
|
||||
});
|
||||
if (count > 0) return;
|
||||
await this.prisma.financialCategory.createMany({
|
||||
data: [
|
||||
...INCOME_CATEGORIES.map((name) => ({
|
||||
organizationId,
|
||||
name,
|
||||
kind: 'income',
|
||||
})),
|
||||
...EXPENSE_CATEGORIES.map((name) => ({
|
||||
organizationId,
|
||||
name,
|
||||
kind: 'expense',
|
||||
})),
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
async listCategories(actor: Actor, kind?: string): Promise<FinancialCategoryDto[]> {
|
||||
await this.ensureCategories(actor.organizationId);
|
||||
const rows = await this.prisma.financialCategory.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
...(kind === 'income' || kind === 'expense' ? { kind } : {}),
|
||||
},
|
||||
orderBy: [{ kind: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind as FinancialCategoryDto['kind'],
|
||||
isActive: row.isActive,
|
||||
}));
|
||||
}
|
||||
|
||||
async createCategory(
|
||||
actor: Actor,
|
||||
input: { name: string; kind: 'income' | 'expense' },
|
||||
): Promise<FinancialCategoryDto> {
|
||||
const name = input.name.trim();
|
||||
if (name.length < 2) throw new BadRequestException('نام دستهبندی کوتاه است.');
|
||||
const row = await this.prisma.financialCategory.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
name,
|
||||
kind: input.kind,
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind as FinancialCategoryDto['kind'],
|
||||
isActive: row.isActive,
|
||||
};
|
||||
}
|
||||
|
||||
async listAccounts(
|
||||
actor: Actor,
|
||||
range?: { from?: string; to?: string },
|
||||
): Promise<FinancialAccountDto[]> {
|
||||
const accounts = await this.prisma.financialAccount.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
orderBy: [{ isActive: 'desc' }, { name: 'asc' }],
|
||||
});
|
||||
return Promise.all(accounts.map((account) => this.toAccountDto(account, range)));
|
||||
}
|
||||
|
||||
async createAccount(
|
||||
actor: Actor,
|
||||
input: {
|
||||
name: string;
|
||||
type: 'bank' | 'cash' | 'petty';
|
||||
bankName?: string | null;
|
||||
accountRef?: string | null;
|
||||
responsibleName?: string | null;
|
||||
openingBalance?: string | number;
|
||||
openingDate?: string;
|
||||
notes?: string | null;
|
||||
},
|
||||
): Promise<FinancialAccountDto> {
|
||||
const name = input.name.trim();
|
||||
if (name.length < 2) throw new BadRequestException('نام حساب را وارد کنید.');
|
||||
if (!['bank', 'cash', 'petty'].includes(input.type)) {
|
||||
throw new BadRequestException('نوع حساب نامعتبر است.');
|
||||
}
|
||||
const opening = dec(input.openingBalance ?? 0);
|
||||
if (opening.isNegative()) {
|
||||
throw new BadRequestException('موجودی اولیه نمیتواند منفی باشد.');
|
||||
}
|
||||
const openingDate = startOfDay(input.openingDate) ?? new Date();
|
||||
const account = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.financialAccount.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
name,
|
||||
type: input.type,
|
||||
bankName: input.bankName?.trim() || null,
|
||||
accountRef: input.accountRef?.trim() || null,
|
||||
responsibleName: input.responsibleName?.trim() || null,
|
||||
openingBalance: opening,
|
||||
openingDate,
|
||||
notes: input.notes?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
if (opening.greaterThan(0)) {
|
||||
await tx.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: created.id,
|
||||
direction: 'in',
|
||||
amount: opening,
|
||||
kind: 'opening',
|
||||
occurredAt: openingDate,
|
||||
memo: 'مانده افتتاحیه',
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
return created;
|
||||
});
|
||||
return this.toAccountDto(account);
|
||||
}
|
||||
|
||||
async updateAccount(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
input: { name?: string; notes?: string | null; isActive?: boolean; responsibleName?: string | null },
|
||||
): Promise<FinancialAccountDto> {
|
||||
const existing = await this.requireAccount(actor.organizationId, id);
|
||||
const row = await this.prisma.financialAccount.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
...(input.name?.trim() ? { name: input.name.trim() } : {}),
|
||||
...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}),
|
||||
...(input.responsibleName !== undefined
|
||||
? { responsibleName: input.responsibleName?.trim() || null }
|
||||
: {}),
|
||||
...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
|
||||
},
|
||||
});
|
||||
return this.toAccountDto(row);
|
||||
}
|
||||
|
||||
async ledger(
|
||||
actor: Actor,
|
||||
accountId: string,
|
||||
range?: { from?: string; to?: string },
|
||||
): Promise<{ account: FinancialAccountDto; items: FinancialMovementDto[] }> {
|
||||
const account = await this.requireAccount(actor.organizationId, accountId);
|
||||
const from = startOfDay(range?.from);
|
||||
const to = startOfDay(range?.to);
|
||||
const rows = await this.prisma.financialMovement.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId,
|
||||
voidedAt: null,
|
||||
...(from || to
|
||||
? {
|
||||
occurredAt: {
|
||||
...(from ? { gte: from } : {}),
|
||||
...(to ? { lte: to } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ occurredAt: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
let running = new Prisma.Decimal(0);
|
||||
if (from) {
|
||||
const prior = await this.prisma.financialMovement.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId,
|
||||
voidedAt: null,
|
||||
occurredAt: { lt: from },
|
||||
},
|
||||
select: { direction: true, amount: true },
|
||||
});
|
||||
for (const row of prior) {
|
||||
running = row.direction === 'in' ? running.plus(row.amount) : running.minus(row.amount);
|
||||
}
|
||||
}
|
||||
const items: FinancialMovementDto[] = rows.map((row) => {
|
||||
running = row.direction === 'in' ? running.plus(row.amount) : running.minus(row.amount);
|
||||
return {
|
||||
id: row.id,
|
||||
accountId: row.accountId,
|
||||
direction: row.direction as 'in' | 'out',
|
||||
amount: money(row.amount),
|
||||
kind: row.kind,
|
||||
kindLabel: KIND_LABEL[row.kind] ?? row.kind,
|
||||
transferGroupId: row.transferGroupId,
|
||||
documentId: row.documentId,
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
memo: row.memo,
|
||||
reference: row.reference,
|
||||
balanceAfter: money(running),
|
||||
};
|
||||
});
|
||||
return { account: await this.toAccountDto(account, range), items };
|
||||
}
|
||||
|
||||
async transfer(
|
||||
actor: Actor,
|
||||
input: {
|
||||
fromAccountId: string;
|
||||
toAccountId: string;
|
||||
amount: string | number;
|
||||
occurredAt?: string;
|
||||
reference?: string | null;
|
||||
memo?: string | null;
|
||||
clientKey?: string | null;
|
||||
},
|
||||
) {
|
||||
if (input.fromAccountId === input.toAccountId) {
|
||||
throw new BadRequestException('حساب مبدأ و مقصد نباید یکسان باشند.');
|
||||
}
|
||||
const amount = dec(input.amount);
|
||||
if (!amount.greaterThan(0)) {
|
||||
throw new BadRequestException('مبلغ انتقال باید بزرگتر از صفر باشد.');
|
||||
}
|
||||
const clientKey = input.clientKey?.trim();
|
||||
if (clientKey) {
|
||||
const dup = await this.prisma.financialMovement.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
transferGroupId: clientKey,
|
||||
voidedAt: null,
|
||||
},
|
||||
});
|
||||
if (dup) throw new BadRequestException('این انتقال قبلاً ثبت شده است.');
|
||||
}
|
||||
const [source, target] = await Promise.all([
|
||||
this.requireAccount(actor.organizationId, input.fromAccountId),
|
||||
this.requireAccount(actor.organizationId, input.toAccountId),
|
||||
]);
|
||||
if (!source.isActive || !target.isActive) {
|
||||
throw new BadRequestException('حساب غیرفعال در انتقال قابل استفاده نیست.');
|
||||
}
|
||||
const balance = await this.accountBalance(source.id);
|
||||
if (balance.lessThan(amount)) {
|
||||
throw new BadRequestException('موجودی حساب مبدأ برای این انتقال کافی نیست.');
|
||||
}
|
||||
const groupId = clientKey || randomUUID();
|
||||
const occurredAt = startOfDay(input.occurredAt) ?? new Date();
|
||||
const memo = input.memo?.trim() || `انتقال از ${source.name} به ${target.name}`;
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: source.id,
|
||||
direction: 'out',
|
||||
amount,
|
||||
kind: 'transfer_out',
|
||||
transferGroupId: groupId,
|
||||
occurredAt,
|
||||
memo,
|
||||
reference: input.reference?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
}),
|
||||
this.prisma.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: target.id,
|
||||
direction: 'in',
|
||||
amount,
|
||||
kind: 'transfer_in',
|
||||
transferGroupId: groupId,
|
||||
occurredAt,
|
||||
memo,
|
||||
reference: input.reference?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { ok: true, transferGroupId: groupId };
|
||||
}
|
||||
|
||||
async listDocuments(
|
||||
actor: Actor,
|
||||
kind: 'income' | 'expense',
|
||||
q?: string,
|
||||
): Promise<FinancialDocumentDto[]> {
|
||||
const rows = await this.prisma.financialDocument.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
kind,
|
||||
...(q?.trim()
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: q.trim(), mode: 'insensitive' } },
|
||||
{ partyName: { contains: q.trim(), mode: 'insensitive' } },
|
||||
{ reference: { contains: q.trim(), mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: { category: true, account: true, movements: true },
|
||||
orderBy: { bookedAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
return rows.map((row) => this.toDocumentDto(row));
|
||||
}
|
||||
|
||||
async createDocument(
|
||||
actor: Actor,
|
||||
input: {
|
||||
kind: 'income' | 'expense';
|
||||
title: string;
|
||||
amount: string | number;
|
||||
bookedAt?: string;
|
||||
dueAt?: string | null;
|
||||
categoryId?: string | null;
|
||||
personId?: string | null;
|
||||
partyName?: string | null;
|
||||
method?: string | null;
|
||||
reference?: string | null;
|
||||
notes?: string | null;
|
||||
settleAmount?: string | number | null;
|
||||
accountId?: string | null;
|
||||
settledAt?: string | null;
|
||||
},
|
||||
): Promise<FinancialDocumentDto> {
|
||||
const title = input.title.trim();
|
||||
const amount = dec(input.amount);
|
||||
if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.');
|
||||
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگتر از صفر باشد.');
|
||||
const settle = input.settleAmount == null || input.settleAmount === ''
|
||||
? new Prisma.Decimal(0)
|
||||
: dec(input.settleAmount);
|
||||
if (settle.isNegative() || settle.greaterThan(amount)) {
|
||||
throw new BadRequestException('مبلغ دریافت/پرداخت از مبلغ سند بیشتر است.');
|
||||
}
|
||||
if (settle.greaterThan(0) && !input.accountId) {
|
||||
throw new BadRequestException('برای ثبت وجه قطعی، حساب را انتخاب کنید.');
|
||||
}
|
||||
if (input.accountId) {
|
||||
const account = await this.requireAccount(actor.organizationId, input.accountId);
|
||||
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
|
||||
if (input.kind === 'expense' && settle.greaterThan(0)) {
|
||||
const balance = await this.accountBalance(account.id);
|
||||
if (balance.lessThan(settle)) {
|
||||
throw new BadRequestException('موجودی حساب برای این پرداخت کافی نیست.');
|
||||
}
|
||||
}
|
||||
}
|
||||
const bookedAt = startOfDay(input.bookedAt) ?? new Date();
|
||||
const doc = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.financialDocument.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
kind: input.kind,
|
||||
title,
|
||||
amount,
|
||||
bookedAt,
|
||||
dueAt: input.dueAt ? startOfDay(input.dueAt) : null,
|
||||
categoryId: input.categoryId || null,
|
||||
personId: input.personId || null,
|
||||
partyName: input.partyName?.trim() || null,
|
||||
accountId: input.accountId || null,
|
||||
method: input.method || null,
|
||||
status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial',
|
||||
reference: input.reference?.trim() || null,
|
||||
notes: input.notes?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
if (settle.greaterThan(0) && input.accountId) {
|
||||
await tx.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: input.accountId,
|
||||
documentId: created.id,
|
||||
direction: input.kind === 'income' ? 'in' : 'out',
|
||||
amount: settle,
|
||||
kind: input.kind === 'income' ? 'receipt' : 'payment',
|
||||
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
|
||||
memo: title,
|
||||
reference: input.reference?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
return tx.financialDocument.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
include: { category: true, account: true, movements: true },
|
||||
});
|
||||
});
|
||||
return this.toDocumentDto(doc);
|
||||
}
|
||||
|
||||
async settleDocument(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
input: { amount: string | number; accountId: string; occurredAt?: string; reference?: string | null },
|
||||
): Promise<FinancialDocumentDto> {
|
||||
const doc = await this.prisma.financialDocument.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
include: { movements: true, category: true, account: true },
|
||||
});
|
||||
if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.');
|
||||
if (doc.status === 'settled' || doc.status === 'void') {
|
||||
throw new BadRequestException('این سند دیگر قابل تسویه نیست.');
|
||||
}
|
||||
const amount = dec(input.amount);
|
||||
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگتر از صفر باشد.');
|
||||
const settled = this.settledOf(doc.movements);
|
||||
const remaining = doc.amount.minus(settled);
|
||||
if (amount.greaterThan(remaining)) {
|
||||
throw new BadRequestException('مبلغ از مانده سند بیشتر است.');
|
||||
}
|
||||
const account = await this.requireAccount(actor.organizationId, input.accountId);
|
||||
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
|
||||
if (doc.kind === 'expense') {
|
||||
const balance = await this.accountBalance(account.id);
|
||||
if (balance.lessThan(amount)) {
|
||||
throw new BadRequestException('موجودی حساب برای این پرداخت کافی نیست.');
|
||||
}
|
||||
}
|
||||
const nextSettled = settled.plus(amount);
|
||||
const status = nextSettled.equals(doc.amount) ? 'settled' : 'partial';
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: account.id,
|
||||
documentId: doc.id,
|
||||
direction: doc.kind === 'income' ? 'in' : 'out',
|
||||
amount,
|
||||
kind: doc.kind === 'income' ? 'receipt' : 'payment',
|
||||
occurredAt: startOfDay(input.occurredAt) ?? new Date(),
|
||||
memo: doc.title,
|
||||
reference: input.reference?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
}),
|
||||
this.prisma.financialDocument.update({
|
||||
where: { id: doc.id },
|
||||
data: { status, accountId: account.id },
|
||||
}),
|
||||
]);
|
||||
if (status === 'settled') {
|
||||
await this.prisma.financialRecurringOccurrence.updateMany({
|
||||
where: { documentId: doc.id, status: 'pending' },
|
||||
data: { status: 'confirmed' },
|
||||
});
|
||||
}
|
||||
const fresh = await this.prisma.financialDocument.findUniqueOrThrow({
|
||||
where: { id: doc.id },
|
||||
include: { category: true, account: true, movements: true },
|
||||
});
|
||||
return this.toDocumentDto(fresh);
|
||||
}
|
||||
|
||||
async voidDocument(actor: Actor, id: string, reason?: string): Promise<FinancialDocumentDto> {
|
||||
const doc = await this.prisma.financialDocument.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
});
|
||||
if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.');
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.financialMovement.updateMany({
|
||||
where: { documentId: doc.id, voidedAt: null },
|
||||
data: { voidedAt: now },
|
||||
}),
|
||||
this.prisma.financialDocument.update({
|
||||
where: { id: doc.id },
|
||||
data: { status: 'void', voidedAt: now, voidReason: reason?.trim() || 'ابطال' },
|
||||
}),
|
||||
]);
|
||||
const fresh = await this.prisma.financialDocument.findUniqueOrThrow({
|
||||
where: { id: doc.id },
|
||||
include: { category: true, account: true, movements: true },
|
||||
});
|
||||
return this.toDocumentDto(fresh);
|
||||
}
|
||||
|
||||
async dashboard(
|
||||
actor: Actor,
|
||||
range?: { from?: string; to?: string },
|
||||
): Promise<TreasuryDashboardDto> {
|
||||
const from = startOfDay(range?.from);
|
||||
const to = startOfDay(range?.to);
|
||||
const bookedWhere = {
|
||||
organizationId: actor.organizationId,
|
||||
status: { not: 'void' },
|
||||
...(from || to
|
||||
? { bookedAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
|
||||
: {}),
|
||||
};
|
||||
const cashWhere = {
|
||||
organizationId: actor.organizationId,
|
||||
voidedAt: null,
|
||||
...(from || to
|
||||
? { occurredAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
|
||||
: {}),
|
||||
};
|
||||
const [income, expense, receipts, payments, openDocs, accounts, recent, upcomingInstallments, payableRows] =
|
||||
await Promise.all([
|
||||
this.prisma.financialDocument.aggregate({
|
||||
where: { ...bookedWhere, kind: 'income' },
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
this.prisma.financialDocument.aggregate({
|
||||
where: { ...bookedWhere, kind: 'expense' },
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
this.prisma.financialMovement.aggregate({
|
||||
where: { ...cashWhere, kind: 'receipt' },
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
this.prisma.financialMovement.aggregate({
|
||||
where: { ...cashWhere, kind: 'payment' },
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
this.prisma.financialDocument.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
status: { in: ['open', 'partial'] },
|
||||
},
|
||||
include: { movements: true },
|
||||
}),
|
||||
this.listAccounts(actor),
|
||||
this.prisma.financialDocument.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
include: { category: true, account: true, movements: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 8,
|
||||
}),
|
||||
this.receivables.upcoming(actor),
|
||||
this.receivables.payables(actor),
|
||||
]);
|
||||
let receivables = new Prisma.Decimal(0);
|
||||
let payables = new Prisma.Decimal(0);
|
||||
for (const doc of openDocs) {
|
||||
const left = doc.amount.minus(this.settledOf(doc.movements));
|
||||
if (left.lessThanOrEqualTo(0)) continue;
|
||||
if (doc.kind === 'income') receivables = receivables.plus(left);
|
||||
else payables = payables.plus(left);
|
||||
}
|
||||
const contracts = await this.receivables.list(actor);
|
||||
for (const contract of contracts) {
|
||||
if (contract.status === 'open') receivables = receivables.plus(contract.remaining);
|
||||
}
|
||||
const openPayroll = await this.prisma.financialPayroll.findMany({
|
||||
where: { organizationId: actor.organizationId, status: { in: ['open', 'partial'] } },
|
||||
include: { movements: true },
|
||||
});
|
||||
for (const row of openPayroll) {
|
||||
const paid = row.movements
|
||||
.filter((item) => !item.voidedAt)
|
||||
.reduce((sum, item) => sum.plus(item.amount), new Prisma.Decimal(0));
|
||||
const left = row.payable.minus(paid);
|
||||
if (left.greaterThan(0)) payables = payables.plus(left);
|
||||
}
|
||||
const bookedIncome = dec(income._sum.amount);
|
||||
const bookedExpense = dec(expense._sum.amount);
|
||||
const totalBalance = accounts.reduce(
|
||||
(sum, account) => sum.plus(account.balance),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
return {
|
||||
bookedIncome: money(bookedIncome),
|
||||
actualReceipts: money(dec(receipts._sum.amount)),
|
||||
bookedExpense: money(bookedExpense),
|
||||
actualPayments: money(dec(payments._sum.amount)),
|
||||
operatingProfit: money(bookedIncome.minus(bookedExpense)),
|
||||
receivables: money(receivables),
|
||||
payables: money(payables),
|
||||
totalBalance: money(totalBalance),
|
||||
recent: recent.map((row) => this.toDocumentDto(row)),
|
||||
upcomingInstallments,
|
||||
upcomingPayables: payableRows.map((row) => this.toDocumentDto(row)),
|
||||
};
|
||||
}
|
||||
|
||||
private settledOf(movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>) {
|
||||
return movements
|
||||
.filter((row) => !row.voidedAt)
|
||||
.reduce((sum, row) => sum.plus(row.amount), new Prisma.Decimal(0));
|
||||
}
|
||||
|
||||
private async accountBalance(accountId: string) {
|
||||
const rows = await this.prisma.financialMovement.findMany({
|
||||
where: { accountId, voidedAt: null },
|
||||
select: { direction: true, amount: true },
|
||||
});
|
||||
return rows.reduce(
|
||||
(sum, row) => (row.direction === 'in' ? sum.plus(row.amount) : sum.minus(row.amount)),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
}
|
||||
|
||||
private async requireAccount(organizationId: string, id: string) {
|
||||
const row = await this.prisma.financialAccount.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!row) throw new NotFoundException('حساب پیدا نشد.');
|
||||
return row;
|
||||
}
|
||||
|
||||
private async toAccountDto(
|
||||
account: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
bankName: string | null;
|
||||
accountRef: string | null;
|
||||
responsibleName: string | null;
|
||||
openingBalance: Prisma.Decimal;
|
||||
openingDate: Date;
|
||||
notes: string | null;
|
||||
isActive: boolean;
|
||||
currency: string;
|
||||
},
|
||||
range?: { from?: string; to?: string },
|
||||
): Promise<FinancialAccountDto> {
|
||||
const from = startOfDay(range?.from);
|
||||
const to = startOfDay(range?.to);
|
||||
const movements = await this.prisma.financialMovement.findMany({
|
||||
where: { accountId: account.id, voidedAt: null },
|
||||
select: { direction: true, amount: true, kind: true, occurredAt: true },
|
||||
});
|
||||
let balance = new Prisma.Decimal(0);
|
||||
let periodIn = new Prisma.Decimal(0);
|
||||
let periodOut = new Prisma.Decimal(0);
|
||||
let last: Date | null = null;
|
||||
for (const row of movements) {
|
||||
balance = row.direction === 'in' ? balance.plus(row.amount) : balance.minus(row.amount);
|
||||
if (!last || row.occurredAt > last) last = row.occurredAt;
|
||||
const inRange =
|
||||
(!from || row.occurredAt >= from) && (!to || row.occurredAt <= to);
|
||||
if (!inRange || row.kind === 'opening') continue;
|
||||
if (row.direction === 'in') periodIn = periodIn.plus(row.amount);
|
||||
else periodOut = periodOut.plus(row.amount);
|
||||
}
|
||||
return {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
type: account.type as FinancialAccountDto['type'],
|
||||
bankName: account.bankName,
|
||||
accountRef: account.accountRef,
|
||||
responsibleName: account.responsibleName,
|
||||
openingBalance: money(account.openingBalance),
|
||||
openingDate: account.openingDate.toISOString(),
|
||||
notes: account.notes,
|
||||
isActive: account.isActive,
|
||||
currency: account.currency,
|
||||
balance: money(balance),
|
||||
periodIn: money(periodIn),
|
||||
periodOut: money(periodOut),
|
||||
lastMovementAt: last ? last.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
private toDocumentDto(row: {
|
||||
id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
amount: Prisma.Decimal;
|
||||
bookedAt: Date;
|
||||
dueAt: Date | null;
|
||||
categoryId: string | null;
|
||||
personId: string | null;
|
||||
partyName: string | null;
|
||||
accountId: string | null;
|
||||
method: string | null;
|
||||
status: string;
|
||||
reference: string | null;
|
||||
notes: string | null;
|
||||
createdAt: Date;
|
||||
category?: { name: string } | null;
|
||||
account?: { name: string } | null;
|
||||
movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>;
|
||||
}): FinancialDocumentDto {
|
||||
const settled = this.settledOf(row.movements);
|
||||
const rawRemaining = row.amount.minus(settled);
|
||||
const remaining = rawRemaining.isNegative() ? new Prisma.Decimal(0) : rawRemaining;
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as FinancialDocumentDto['kind'],
|
||||
title: row.title,
|
||||
amount: money(row.amount),
|
||||
settledAmount: money(settled),
|
||||
remaining: money(remaining),
|
||||
bookedAt: row.bookedAt.toISOString(),
|
||||
dueAt: row.dueAt ? row.dueAt.toISOString() : null,
|
||||
categoryId: row.categoryId,
|
||||
categoryName: row.category?.name ?? null,
|
||||
personId: row.personId,
|
||||
partyName: row.partyName,
|
||||
accountId: row.accountId,
|
||||
accountName: row.account?.name ?? null,
|
||||
method: row.method,
|
||||
status: row.status as FinancialDocumentDto['status'],
|
||||
reference: row.reference,
|
||||
notes: row.notes,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user