Files
yara724api/src/sand-hub/sand-hub.service.ts
2026-05-30 17:15:21 +03:30

775 lines
24 KiB
TypeScript

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<boolean> {
return this.systemSettingsService.isSandHubLiveEnabled();
}
/** Fixed plate/insurance inquiry payload used everywhere we mock block-inquiry style APIs. */
private getDefaultMockPlateInquiryRaw(): Record<string, unknown> {
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<string> {
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<string> {
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);
}
/**
* Tejarat block inquiry for Car Body Insurance (Badane).
* Returns both raw API response and mapped old-format response structure.
*/
async getTejaratCarBodyInquiry(userDetail: SandHubDetailDto): Promise<{
raw: any;
}> {
const baseUrl =
process.env.TEJARAT_INQUIRY_BASE_URL ?? "http://82.99.202.245:3027";
// 1. Build payload following the strict data requirements of the external API
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.useLiveSandHubApis();
let raw: any;
if (live) {
// 2. Safely obtain a fresh or cached access token using your helper
const token = await this.getTejaratAccessToken();
try {
// 3. Make the live call using NestJS HttpService + firstValueFrom pattern
const response = await firstValueFrom(
this.httpService.post(requestUrl, requestPayload, {
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
timeout: 30000,
}),
);
raw = response.data;
} catch (error) {
this.logger.error(
`[TEJARAT BADANE ERROR] Request failed for ${requestUrl}: ${error?.message || error}`,
error?.stack,
);
throw error;
}
}
return { raw };
}
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<SandHubModel>,
) {
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,
);
}
}
}