Add Tahesab jewelry accounting integration with multi-DB connections.

Encrypt tokens server-side, sync customers both ways, show balances/documents on the person Assets tab, and push sanads with idempotent Factor_Code logging.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-04 22:00:23 +03:30
co-authored by Cursor
parent d2f66d11fc
commit 4b47b753c3
26 changed files with 13784 additions and 8 deletions
@@ -0,0 +1,156 @@
-- CreateTable
CREATE TABLE "AccountingConnection" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"provider" TEXT NOT NULL DEFAULT 'tahesab',
"label" TEXT NOT NULL,
"baseUrl" TEXT NOT NULL,
"dbName" TEXT NOT NULL,
"tokenCiphertext" TEXT NOT NULL,
"tokenIv" TEXT NOT NULL,
"tokenTag" TEXT NOT NULL,
"tokenLast4" TEXT NOT NULL DEFAULT '',
"enabled" BOOLEAN NOT NULL DEFAULT true,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"allowInsecureTls" BOOLEAN NOT NULL DEFAULT false,
"lastHealthAt" TIMESTAMP(3),
"lastHealthOk" BOOLEAN,
"lastError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AccountingConnection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PersonAccountingLink" (
"id" TEXT NOT NULL,
"personId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"moshtariCode" INTEGER NOT NULL,
"matchMethod" TEXT NOT NULL,
"linkedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastBalanceSyncedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PersonAccountingLink_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AccountingDocumentLog" (
"id" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"personId" TEXT,
"docType" TEXT NOT NULL,
"factorCode" TEXT NOT NULL,
"requestPayload" JSONB NOT NULL,
"responsePayload" JSONB,
"status" TEXT NOT NULL DEFAULT 'pending',
"tahesabOk" TEXT,
"tahesabFactorId" INTEGER,
"errorMessage" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AccountingDocumentLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AccountingBalanceCache" (
"id" TEXT NOT NULL,
"linkId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"goldBalance" DOUBLE PRECISION,
"rialBalance" DOUBLE PRECISION,
"currencyBalance" DOUBLE PRECISION,
"totalGoldEquiv" DOUBLE PRECISION,
"mandehDetail" JSONB NOT NULL DEFAULT '[]',
"rawResponse" JSONB,
"fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AccountingBalanceCache_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AccountingSyncJob" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"connectionId" TEXT,
"kind" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"request" JSONB NOT NULL DEFAULT '{}',
"result" JSONB,
"errorMessage" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AccountingSyncJob_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AccountingConnection_organizationId_enabled_idx" ON "AccountingConnection"("organizationId", "enabled");
-- CreateIndex
CREATE INDEX "AccountingConnection_organizationId_isDefault_idx" ON "AccountingConnection"("organizationId", "isDefault");
-- CreateIndex
CREATE UNIQUE INDEX "AccountingConnection_organizationId_baseUrl_dbName_key" ON "AccountingConnection"("organizationId", "baseUrl", "dbName");
-- CreateIndex
CREATE INDEX "PersonAccountingLink_personId_idx" ON "PersonAccountingLink"("personId");
-- CreateIndex
CREATE INDEX "PersonAccountingLink_connectionId_idx" ON "PersonAccountingLink"("connectionId");
-- CreateIndex
CREATE UNIQUE INDEX "PersonAccountingLink_personId_connectionId_key" ON "PersonAccountingLink"("personId", "connectionId");
-- CreateIndex
CREATE UNIQUE INDEX "PersonAccountingLink_connectionId_moshtariCode_key" ON "PersonAccountingLink"("connectionId", "moshtariCode");
-- CreateIndex
CREATE INDEX "AccountingDocumentLog_connectionId_status_idx" ON "AccountingDocumentLog"("connectionId", "status");
-- CreateIndex
CREATE INDEX "AccountingDocumentLog_personId_idx" ON "AccountingDocumentLog"("personId");
-- CreateIndex
CREATE INDEX "AccountingDocumentLog_docType_idx" ON "AccountingDocumentLog"("docType");
-- CreateIndex
CREATE UNIQUE INDEX "AccountingDocumentLog_connectionId_factorCode_key" ON "AccountingDocumentLog"("connectionId", "factorCode");
-- CreateIndex
CREATE UNIQUE INDEX "AccountingBalanceCache_linkId_key" ON "AccountingBalanceCache"("linkId");
-- CreateIndex
CREATE INDEX "AccountingBalanceCache_connectionId_idx" ON "AccountingBalanceCache"("connectionId");
-- CreateIndex
CREATE INDEX "AccountingSyncJob_organizationId_createdAt_idx" ON "AccountingSyncJob"("organizationId", "createdAt");
-- AddForeignKey
ALTER TABLE "AccountingConnection" ADD CONSTRAINT "AccountingConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PersonAccountingLink" ADD CONSTRAINT "PersonAccountingLink_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PersonAccountingLink" ADD CONSTRAINT "PersonAccountingLink_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "AccountingConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountingDocumentLog" ADD CONSTRAINT "AccountingDocumentLog_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "AccountingConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountingDocumentLog" ADD CONSTRAINT "AccountingDocumentLog_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountingBalanceCache" ADD CONSTRAINT "AccountingBalanceCache_linkId_fkey" FOREIGN KEY ("linkId") REFERENCES "PersonAccountingLink"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountingBalanceCache" ADD CONSTRAINT "AccountingBalanceCache_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "AccountingConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountingSyncJob" ADD CONSTRAINT "AccountingSyncJob_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+129 -6
View File
@@ -162,9 +162,11 @@ model Organization {
projectBoards ProjectBoard[]
projectCards ProjectCard[]
loyaltyTiers LoyaltyTier[]
automationRules AutomationRule[]
memoryItems MemoryItem[]
noteAiSuggestions NoteAiSuggestion[]
automationRules AutomationRule[]
memoryItems MemoryItem[]
noteAiSuggestions NoteAiSuggestion[]
accountingConnections AccountingConnection[]
accountingSyncJobs AccountingSyncJob[]
}
model User {
@@ -257,9 +259,11 @@ model Person {
owner User? @relation("PersonOwner", fields: [ownerId], references: [id], onDelete: SetNull)
tickets Ticket[]
projectCards ProjectCard[]
memoryItems MemoryItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memoryItems MemoryItem[]
accountingLinks PersonAccountingLink[]
accountingDocuments AccountingDocumentLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
@@index([organizationId, email])
@@ -929,3 +933,122 @@ model AutomationRule {
@@index([organizationId, isActive, trigger])
}
/// اتصال به نرم‌افزار حسابداری خارجی (ته‌حساب) — چند اتصال/DB به‌ازای هر سازمان
model AccountingConnection {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
/// فعلاً فقط tahesab
provider String @default("tahesab")
label String
baseUrl String
dbName String
/// AES-256-GCM ciphertext (base64)
tokenCiphertext String
tokenIv String
tokenTag String
/// چهار کاراکتر آخر توکن برای نمایش ماسک‌شده
tokenLast4 String @default("")
enabled Boolean @default(true)
isDefault Boolean @default(false)
/// اگر true باشد verify SSL نادیده گرفته می‌شود (فقط برای نصب‌های داخلی با گواهی خودامضا)
allowInsecureTls Boolean @default(false)
lastHealthAt DateTime?
lastHealthOk Boolean?
lastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
personLinks PersonAccountingLink[]
documentLogs AccountingDocumentLog[]
balanceCaches AccountingBalanceCache[]
@@unique([organizationId, baseUrl, dbName])
@@index([organizationId, enabled])
@@index([organizationId, isDefault])
}
model PersonAccountingLink {
id String @id @default(cuid())
personId String
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
connectionId String
connection AccountingConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
/// کد مشتری در ته‌حساب (Moshtari_Code)
moshtariCode Int
/// manual | mobile | name | created
matchMethod String
linkedAt DateTime @default(now())
lastBalanceSyncedAt DateTime?
balanceCache AccountingBalanceCache?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([personId, connectionId])
@@unique([connectionId, moshtariCode])
@@index([personId])
@@index([connectionId])
}
model AccountingDocumentLog {
id String @id @default(cuid())
connectionId String
connection AccountingConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
/// مثلاً DoNewSanadBuySaleGOLD
docType String
/// کد یکتای ≥۲۰ کاراکتر — idempotency
factorCode String
requestPayload Json
responsePayload Json?
/// pending | success | failed
status String @default("pending")
tahesabOk String?
tahesabFactorId Int?
errorMessage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([connectionId, factorCode])
@@index([connectionId, status])
@@index([personId])
@@index([docType])
}
model AccountingBalanceCache {
id String @id @default(cuid())
linkId String @unique
link PersonAccountingLink @relation(fields: [linkId], references: [id], onDelete: Cascade)
connectionId String
connection AccountingConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
goldBalance Float?
rialBalance Float?
currencyBalance Float?
/// مانده هم‌ارز طلا (MandeyeKolBeTALA) در صورت وجود
totalGoldEquiv Float?
/// جزئیات گروه‌بندی‌شده برای UI
mandehDetail Json @default("[]")
rawResponse Json?
fetchedAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([connectionId])
}
model AccountingSyncJob {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
connectionId String?
/// search | import | match
kind String
status String @default("pending")
request Json @default("{}")
result Json?
errorMessage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, createdAt])
}