1
0
forked from Yara724/api
Files
yara724-api/src/sand-hub/sand-hub.service.ts
2026-06-15 16:24:49 +03:30

891 lines
29 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();
}
/** Tenant-specific company fields for mocked external inquiries (MOCK_INQUIRY_COMPANY_*). */
private getMockInquiryCompanyId(): string {
return process.env.CLIENT_ID ?? "15";
}
private getMockInquiryCompanyName(): string {
return process.env.CLIENT_NAME ?? "بیمه سامان";
}
/** Fixed plate/insurance inquiry payload used everywhere we mock block-inquiry style APIs. */
private getDefaultMockPlateInquiryRaw(): Record<string, unknown> {
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: process.env.CLIENT_NAME ?? "بیمه سامان",
CompanyCode: `${process.env.CLIENT_ID ?? 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: "فائو ( FAW )",
UsageNameCii: "سواري",
UsageCodeCii: 1,
ModelCii: 2024,
damageTypes: [],
StatusTypeCode: 1,
};
}
private getDefaultMockCarBodyInquiryRaw(
userDetail: SandHubDetailDto,
): Record<string, unknown> {
const companyId = Number(this.getMockInquiryCompanyId());
return {
data: {
id: 70016075946,
printNumber: "1405/1143-70591/220/1/0",
companyId: Number.isNaN(companyId) ? 15 : companyId,
companyName: this.getMockInquiryCompanyName(),
beginDate: "1405/01/17",
endDate: "1406/01/17",
issueDate: "1405/01/16",
hasEndorsement: null,
motorNumber: "TZ196XYAP223A210074",
chassisNumber: "LFP8C7PC3R1K12157",
vin: "LFP8C7PC3R1K12157",
plateTypeId: 9,
plateTypeTitle: "پلاک قدیمی",
platePartOne: 16,
plateSerialNumber: 60,
plateLetterid: 12,
plateLetterTitle: "م ",
vehicleSystemTitle: "فائو ( FAW )",
vehicleGroupId: 2,
vehicleGroupTitle: "سواری چهار سیلندر",
insurerNationalCode: userDetail.nationalCodeOfInsurer,
insurerName: "هانيه کارخانهء",
ownerNationalCode: userDetail.nationalCodeOfInsurer,
previousId: 70006331822,
noLossYearsCount: 4,
platePartThree: 498,
lossDocuments: [],
},
isSuccess: true,
statusCode: 200,
message: "عملیات با موفقیت انجام شد",
};
}
/**
* 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);
}
async getTejaratCarBodyInquiry(userDetail: SandHubDetailDto): Promise<{
raw: any;
mapped: Record<string, unknown>;
}> {
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.useLiveSandHubApis();
let raw: any;
if (live) {
const token = await this.getTejaratAccessToken();
try {
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;
}
} else {
// Mock — reuse the default mock and adapt it to the car-body shape
this.logger.debug(
`[MOCK] getTejaratCarBodyInquiry plate=${JSON.stringify(requestPayload)}`,
);
raw = this.getDefaultMockCarBodyInquiryRaw(userDetail);
}
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<string, unknown> {
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, 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);
console.log(gregorianBirthdate);
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,
);
}
}
}