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
+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 {}