feat: add internal finance ledger for accounts, claims, and payroll

Keep booked income and expense separate from cash balances, with installments, recurring items, and reports.

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