import { HttpService } from "@nestjs/axios"; import { BadGatewayException, BadRequestException, GatewayTimeoutException, Injectable, Logger, NotFoundException, ServiceUnavailableException, UnauthorizedException, } from "@nestjs/common"; import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service"; import { SandHubModel } from "src/sand-hub/entity/schema/sand-hub.schema"; import { ExternalInquirySettingsService } from "src/client/external-inquiry-settings.service"; import type { ExternalInquiryType } from "src/common/types/external-inquiry.types"; import type { MockInquiryCompanyContext } from "src/common/types/external-inquiry.types"; import { SandHubDetailDto, SandHubInquiryOptions } from "./dto/sand-hub.dto"; import { jalaliToGregorianDate } from "src/helpers/date-jalali"; import { firstValueFrom } from "rxjs"; @Injectable() export class SandHubService { private static readonly ESG_INQUIRY_UNAVAILABLE_MESSAGE = "استعلام در دسترس نیست"; private readonly logger = new Logger(SandHubService.name); private loginToken: string | null = null; private tokenExpiry: Date | null = null; // Tejarat inquiry auth (82.99.202.245:3027) private tejaratAccessToken: string | null = null; private tejaratTokenExpiry: Date | null = null; // ESG inquiry auth (used for selected tenants, e.g. CLIENT_ID=8) private esgAccessToken: string | null = null; private esgTokenExpiry: Date | null = null; constructor( private readonly httpService: HttpService, private readonly sandHubDbService: SandHubDbService, private readonly externalInquirySettings: ExternalInquirySettingsService, ) {} private clientRefFrom(options?: SandHubInquiryOptions): string | undefined { return options?.clientId; } private async isInquiryLive( type: ExternalInquiryType, options?: SandHubInquiryOptions, ): Promise { return this.externalInquirySettings.isInquiryLive( type, this.clientRefFrom(options), ); } private async mockCompanyContext( options?: SandHubInquiryOptions, ): Promise { return this.externalInquirySettings.getMockCompanyContext( this.clientRefFrom(options), ); } private shouldUseEsgInquiryProvider(): boolean { return String(process.env.CLIENT_ID ?? "") === "8"; } /** Fixed plate/insurance inquiry payload used everywhere we mock block-inquiry style APIs. */ private buildMockPlateInquiryRaw( ctx: MockInquiryCompanyContext, ): Record { return { PrntPlcyCmpDocNo: "1404/1143-70591/200/123", MapTypNam: "فائو ( FAW )", MtrNum: "TZ196XYAP223A210074", ShsNum: "LFP8C7PC3R1K12157", DisFnYrNum: null, DisLfYrNum: null, DisPrsnYrNum: null, DisPrsnYrPrcnt: null, DisFnYrPrcnt: "5", DisLfYrPrcnt: "5", vin: "LFP8C7PC3R1K12157", MapVehicleSystemName: "ثبت نشده", LfCvrCptl: 0, FnCvrCptl: 0, PrsnCvrCptl: 0, VehicleSystemCode: 1, EdrsJson: "", PersonCvrCptl: 12000000000, LifeCvrCptl: 16000000000, FinancialCvrCptl: 4000000000, CarGroupCode: 3, CylCnt: 4, LastCompanyDocumentNumber: "31/3100/03/18350", UsageCode: "8", MapUsageCode: 1, MapUsageName: "شخصي", Plk1: 16, Plk2: 12, Plk3: 498, PlkSrl: 60, PrintEndorsCompanyDocumentNumber: "", EndorseDate: null, InsuranceFullName: null, SystemField: "سايپا", TypeField: "111SE", UsageField: "سواري", MainColorField: "سفيد", SecondColorField: "سفيد شيري", ModelField: "2024", CapacityField: "جمعا 4 نفر", CylinderNumberField: "4", EngineNumberField: "5215907", ChassisNumberField: "LFP8C7PC3R1K12157", VinNumberField: "LFP8C7PC3R1K12157", InstallDateField: "1403/03/01", AxelNumberField: "2", WheelNumberField: "4", CompanyName: ctx.companyName, CompanyCode: ctx.companyId, IssueDate: "1403/04/06", SatrtDate: "1403/04/06", EndDate: "1404/04/06", Thrname: "", EndorseText: null, PolicyHealthLossCount: 0, PolicyFinancialLossCount: 0, PolicyPersonLossCount: 0, Tonage: 0, ThirdPolicyCode: 10502330579, DiscountPersonPercent: null, DiscountThirdPercent: null, SystemCodeCii: 0, SystemNameCii: "ثبت نشده", TypeCodeCii: 12189, TypeNameCii: "فائو ( FAW )", UsageNameCii: "سواري", UsageCodeCii: 1, ModelCii: 2024, damageTypes: [], StatusTypeCode: 1, }; } /** Build {@link SandHubDetailDto} from Tejarat badane / block-inquiry request bodies. */ private tejaratPayloadToSandHubDetail(payload: { nationalCode?: string | number; part1?: string | number; part2?: string | number; part3?: string | number; part4?: string | number; leftTwoDigits?: string | number; serialLetter?: string | number; threeDigits?: string | number; rightTwoDigits?: string | number; }): SandHubDetailDto { const nationalCode = String(payload.nationalCode ?? ""); return { nationalCodeOfInsurer: nationalCode, plate: { leftDigits: Number(payload.part1 ?? payload.leftTwoDigits ?? 16), centerAlphabet: String( payload.part2 ?? payload.serialLetter ?? "12", ), centerDigits: Number(payload.part3 ?? payload.threeDigits ?? 498), ir: Number(payload.part4 ?? payload.rightTwoDigits ?? 60), nationalCode, }, }; } private buildMockCarBodyInquiryRaw( userDetail: SandHubDetailDto, ctx: MockInquiryCompanyContext, ): Record { const companyId = Number(ctx.companyId); const plate = userDetail?.plate; const platePartOne = Number(plate?.leftDigits ?? 16); const platePartThree = Number(plate?.centerDigits ?? 498); const plateSerialNumber = Number(plate?.ir ?? 60); const plateLetterid = Number(plate?.centerAlphabet ?? 12); const nationalCode = String(userDetail?.nationalCodeOfInsurer ?? ""); return { data: { id: 70016075946, printNumber: "1405/1143-70591/220/1/0", companyId: Number.isNaN(companyId) ? 15 : companyId, companyName: ctx.companyName, beginDate: "1405/01/17", endDate: "1406/01/17", issueDate: "1405/01/16", hasEndorsement: null, motorNumber: "TZ196XYAP223A210074", chassisNumber: "LFP8C7PC3R1K12157", vin: "LFP8C7PC3R1K12157", plateTypeId: 9, plateTypeTitle: "پلاک قدیمی", platePartOne, plateSerialNumber, plateLetterid, plateLetterTitle: "م ", vehicleSystemTitle: "فائو ( FAW )", vehicleGroupId: 2, vehicleGroupTitle: "سواری چهار سیلندر", insurerNationalCode: nationalCode, insurerName: "هانيه کارخانهء", ownerNationalCode: nationalCode, previousId: 70006331822, noLossYearsCount: 4, platePartThree, lossDocuments: [], }, isSuccess: true, statusCode: 200, message: "عملیات با موفقیت انجام شد", }; } private buildMockInquiryRaw( inquiryType: ExternalInquiryType, ctx: MockInquiryCompanyContext, payload?: unknown, userDetail?: SandHubDetailDto, ): Record { if (inquiryType === "carBodyPlate") { const detail = userDetail ?? this.tejaratPayloadToSandHubDetail( (payload ?? {}) as Record, ); return this.buildMockCarBodyInquiryRaw(detail, ctx); } return this.buildMockPlateInquiryRaw(ctx); } /** * Bodies returned from `makeSandHubRequest` in mock mode (same shape callers expect from real API). */ private buildMockSandHubResponseForUrl( url: string, payload?: unknown, plateMock?: Record, ): any { const u = (url || "").toLowerCase(); if (u.includes("personal-inquiry")) { const p = payload as { nationalCode?: string } | undefined; const nin = typeof p?.nationalCode === "string" ? p.nationalCode : "-"; return { message: "success", data: { firstName: "نام", lastName: "خانوادگی", fatherName: "-", birthCertificateNumber: "-", nin, }, }; } if (u.includes("driver-license-check")) { return { data: { IsSucceed: true, Message: "mock", }, }; } if (u.includes("ownership")) { return { data: { IsSuccess: true, }, }; } if (u.includes("sheba")) { return { ReturnValue: true, HasError: false, Message: "mock-sheba-ok", }; } if (u.includes("badane")) { return ( plateMock ?? this.buildMockCarBodyInquiryRaw( this.tejaratPayloadToSandHubDetail( (payload ?? {}) as Record, ), { companyId: process.env.CLIENT_ID ?? "15", companyName: process.env.CLIENT_NAME ?? "بیمه سامان", }, ) ); } if (u.includes("block-inquiry") || u.includes("tejarat")) { return this.mapNewApiResponseToOldFormat( plateMock ?? this.buildMockPlateInquiryRaw({ companyId: process.env.CLIENT_ID ?? "15", companyName: process.env.CLIENT_NAME ?? "بیمه سامان", }), ); } this.logger.warn( `[MOCK] Unrecognized SandHub URL; returning generic OK: ${url}`, ); return { data: {}, message: "mock-ok" }; } private async getAccessToken(): Promise { if (this.loginToken && this.tokenExpiry && this.tokenExpiry > new Date()) { return this.loginToken; } this.logger.log( "SandHub token is missing or expired. Fetching a new one...", ); try { const response = await firstValueFrom( this.httpService.post(process.env.SANHUB_URL_LOGIN, { email: process.env.SANHUB_USERNAME, password: process.env.SANHUB_PASSWORD, }), ); const token = response.data.accessToken; if (!token) throw new Error("No access token returned from SandHub login"); this.loginToken = token; this.tokenExpiry = new Date(Date.now() + 55 * 60 * 1000); // 55-minute expiry this.logger.log("Successfully obtained a new SandHub token."); return this.loginToken; } catch (er) { this.logger.error("Failed to login to SandHub:", er.message); this.loginToken = null; this.tokenExpiry = null; throw new UnauthorizedException("SandHub authentication failed"); } } private async getTejaratAccessToken(): Promise { if ( this.tejaratAccessToken && this.tejaratTokenExpiry && this.tejaratTokenExpiry > new Date() ) { return this.tejaratAccessToken; } const baseUrl = process.env.TEJARAT_INQUIRY_BASE_URL ?? "http://82.99.202.245:3027"; const email = process.env.TEJARAT_INQUIRY_EMAIL; const password = process.env.TEJARAT_INQUIRY_PASSWORD; if (!email || !password) { throw new UnauthorizedException( "Tejarat inquiry credentials are not configured (TEJARAT_INQUIRY_EMAIL/TEJARAT_INQUIRY_PASSWORD)", ); } this.logger.log( "Tejarat inquiry token is missing or expired. Fetching a new one...", ); try { const response = await firstValueFrom( this.httpService.post( `${baseUrl}/user/login`, { email, password }, { headers: { Accept: "*/*", "Content-Type": "application/json", }, timeout: 30000, }, ), ); const token = response.data?.accessToken; if (!token) { throw new Error("No access token returned from Tejarat login"); } this.tejaratAccessToken = token; // No explicit expiry in response; keep a conservative cache window. this.tejaratTokenExpiry = new Date(Date.now() + 55 * 60 * 1000); return this.tejaratAccessToken; } catch (er) { this.logger.error( "Failed to login to Tejarat inquiry:", er?.message || er, ); this.tejaratAccessToken = null; this.tejaratTokenExpiry = null; throw new UnauthorizedException("Tejarat inquiry authentication failed"); } } private async getEsgAccessToken(): Promise { if (this.esgAccessToken && this.esgTokenExpiry && this.esgTokenExpiry > new Date()) { return this.esgAccessToken; } const baseUrl = process.env.ESG_URL; const username = process.env.ESG_USERNAME; const password = process.env.ESG_PASSWORD; if (!baseUrl || !username || !password) { throw new UnauthorizedException( "ESG credentials are not configured (ESG_URL/ESG_USERNAME/ESG_PASSWORD)", ); } this.logger.log("ESG token is missing or expired. Fetching a new one..."); try { const response = await firstValueFrom( this.httpService.post( `${baseUrl}/auth/login`, { username, password }, { headers: { Accept: "application/json", "Content-Type": "application/json", }, timeout: 30000, }, ), ); const token = response.data?.accessToken; if (!token) { throw new Error("No access token returned from ESG login"); } const expiresInSeconds = Number(response.data?.expiresIn ?? 900); const ttlMs = Number.isFinite(expiresInSeconds) ? Math.max(30, expiresInSeconds - 60) * 1000 : 14 * 60 * 1000; this.esgAccessToken = token; this.esgTokenExpiry = new Date(Date.now() + ttlMs); return this.esgAccessToken; } catch (er: any) { this.logger.error("Failed to login to ESG inquiry:", er?.message || er); this.esgAccessToken = null; this.esgTokenExpiry = null; throw new UnauthorizedException("ESG inquiry authentication failed"); } } private async makeEsgRequest( url: string, payload: any, inquiryType: ExternalInquiryType, options?: SandHubInquiryOptions, maxRetries = 2, ) { if (!(await this.isInquiryLive(inquiryType, options))) { this.logger.log(`[MOCK] ESG POST skipped (${inquiryType}): ${url}`); const ctx = await this.mockCompanyContext(options); if (inquiryType === "personalIdentity") { const nin = typeof payload?.nationalCode === "string" ? payload.nationalCode : "-"; return this.getDefaultMockPersonInquiry(nin); } if (inquiryType === "sheba") { return { ReturnValue: true, HasError: false, Message: "mock-sheba-ok", }; } return this.buildMockInquiryRaw(inquiryType, ctx, payload); } const INITIAL_DELAY = 500; const BACKOFF_FACTOR = 2; for (let attempt = 0; attempt < maxRetries; attempt++) { const token = await this.getEsgAccessToken(); try { const response = await firstValueFrom( this.httpService.post(url, payload, { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json", }, timeout: 30000, }), ); if (!response?.data) throw new Error("EMPTY_RESPONSE"); return response.data; } catch (err: any) { const status = err?.response?.status; const data = err?.response?.data; this.logger.error( `ESG request failed (attempt ${attempt + 1}/${maxRetries}) to ${url} with status ${ status ?? "NO_STATUS" }`, data ? JSON.stringify(data) : err?.message || err, ); if (status === 401) { this.esgAccessToken = null; this.esgTokenExpiry = null; } const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt); await new Promise((resolve) => setTimeout(resolve, delay)); if (attempt === maxRetries - 1) throw err; } } } private mapEsgPolicyByPlateToOldFormat(raw: any): any { if (!raw) return raw; if (raw?.success === false) { this.logger.warn( "ESG policyByPlate inquiry returned success=false", raw, ); return { Error: { Message: SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE, }, }; } // Real ESG response wraps payload in { success: true, data: { ... } }. // Mock data and Tejarat-format fallback are flat objects with no .data wrapper — // in that case use raw itself so CompanyName / CompanyCode are preserved. const data = raw?.data != null && typeof raw?.data === "object" ? raw.data : raw; return this.mapNewApiResponseToOldFormat({ ...data, companyId: data.CmpCod ?? data.companyId ?? data.CompanyCode, companyPersianName: data.CmpNam ?? data.companyPersianName ?? data.CompanyName, carGrpCod: data.CarGrpCod ?? data.carGrpCod, usgCod: data.UsgCod ?? data.usgCod, vehSysCod: data.VehSysCod ?? data.vehSysCod, mtrnum: data.MtrNum ?? data.mtrnum, shsNam: data.ShsNum ?? data.ShsNam ?? data.shsNam, vin: data.VIN ?? data.vin, ntnlId: data.NtnlId ?? data.ntnlId, fullname: data.InsNam ?? data.fullname, ThirdPolicyCode: data.PlcyUnqCod ?? data.ThirdPolicyCode, LastCompanyDocumentNumber: data.LastCmpDocNo ?? data.LastCompanyDocumentNumber, IssueDate: data.HIsuDte ?? data.IssueDate, StartDate: data.HBgnDte ?? data.StartDate, EndDate: data.HEndDte ?? data.EndDate, EdrsJson: data.Edrses ?? data.EdrsJson, }); } /** * Normalises Jalali birth dates to `YYYY-MM-DD` for ESG person inquiry. * Unlike Tejarat/SandHub personal inquiry, ESG expects Jalali — not Gregorian. */ private normalizeJalaliBirthDateForEsg( input: string | number | null | undefined, ): string | null { if (input === null || input === undefined) return null; const raw = typeof input === "number" ? String(input) : String(input).trim(); if (!raw) return null; let year = 0; let month = 0; let day = 0; const separated = raw.match(/^(\d{4})[\-/](\d{1,2})[\-/](\d{1,2})$/); if (separated) { year = parseInt(separated[1], 10); month = parseInt(separated[2], 10); day = parseInt(separated[3], 10); } else { const digits = raw.replace(/\D/g, ""); if (digits.length === 8) { year = parseInt(digits.slice(0, 4), 10); month = parseInt(digits.slice(4, 6), 10); day = parseInt(digits.slice(6, 8), 10); } else { return null; } } if (!year || !month || !day || year < 1300) return null; const mm = String(month).padStart(2, "0"); const dd = String(day).padStart(2, "0"); return `${year}-${mm}-${dd}`; } private getDefaultMockPersonInquiry(nationalCode: string): Record { return { firstName: "نام", lastName: "خانوادگی", fatherName: "-", birthCertificateNumber: "-", nin: nationalCode, }; } private mapEsgPersonInquiryToOldFormat(raw: any): Record { if (raw?.success === false) { this.logger.warn("ESG person inquiry returned success=false", raw); throw new BadRequestException( SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE, ); } const data = raw?.data ?? {}; return { firstName: data.Name ?? data.firstName, lastName: data.Family ?? data.lastName, fatherName: data.FatherName ?? data.fatherName, birthCertificateNumber: data.Shenasnameserial ?? data.ShenasnameNo ?? data.birthCertificateNumber, nin: data.Nin ?? data.nationalCode ?? data.nin, fullName: data.fullName, birthDate: data.BirthDate ?? data.birthDate, gender: data.Gender, trackingCode: raw?.trackingCode, }; } private mapEsgShebaInquiryToOldFormat(raw: any): Record { if (raw?.success === false) { this.logger.warn("ESG sheba inquiry returned success=false", raw); throw new BadRequestException( SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE, ); } const data = raw?.data ?? {}; return { ReturnValue: data.ReturnValue ?? true, HasError: data.HasError ?? false, Errors: data.Errors ?? {}, Message: raw?.message ?? data.Message, trackingCode: raw?.trackingCode, }; } private async makeTejaratRequest( url: string, payload: any, inquiryType: ExternalInquiryType, options?: SandHubInquiryOptions, maxRetries = 2, ) { if (!(await this.isInquiryLive(inquiryType, options))) { this.logger.log(`[MOCK] Tejarat POST skipped (${inquiryType}): ${url}`); const ctx = await this.mockCompanyContext(options); return this.buildMockInquiryRaw(inquiryType, ctx, payload); } const INITIAL_DELAY = 500; const BACKOFF_FACTOR = 2; for (let attempt = 0; attempt < maxRetries; attempt++) { const token = await this.getTejaratAccessToken(); try { const response = await firstValueFrom( this.httpService.post(url, payload, { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json", }, timeout: 30000, }), ); if (!response?.data) throw new Error("EMPTY_RESPONSE"); return response.data; } catch (err: any) { const status = err?.response?.status; const data = err?.response?.data; this.logger.error( `Tejarat request failed (attempt ${attempt + 1}/${maxRetries}) to ${url} with status ${ status ?? "NO_STATUS" }`, data ? JSON.stringify(data) : err?.message || err, ); // If unauthorized, clear token and retry once if (status === 401) { this.tejaratAccessToken = null; this.tejaratTokenExpiry = null; } const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt); await new Promise((resolve) => setTimeout(resolve, delay)); if (attempt === maxRetries - 1) { throw err; } } } } /** * Tejarat block inquiry (replaces SandHub call for V2 flows). * Returns both raw + mapped (old-format) response. */ async getTejaratBlockInquiry( userDetail: SandHubDetailDto, options?: SandHubInquiryOptions, ): Promise<{ raw: any; mapped: any; }> { const ctx = await this.mockCompanyContext(options); if (this.shouldUseEsgInquiryProvider()) { const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085"; const requestPayload = { nationalCode: String(userDetail.nationalCodeOfInsurer), plk1: String(userDetail.plate.leftDigits), plk2: String(userDetail.plate.centerAlphabet), plk3: String(userDetail.plate.centerDigits), plksrl: String(userDetail.plate.ir), }; const requestUrl = `${baseUrl}/inquiry/policyByPlate`; const live = await this.isInquiryLive("thirdPartyPlate", options); const raw = live ? await this.makeEsgRequest( requestUrl, requestPayload, "thirdPartyPlate", options, ) : this.buildMockPlateInquiryRaw(ctx); if (!live) { this.logger.debug( `[MOCK] getEsgPolicyByPlateInquiry plate=${JSON.stringify(requestPayload)}`, ); } const mapped = this.mapEsgPolicyByPlateToOldFormat(raw); return { raw, mapped }; } const baseUrl = process.env.TEJARAT_INQUIRY_BASE_URL ?? "http://82.99.202.245:3027"; const requestPayload = { leftTwoDigits: String(userDetail.plate.leftDigits), serialLetter: String(userDetail.plate.centerAlphabet), threeDigits: String(userDetail.plate.centerDigits), rightTwoDigits: String(userDetail.plate.ir), nationalCode: userDetail.nationalCodeOfInsurer, }; const requestUrl = `${baseUrl}/block-inquiry-tejarat`; const live = await this.isInquiryLive("thirdPartyPlate", options); const raw = live ? await this.makeTejaratRequest( requestUrl, requestPayload, "thirdPartyPlate", options, ) : this.buildMockPlateInquiryRaw(ctx); if (!live) { this.logger.debug( `[MOCK] getTejaratBlockInquiry plate=${JSON.stringify(requestPayload)}`, ); } const mapped = this.mapNewApiResponseToOldFormat(raw); return { raw, mapped }; } async getSandHubDataFromId(sandHubId) { return await this.sandHubDbService.findOneBySandHubId(sandHubId); } async getTejaratCarBodyInquiry( userDetail: SandHubDetailDto, options?: SandHubInquiryOptions, ): Promise<{ raw: any; mapped: Record; }> { const baseUrl = process.env.TEJARAT_INQUIRY_BASE_URL ?? "http://82.99.202.245:3027"; const requestPayload = { part1: Number(userDetail.plate.leftDigits), part2: String(userDetail.plate.centerAlphabet), part3: Number(userDetail.plate.centerDigits), part4: Number(userDetail.plate.ir), nationalCode: String(userDetail.nationalCodeOfInsurer), }; const requestUrl = `${baseUrl}/block-inquiry-tejarat/badane`; const live = await this.isInquiryLive("carBodyPlate", options); if (!live) { this.logger.debug( `[MOCK] getTejaratCarBodyInquiry plate=${JSON.stringify(requestPayload)}`, ); } const raw = await this.makeTejaratRequest( requestUrl, requestPayload, "carBodyPlate", options, ); const mapped = this.mapCarBodyInquiryResponse(raw); return { raw, mapped }; } /** * Maps the Tejarat car-body (badane) API response to a consistent * shape that mirrors the third-party inquiry mapped format. * Callers should always use `mapped` for display; `raw` is kept for audit. */ private mapCarBodyInquiryResponse(raw: any): Record { if (!raw) return {}; const data = raw?.data ?? raw; // some responses nest under `data` return { // Identity policyNumber: data.printNumber ?? data.insuranceNumber ?? null, companyId: data.companyId ?? null, CompanyCode: data.companyId ?? null, CompanyName: data.companyName ?? data.companyPersianName ?? null, // Insurer insurerNationalCode: data.insurerNationalCode ?? data.ownerNationalCode ?? null, insurerName: data.insurerName ?? null, ownerNationalCode: data.ownerNationalCode ?? null, InsuranceFullName: data.insurerName ?? data.ownerName ?? null, // Vehicle motorNumber: data.motorNumber ?? data.EngineNumberField ?? null, EngineNumberField: data.motorNumber ?? data.EngineNumberField ?? null, chassisNumber: data.chassisNumber ?? data.ChassisNumberField ?? null, ChassisNumberField: data.chassisNumber ?? data.ChassisNumberField ?? null, vin: data.vin ?? data.VinNumberField ?? null, VinNumberField: data.vin ?? data.VinNumberField ?? null, vehicleGroupTitle: data.vehicleGroupTitle ?? null, vehicleSystemTitle: data.vehicleSystemTitle ?? null, MapTypNam: data.vehicleGroupTitle ?? data.vehicleSystemTitle ?? null, // Policy dates (Jalali strings from the API) IssueDate: data.issueDate ?? data.persianStartDate ?? null, StartDate: data.beginDate ?? data.persianStartDate ?? null, EndDate: data.endDate ?? data.persianEndDate ?? null, // Plate plateTypeTitle: data.plateTypeTitle ?? null, platePartOne: data.platePartOne ?? null, platePartThree: data.platePartThree ?? null, plateSerialNumber: data.plateSerialNumber ?? null, plateLetterTitle: data.plateLetterTitle ?? null, // Loss / no-claim history noLossYearsCount: data.noLossYearsCount ?? null, lossDocuments: Array.isArray(data.lossDocuments) ? data.lossDocuments : [], // Misc hasEndorsement: data.hasEndorsement ?? null, previousId: data.previousId ?? null, // Status passthrough isSuccess: raw?.isSuccess ?? true, statusCode: raw?.statusCode ?? 200, message: raw?.message ?? null, }; } private async makeSandHubRequest( url: string, payload: any, inquiryType: ExternalInquiryType, options?: SandHubInquiryOptions, maxRetries = 3, ) { if (!(await this.isInquiryLive(inquiryType, options))) { this.logger.log(`[MOCK] SandHub POST skipped (${inquiryType}): ${url}`); const ctx = await this.mockCompanyContext(options); if (inquiryType === "carBodyPlate") { return this.buildMockInquiryRaw(inquiryType, ctx, payload); } const plateMock = inquiryType === "thirdPartyPlate" ? this.buildMockPlateInquiryRaw(ctx) : undefined; return this.buildMockSandHubResponseForUrl(url, payload, plateMock); } const token = await this.getAccessToken(); const INITIAL_DELAY = 1000; const BACKOFF_FACTOR = 2; for (let attempt = 0; attempt < maxRetries; attempt++) { try { const response = await firstValueFrom( this.httpService.post(url, payload, { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json", }, timeout: 50000, }), ); if (!response?.data) throw new Error("EMPTY_RESPONSE"); return response.data; } catch (err) { this.handleSandHubError(err, attempt, maxRetries); const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt); await new Promise((resolve) => setTimeout(resolve, delay)); } } throw new BadGatewayException( "Failed to fetch data from SandHub after multiple retries", ); } /** * Maps the new SandHub API response format to the old format expected by the application */ private mapNewApiResponseToOldFormat(newResponse: any): any { if (!newResponse) return newResponse; // Map the new field names to the old field names return { ...newResponse, // Company information CompanyCode: newResponse.companyId || newResponse.CompanyCode, CompanyName: newResponse.companyPersianName || newResponse.CompanyName, // Vehicle information MapTypNam: newResponse.vehiclePersianName || newResponse.MapTypNam, UsageField: newResponse.persianCarType || newResponse.UsageField || (newResponse.usgCod === "8" ? "شخصی" : "نامشخص"), MapUsageName: newResponse.MapUsageName || newResponse.MapUsageName, // Financial coverage FinancialCvrCptl: newResponse.financeCoverage || newResponse.FinancialCvrCptl || "0", // Dates IssueDate: newResponse.persianStartDate || newResponse.IssueDate, EndDate: newResponse.persianEndDate || newResponse.EndDate, // Insurance details LastCompanyDocumentNumber: newResponse.lastCompanyInsuranceNumber || newResponse.LastCompanyDocumentNumber || newResponse.insuranceNumber, // Technical details MtrNum: newResponse.MtrNum || newResponse.mtrnum, ShsNum: newResponse.ShsNum || newResponse.shsNam || newResponse.ChassisNumberField, vin: newResponse.vin || newResponse.VinNumberField || newResponse.vin, VinNumberField: newResponse.vin || newResponse.VinNumberField, ChassisNumberField: newResponse.ChassisNumberField || newResponse.shsNam, EngineNumberField: newResponse.EngineNumberField || newResponse.mtrnum, ModelField: newResponse.ModelField || newResponse.ModelField, // Discount information DisFnYrPrcnt: newResponse.DisFnYrPrcnt || newResponse.disFnYrPrcnt || "0", DisLfYrPrcnt: newResponse.DisLfYrPrcnt || newResponse.disLfYrPrcnt || "0", DisPrsnYrPrcnt: newResponse.DisPrsnYrPrcnt || newResponse.disPrsnYrPrcnt || "0", DisFnYrNum: newResponse.DisFnYrNum || newResponse.disFnYrNum, DisLfYrNum: newResponse.DisLfYrNum || newResponse.disLfYrNum, DisPrsnYrNum: newResponse.DisPrsnYrNum || newResponse.disPrsnYrNum, // Usage and vehicle system codes UsageCode: newResponse.UsageCode || newResponse.usgCod, VehicleSystemCode: newResponse.VehicleSystemCode || newResponse.vehSysCod, CarGroupCode: newResponse.CarGroupCode || newResponse.carGrpCod, // Policy information ThirdPolicyCode: newResponse.ThirdPolicyCode || newResponse.ThirdPolicyCode, // Endorsement data EdrsJson: newResponse.EdrsJson || (newResponse.edrSes ? JSON.stringify(newResponse.edrSes) : undefined), // Insurance fullname InsuranceFullName: newResponse.InsuranceFullName || newResponse.fullname, // National code nationalCode: newResponse.nationalCode || newResponse.ntnlId, }; } async getSandHubResponse( userDetail: SandHubDetailDto, options?: SandHubInquiryOptions, ) { try { const requestPayload = { leftTwoDigits: String(userDetail.plate.leftDigits), serialLetter: String(userDetail.plate.centerAlphabet), threeDigits: String(userDetail.plate.centerDigits), rightTwoDigits: String(userDetail.plate.ir), nationalCode: userDetail.nationalCodeOfInsurer, }; const base = process.env.SANHUB_BASE_URL ?? ""; const requestUrl = `${base}/block-inquiry-tejarat`; let response: any; if (await this.isInquiryLive("thirdPartyPlate", options)) { response = await this.makeSandHubRequest( requestUrl, requestPayload, "thirdPartyPlate", options, ); } else { this.logger.debug( `[MOCK] getSandHubResponse plate=${JSON.stringify(requestPayload)}`, ); const ctx = await this.mockCompanyContext(options); response = this.buildMockPlateInquiryRaw(ctx); } const result = this.mapNewApiResponseToOldFormat(response); // if (result.usgCod !== "8") { // throw new Error("خودرو شما شخصی / سواری نمی باشد") // } return result; } catch (err) { throw new Error(err); } } /** * Personal identity check. * - CLIENT_ID=8 (Parsian/ESG): Jalali birth date sent as-is to `/inquiry/person`. * - Other tenants: Tejarat/SandHub hub with Gregorian conversion. */ async getPersonalInquiry( nationalCode: string, birthDate: number | string, options?: SandHubInquiryOptions, ) { try { if (this.shouldUseEsgInquiryProvider()) { const jalaliBirthDate = this.normalizeJalaliBirthDateForEsg(birthDate); if (!jalaliBirthDate) { throw new BadRequestException( `Invalid birth date for personal inquiry: ${birthDate}. Expected a Jalali date (e.g. 13781124 or "1378-11-24").`, ); } const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085"; const requestPayload = { nationalCode: String(nationalCode), birthDate: jalaliBirthDate, dateHasPostfix: 0, }; const requestUrl = `${baseUrl}/inquiry/person`; const live = await this.isInquiryLive("personalIdentity", options); if (!live) { this.logger.debug( `[MOCK] getEsgPersonInquiry nationalCode=${nationalCode} birthDate=${jalaliBirthDate}`, ); return this.getDefaultMockPersonInquiry(String(nationalCode)); } const raw = await this.makeEsgRequest( requestUrl, requestPayload, "personalIdentity", options, ); return this.mapEsgPersonInquiryToOldFormat(raw); } const requestUrl = `${process.env.SANHUB_BASE_URL}/personal-inquiry/tejarat-no`; const gregorianBirthdate = jalaliToGregorianDate(birthDate); if (!gregorianBirthdate) { throw new BadRequestException( `Invalid birth date for personal inquiry: ${birthDate}. Expected a Jalali date (e.g. 13770624 or "1377-06-24").`, ); } const requestPayload = { nationalCode, birthdate: gregorianBirthdate, }; const response = await this.makeSandHubRequest( requestUrl, requestPayload, "personalIdentity", options, ); if (response?.message?.includes("err.record.not.found")) { throw new NotFoundException( "Personal inquiry failed: Record not found for the given national code and birth date.", ); } return response.data; } catch (err) { if ( err instanceof BadRequestException || err instanceof NotFoundException ) { throw err; } throw new Error(`Error in finding personal inquiry: ${err}`); } } async getDrivingLicenseInfo( nationalCode: string, driverLicenseNumber: string, options?: SandHubInquiryOptions, ) { const requestUrl = `${process.env.SANHUB_BASE_URL}/driver-license-check`; const requestPayload = { driverLicenseNumber, nationalCode, }; this.logger.log( `Fetching driving license info for national code: ${nationalCode}`, ); try { const response = await this.makeSandHubRequest( requestUrl, requestPayload, "drivingLicense", options, ); if (response?.data?.IsSucceed === false) { throw new NotFoundException( "Driving license check failed: The license is not valid or could not be found.", ); } return response.data; } catch (error) { if ( error instanceof BadGatewayException && error.message.includes("multiple retries") ) { throw new BadGatewayException( `Driving license check failed after multiple retries. The service may be down.`, ); } // For all other errors (like 400, 404, etc.), re-throw them as-is. throw new Error(`Error in finding driving license: ${error}`); } } async getCarOwnershipInfo( plate: any, nationalCode: string, options?: SandHubInquiryOptions, ) { try { const requestUrl = `${process.env.SANHUB_BASE_URL}/ownership`; const requestPayload = { Plk1: String(plate.leftDigits), Plk2: String(plate.centerAlphabet), Plk3: String(plate.centerDigits), plkSrl: String(plate.ir), nationalCode: nationalCode, }; this.logger.log( `Checking car ownership for national code: ${nationalCode}`, ); const response = await this.makeSandHubRequest( requestUrl, requestPayload, "carOwnership", options, ); // Check the 'IsSuccess' field in the nested 'data' object. if (response?.data?.IsSuccess === false) { this.logger.warn( `Car ownership check failed for national code ${nationalCode}. Response:`, response, ); throw new BadRequestException( "Ownership validation failed: The provided national ID is not the owner of this vehicle.", ); } return response; } catch (err) { throw new Error(`Error in finding car ownership: ${err}`); } } async getShebaValidation( nationalId: string, shebaId: string, options?: SandHubInquiryOptions, ) { try { if (this.shouldUseEsgInquiryProvider()) { const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085"; const requestPayload = { accountOwnerType: "1", nationalCode: String(nationalId), legalId: "", sheba: String(shebaId), }; const requestUrl = `${baseUrl}/inquiry/sheba`; const live = await this.isInquiryLive("sheba", options); this.logger.log( `Validating Sheba ID via ESG for national code: ${nationalId}`, ); if (!live) { this.logger.debug( `[MOCK] getEsgShebaInquiry nationalCode=${nationalId} sheba=${shebaId}`, ); return { ReturnValue: true, HasError: false, Message: "mock-sheba-ok", }; } const raw = await this.makeEsgRequest( requestUrl, requestPayload, "sheba", options, ); const response = this.mapEsgShebaInquiryToOldFormat(raw); if (response?.ReturnValue === false || response?.HasError === true) { this.logger.warn( `Sheba validation failed for national code ${nationalId}. Response:`, response, ); throw new BadRequestException( "Sheba ID validation failed. The provided Sheba ID does not match the national ID.", ); } return response; } const requestUrl = `${process.env.SANHUB_BASE_URL}/sheba/sheba-tejaratno`; const requestPayload = { AccountOwnerType: "1", NationalId: nationalId, ShebaId: shebaId, }; this.logger.log(`Validating Sheba ID for national code: ${nationalId}`); const response = await this.makeSandHubRequest( requestUrl, requestPayload, "sheba", options, ); if (response?.ReturnValue === false || response?.HasError === true) { this.logger.warn( `Sheba validation failed for national code ${nationalId}. Response:`, response, ); throw new BadRequestException( "Sheba ID validation failed. The provided Sheba ID does not match the national ID.", ); } return response; } catch (err) { if (err instanceof BadRequestException) { throw err; } throw new Error(`Error in matching sheba validation: ${err}`); } } private handleSandHubError( err: any, attempt: number, maxRetries: number, ): void { if (err.response) { this.logger.error( `❌ SandHub responded with status ${err.response.status} on attempt ${attempt + 1}:`, JSON.stringify(err.response.data), ); if (err.response.status === 400) { throw new BadGatewayException( `SandHub rejected the request with a 400 Bad Request. Details: ${JSON.stringify(err.response.data)}`, ); } } else { this.logger.error( `❌ Network error during SandHub request (attempt ${attempt + 1}):`, err.message, ); } if (err.message === "EMPTY_RESPONSE") { throw new BadGatewayException( "SandHub is offline or returned an empty response", ); } if (err.code === "ECONNABORTED") { throw new GatewayTimeoutException("SandHub request timed out"); } if (err.code === "ECONNRESET" || err.message.includes("socket hang up")) { throw new ServiceUnavailableException( "SandHub connection was reset or closed unexpectedly", ); } // This final check is for when all retries have failed for a retryable error. if (attempt >= maxRetries) { throw new BadGatewayException( "Failed to fetch data from SandHub after multiple retries", err.message, ); } } async sandHubDocumentCreator( userId: string, requestId: string, doc: Partial, ) { const existingDoc = await this.sandHubDbService.findOneByRequestId( requestId, userId, ); if (!existingDoc) { return await this.sandHubDbService.create({ ...doc, userId, requestId }); } else { const updatePayload = { ...existingDoc, ...doc }; return await this.sandHubDbService.findOneByRequestIdAndUpdate( requestId, userId, updatePayload, ); } } }