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 { SystemSettingsService } from "src/system-settings/system-settings.service"; import { SandHubDetailDto } from "./dto/sand-hub.dto"; import { jalaliToGregorianDate } from "src/helpers/date-jalali"; import { firstValueFrom } from "rxjs"; @Injectable() export class SandHubService { 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; constructor( private readonly httpService: HttpService, private readonly sandHubDbService: SandHubDbService, private readonly systemSettingsService: SystemSettingsService, ) {} /** * When false, no outbound HTTP to SandHub/Tejarat — inquiries use permissive mocks. * Controlled by `system_settings.externalApis.sandHubUseLiveApi` (PATCH /system-settings). */ private async useLiveSandHubApis(): Promise { return this.systemSettingsService.isSandHubLiveEnabled(); } /** Fixed plate/insurance inquiry payload used everywhere we mock block-inquiry style APIs. */ private getDefaultMockPlateInquiryRaw(): Record { return { PrntPlcyCmpDocNo: "1403/1143-70591/200/35", MapTypNam: "پرايد هاچ بک -111", MtrNum: "5215907", ShsNum: "NAS431100E5798656", DisFnYrNum: "0", DisLfYrNum: "0", DisPrsnYrNum: "0", DisPrsnYrPrcnt: "0", DisFnYrPrcnt: "0", DisLfYrPrcnt: "0", vin: "IRPC941V2BD798656", MapVehicleSystemName: "ثبت نشده", LfCvrCptl: 0, FnCvrCptl: 0, PrsnCvrCptl: 0, VehicleSystemCode: 1, EdrsJson: '[{"id":1,"Dsc":" الحاقيه توضيحات ندارد"}]', PersonCvrCptl: 12000000000, LifeCvrCptl: 16000000000, FinancialCvrCptl: 4000000000, CarGroupCode: 3, CylCnt: 4, LastCompanyDocumentNumber: "03/1031/2835/1001/662", UsageCode: "8", MapUsageCode: 1, MapUsageName: "شخصي", Plk1: 59, Plk2: 16, Plk3: 419, PlkSrl: 78, PrintEndorsCompanyDocumentNumber: "بيمه نامه الحاقيه ندارد.", EndorseDate: null, InsuranceFullName: null, SystemField: "سايپا", TypeField: "111SE", UsageField: "سواري", MainColorField: "سفيد", SecondColorField: "سفيد شيري", ModelField: "1394", CapacityField: "جمعا 4 نفر", CylinderNumberField: "4", EngineNumberField: "5215907", ChassisNumberField: "NAS431100E5798656", VinNumberField: "IRPC941V2BD798656", InstallDateField: "1403/03/01", AxelNumberField: "2", WheelNumberField: "4", CompanyName: "بیمه سامان", CompanyCode: "15", 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: "پرايد هاچ بک -111", UsageNameCii: "سواري", UsageCodeCii: 1, ModelCii: 1394, damageTypes: [], StatusTypeCode: 1, }; } /** * Bodies returned from `makeSandHubRequest` in mock mode (same shape callers expect from real API). */ private buildMockSandHubResponseForUrl(url: string, payload?: unknown): 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("block-inquiry") || u.includes("tejarat")) { return this.mapNewApiResponseToOldFormat( this.getDefaultMockPlateInquiryRaw(), ); } this.logger.warn( `[MOCK] Unrecognized SandHub URL; returning generic OK: ${url}`, ); return { data: {}, message: "mock-ok" }; } private async getAccessToken(): Promise { if (!(await this.useLiveSandHubApis())) { return "mock-sandhub-access-token"; } 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 (!(await this.useLiveSandHubApis())) { return "mock-tejarat-access-token"; } 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 makeTejaratRequest(url: string, payload: any, maxRetries = 2) { if (!(await this.useLiveSandHubApis())) { this.logger.log(`[MOCK] Tejarat POST skipped: ${url}`); return this.getDefaultMockPlateInquiryRaw(); } 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): Promise<{ raw: any; mapped: any; }> { 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.useLiveSandHubApis(); const raw = live ? await this.makeTejaratRequest(requestUrl, requestPayload) : this.getDefaultMockPlateInquiryRaw(); 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); } private async makeSandHubRequest(url: string, payload: any, maxRetries = 3) { if (!(await this.useLiveSandHubApis())) { this.logger.log(`[MOCK] SandHub POST skipped: ${url}`); return this.buildMockSandHubResponseForUrl(url, payload); } 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) { 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.useLiveSandHubApis()) { response = await this.makeSandHubRequest(requestUrl, requestPayload); } else { this.logger.debug( `[MOCK] getSandHubResponse plate=${JSON.stringify(requestPayload)}`, ); response = this.getDefaultMockPlateInquiryRaw(); } const result = this.mapNewApiResponseToOldFormat(response); // if (result.usgCod !== "8") { // throw new Error("خودرو شما شخصی / سواری نمی باشد") // } return result; } catch (err) { throw new Error(err); } } /** * Personal identity check against the Tejarat hub. The upstream gateway only * accepts a Gregorian birthdate in `YYYY-MM-DD` form, but every caller in * this codebase has the value as a Jalali date (number `13770624` or string * `"1377-06-24"`/`"1377/06/24"`). We convert here so callers don't have to. */ async getPersonalInquiry(nationalCode: string, birthDate: number | string) { try { 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, ); 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, ) { 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, ); 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) { 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, ); // 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) { try { 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, ); 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) { 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, ); } } }