diff --git a/apps/api/src/integrations/tahesab/connections.service.ts b/apps/api/src/integrations/tahesab/connections.service.ts index 185eb42..40a1ca5 100644 --- a/apps/api/src/integrations/tahesab/connections.service.ts +++ b/apps/api/src/integrations/tahesab/connections.service.ts @@ -251,12 +251,7 @@ export class TahesabConnectionsService { 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 result = await api.checkHealth(); const updated = await this.prisma.accountingConnection.update({ where: { id: row.id }, data: { @@ -268,15 +263,20 @@ export class TahesabConnectionsService { return { ok: true, connection: this.toDto(updated), health: result }; } catch (err) { const message = (err as Error).message ?? 'خطای ناشناخته'; + const hint = + /مهلت اتصال|timeout/i.test(message) && !row.allowInsecureTls && /^https:/i.test(row.baseUrl) + ? ' اگر گواهی ته‌حساب خودامضاست، «پذیرش گواهی SSL خودامضا» را فعال کنید.' + : ''; + const full = `${message}${hint}`.slice(0, 500); const updated = await this.prisma.accountingConnection.update({ where: { id: row.id }, data: { lastHealthAt: new Date(), lastHealthOk: false, - lastError: message.slice(0, 500), + lastError: full, }, }); - return { ok: false, connection: this.toDto(updated), error: message }; + return { ok: false, connection: this.toDto(updated), error: full }; } } } diff --git a/apps/api/src/integrations/tahesab/tahesab-http.client.ts b/apps/api/src/integrations/tahesab/tahesab-http.client.ts index 7c8d1c2..01d11d9 100644 --- a/apps/api/src/integrations/tahesab/tahesab-http.client.ts +++ b/apps/api/src/integrations/tahesab/tahesab-http.client.ts @@ -44,17 +44,18 @@ export class TahesabHttpClient { async call(opts: TahesabCallOptions): Promise { const base = opts.baseUrl.replace(/\/+$/, ''); - const path = opts.path.replace(/^\/+/, ''); - const url = new URL(`${base}/${path}`); + const path = (opts.path ?? '').replace(/^\/+/, ''); + // Postman ته‌حساب برای health به ریشه می‌زند (بدون path) + const url = path ? new URL(`${base}/${path}`) : new URL(base); const method = opts.method ?? (opts.bodyKey ? 'POST' : 'GET'); const timeoutMs = opts.timeoutMs ?? 30_000; + const label = path || '(root)'; const headers: Record = { Authorization: `Bearer ${opts.token}`, DBname: opts.dbName, DBName: opts.dbName, Accept: 'application/json', - Host: url.host, }; let body: string | undefined; @@ -72,12 +73,20 @@ export class TahesabHttpClient { : undefined; return new Promise((resolve, reject) => { + let settled = false; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(hardTimer); + fn(); + }; + const req = lib.request( { protocol: url.protocol, hostname: url.hostname, port: url.port || (isHttps ? 443 : 80), - path: `${url.pathname}${url.search}`, + path: `${url.pathname}${url.search}` || '/', method, headers, timeout: timeoutMs, @@ -106,19 +115,21 @@ export class TahesabHttpClient { if (status < 200 || status >= 300) { if (isEmptyOk) { this.logger.debug( - `Tahesab ${path} ${status} empty-on-error: ${detail.slice(0, 120)}`, + `Tahesab ${label} ${status} empty-on-error: ${detail.slice(0, 120)}`, ); - resolve([] as T); + finish(() => resolve([] as T)); return; } this.logger.warn( - `Tahesab ${method} ${path} → ${status}: ${text.slice(0, 400)}`, + `Tahesab ${method} ${label} → ${status}: ${text.slice(0, 400)}`, ); - reject( - new BadGatewayException( - detail - ? `ته‌حساب (${status}): ${detail}` - : `پاسخ نامعتبر ته‌حساب (${status}) برای ${path}`, + finish(() => + reject( + new BadGatewayException( + detail + ? `ته‌حساب (${status}): ${detail}` + : `پاسخ نامعتبر ته‌حساب (${status}) برای ${label}`, + ), ), ); return; @@ -127,42 +138,61 @@ export class TahesabHttpClient { if (json && typeof json === 'object' && 'ERROR' in (json as object)) { if (isEmptyOk) { this.logger.debug( - `Tahesab ${path} empty-on-error: ${detail.slice(0, 120)}`, + `Tahesab ${label} empty-on-error: ${detail.slice(0, 120)}`, ); - resolve([] as T); + finish(() => resolve([] as T)); return; } - reject( - new BadGatewayException( - detail ? `ته‌حساب: ${detail}` : `خطای ته‌حساب برای ${path}`, + finish(() => + reject( + new BadGatewayException( + detail ? `ته‌حساب: ${detail}` : `خطای ته‌حساب برای ${label}`, + ), ), ); return; } - resolve(json as T); + finish(() => resolve(json as T)); }); }, ); + // مهلت از لحظهٔ شروع (شامل اتصال TCP/TLS) — timeout سوکت Node فقط بعد از connect است + const hardTimer = setTimeout(() => { + req.destroy(); + finish(() => + reject( + new RequestTimeoutException( + `مهلت اتصال به ته‌حساب برای ${label} به پایان رسید.`, + ), + ), + ); + }, timeoutMs); + req.on('timeout', () => { req.destroy(); - reject( - new RequestTimeoutException( - `مهلت اتصال به ته‌حساب برای ${path} به پایان رسید.`, + finish(() => + reject( + new RequestTimeoutException( + `مهلت اتصال به ته‌حساب برای ${label} به پایان رسید.`, + ), ), ); }); req.on('error', (err) => { - this.logger.error(`Tahesab call failed: ${path}`, err); + if (settled) return; + this.logger.error(`Tahesab call failed: ${label}`, err); const raw = err.message || String(err); const tlsHint = /self-signed|certificate|UNABLE_TO_VERIFY|CERT_/i.test(raw) ? ' — در تنظیمات اتصال، گزینه «پذیرش گواهی SSL خودامضا» را فعال کنید.' : ''; - reject( - new BadGatewayException( - `اتصال به ته‌حساب ناموفق بود: ${raw}${tlsHint}`, + finish(() => + reject( + new BadGatewayException( + `اتصال به ته‌حساب ناموفق بود: ${raw}${tlsHint}`, + ), ), ); }); diff --git a/apps/api/src/integrations/tahesab/tahesab-methods.ts b/apps/api/src/integrations/tahesab/tahesab-methods.ts index 6f16c7d..04087b2 100644 --- a/apps/api/src/integrations/tahesab/tahesab-methods.ts +++ b/apps/api/src/integrations/tahesab/tahesab-methods.ts @@ -177,6 +177,52 @@ export class TahesabApi { ); } + /** + * تست سلامت مطابق Postman ته‌حساب: + * ابتدا ریشهٔ baseUrl، بعد /CheckHealth، در نهایت مسیر SSL یا No-SSL بر اساس پروتکل. + */ + async checkHealth() { + const isHttps = /^https:/i.test(this.creds.baseUrl.trim()); + const timeoutMs = 12_000; + type Health = { Status?: string; Api_Status?: string }; + const attempts: Array<{ + path: string; + bodyKey?: string; + method: 'GET' | 'POST'; + }> = [ + { path: '', method: 'GET' }, + { path: '', bodyKey: 'CheckHealth', method: 'POST' }, + { path: 'CheckHealth', method: 'GET' }, + { + path: isHttps ? 'CheckHealth-SSL' : 'CheckHealth-No-SSL', + method: 'GET', + }, + ]; + + let lastErr: unknown; + for (const attempt of attempts) { + try { + return await this.call( + attempt.path, + attempt.bodyKey, + attempt.bodyKey ? [] : undefined, + attempt.method, + timeoutMs, + ); + } catch (err) { + lastErr = err; + const msg = err instanceof Error ? err.message : String(err); + // خطای گواهی با تلاش‌های بعدی روی همان https حل نمی‌شود + if (/certificate|UNABLE_TO_VERIFY|self-signed|CERT_/i.test(msg)) { + throw err; + } + } + } + throw lastErr instanceof Error + ? lastErr + : new Error('تست اتصال ته‌حساب ناموفق بود.'); + } + /** ثبت مشتری — پاسخ { OK: moshtariCode } */ doNewMoshtari(args: unknown[]) { return this.call<{ OK?: number }>('DoNewMoshtari', 'DoNewMoshtari', args);