diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 18ca9db..6a2c4b1 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -134,6 +134,30 @@ function formatJalaliCompact(raw: number | string | null | undefined): string | export class RequestManagementService { private readonly logger = new Logger(RequestManagementService.name); + /** + * Only the guilty (currently first) party's THIRD_PARTY policy must belong + * to this deployment. CAR_BODY flows deliberately do not constrain that + * separate third-party policy; their car-body lookup is checked on its own. + */ + private policyInquiryOptions( + requestType: BlameRequestType | string, + partyRole: PartyRole, + clientId?: string, + ) { + const enforceDeploymentClientMatch = + requestType === BlameRequestType.THIRD_PARTY && + partyRole === PartyRole.FIRST; + + if (!clientId && !enforceDeploymentClientMatch) return undefined; + + return { + ...(clientId ? { clientId } : {}), + ...(enforceDeploymentClientMatch + ? { enforceDeploymentClientMatch: true } + : {}), + }; + } + private stepKeyToPartyRole(stepKey: WorkflowStep): PartyRole { if (String(stepKey).startsWith("FIRST_")) return PartyRole.FIRST; if (String(stepKey).startsWith("SECOND_")) return PartyRole.SECOND; @@ -1283,9 +1307,11 @@ export class RequestManagementService { const inquiryClientId = party.person?.clientId ? String(party.person.clientId) : undefined; - const inquiryOptions = inquiryClientId - ? { clientId: inquiryClientId } - : undefined; + const inquiryOptions = this.policyInquiryOptions( + req.type, + role, + inquiryClientId, + ); try { const inquiry = await this.sandHubService.getTejaratBlockInquiry( { @@ -1513,15 +1539,10 @@ export class RequestManagementService { if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) { let carBodyInfo: any; try { - carBodyInfo = await this.sandHubService.getCarBodyInquiry( - { - nationalCodeOfInsurer: body.nationalCodeOfInsurer, - plate: body.plate, - }, - resolvedClientId - ? { clientId: String(resolvedClientId) } - : inquiryOptions, - ); + carBodyInfo = await this.sandHubService.getCarBodyInquiry({ + nationalCodeOfInsurer: body.nationalCodeOfInsurer, + plate: body.plate, + }); this.recordPartyCaseInquiryStatus(req, "carBody", role, true, { source: carBodyInfo.source, raw: carBodyInfo.raw, @@ -1763,9 +1784,11 @@ export class RequestManagementService { const inquiryClientId = party.person?.clientId ? String(party.person.clientId) : undefined; - const inquiryOptions = inquiryClientId - ? { clientId: inquiryClientId } - : undefined; + const inquiryOptions = this.policyInquiryOptions( + req.type, + role, + inquiryClientId, + ); let inquiryRaw: any; let inquiryMapped: any; @@ -1952,6 +1975,79 @@ export class RequestManagementService { inquiryMapped?.HEndDte || inquiryMapped?.persianEndDate; + // CAR_BODY eligibility is independent of the third-party policy above. + // The VIN flow must make the same deployment-scoped lookup as the plate + // flow before the case is allowed to advance. + if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) { + try { + const carBodyInfo = await this.sandHubService.getCarBodyInquiry({ + nationalCodeOfInsurer: body.nationalCodeOfInsurer, + plate: body.vin, + }); + this.recordPartyCaseInquiryStatus(req, "carBody", role, true, { + source: carBodyInfo.source, + raw: carBodyInfo.raw, + mapped: carBodyInfo.mapped, + }); + party.vehicle.inquiry = { + ...party.vehicle.inquiry, + carBody: { + source: carBodyInfo.source, + raw: carBodyInfo.raw, + mapped: carBodyInfo.mapped, + }, + }; + + const m = carBodyInfo.mapped as any; + (party.insurance as any).carBodyInsurance = { + policyNumber: m.policyNumber ?? null, + companyId: m.companyId ?? m.CompanyCode ?? null, + companyName: m.CompanyName ?? null, + insurerName: m.insurerName ?? null, + chassisNumber: m.ChassisNumberField ?? null, + vin: m.VinNumberField ?? null, + motorNumber: m.EngineNumberField ?? null, + startDate: m.StartDate ?? null, + endDate: m.EndDate ?? null, + issueDate: m.IssueDate ?? null, + noLossYearsCount: m.noLossYearsCount ?? null, + lossDocuments: m.lossDocuments ?? [], + }; + + const carBodyCompanyCode = m.companyId ?? m.CompanyCode; + const carBodyCompanyName = m.CompanyName ?? m.companyPersianName; + if (carBodyCompanyCode && carBodyCompanyName) { + const carBodyClient = + await this.clientService.findOrCreateClientByCompanyCode( + carBodyCompanyCode, + carBodyCompanyName, + ); + const carBodyClientId = + (carBodyClient as any)?._id ?? (carBodyClient as any)?._doc?._id; + if (carBodyClientId) party.person.clientId = carBodyClientId; + } + } catch (err: any) { + this.logger.error( + `[CAR_BODY] VIN inquiry failed for request=${req._id}: ${err?.message || err}`, + ); + this.recordPartyCaseInquiryStatus( + req, + "carBody", + role, + false, + {}, + err, + ); + await this.blameRequestDbService.findByIdAndUpdate(req._id, { + $set: { inquiries: req.inquiries }, + }); + throw new HttpException( + "Car body inquiry failed", + HttpStatus.BAD_REQUEST, + ); + } + } + // Advance workflow await this.advanceWorkflowToNext(req, stepKey); @@ -2934,6 +3030,13 @@ export class RequestManagementService { body: AddPlateDto, partyType: "firstParty" | "secondParty", ) { + if (request.type === "THIRD_PARTY" && partyType === "firstParty") { + this.sandHubService.assertInsuranceMatchesDeployment( + sandHubReport, + "THIRD_PARTY", + ); + } + const clientName = sandHubReport?.CompanyName; const companyCode = sandHubReport?.CompanyCode; @@ -3024,12 +3127,10 @@ export class RequestManagementService { // For CAR_BODY type, persist the provider response and its mapped fields. if (request.type === "CAR_BODY" && partyType === "firstParty") { - const carBodyInquiry = await this.sandHubService.getCarBodyInquiry( - { - nationalCodeOfInsurer: body.nationalCodeOfInsurer, - plate: body.plate, - } as any, - ); + const carBodyInquiry = await this.sandHubService.getCarBodyInquiry({ + nationalCodeOfInsurer: body.nationalCodeOfInsurer, + plate: body.plate, + } as any); const carBodyInfo = carBodyInquiry.mapped as any; this.logger.log( @@ -6470,6 +6571,13 @@ export class RequestManagementService { "secondParty and guiltyPartyPhoneNumber are required.", ); } + if ( + formData.guiltyPartyPhoneNumber !== formData.firstPartyPhoneNumber + ) { + throw new BadRequestException( + "The first party is the guilty party in this flow.", + ); + } if (!formData?.expertDescription?.desc) { throw new BadRequestException("expertDescription.desc is required."); } @@ -6508,6 +6616,12 @@ export class RequestManagementService { throw err; } const sandHubReport = (sandHubResponse["_doc"] || sandHubResponse) as any; + if (role === PartyRole.FIRST) { + this.sandHubService.assertInsuranceMatchesDeployment( + sandHubReport, + "THIRD_PARTY", + ); + } this.recordPartyCaseInquiryStatus( req, "thirdParty", @@ -7119,15 +7233,13 @@ export class RequestManagementService { ); } - // Validate guilty party phone number matches one of the parties + // The first party is the guilty party in every supported THIRD_PARTY flow. if (request.type === "THIRD_PARTY") { - const guiltyMatchesFirst = - formData.guiltyPartyPhoneNumber === formData.firstPartyPhoneNumber; - const guiltyMatchesSecond = - formData.guiltyPartyPhoneNumber === formData.secondParty.phoneNumber; - if (!guiltyMatchesFirst && !guiltyMatchesSecond) { + if ( + formData.guiltyPartyPhoneNumber !== formData.firstPartyPhoneNumber + ) { throw new BadRequestException( - "Guilty party phone number must match either first or second party phone number", + "The first party is the guilty party in this flow.", ); } } @@ -7199,6 +7311,10 @@ export class RequestManagementService { nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, }); const sandHubReport = sandHubResponse["_doc"] || sandHubResponse; + this.sandHubService.assertInsuranceMatchesDeployment( + sandHubReport, + "THIRD_PARTY", + ); const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName; @@ -8693,7 +8809,7 @@ export class RequestManagementService { const roleLabel = partyRole === PartyRole.FIRST ? "FIRST" : "SECOND"; let clientId: string | undefined; const inquiryOptions = (cid?: string) => - cid ? { clientId: cid } : undefined; + this.policyInquiryOptions(req.type, partyRole, cid); let inquiryRaw: any; let inquiryMapped: any; @@ -8813,13 +8929,10 @@ export class RequestManagementService { partyRole === PartyRole.FIRST ) { try { - const carBodyInfo = await this.sandHubService.getCarBodyInquiry( - { - nationalCodeOfInsurer: partyData.nationalCodeOfInsurer, - plate: partyData.plate as any, - }, - clientId ? { clientId } : undefined, - ); + const carBodyInfo = await this.sandHubService.getCarBodyInquiry({ + nationalCodeOfInsurer: partyData.nationalCodeOfInsurer, + plate: partyData.plate as any, + }); this.recordPartyCaseInquiryStatus(req, "carBody", partyRole, true, { source: carBodyInfo.source, raw: carBodyInfo.raw, @@ -9548,7 +9661,7 @@ export class RequestManagementService { const roleLabel = partyRole === PartyRole.FIRST ? "FIRST" : "SECOND"; let clientId: string | undefined; const inquiryOptions = (cid?: string) => - cid ? { clientId: cid } : undefined; + this.policyInquiryOptions(req.type, partyRole, cid); let inquiryRaw: any; let inquiryMapped: any; @@ -9668,13 +9781,10 @@ export class RequestManagementService { partyRole === PartyRole.FIRST ) { try { - const carBodyInfo = await this.sandHubService.getCarBodyInquiry( - { - nationalCodeOfInsurer: partyData.nationalCodeOfInsurer, - plate: partyData.vin as any, // VIN used as identifier for CAR_BODY - }, - clientId ? { clientId } : undefined, - ); + const carBodyInfo = await this.sandHubService.getCarBodyInquiry({ + nationalCodeOfInsurer: partyData.nationalCodeOfInsurer, + plate: partyData.vin as any, // VIN used as identifier for CAR_BODY + }); this.recordPartyCaseInquiryStatus(req, "carBody", partyRole, true, { source: carBodyInfo.source, raw: carBodyInfo.raw, diff --git a/src/sand-hub/dto/sand-hub.dto.ts b/src/sand-hub/dto/sand-hub.dto.ts index 4cdf1e2..ed3cff2 100644 --- a/src/sand-hub/dto/sand-hub.dto.ts +++ b/src/sand-hub/dto/sand-hub.dto.ts @@ -9,6 +9,9 @@ export class SandHubInquiryOptionsDto { "Insurer client Mongo ObjectId. When omitted, resolves from deployment CLIENT_ID env.", }) clientId?: string; + + /** Require the returned policy insurer to match the deployment CLIENT_ID. */ + enforceDeploymentClientMatch?: boolean; } export type SandHubInquiryOptions = SandHubInquiryOptionsDto; diff --git a/src/sand-hub/sand-hub.service.spec.ts b/src/sand-hub/sand-hub.service.spec.ts index 699f100..be19633 100644 --- a/src/sand-hub/sand-hub.service.spec.ts +++ b/src/sand-hub/sand-hub.service.spec.ts @@ -1,4 +1,8 @@ import { SandHubService } from "./sand-hub.service"; +import { + ForbiddenException, + ServiceUnavailableException, +} from "@nestjs/common"; import { ExternalInquirySettingsService } from "src/client/external-inquiry-settings.service"; import { SandHubDetailDto } from "./dto/sand-hub.dto"; @@ -34,7 +38,7 @@ describe("SandHubService inquiry mocks", () => { beforeEach(() => { jest.clearAllMocks(); - delete process.env.CLIENT_ID; + process.env.CLIENT_ID = "8"; delete process.env.FANAVARAN_CLIENT; service = new SandHubService( httpService as any, @@ -132,6 +136,87 @@ describe("SandHubService inquiry mocks", () => { expect(result.mapped.PrntPlcyCmpDocNo).toBe("REAL-ESG-POLICY"); }); + it("rejects a guilty third-party policy issued by another insurer", async () => { + process.env.CLIENT_ID = "15"; + externalInquirySettings.isInquiryLive.mockResolvedValue(true); + jest.spyOn(service as any, "makeTejaratRequest").mockResolvedValue({ + CompanyCode: "8", + CompanyName: "بیمه پارسیان", + PrntPlcyCmpDocNo: "OTHER-INSURER-POLICY", + }); + + await expect( + service.getTejaratBlockInquiry(userDetail, { + enforceDeploymentClientMatch: true, + } as any), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it("allows an unchecked third-party policy from another insurer", async () => { + process.env.CLIENT_ID = "15"; + externalInquirySettings.isInquiryLive.mockResolvedValue(true); + jest.spyOn(service as any, "makeTejaratRequest").mockResolvedValue({ + CompanyCode: "8", + CompanyName: "بیمه پارسیان", + }); + + await expect(service.getTejaratBlockInquiry(userDetail)).resolves.toEqual( + expect.objectContaining({ + mapped: expect.objectContaining({ CompanyCode: "8" }), + }), + ); + }); + + it("rejects a guilty VIN third-party policy issued by another insurer", async () => { + process.env.CLIENT_ID = "15"; + externalInquirySettings.isInquiryLive.mockResolvedValue(true); + jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({ + success: true, + data: { CmpCod: "8", CmpNam: "بیمه پارسیان" }, + }); + + await expect( + service.getPolicyByChassisInquiry("NAAR03HFFRDE07024", { + enforceDeploymentClientMatch: true, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it("does not accept a policy when CLIENT_ID is not configured", async () => { + delete process.env.CLIENT_ID; + + await expect(service.getCarBodyInquiry(userDetail)).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + }); + + it("rejects a CAR_BODY policy from another insurer on the Parsian lookup route", async () => { + process.env.CLIENT_ID = "15"; + process.env.FANAVARAN_CLIENT = "parsian"; + externalInquirySettings.isInquiryLive.mockResolvedValue(true); + lookupsService.findLastProcessedCarPolicy.mockResolvedValue({ + policy: { CINumber: "70019846985" }, + customer: {}, + vehicle: {}, + }); + + await expect(service.getCarBodyInquiry(userDetail)).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); + + it("rejects a CAR_BODY policy from another insurer on the Tejarat lookup route", async () => { + process.env.CLIENT_ID = "15"; + jest.spyOn(service, "getTejaratCarBodyInquiry").mockResolvedValue({ + raw: { data: { companyId: "8" } }, + mapped: { companyId: "8", CompanyCode: "8" }, + }); + + await expect(service.getCarBodyInquiry(userDetail)).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); + it("uses the processed CAR_BODY lookup when the active Fanavaran client is Parsian", async () => { process.env.FANAVARAN_CLIENT = "parsian"; externalInquirySettings.isInquiryLive.mockResolvedValue(true); diff --git a/src/sand-hub/sand-hub.service.ts b/src/sand-hub/sand-hub.service.ts index d489e14..40b0706 100644 --- a/src/sand-hub/sand-hub.service.ts +++ b/src/sand-hub/sand-hub.service.ts @@ -2,6 +2,7 @@ import { HttpService } from "@nestjs/axios"; import { BadGatewayException, BadRequestException, + ForbiddenException, GatewayTimeoutException, Injectable, Logger, @@ -89,6 +90,60 @@ export class SandHubService { return resolveFanavaranClientKey() === "parsian"; } + /** + * A case may proceed only when the policy belongs to the insurer served by + * this deployment. Callers opt in for the guilty/first-party policy only. + */ + assertInsuranceMatchesDeployment( + mapped: Record | null | undefined, + insuranceLine: "THIRD_PARTY" | "CAR_BODY", + ): void { + const expectedClientCode = String(process.env.CLIENT_ID ?? "").trim(); + if (!expectedClientCode) { + throw new ServiceUnavailableException( + "CLIENT_ID must be configured before insurance eligibility can be checked.", + ); + } + + const actualClientCode = String( + mapped?.CompanyCode ?? mapped?.companyId ?? mapped?.CompanyId ?? "", + ).trim(); + if (actualClientCode === expectedClientCode) return; + + throw new ForbiddenException( + `${insuranceLine} policy insurer does not match this deployment.`, + ); + } + + private enforceDeploymentClientMatch( + mapped: Record, + insuranceLine: "THIRD_PARTY" | "CAR_BODY", + options?: SandHubInquiryOptions, + ): void { + if (options?.enforceDeploymentClientMatch) { + this.assertInsuranceMatchesDeployment(mapped, insuranceLine); + } + } + + /** + * The processed Fanavaran CAR_BODY endpoint is a Parsian-only source and its + * response does not contain a reliable insurer company code. Treat the source + * itself as proof that the returned policy is Parsian. + */ + private assertParsianCarBodyLookupMatchesDeployment(): void { + const expectedClientCode = String(process.env.CLIENT_ID ?? "").trim(); + if (!expectedClientCode) { + throw new ServiceUnavailableException( + "CLIENT_ID must be configured before insurance eligibility can be checked.", + ); + } + if (expectedClientCode === "8") return; + + throw new ForbiddenException( + "CAR_BODY policy insurer does not match this deployment.", + ); + } + /** Fixed plate/insurance inquiry payload used everywhere we mock block-inquiry style APIs. */ private buildMockPlateInquiryRaw( ctx: MockInquiryCompanyContext, @@ -779,6 +834,11 @@ export class SandHubService { ir: String(userDetail.plate.ir), }); if (offlineHit) { + this.enforceDeploymentClientMatch( + offlineHit.mapped, + "THIRD_PARTY", + options, + ); return { raw: offlineHit.raw, mapped: offlineHit.mapped, @@ -818,6 +878,7 @@ export class SandHubService { ); } const mapped = this.mapEsgPolicyByPlateToOldFormat(raw); + this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options); return { raw, mapped }; } @@ -846,6 +907,7 @@ export class SandHubService { ); } const mapped = this.mapNewApiResponseToOldFormat(raw); + this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options); return { raw, mapped }; } @@ -874,6 +936,7 @@ export class SandHubService { userDetail as SandHubDetailDto, options, ); + this.assertInsuranceMatchesDeployment(result.mapped, "CAR_BODY"); return { source: typeof userDetail.plate === "string" @@ -890,6 +953,7 @@ export class SandHubService { userDetail as SandHubDetailDto, options, ); + this.assertParsianCarBodyLookupMatchesDeployment(); return { source: typeof userDetail.plate === "string" @@ -917,6 +981,7 @@ export class SandHubService { "car-body", query, ); + this.assertParsianCarBodyLookupMatchesDeployment(); return { source: @@ -1057,6 +1122,7 @@ export class SandHubService { `[MOCK] getPolicyByChassisInquiry chassisNo=${chassisNo}`, ); const mapped = this.mapEsgPolicyByPlateToOldFormat(raw); + this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options); return { raw, mapped }; } @@ -1067,6 +1133,7 @@ export class SandHubService { options, ); const mapped = this.mapEsgPolicyByPlateToOldFormat(raw); + this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options); return { raw, mapped }; } @@ -1297,6 +1364,7 @@ export class SandHubService { } const result = this.mapNewApiResponseToOldFormat(response); + this.enforceDeploymentClientMatch(result, "THIRD_PARTY", options); // if (result.usgCod !== "8") { // throw new Error("خودرو شما شخصی / سواری نمی باشد") @@ -1304,6 +1372,12 @@ export class SandHubService { return result; } catch (err) { + if ( + err instanceof ForbiddenException || + err instanceof ServiceUnavailableException + ) { + throw err; + } throw new Error(err); } }