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])
}
+2
View File
@@ -26,6 +26,7 @@ import { LoyaltyModule } from './loyalty/loyalty.module';
import { AutomationsModule } from './automations/automations.module';
import { AiModule } from './ai/ai.module';
import { MemoryModule } from './memory/memory.module';
import { TahesabModule } from './integrations/tahesab/tahesab.module';
@Module({
imports: [
@@ -59,6 +60,7 @@ import { MemoryModule } from './memory/memory.module';
LoyaltyModule,
AutomationsModule,
MemoryModule,
TahesabModule,
],
})
export class AppModule {}
@@ -0,0 +1,128 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
import {
assertFactorCode,
buildFactorCode,
FACTOR_CODE_MIN_LEN,
normalizeIranMobile,
normalizeMandehResponse,
SanadBuilders,
} from '../tahesab-methods';
function assert(cond: unknown, msg: string): asserts cond {
if (!cond) throw new Error(msg);
}
function testCryptoRoundtrip() {
const key = randomBytes(32);
const plain = 'test-tahesab-token-secret-value';
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
const out = Buffer.concat([
decipher.update(enc),
decipher.final(),
]).toString('utf8');
assert(out === plain, 'crypto roundtrip failed');
}
function testFactorCode() {
const code = buildFactorCode('crm');
assert(code.length >= FACTOR_CODE_MIN_LEN, 'factor code too short');
assertFactorCode(code);
let threw = false;
try {
assertFactorCode('short');
} catch {
threw = true;
}
assert(threw, 'short factor code should throw');
}
function testMobile() {
assert(normalizeIranMobile('09121234567') === '9121234567', 'mobile 09');
assert(normalizeIranMobile('989121234567') === '9121234567', 'mobile 98');
assert(normalizeIranMobile('9121234567') === '9121234567', 'mobile bare');
}
function testMandehNormalize() {
const raw = {
MandeHesab: [
{
MandeyeVazni: 12.5,
MandeyeMali: -1000,
MandeyeKolBePool: 0,
MandeyeKolBeTALA: 12.5,
details: [
{
Name: 'طلا',
Value1: 12.5,
Value2: null,
Value3: null,
Value4: null,
},
{
Name: 'ریال',
Value1: null,
Value2: null,
Value3: null,
Value4: -1000,
},
],
},
],
};
const n = normalizeMandehResponse(raw);
assert(n.goldBalance === 12.5, 'gold');
assert(n.rialBalance === -1000, 'rial');
assert(n.mandehDetail.length === 2, 'detail count');
assert(n.mandehDetail[1]!.values[0]!.status === 'بس', 'debtor status');
}
function testSanadBuilders() {
const fc = buildFactorCode('crm');
const gold = SanadBuilders.buySaleGold({
moshtariCode: 1,
year: 1405,
month: 1,
day: 1,
vazn: 1,
ayar: 750,
buyOrSale: 0,
mazaneh: 100,
mazanehIsGeram: 0,
isAbshode: 1,
mablaghKol: 1000,
sharh: 't',
factorCode: fc,
});
assert(gold.length >= 20, 'gold args length');
assert(gold[17] === fc, 'factor at index 17');
const bank = SanadBuilders.vkhBank({
moshtariCode: 1,
year: 1405,
month: 1,
day: 1,
isDaryaftOrPardakht: 1,
mablagh: 5000,
sharh: 't',
nameBank: 'سامان',
codeRahgiri: '1',
factorCode: fc,
});
assert(bank[12] === fc, 'bank factor index');
}
function main() {
testCryptoRoundtrip();
testFactorCode();
testMobile();
testMandehNormalize();
testSanadBuilders();
console.log('tahesab unit checks: OK');
}
main();
@@ -0,0 +1,132 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TahesabConnectionsService } from './connections.service';
import { normalizeMandehResponse } from './tahesab-methods';
@Injectable()
export class TahesabBalanceService {
constructor(
private readonly prisma: PrismaService,
private readonly connections: TahesabConnectionsService,
) {}
async getAssets(
organizationId: string,
personId: string,
connectionId?: string,
) {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
});
if (!person) throw new NotFoundException('شخص یافت نشد.');
const resolvedId = await this.connections.resolveConnectionId(
organizationId,
connectionId,
);
const link = await this.prisma.personAccountingLink.findFirst({
where: { personId, connectionId: resolvedId },
include: {
connection: {
select: { id: true, label: true, dbName: true, isDefault: true },
},
balanceCache: true,
},
});
if (!link) {
throw new BadRequestException(
'این شخص به اتصال ته‌حساب لینک نشده است. ابتدا تطبیق یا ثبت در ته‌حساب را انجام دهید.',
);
}
const { api } = await this.connections.getApi(organizationId, resolvedId);
const raw = await api.getMandeHesabByCode(link.moshtariCode);
const normalized = normalizeMandehResponse(raw);
const cache = await this.prisma.accountingBalanceCache.upsert({
where: { linkId: link.id },
create: {
linkId: link.id,
connectionId: resolvedId,
goldBalance: normalized.goldBalance,
rialBalance: normalized.rialBalance,
currencyBalance: normalized.currencyBalance,
totalGoldEquiv: normalized.totalGoldEquiv,
mandehDetail: normalized.mandehDetail as unknown as Prisma.InputJsonValue,
rawResponse: normalized.raw as Prisma.InputJsonValue,
fetchedAt: new Date(),
},
update: {
goldBalance: normalized.goldBalance,
rialBalance: normalized.rialBalance,
currencyBalance: normalized.currencyBalance,
totalGoldEquiv: normalized.totalGoldEquiv,
mandehDetail: normalized.mandehDetail as unknown as Prisma.InputJsonValue,
rawResponse: normalized.raw as Prisma.InputJsonValue,
fetchedAt: new Date(),
},
});
await this.prisma.personAccountingLink.update({
where: { id: link.id },
data: { lastBalanceSyncedAt: cache.fetchedAt },
});
return {
connectionId: resolvedId,
connectionLabel: link.connection.label,
dbName: link.connection.dbName,
moshtariCode: link.moshtariCode,
goldBalance: normalized.goldBalance,
rialBalance: normalized.rialBalance,
currencyBalance: normalized.currencyBalance,
totalGoldEquiv: normalized.totalGoldEquiv,
mandehDetail: normalized.mandehDetail,
fetchedAt: cache.fetchedAt.toISOString(),
};
}
async getCachedOrFetch(
organizationId: string,
personId: string,
connectionId?: string,
refresh = true,
) {
if (refresh) {
return this.getAssets(organizationId, personId, connectionId);
}
const resolvedId = await this.connections.resolveConnectionId(
organizationId,
connectionId,
);
const link = await this.prisma.personAccountingLink.findFirst({
where: { personId, connectionId: resolvedId },
include: {
connection: { select: { label: true, dbName: true } },
balanceCache: true,
},
});
if (!link?.balanceCache) {
return this.getAssets(organizationId, personId, connectionId);
}
const c = link.balanceCache;
return {
connectionId: resolvedId,
connectionLabel: link.connection.label,
dbName: link.connection.dbName,
moshtariCode: link.moshtariCode,
goldBalance: c.goldBalance,
rialBalance: c.rialBalance,
currencyBalance: c.currencyBalance,
totalGoldEquiv: c.totalGoldEquiv,
mandehDetail: c.mandehDetail,
fetchedAt: c.fetchedAt.toISOString(),
};
}
}
@@ -0,0 +1,282 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { TahesabCryptoService } from './tahesab-crypto.service';
import { TahesabHttpClient } from './tahesab-http.client';
import { TahesabApi } from './tahesab-methods';
import {
CreateTahesabConnectionDto,
UpdateTahesabConnectionDto,
} from './dto';
function normalizeBaseUrl(url: string): string {
let u = url.trim().replace(/\/+$/, '');
if (!/^https?:\/\//i.test(u)) {
throw new BadRequestException('baseUrl باید با http:// یا https:// شروع شود.');
}
return u;
}
@Injectable()
export class TahesabConnectionsService {
constructor(
private readonly prisma: PrismaService,
private readonly crypto: TahesabCryptoService,
private readonly http: TahesabHttpClient,
) {}
private toDto(row: {
id: string;
organizationId: string;
provider: string;
label: string;
baseUrl: string;
dbName: string;
tokenLast4: string;
enabled: boolean;
isDefault: boolean;
allowInsecureTls: boolean;
lastHealthAt: Date | null;
lastHealthOk: boolean | null;
lastError: string | null;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: row.id,
organizationId: row.organizationId,
provider: row.provider,
label: row.label,
baseUrl: row.baseUrl,
dbName: row.dbName,
tokenMasked: row.tokenLast4 ? `••••••${row.tokenLast4}` : '••••',
enabled: row.enabled,
isDefault: row.isDefault,
allowInsecureTls: row.allowInsecureTls,
lastHealthAt: row.lastHealthAt?.toISOString() ?? null,
lastHealthOk: row.lastHealthOk,
lastError: row.lastError,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
async list(organizationId: string) {
const rows = await this.prisma.accountingConnection.findMany({
where: { organizationId, provider: 'tahesab' },
orderBy: [{ isDefault: 'desc' }, { createdAt: 'asc' }],
});
return rows.map((r) => this.toDto(r));
}
async getOrThrow(organizationId: string, id: string) {
const row = await this.prisma.accountingConnection.findFirst({
where: { id, organizationId, provider: 'tahesab' },
});
if (!row) throw new NotFoundException('اتصال ته‌حساب یافت نشد.');
return row;
}
async getApi(organizationId: string, connectionId: string) {
const row = await this.getOrThrow(organizationId, connectionId);
if (!row.enabled) {
throw new BadRequestException('این اتصال غیرفعال است.');
}
const token = this.crypto.decrypt({
ciphertext: row.tokenCiphertext,
iv: row.tokenIv,
tag: row.tokenTag,
});
return {
row,
api: new TahesabApi(this.http, {
baseUrl: row.baseUrl,
token,
dbName: row.dbName,
allowInsecureTls: row.allowInsecureTls,
}),
};
}
async resolveConnectionId(
organizationId: string,
connectionId?: string | null,
) {
if (connectionId) {
await this.getOrThrow(organizationId, connectionId);
return connectionId;
}
const def = await this.prisma.accountingConnection.findFirst({
where: { organizationId, provider: 'tahesab', enabled: true, isDefault: true },
});
if (def) return def.id;
const any = await this.prisma.accountingConnection.findFirst({
where: { organizationId, provider: 'tahesab', enabled: true },
orderBy: { createdAt: 'asc' },
});
if (!any) {
throw new BadRequestException(
'هیچ اتصال فعالی به ته‌حساب تعریف نشده است. از تنظیمات یک اتصال اضافه کنید.',
);
}
return any.id;
}
private async clearOtherDefaults(organizationId: string, exceptId?: string) {
await this.prisma.accountingConnection.updateMany({
where: {
organizationId,
provider: 'tahesab',
...(exceptId ? { id: { not: exceptId } } : {}),
},
data: { isDefault: false },
});
}
async create(organizationId: string, dto: CreateTahesabConnectionDto) {
const baseUrl = normalizeBaseUrl(dto.baseUrl);
const existing = await this.prisma.accountingConnection.findFirst({
where: { organizationId, baseUrl, dbName: dto.dbName.trim() },
});
if (existing) {
throw new ConflictException(
'اتصالی با همین آدرس و نام دیتابیس از قبل وجود دارد.',
);
}
const count = await this.prisma.accountingConnection.count({
where: { organizationId, provider: 'tahesab' },
});
const makeDefault = dto.isDefault === true || count === 0;
if (makeDefault) await this.clearOtherDefaults(organizationId);
const enc = this.crypto.encrypt(dto.token.trim());
const row = await this.prisma.accountingConnection.create({
data: {
organizationId,
provider: 'tahesab',
label: dto.label.trim(),
baseUrl,
dbName: dto.dbName.trim(),
tokenCiphertext: enc.ciphertext,
tokenIv: enc.iv,
tokenTag: enc.tag,
tokenLast4: this.crypto.last4(dto.token),
enabled: dto.enabled ?? true,
isDefault: makeDefault,
allowInsecureTls: dto.allowInsecureTls ?? false,
},
});
return this.toDto(row);
}
async update(
organizationId: string,
id: string,
dto: UpdateTahesabConnectionDto,
) {
const row = await this.getOrThrow(organizationId, id);
const baseUrl = dto.baseUrl ? normalizeBaseUrl(dto.baseUrl) : row.baseUrl;
const dbName = dto.dbName?.trim() ?? row.dbName;
if (baseUrl !== row.baseUrl || dbName !== row.dbName) {
const clash = await this.prisma.accountingConnection.findFirst({
where: {
organizationId,
baseUrl,
dbName,
id: { not: id },
},
});
if (clash) {
throw new ConflictException(
'اتصالی با همین آدرس و نام دیتابیس از قبل وجود دارد.',
);
}
}
if (dto.isDefault === true) await this.clearOtherDefaults(organizationId, id);
let enc = {
ciphertext: row.tokenCiphertext,
iv: row.tokenIv,
tag: row.tokenTag,
};
let tokenLast4 = row.tokenLast4;
if (dto.token?.trim()) {
enc = this.crypto.encrypt(dto.token.trim());
tokenLast4 = this.crypto.last4(dto.token);
}
const updated = await this.prisma.accountingConnection.update({
where: { id },
data: {
label: dto.label?.trim() ?? row.label,
baseUrl,
dbName,
tokenCiphertext: enc.ciphertext,
tokenIv: enc.iv,
tokenTag: enc.tag,
tokenLast4,
enabled: dto.enabled ?? row.enabled,
isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault,
allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls,
},
});
return this.toDto(updated);
}
async remove(organizationId: string, id: string) {
const row = await this.getOrThrow(organizationId, id);
await this.prisma.accountingConnection.delete({ where: { id: row.id } });
if (row.isDefault) {
const next = await this.prisma.accountingConnection.findFirst({
where: { organizationId, provider: 'tahesab', enabled: true },
orderBy: { createdAt: 'asc' },
});
if (next) {
await this.prisma.accountingConnection.update({
where: { id: next.id },
data: { isDefault: true },
});
}
}
return { ok: true };
}
async test(organizationId: string, id: string) {
const { row, api } = await this.getApi(organizationId, id);
try {
let result: unknown;
try {
result = await api.checkHealthSsl();
} catch {
result = await api.checkHealthNoSsl();
}
const updated = await this.prisma.accountingConnection.update({
where: { id: row.id },
data: {
lastHealthAt: new Date(),
lastHealthOk: true,
lastError: null,
},
});
return { ok: true, connection: this.toDto(updated), health: result };
} catch (err) {
const message = (err as Error).message ?? 'خطای ناشناخته';
const updated = await this.prisma.accountingConnection.update({
where: { id: row.id },
data: {
lastHealthAt: new Date(),
lastHealthOk: false,
lastError: message.slice(0, 500),
},
});
return { ok: false, connection: this.toDto(updated), error: message };
}
}
}
@@ -0,0 +1,386 @@
import { Prisma } from '@prisma/client';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { TahesabConnectionsService } from './connections.service';
import { PushSanadDto } from './dto';
import {
buildFactorCode,
SanadBuilders,
} from './tahesab-methods';
function asAsnadList(raw: unknown): Array<Record<string, unknown>> {
if (!raw) return [];
if (Array.isArray(raw)) return raw as Array<Record<string, unknown>>;
if (typeof raw === 'object') {
return Object.values(raw as Record<string, unknown>).filter(
(v) => v && typeof v === 'object',
) as Array<Record<string, unknown>>;
}
return [];
}
@Injectable()
export class TahesabDocumentsService {
constructor(
private readonly prisma: PrismaService,
private readonly connections: TahesabConnectionsService,
) {}
private async requireLink(
organizationId: string,
personId: string,
connectionId: string,
) {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
});
if (!person) throw new NotFoundException('شخص یافت نشد.');
const link = await this.prisma.personAccountingLink.findFirst({
where: { personId, connectionId },
});
if (!link) {
throw new BadRequestException(
'شخص به این اتصال ته‌حساب لینک نشده است.',
);
}
return link;
}
async listAsnad(
organizationId: string,
personId: string,
from: string,
to: string,
connectionId?: string,
) {
if (!/^\d{4}-\d{1,2}-\d{1,2}$/.test(from) || !/^\d{4}-\d{1,2}-\d{1,2}$/.test(to)) {
throw new BadRequestException(
'تاریخ از/تا باید شمسی با قالب YYYY-MM-DD باشد.',
);
}
const resolvedId = await this.connections.resolveConnectionId(
organizationId,
connectionId,
);
const link = await this.requireLink(organizationId, personId, resolvedId);
const { api } = await this.connections.getApi(organizationId, resolvedId);
// Count_Last = -1 برای مانده ردیف‌به‌ردیف مثل همکار
const raw = await api.doListAsnad(-1, link.moshtariCode, from, to);
const items = asAsnadList(raw);
return {
connectionId: resolvedId,
moshtariCode: link.moshtariCode,
from,
to,
items,
count: items.length,
};
}
async listLookups(organizationId: string, connectionId?: string) {
const resolvedId = await this.connections.resolveConnectionId(
organizationId,
connectionId,
);
const { api } = await this.connections.getApi(organizationId, resolvedId);
const [sekeh, arz, banks] = await Promise.all([
api.doListNameSekeh().catch(() => []),
api.doListNameArz().catch(() => []),
api.doListHesabBanki().catch(() => []),
]);
return { connectionId: resolvedId, sekeh, arz, banks };
}
async pushSanad(
organizationId: string,
personId: string,
dto: PushSanadDto,
) {
const link = await this.requireLink(
organizationId,
personId,
dto.connectionId,
);
const factorCode = dto.factorCode?.trim() || buildFactorCode('crm');
const existing = await this.prisma.accountingDocumentLog.findUnique({
where: {
connectionId_factorCode: {
connectionId: dto.connectionId,
factorCode,
},
},
});
if (existing?.status === 'success') {
return {
idempotent: true,
log: this.logDto(existing),
response: existing.responsePayload,
};
}
const { api } = await this.connections.getApi(
organizationId,
dto.connectionId,
);
const code = link.moshtariCode;
const sharh = dto.sharh ?? '';
let docTypeApi = '';
let args: unknown[] = [];
switch (dto.docType) {
case 'buy_sale_gold': {
if (dto.vazn == null || dto.ayar == null || dto.mazaneh == null || dto.mablaghKol == null || dto.buyOrSale == null) {
throw new BadRequestException(
'برای خرید/فروش آبشده: vazn، ayar، mazaneh، mablaghKol، buyOrSale الزامی است.',
);
}
docTypeApi = 'DoNewSanadBuySaleGOLD';
args = SanadBuilders.buySaleGold({
moshtariCode: code,
year: dto.year,
month: dto.month,
day: dto.day,
vazn: dto.vazn,
ayar: dto.ayar,
buyOrSale: dto.buyOrSale,
mazaneh: dto.mazaneh,
mazanehIsGeram: dto.mazanehIsGeram ?? 0,
isAbshode: dto.isAbshode ?? 1,
mablaghKol: dto.mablaghKol,
sharh,
factorCode,
havalehBeMcode: dto.havalehBeMcode,
jensFelez: dto.jensFelez,
});
break;
}
case 'buy_sale_sekeh': {
if (
dto.vazn == null ||
dto.ayar == null ||
dto.count == null ||
!dto.nameSekeh ||
dto.buyOrSale == null ||
dto.mazaneh == null ||
dto.mablaghKol == null
) {
throw new BadRequestException(
'برای خرید/فروش سکه فیلدهای وزن، عیار، تعداد، نام سکه، خرید/فروش، مظنه و مبلغ کل الزامی است.',
);
}
docTypeApi = 'DoNewSanadBuySaleSEKEH';
args = SanadBuilders.buySaleSekeh({
moshtariCode: code,
year: dto.year,
month: dto.month,
day: dto.day,
vazn: dto.vazn,
ayar: dto.ayar,
count: dto.count,
nameSekeh: dto.nameSekeh,
buyOrSale: dto.buyOrSale,
mazaneh: dto.mazaneh,
mablaghKol: dto.mablaghKol,
sharh,
factorCode,
jensFelez: dto.jensFelez,
arzName: dto.arzName,
});
break;
}
case 'talab_bedehi': {
if (dto.isBedehiOrTalab == null || dto.mablagh == null) {
throw new BadRequestException(
'برای طلب/بدهی: isBedehiOrTalab و mablagh الزامی است.',
);
}
docTypeApi = 'DoNewSanadTalabBedehi';
args = SanadBuilders.talabBedehi({
moshtariCode: code,
year: dto.year,
month: dto.month,
day: dto.day,
isBedehiOrTalab: dto.isBedehiOrTalab,
mablagh: dto.mablagh,
sharh,
baabat: dto.baabat ?? 'CRM',
vazn: dto.vazn ?? 0,
factorCode,
havalehBeMcode: dto.havalehBeMcode,
jensFelez: dto.jensFelez,
arzName: dto.arzName,
});
break;
}
case 'vkh_gold': {
if (
dto.vazn == null ||
dto.ayar == null ||
dto.isVoroodOrKhorooj == null
) {
throw new BadRequestException(
'برای واریز/خروج طلا: vazn، ayar، isVoroodOrKhorooj الزامی است.',
);
}
docTypeApi = 'DoNewSanadVKHGOLD';
args = SanadBuilders.vkhGold({
moshtariCode: code,
year: dto.year,
month: dto.month,
day: dto.day,
vazn: dto.vazn,
ayar: dto.ayar,
angNumber: dto.angNumber ?? '0',
nameAz: dto.nameAz ?? '',
isVoroodOrKhorooj: dto.isVoroodOrKhorooj,
isAbshode: dto.isAbshode ?? 1,
sharh,
factorCode,
});
break;
}
case 'vkh_bank': {
if (
dto.isDaryaftOrPardakht == null ||
dto.mablagh == null ||
!dto.nameBank
) {
throw new BadRequestException(
'برای واریز/خروج بانکی: isDaryaftOrPardakht، mablagh و nameBank الزامی است.',
);
}
docTypeApi = 'DoNewSanadVKHBank';
args = SanadBuilders.vkhBank({
moshtariCode: code,
year: dto.year,
month: dto.month,
day: dto.day,
isDaryaftOrPardakht: dto.isDaryaftOrPardakht,
mablagh: dto.mablagh,
sharh,
nameBank: dto.nameBank,
codeRahgiri: dto.codeRahgiri ?? '',
factorCode,
});
break;
}
default:
throw new BadRequestException('نوع سند نامعتبر است.');
}
const requestPayload = { [docTypeApi]: args } as Prisma.InputJsonValue;
const log = existing
? await this.prisma.accountingDocumentLog.update({
where: { id: existing.id },
data: {
docType: docTypeApi,
requestPayload,
status: 'pending',
errorMessage: null,
personId,
},
})
: await this.prisma.accountingDocumentLog.create({
data: {
connectionId: dto.connectionId,
personId,
docType: docTypeApi,
factorCode,
requestPayload,
status: 'pending',
},
});
try {
let response: unknown;
switch (dto.docType) {
case 'buy_sale_gold':
response = await api.doNewSanadBuySaleGold(args);
break;
case 'buy_sale_sekeh':
response = await api.doNewSanadBuySaleSekeh(args);
break;
case 'talab_bedehi':
response = await api.doNewSanadTalabBedehi(args);
break;
case 'vkh_gold':
response = await api.doNewSanadVkhGold(args);
break;
case 'vkh_bank':
response = await api.doNewSanadVkhBank(args);
break;
}
const resObj = (response ?? {}) as Record<string, unknown>;
const okVal = resObj.OK ?? resObj.Factor_Code;
if (okVal === undefined || okVal === null || okVal === '') {
throw new BadRequestException(
'ته‌حساب پاسخ موفق معتبر برنگرداند: ' + JSON.stringify(response),
);
}
const updated = await this.prisma.accountingDocumentLog.update({
where: { id: log.id },
data: {
status: 'success',
responsePayload: response as Prisma.InputJsonValue,
tahesabOk: String(okVal),
tahesabFactorId:
typeof resObj.ID === 'number' ? resObj.ID : undefined,
errorMessage: null,
},
});
return {
idempotent: false,
log: this.logDto(updated),
response,
};
} catch (err) {
const message = (err as Error).message ?? 'خطای ناشناخته';
await this.prisma.accountingDocumentLog.update({
where: { id: log.id },
data: {
status: 'failed',
errorMessage: message.slice(0, 1000),
},
});
throw err;
}
}
private logDto(log: {
id: string;
connectionId: string;
personId: string | null;
docType: string;
factorCode: string;
status: string;
tahesabOk: string | null;
tahesabFactorId: number | null;
errorMessage: string | null;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: log.id,
connectionId: log.connectionId,
personId: log.personId,
docType: log.docType,
factorCode: log.factorCode,
status: log.status,
tahesabOk: log.tahesabOk,
tahesabFactorId: log.tahesabFactorId,
errorMessage: log.errorMessage,
createdAt: log.createdAt.toISOString(),
updatedAt: log.updatedAt.toISOString(),
};
}
}
+290
View File
@@ -0,0 +1,290 @@
import {
IsBoolean,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUrl,
MaxLength,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
export class CreateTahesabConnectionDto {
@IsString()
@MinLength(1)
@MaxLength(120)
label!: string;
@IsString()
@MinLength(8)
@MaxLength(500)
baseUrl!: string;
@IsString()
@MinLength(1)
@MaxLength(120)
dbName!: string;
@IsString()
@MinLength(8)
@MaxLength(2000)
token!: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@IsOptional()
@IsBoolean()
allowInsecureTls?: boolean;
}
export class UpdateTahesabConnectionDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(120)
label?: string;
@IsOptional()
@IsString()
@MinLength(8)
@MaxLength(500)
baseUrl?: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(120)
dbName?: string;
@IsOptional()
@IsString()
@MinLength(8)
@MaxLength(2000)
token?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@IsOptional()
@IsBoolean()
allowInsecureTls?: boolean;
}
export class ManualLinkDto {
@IsString()
connectionId!: string;
@IsInt()
@Min(1)
moshtariCode!: number;
}
export class MatchPersonDto {
@IsString()
connectionId!: string;
/** اگر true باشد و دقیقاً یک نتیجه باشد، لینک می‌شود */
@IsOptional()
@IsBoolean()
autoLinkIfUnique?: boolean;
}
export class CreateInTahesabDto {
@IsString()
connectionId!: string;
@IsOptional()
@IsString()
groupName?: string;
@IsOptional()
@IsInt()
jensFelez?: number;
}
export class ImportFromTahesabDto {
@IsString()
connectionId!: string;
@IsInt()
@Min(1)
moshtariCode!: number;
/** اگر داده شود به این شخص لینک می‌شود؛ وگرنه شخص جدید ساخته می‌شود */
@IsOptional()
@IsString()
personId?: string;
}
export class SearchTahesabDto {
@IsString()
connectionId!: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsInt()
moshtariCode?: number;
}
export class ListAsnadQueryDto {
@IsOptional()
@IsString()
connectionId?: string;
/** تاریخ شمسی YYYY-MM-DD */
@IsString()
from!: string;
@IsString()
to!: string;
}
const SANAD_TYPES = [
'buy_sale_gold',
'buy_sale_sekeh',
'talab_bedehi',
'vkh_gold',
'vkh_bank',
] as const;
export class PushSanadDto {
@IsString()
connectionId!: string;
@IsIn(SANAD_TYPES)
docType!: (typeof SANAD_TYPES)[number];
@IsInt()
year!: number;
@IsInt()
@Min(1)
month!: number;
@IsInt()
@Min(1)
day!: number;
@IsOptional()
@IsString()
@MaxLength(500)
sharh?: string;
// gold / sekeh / vkh gold
@IsOptional()
@IsNumber()
vazn?: number;
@IsOptional()
@IsNumber()
ayar?: number;
@IsOptional()
@IsIn([0, 1])
buyOrSale?: 0 | 1;
@IsOptional()
@IsNumber()
mazaneh?: number;
@IsOptional()
@IsIn([0, 1])
mazanehIsGeram?: 0 | 1;
@IsOptional()
@IsIn([0, 1])
isAbshode?: 0 | 1;
@IsOptional()
@IsNumber()
mablaghKol?: number;
@IsOptional()
@IsNumber()
count?: number;
@IsOptional()
@IsString()
nameSekeh?: string;
@IsOptional()
@IsIn([0, 1])
isBedehiOrTalab?: 0 | 1;
@IsOptional()
@IsNumber()
mablagh?: number;
@IsOptional()
@IsString()
baabat?: string;
@IsOptional()
@IsInt()
havalehBeMcode?: number;
@IsOptional()
@IsString()
angNumber?: string;
@IsOptional()
@IsString()
nameAz?: string;
@IsOptional()
@IsIn([0, 1])
isVoroodOrKhorooj?: 0 | 1;
@IsOptional()
@IsIn([0, 1])
isDaryaftOrPardakht?: 0 | 1;
@IsOptional()
@IsString()
nameBank?: string;
@IsOptional()
@IsString()
codeRahgiri?: string;
@IsOptional()
@IsInt()
jensFelez?: number;
@IsOptional()
@IsString()
arzName?: string;
/** اگر ارسال شود و قبلاً success بوده، همان پاسخ برمی‌گردد (retry امن) */
@IsOptional()
@IsString()
@MinLength(20)
factorCode?: string;
}
// silence unused import warnings for ValidateNested/Type if unused
void ValidateNested;
void Type;
void IsUrl;
@@ -0,0 +1,438 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TahesabConnectionsService } from './connections.service';
import {
CreateInTahesabDto,
ImportFromTahesabDto,
ManualLinkDto,
MatchPersonDto,
SearchTahesabDto,
} from './dto';
import { normalizeIranMobile, SanadBuilders } from './tahesab-methods';
function asMoshtariList(raw: unknown): Array<Record<string, unknown>> {
if (!raw) return [];
if (Array.isArray(raw)) return raw as Array<Record<string, unknown>>;
if (typeof raw === 'object') {
const obj = raw as Record<string, unknown>;
// پاسخ گاهی دیکشنری با کلید عددی است
const vals = Object.values(obj);
if (vals.every((v) => v && typeof v === 'object')) {
return vals as Array<Record<string, unknown>>;
}
}
return [];
}
function pickCode(row: Record<string, unknown>): number | null {
const c =
row.Moshtari_Code ??
row.Code ??
row.MCode ??
row.code ??
row.moshtari_code;
const n = Number(c);
return Number.isFinite(n) ? n : null;
}
function pickName(row: Record<string, unknown>): string {
return String(
row.Moshtari_Name ?? row.Name ?? row.name ?? row.FullName ?? '',
);
}
function pickTel(row: Record<string, unknown>): string {
return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? '');
}
@Injectable()
export class TahesabPersonLinkService {
constructor(
private readonly prisma: PrismaService,
private readonly connections: TahesabConnectionsService,
) {}
private async getPerson(organizationId: string, personId: string) {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
});
if (!person) throw new NotFoundException('شخص یافت نشد.');
return person;
}
private linkDto(link: {
id: string;
personId: string;
connectionId: string;
moshtariCode: number;
matchMethod: string;
linkedAt: Date;
lastBalanceSyncedAt: Date | null;
connection?: { label: string; dbName: string; isDefault: boolean };
}) {
return {
id: link.id,
personId: link.personId,
connectionId: link.connectionId,
moshtariCode: link.moshtariCode,
matchMethod: link.matchMethod,
linkedAt: link.linkedAt.toISOString(),
lastBalanceSyncedAt: link.lastBalanceSyncedAt?.toISOString() ?? null,
connectionLabel: link.connection?.label,
dbName: link.connection?.dbName,
isDefaultConnection: link.connection?.isDefault,
};
}
async listLinks(organizationId: string, personId: string) {
await this.getPerson(organizationId, personId);
const links = await this.prisma.personAccountingLink.findMany({
where: {
personId,
connection: { organizationId, provider: 'tahesab' },
},
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
orderBy: { linkedAt: 'desc' },
});
return links.map((l) => this.linkDto(l));
}
async manualLink(
organizationId: string,
personId: string,
dto: ManualLinkDto,
) {
await this.getPerson(organizationId, personId);
await this.connections.getOrThrow(organizationId, dto.connectionId);
const clash = await this.prisma.personAccountingLink.findFirst({
where: {
connectionId: dto.connectionId,
moshtariCode: dto.moshtariCode,
personId: { not: personId },
},
});
if (clash) {
throw new ConflictException(
'این کد مشتری ته‌حساب قبلاً به شخص دیگری لینک شده است.',
);
}
const link = await this.prisma.personAccountingLink.upsert({
where: {
personId_connectionId: {
personId,
connectionId: dto.connectionId,
},
},
create: {
personId,
connectionId: dto.connectionId,
moshtariCode: dto.moshtariCode,
matchMethod: 'manual',
},
update: {
moshtariCode: dto.moshtariCode,
matchMethod: 'manual',
linkedAt: new Date(),
},
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
});
return this.linkDto(link);
}
async unlink(
organizationId: string,
personId: string,
connectionId: string,
) {
await this.getPerson(organizationId, personId);
const link = await this.prisma.personAccountingLink.findFirst({
where: {
personId,
connectionId,
connection: { organizationId },
},
});
if (!link) throw new NotFoundException('لینک یافت نشد.');
await this.prisma.personAccountingLink.delete({ where: { id: link.id } });
return { ok: true };
}
async match(
organizationId: string,
personId: string,
dto: MatchPersonDto,
) {
const person = await this.getPerson(organizationId, personId);
const { api } = await this.connections.getApi(
organizationId,
dto.connectionId,
);
const candidates: Array<{
moshtariCode: number;
name: string;
tel: string;
matchBy: 'mobile' | 'name';
raw: Record<string, unknown>;
}> = [];
const mobile = normalizeIranMobile(person.mobile ?? person.phone);
if (mobile) {
const raw = await api.doListMoshtariByPhone(mobile);
for (const row of asMoshtariList(raw)) {
const code = pickCode(row);
if (code == null) continue;
candidates.push({
moshtariCode: code,
name: pickName(row),
tel: pickTel(row),
matchBy: 'mobile',
raw: row,
});
}
}
if (candidates.length === 0) {
const name = `${person.firstName} ${person.lastName}`.trim();
if (name) {
const raw = await api.doListMoshtariByName(name);
for (const row of asMoshtariList(raw)) {
const code = pickCode(row);
if (code == null) continue;
candidates.push({
moshtariCode: code,
name: pickName(row),
tel: pickTel(row),
matchBy: 'name',
raw: row,
});
}
}
}
// unique by code
const uniq = new Map<number, (typeof candidates)[0]>();
for (const c of candidates) uniq.set(c.moshtariCode, c);
const list = [...uniq.values()];
if (dto.autoLinkIfUnique && list.length === 1) {
const only = list[0]!;
const link = await this.manualLink(organizationId, personId, {
connectionId: dto.connectionId,
moshtariCode: only.moshtariCode,
});
// override match method
const updated = await this.prisma.personAccountingLink.update({
where: { id: link.id },
data: { matchMethod: only.matchBy },
include: {
connection: {
select: { label: true, dbName: true, isDefault: true },
},
},
});
return {
status: 'linked' as const,
candidates: list,
link: this.linkDto(updated),
};
}
if (list.length === 0) {
return { status: 'none' as const, candidates: list, link: null };
}
if (list.length === 1) {
return { status: 'unique' as const, candidates: list, link: null };
}
return { status: 'ambiguous' as const, candidates: list, link: null };
}
async createInTahesab(
organizationId: string,
personId: string,
dto: CreateInTahesabDto,
) {
const person = await this.getPerson(organizationId, personId);
const existing = await this.prisma.personAccountingLink.findFirst({
where: { personId, connectionId: dto.connectionId },
});
if (existing) {
throw new ConflictException(
'این شخص از قبل به این اتصال لینک شده است.',
);
}
const { api } = await this.connections.getApi(
organizationId,
dto.connectionId,
);
const tel =
normalizeIranMobile(person.mobile ?? person.phone) ??
person.mobile ??
person.phone ??
'';
const args = SanadBuilders.newMoshtari({
name: `${person.firstName} ${person.lastName}`.trim(),
groupName: dto.groupName ?? 'CRM',
tel,
jensFelez: dto.jensFelez ?? 0,
});
const res = await api.doNewMoshtari(args);
const code = Number(res?.OK);
if (!Number.isFinite(code) || code <= 0) {
throw new BadRequestException(
'ته‌حساب کد مشتری معتبر برنگرداند. پاسخ: ' + JSON.stringify(res),
);
}
const link = await this.prisma.personAccountingLink.create({
data: {
personId,
connectionId: dto.connectionId,
moshtariCode: code,
matchMethod: 'created',
},
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
});
return this.linkDto(link);
}
async search(organizationId: string, dto: SearchTahesabDto) {
const { api } = await this.connections.getApi(
organizationId,
dto.connectionId,
);
let raw: unknown = [];
if (dto.moshtariCode) {
raw = await api.doListMoshtariByCode(dto.moshtariCode, dto.moshtariCode);
} else if (dto.phone) {
const m = normalizeIranMobile(dto.phone) ?? dto.phone;
raw = await api.doListMoshtariByPhone(m);
} else if (dto.name?.trim()) {
raw = await api.doListMoshtariByName(dto.name.trim());
} else {
throw new BadRequestException('یکی از phone، name یا moshtariCode لازم است.');
}
const results = asMoshtariList(raw).map((row) => ({
moshtariCode: pickCode(row),
name: pickName(row),
tel: pickTel(row),
raw: row,
}));
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'search',
status: 'success',
request: dto as unknown as Prisma.InputJsonValue,
result: { count: results.length } as Prisma.InputJsonValue,
},
});
return { results };
}
async importFromTahesab(
organizationId: string,
dto: ImportFromTahesabDto,
) {
const clash = await this.prisma.personAccountingLink.findFirst({
where: {
connectionId: dto.connectionId,
moshtariCode: dto.moshtariCode,
},
include: { person: true },
});
if (clash && (!dto.personId || clash.personId !== dto.personId)) {
throw new ConflictException(
`این کد ته‌حساب قبلاً به «${clash.person.firstName} ${clash.person.lastName}» لینک شده است.`,
);
}
const { api } = await this.connections.getApi(
organizationId,
dto.connectionId,
);
const raw = await api.doListMoshtariByCode(
dto.moshtariCode,
dto.moshtariCode,
);
const rows = asMoshtariList(raw);
const row = rows[0] ?? {};
const name = pickName(row) || `مشتری ته‌حساب ${dto.moshtariCode}`;
const tel = pickTel(row);
const parts = name.trim().split(/\s+/);
const firstName = parts[0] || name;
const lastName = parts.slice(1).join(' ') || '—';
let personId = dto.personId;
if (personId) {
await this.getPerson(organizationId, personId);
} else {
const created = await this.prisma.person.create({
data: {
organizationId,
firstName,
lastName,
mobile: tel || null,
phone: tel || null,
notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`,
},
});
personId = created.id;
}
const link = await this.prisma.personAccountingLink.upsert({
where: {
personId_connectionId: {
personId,
connectionId: dto.connectionId,
},
},
create: {
personId,
connectionId: dto.connectionId,
moshtariCode: dto.moshtariCode,
matchMethod: 'manual',
},
update: {
moshtariCode: dto.moshtariCode,
matchMethod: 'manual',
linkedAt: new Date(),
},
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
});
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'import',
status: 'success',
request: dto as unknown as Prisma.InputJsonValue,
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
},
});
return { personId, link: this.linkDto(link) };
}
}
@@ -0,0 +1,68 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export type EncryptedSecret = {
ciphertext: string;
iv: string;
tag: string;
};
@Injectable()
export class TahesabCryptoService {
constructor(private readonly config: ConfigService) {}
private getKey(): Buffer {
const raw = this.config.get<string>('INTEGRATIONS_ENCRYPTION_KEY')?.trim();
if (!raw) {
throw new ServiceUnavailableException(
'INTEGRATIONS_ENCRYPTION_KEY تنظیم نشده است. بدون این کلید ذخیره توکن ته‌حساب ممکن نیست.',
);
}
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
return Buffer.from(raw, 'hex');
}
const b64 = Buffer.from(raw, 'base64');
if (b64.length === 32) return b64;
throw new ServiceUnavailableException(
'INTEGRATIONS_ENCRYPTION_KEY باید ۳۲ بایت باشد (۶۴ کاراکتر hex یا base64 معادل).',
);
}
encrypt(plain: string): EncryptedSecret {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', this.getKey(), iv);
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return {
ciphertext: enc.toString('base64'),
iv: iv.toString('base64'),
tag: tag.toString('base64'),
};
}
decrypt(secret: EncryptedSecret): string {
const decipher = createDecipheriv(
'aes-256-gcm',
this.getKey(),
Buffer.from(secret.iv, 'base64'),
);
decipher.setAuthTag(Buffer.from(secret.tag, 'base64'));
const dec = Buffer.concat([
decipher.update(Buffer.from(secret.ciphertext, 'base64')),
decipher.final(),
]);
return dec.toString('utf8');
}
maskToken(token: string): string {
const t = token.trim();
if (t.length <= 4) return '••••';
return `••••••${t.slice(-4)}`;
}
last4(token: string): string {
const t = token.trim();
return t.length <= 4 ? t : t.slice(-4);
}
}
@@ -0,0 +1,122 @@
import * as http from 'http';
import * as https from 'https';
import { URL } from 'url';
import {
BadGatewayException,
Injectable,
Logger,
RequestTimeoutException,
} from '@nestjs/common';
export type TahesabCallOptions = {
baseUrl: string;
token: string;
dbName: string;
/** مسیر OpenAPI مثل DoNewMoshtari یا CheckHealth-SSL */
path: string;
/** کلید body؛ اگر خالی باشد فقط GET بدون body */
bodyKey?: string;
args?: unknown[];
method?: 'GET' | 'POST';
allowInsecureTls?: boolean;
timeoutMs?: number;
};
@Injectable()
export class TahesabHttpClient {
private readonly logger = new Logger(TahesabHttpClient.name);
async call<T = unknown>(opts: TahesabCallOptions): Promise<T> {
const base = opts.baseUrl.replace(/\/+$/, '');
const path = opts.path.replace(/^\/+/, '');
const url = new URL(`${base}/${path}`);
const method = opts.method ?? (opts.bodyKey ? 'POST' : 'GET');
const timeoutMs = opts.timeoutMs ?? 30_000;
const headers: Record<string, string> = {
Authorization: `Bearer ${opts.token}`,
DBname: opts.dbName,
DBName: opts.dbName,
Accept: 'application/json',
Host: url.host,
};
let body: string | undefined;
if (method === 'POST' && opts.bodyKey) {
headers['Content-Type'] = 'application/json';
body = JSON.stringify({ [opts.bodyKey]: opts.args ?? [] });
headers['Content-Length'] = Buffer.byteLength(body).toString();
}
const isHttps = url.protocol === 'https:';
const lib = isHttps ? https : http;
const agent =
isHttps && opts.allowInsecureTls
? new https.Agent({ rejectUnauthorized: false })
: undefined;
return new Promise<T>((resolve, reject) => {
const req = lib.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: `${url.pathname}${url.search}`,
method,
headers,
timeout: timeoutMs,
agent,
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
let json: unknown = null;
if (text) {
try {
json = JSON.parse(text);
} catch {
json = { raw: text };
}
}
const status = res.statusCode ?? 0;
if (status < 200 || status >= 300) {
this.logger.warn(
`Tahesab ${method} ${path} → ${status}: ${text.slice(0, 400)}`,
);
reject(
new BadGatewayException(
`پاسخ نامعتبر ته‌حساب (${status}) برای ${path}`,
),
);
return;
}
resolve(json as T);
});
},
);
req.on('timeout', () => {
req.destroy();
reject(
new RequestTimeoutException(
`مهلت اتصال به ته‌حساب برای ${path} به پایان رسید.`,
),
);
});
req.on('error', (err) => {
this.logger.error(`Tahesab call failed: ${path}`, err);
reject(
new BadGatewayException(
`اتصال به ته‌حساب ناموفق بود: ${err.message}`,
),
);
});
if (body) req.write(body);
req.end();
});
}
}
@@ -0,0 +1,480 @@
import { randomBytes } from 'crypto';
import type { TahesabHttpClient } from './tahesab-http.client';
export type ConnCreds = {
baseUrl: string;
token: string;
dbName: string;
allowInsecureTls?: boolean;
};
/** حداقل طول Factor_Code طبق مستندات ته‌حساب */
export const FACTOR_CODE_MIN_LEN = 20;
export function buildFactorCode(prefix = 'crm'): string {
const stamp = Date.now().toString(36);
const rand = randomBytes(12).toString('hex');
const code = `${prefix}${stamp}${rand}`;
return code.length >= FACTOR_CODE_MIN_LEN
? code
: code.padEnd(FACTOR_CODE_MIN_LEN, '0');
}
export function assertFactorCode(code: string): void {
if (!code || code.trim().length < FACTOR_CODE_MIN_LEN) {
throw new Error(
`Factor_Code باید حداقل ${FACTOR_CODE_MIN_LEN} کاراکتر باشد.`,
);
}
}
/** نرمال‌سازی موبایل ایران برای جستجو در ته‌حساب */
export function normalizeIranMobile(input: string | null | undefined): string | null {
if (!input) return null;
let d = input.replace(/[^\d]/g, '');
if (d.startsWith('98') && d.length >= 12) d = d.slice(2);
if (d.startsWith('0') && d.length >= 11) d = d.slice(1);
if (d.length < 10) return null;
return d;
}
export type MandehValue = {
amount: string | number;
unit: string;
status: 'بس' | 'بد';
};
export type MandehDetailItem = {
Name: string;
values: MandehValue[];
};
export type NormalizedAssets = {
goldBalance: number | null;
rialBalance: number | null;
currencyBalance: number | null;
totalGoldEquiv: number | null;
mandehDetail: MandehDetailItem[];
raw: unknown;
};
function num(v: unknown): number | null {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function detailStatus(amount: number): 'بس' | 'بد' {
return amount < 0 ? 'بس' : 'بد';
}
/** نرمال‌سازی پاسخ getmandehesabbycode مثل همکار */
export function normalizeMandehResponse(raw: unknown): NormalizedAssets {
const root = raw as {
MandeHesab?: Array<Record<string, unknown>>;
};
const row = root?.MandeHesab?.[0];
if (!row) {
return {
goldBalance: null,
rialBalance: null,
currencyBalance: null,
totalGoldEquiv: null,
mandehDetail: [],
raw,
};
}
const details = Array.isArray(row.details) ? row.details : [];
const mandehDetail: MandehDetailItem[] = details.map((item) => {
const it = item as Record<string, unknown>;
const values: MandehValue[] = [];
if (it.Value1 !== null && it.Value1 !== undefined) {
const a = Number(it.Value1);
values.push({
amount: it.Value1 as string | number,
unit: 'گرم',
status: detailStatus(a),
});
}
if (it.Value2 !== null && it.Value2 !== undefined) {
const a = Number(it.Value2);
values.push({
amount: it.Value2 as string | number,
unit: 'عدد',
status: detailStatus(a),
});
}
if (it.Value3 !== null && it.Value3 !== undefined) {
const a = Number(it.Value3);
values.push({
amount: it.Value3 as string | number,
unit: 'ارز',
status: detailStatus(a),
});
}
if (it.Value4 !== null && it.Value4 !== undefined) {
const a = Number(it.Value4);
values.push({
amount: it.Value4 as string | number,
unit: 'ریال',
status: detailStatus(a),
});
}
return { Name: String(it.Name ?? ''), values };
});
return {
goldBalance: num(row.MandeyeVazni),
rialBalance: num(row.MandeyeMali),
currencyBalance: num(row.MandeyeKolBePool),
totalGoldEquiv: num(row.MandeyeKolBeTALA),
mandehDetail,
raw,
};
}
export class TahesabApi {
constructor(
private readonly http: TahesabHttpClient,
private readonly creds: ConnCreds,
) {}
private call<T>(
path: string,
bodyKey?: string,
args?: unknown[],
method?: 'GET' | 'POST',
) {
return this.http.call<T>({
...this.creds,
path,
bodyKey,
args,
method,
});
}
checkHealthSsl() {
return this.call<{ Status?: string; Api_Status?: string }>(
'CheckHealth-SSL',
undefined,
undefined,
'GET',
);
}
checkHealthNoSsl() {
return this.call<{ Status?: string; Api_Status?: string }>(
'CheckHealth-No-SSL',
undefined,
undefined,
'GET',
);
}
/** ثبت مشتری — پاسخ { OK: moshtariCode } */
doNewMoshtari(args: unknown[]) {
return this.call<{ OK?: number }>('DoNewMoshtari', 'DoNewMoshtari', args);
}
doEditMoshtari(args: unknown[]) {
return this.call('DoEditMoshtari', 'DoEditMoshtari', args);
}
doListMoshtariByCode(fromCode: number, toCode: number) {
return this.call(
'DoListMoshtari-ByCode',
'DoListMoshtari',
[fromCode, toCode],
);
}
doListMoshtariByPhone(phone: string | number) {
return this.call('DoListMoshtari-ByPhone', 'DoListMoshtari', [phone]);
}
doListMoshtariByName(name: string) {
return this.call('DoListMoshtari-ByName', 'DoListMoshtari', [name]);
}
/** body key حساس به حروف کوچک مثل همکار */
getMandeHesabByCode(moshtariCode: number | string) {
return this.call(
'GetMandeHesabByCode',
'getmandehesabbycode',
[String(moshtariCode)],
);
}
doListAsnad(
countLast: number,
moshtariCode: number,
fromShamsi: string,
toShamsi: string,
) {
return this.call('DoListAsnad', 'DoListAsnad', [
countLast,
moshtariCode,
fromShamsi,
toShamsi,
]);
}
doListNameSekeh() {
return this.call('DoListNameSekeh', 'DoListNameSekeh', []);
}
doListNameArz() {
return this.call('DoListNameARZ', 'DoListNameARZ', []);
}
doListHesabBanki() {
return this.call('DoListHesabBanki', 'DoListHesabBanki', []);
}
doNewSanadBuySaleGold(args: unknown[]) {
return this.call('DoNewSanadBuySaleGOLD', 'DoNewSanadBuySaleGOLD', args);
}
doNewSanadBuySaleSekeh(args: unknown[]) {
return this.call('DoNewSanadBuySaleSEKEH', 'DoNewSanadBuySaleSEKEH', args);
}
doNewSanadTalabBedehi(args: unknown[]) {
return this.call('DoNewSanadTalabBedehi', 'DoNewSanadTalabBedehi', args);
}
doNewSanadVkhGold(args: unknown[]) {
return this.call('DoNewSanadVKHGOLD', 'DoNewSanadVKHGOLD', args);
}
doNewSanadVkhBank(args: unknown[]) {
return this.call('DoNewSanadVKHBank', 'DoNewSanadVKHBank', args);
}
}
/** سازنده‌های آرایه سند — ترتیب پارامترها قفل‌شده با OpenAPI/همکار */
export const SanadBuilders = {
buySaleGold(p: {
moshtariCode: number;
year: number;
month: number;
day: number;
vazn: number;
ayar: number;
buyOrSale: 0 | 1;
mazaneh: number;
mazanehIsGeram: 0 | 1;
isAbshode: 0 | 1;
mablaghKol: number;
sharh: string;
factorCode: string;
havalehBeMcode?: number;
jensFelez?: number;
zamanTasvie?: string;
}): unknown[] {
assertFactorCode(p.factorCode);
return [
1,
p.moshtariCode,
-1,
1,
p.year,
p.month,
p.day,
p.vazn,
p.ayar,
123456,
'Reygiri',
p.buyOrSale,
p.mazaneh,
p.mazanehIsGeram,
p.isAbshode,
p.mablaghKol,
p.sharh,
p.factorCode,
p.havalehBeMcode ?? -1,
-1,
p.jensFelez ?? 0,
p.zamanTasvie ?? '',
];
},
buySaleSekeh(p: {
moshtariCode: number;
year: number;
month: number;
day: number;
vazn: number;
ayar: number;
count: number;
nameSekeh: string;
buyOrSale: 0 | 1;
mazaneh: number;
mablaghKol: number;
sharh: string;
factorCode: string;
jensFelez?: number;
arzName?: string;
}): unknown[] {
assertFactorCode(p.factorCode);
return [
1,
p.moshtariCode,
-1,
1,
p.year,
p.month,
p.day,
p.vazn,
p.ayar,
p.count,
p.nameSekeh,
p.buyOrSale,
p.mazaneh,
p.mablaghKol,
p.sharh,
p.factorCode,
-1,
-1,
p.jensFelez ?? 0,
p.arzName ?? 'IRR',
];
},
/** قرارداد همکار HavalehTahesabService */
talabBedehi(p: {
moshtariCode: number;
year: number;
month: number;
day: number;
isBedehiOrTalab: 0 | 1;
mablagh: number;
sharh: string;
baabat: string;
vazn: number;
factorCode: string;
havalehBeMcode?: number;
jensFelez?: number;
arzName?: string;
}): unknown[] {
assertFactorCode(p.factorCode);
return [
1,
p.moshtariCode,
-1,
0,
p.year,
p.month,
p.day,
p.isBedehiOrTalab,
p.mablagh,
p.sharh,
p.baabat,
p.vazn,
p.factorCode,
p.havalehBeMcode ?? -1,
0,
p.jensFelez ?? 0,
p.arzName ?? 'ریال',
];
},
vkhGold(p: {
moshtariCode: number;
year: number;
month: number;
day: number;
vazn: number;
ayar: number;
angNumber: string | number;
nameAz: string;
isVoroodOrKhorooj: 0 | 1;
isAbshode: 0 | 1;
sharh: string;
factorCode: string;
isSharti?: boolean;
}): unknown[] {
assertFactorCode(p.factorCode);
return [
1,
p.moshtariCode,
-1,
1,
p.year,
p.month,
p.day,
p.vazn,
p.ayar,
p.angNumber,
p.nameAz,
p.isVoroodOrKhorooj,
p.isAbshode,
p.sharh,
p.factorCode,
-1,
-1,
0,
p.isSharti ?? false,
];
},
vkhBank(p: {
moshtariCode: number;
year: number;
month: number;
day: number;
isDaryaftOrPardakht: 0 | 1;
mablagh: number;
sharh: string;
nameBank: string;
codeRahgiri: string;
factorCode: string;
}): unknown[] {
assertFactorCode(p.factorCode);
return [
1,
p.moshtariCode,
-1,
1,
p.year,
p.month,
p.day,
p.isDaryaftOrPardakht,
p.mablagh,
p.sharh,
p.nameBank,
p.codeRahgiri,
p.factorCode,
-1,
0,
];
},
newMoshtari(p: {
name: string;
groupName?: string;
tel?: string;
address?: string;
codeMeli?: string;
tarikhTavalod?: string;
moaref?: string;
codeMoaref?: string;
moshtariCode?: number;
jensFelez?: number;
}): unknown[] {
return [
p.name,
p.groupName ?? 'CRM',
p.tel ?? '',
p.address ?? '',
p.codeMeli ?? '',
p.tarikhTavalod ?? '',
p.moaref ?? '',
p.codeMoaref ?? '-1',
p.moshtariCode ?? -1,
p.jensFelez ?? 0,
];
},
};
@@ -0,0 +1,203 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { CurrentUser } from '../../auth/current-user.decorator';
import { Roles } from '../../auth/roles.decorator';
import { RolesGuard } from '../../auth/roles.guard';
import { TahesabConnectionsService } from './connections.service';
import { TahesabPersonLinkService } from './person-link.service';
import { TahesabBalanceService } from './balance.service';
import { TahesabDocumentsService } from './documents.service';
import {
CreateInTahesabDto,
CreateTahesabConnectionDto,
ImportFromTahesabDto,
ManualLinkDto,
MatchPersonDto,
PushSanadDto,
SearchTahesabDto,
UpdateTahesabConnectionDto,
} from './dto';
@Controller('integrations/tahesab')
@UseGuards(JwtAuthGuard, RolesGuard)
export class TahesabController {
constructor(
private readonly connections: TahesabConnectionsService,
private readonly links: TahesabPersonLinkService,
private readonly balance: TahesabBalanceService,
private readonly documents: TahesabDocumentsService,
) {}
// —— Connections ——
/** لیست اتصالات برای انتخاب در پروفایل (بدون توکن خام) */
@Get('connections/public')
listPublicConnections(@CurrentUser() user: { organizationId: string }) {
return this.connections.list(user.organizationId);
}
@Get('connections')
@Roles('owner', 'admin')
listConnections(@CurrentUser() user: { organizationId: string }) {
return this.connections.list(user.organizationId);
}
@Post('connections')
@Roles('owner', 'admin')
createConnection(
@CurrentUser() user: { organizationId: string },
@Body() dto: CreateTahesabConnectionDto,
) {
return this.connections.create(user.organizationId, dto);
}
@Patch('connections/:id')
@Roles('owner', 'admin')
updateConnection(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@Body() dto: UpdateTahesabConnectionDto,
) {
return this.connections.update(user.organizationId, id, dto);
}
@Delete('connections/:id')
@Roles('owner', 'admin')
removeConnection(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
return this.connections.remove(user.organizationId, id);
}
@Post('connections/:id/test')
@Roles('owner', 'admin')
testConnection(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
return this.connections.test(user.organizationId, id);
}
// —— Person links (all authenticated CRM users in org) ——
@Get('people/:personId/links')
listLinks(
@CurrentUser() user: { organizationId: string },
@Param('personId') personId: string,
) {
return this.links.listLinks(user.organizationId, personId);
}
@Post('people/:personId/links')
manualLink(
@CurrentUser() user: { organizationId: string },
@Param('personId') personId: string,
@Body() dto: ManualLinkDto,
) {
return this.links.manualLink(user.organizationId, personId, dto);
}
@Delete('people/:personId/links/:connectionId')
unlink(
@CurrentUser() user: { organizationId: string },
@Param('personId') personId: string,
@Param('connectionId') connectionId: string,
) {
return this.links.unlink(user.organizationId, personId, connectionId);
}
@Post('people/:personId/match')
match(
@CurrentUser() user: { organizationId: string },
@Param('personId') personId: string,
@Body() dto: MatchPersonDto,
) {
return this.links.match(user.organizationId, personId, dto);
}
@Post('people/:personId/create-in-tahesab')
createInTahesab(
@CurrentUser() user: { organizationId: string },
@Param('personId') personId: string,
@Body() dto: CreateInTahesabDto,
) {
return this.links.createInTahesab(user.organizationId, personId, dto);
}
@Get('people/:personId/assets')
getAssets(
@CurrentUser() user: { organizationId: string },
@Param('personId') personId: string,
@Query('connectionId') connectionId?: string,
@Query('refresh') refresh?: string,
) {
return this.balance.getCachedOrFetch(
user.organizationId,
personId,
connectionId,
refresh !== '0',
);
}
@Get('people/:personId/asnad')
listAsnad(
@CurrentUser() user: { organizationId: string },
@Param('personId') personId: string,
@Query('from') from: string,
@Query('to') to: string,
@Query('connectionId') connectionId?: string,
) {
return this.documents.listAsnad(
user.organizationId,
personId,
from,
to,
connectionId,
);
}
@Get('lookups')
lookups(
@CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId?: string,
) {
return this.documents.listLookups(user.organizationId, connectionId);
}
@Post('people/:personId/sanad')
@Roles('owner', 'admin', 'sales')
pushSanad(
@CurrentUser() user: { organizationId: string },
@Param('personId') personId: string,
@Body() dto: PushSanadDto,
) {
return this.documents.pushSanad(user.organizationId, personId, dto);
}
@Post('sync/search')
@Roles('owner', 'admin', 'sales')
search(
@CurrentUser() user: { organizationId: string },
@Body() dto: SearchTahesabDto,
) {
return this.links.search(user.organizationId, dto);
}
@Post('sync/import')
@Roles('owner', 'admin', 'sales')
importFromTahesab(
@CurrentUser() user: { organizationId: string },
@Body() dto: ImportFromTahesabDto,
) {
return this.links.importFromTahesab(user.organizationId, dto);
}
}
@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { TahesabCryptoService } from './tahesab-crypto.service';
import { TahesabHttpClient } from './tahesab-http.client';
import { TahesabConnectionsService } from './connections.service';
import { TahesabPersonLinkService } from './person-link.service';
import { TahesabBalanceService } from './balance.service';
import { TahesabDocumentsService } from './documents.service';
import { TahesabController } from './tahesab.controller';
@Module({
imports: [AuthModule],
controllers: [TahesabController],
providers: [
TahesabCryptoService,
TahesabHttpClient,
TahesabConnectionsService,
TahesabPersonLinkService,
TahesabBalanceService,
TahesabDocumentsService,
],
exports: [
TahesabConnectionsService,
TahesabPersonLinkService,
TahesabBalanceService,
TahesabDocumentsService,
],
})
export class TahesabModule {}