import { BadRequestException } from "@nestjs/common"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { jalaliToGregorianDate } from "src/helpers/date-jalali"; import { gregorianDateInIran } from "src/helpers/iran-datetime"; import { InquiryParticipantFieldsDto, InquiryParticipantInputDto, InquiryParticipantRole, InquiryVehicleInputDto, VehicleRegistrationState, } from "src/common/dto/inquiry-participants.dto"; export interface ResolvedInquiryParticipant { participantId: string; nationalCode: string; birthday: string; fullName?: string; hasDrivingLicense?: boolean; licenseNumber?: string; licenseType?: string; } export interface InquiryParticipantRoleAssignments { driver: string; vehicleOwner?: string; thirdPartyPolicyholder: string; carBodyPolicyholder?: string; } export interface ResolvedInquiryParticipants { participants: ResolvedInquiryParticipant[]; roles: InquiryParticipantRoleAssignments; legacy: boolean; } export interface NormalizedInquirySubmission> { dto: T & { nationalCodeOfDriver: string; driverBirthday: string; driverLicense?: string; licenseType?: string; userNoCertificate?: boolean; nationalCodeOfInsurer: string; insurerBirthday: string; driverIsInsurer: boolean; insurerLicense?: string; }; participants: ResolvedInquiryParticipants; driver: ResolvedInquiryParticipant; vehicleOwner?: ResolvedInquiryParticipant; thirdPartyPolicyholder: ResolvedInquiryParticipant; carBodyPolicyholder?: ResolvedInquiryParticipant; vehicle?: ResolvedInquiryVehicle; } export interface InquirySubjects { thirdPartyPolicyNationalCode: string; carBodyPolicyNationalCode?: string; shebaNationalCode: string; driverNationalCode: string; } /** * The single routing seam for external inquiries. Policy checks belong to * their policyholder, Sheba belongs to the vehicle owner, and licence data * belongs to the driver even when those roles resolve to different people. */ export function resolveInquirySubjects( submission: NormalizedInquirySubmission>, ): InquirySubjects { if (!submission.vehicleOwner) { throw new BadRequestException( "Vehicle owner identity is required for Sheba validation.", ); } return { thirdPartyPolicyNationalCode: submission.thirdPartyPolicyholder.nationalCode, ...(submission.carBodyPolicyholder ? { carBodyPolicyNationalCode: submission.carBodyPolicyholder.nationalCode, } : {}), shebaNationalCode: submission.vehicleOwner.nationalCode, driverNationalCode: submission.driver.nationalCode, }; } export type ResolvedInquiryVehicle = InquiryVehicleInputDto & { registrationState: VehicleRegistrationState; }; function assertCompleteInquiryPlate( plate: InquiryVehicleInputDto["currentPlate"], path: "vehicle.currentPlate" | "vehicle.previousPlate", ): void { for (const field of [ "leftDigits", "centerAlphabet", "centerDigits", "ir", ] as const) { if (plate?.[field] == null || String(plate[field]).trim() === "") { throw new BadRequestException(`${path}.${field} is required.`); } } } export function participantForRole( resolved: ResolvedInquiryParticipants, role: InquiryParticipantRole, ): ResolvedInquiryParticipant | undefined { const roleField = ROLE_FIELDS[ role ] as keyof InquiryParticipantRoleAssignments; const participantId = resolved.roles[roleField]; return resolved.participants.find( (participant) => participant.participantId === participantId, ); } /** Resolve a participant from the role links persisted on a blame party. */ export function participantForStoredPartyRole( party: { participants?: Array>; participantRoles?: Partial; }, role: InquiryParticipantRole, ): Record | undefined { const roleField = ROLE_FIELDS[ role ] as keyof InquiryParticipantRoleAssignments; const participantId = party.participantRoles?.[roleField]; if (!participantId || !Array.isArray(party.participants)) return undefined; return party.participants.find( (participant) => participant?.participantId === participantId, ); } /** Remove fields retired from the participant contract from historical records. */ export function sanitizeStoredInquiryParticipants( participants: unknown, ): Array> | undefined { if (!Array.isArray(participants)) return undefined; return participants.map((participant) => { const plain = participant && typeof participant === "object" && typeof (participant as { toObject?: unknown }).toObject === "function" ? (participant as { toObject: () => Record }).toObject() : { ...(participant as Record) }; delete plain.unknown; return plain; }); } const ROLE_FIELDS: Record< InquiryParticipantRole, keyof InquiryParticipantFieldsDto > = { [InquiryParticipantRole.DRIVER]: "driver", [InquiryParticipantRole.VEHICLE_OWNER]: "vehicleOwner", [InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER]: "thirdPartyPolicyholder", [InquiryParticipantRole.CAR_BODY_POLICYHOLDER]: "carBodyPolicyholder", }; function requiredIdentity( role: InquiryParticipantRole, input: InquiryParticipantInputDto, ): ResolvedInquiryParticipant { if (Object.prototype.hasOwnProperty.call(input, "phoneNumber")) { throw new BadRequestException( `${role} does not accept phoneNumber; phone numbers are collected separately from inquiry identity.`, ); } const nationalCode = String(input.nationalCode ?? "").trim(); const birthday = String(input.birthday ?? "").trim(); if (!nationalCode || !birthday) { throw new BadRequestException( `${role} requires nationalCode and birthday.`, ); } if ( role === InquiryParticipantRole.DRIVER && typeof input.hasDrivingLicense !== "boolean" ) { throw new BadRequestException("DRIVER requires hasDrivingLicense."); } if ( role === InquiryParticipantRole.DRIVER && input.hasDrivingLicense === true && (!String(input.licenseNumber ?? "").trim() || !String(input.licenseType ?? "").trim()) ) { throw new BadRequestException( "DRIVER requires licenseNumber and licenseType when hasDrivingLicense is true.", ); } return { participantId: role, nationalCode, birthday, ...(input.fullName ? { fullName: input.fullName } : {}), ...(input.hasDrivingLicense != null ? { hasDrivingLicense: input.hasDrivingLicense } : {}), ...(input.licenseNumber ? { licenseNumber: input.licenseNumber } : {}), ...(input.licenseType ? { licenseType: input.licenseType } : {}), }; } export function resolveInquiryParticipants( caseType: BlameRequestType, input: Partial & Record, ): ResolvedInquiryParticipants { assertStructuredInquiryInput(input); const hasRoleCompleteInput = Object.values(ROLE_FIELDS).some( (field) => input[field] != null, ); if (!hasRoleCompleteInput) { throw new BadRequestException( "driver, vehicleOwner, and thirdPartyPolicyholder are required in the structured inquiry format.", ); } if ( caseType === BlameRequestType.THIRD_PARTY && input.carBodyPolicyholder != null ) { throw new BadRequestException( "CAR_BODY_POLICYHOLDER is not allowed for a THIRD_PARTY case.", ); } const requiredRoles = [ InquiryParticipantRole.DRIVER, InquiryParticipantRole.VEHICLE_OWNER, InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER, ...(caseType === BlameRequestType.CAR_BODY ? [InquiryParticipantRole.CAR_BODY_POLICYHOLDER] : []), ]; const participants = new Map(); const resolvedRoleIds = new Map(); const resolving = new Set(); const resolveRole = (role: InquiryParticipantRole): string => { const existing = resolvedRoleIds.get(role); if (existing) return existing; if (resolving.has(role)) { throw new BadRequestException( "Participant sameAs references cannot be circular.", ); } const value = input[ROLE_FIELDS[role]] as | InquiryParticipantInputDto | undefined; if (!value) throw new BadRequestException(`${role} is required.`); resolving.add(role); let participantId: string; if (Object.prototype.hasOwnProperty.call(value, "unknown")) { throw new BadRequestException( `${role} does not support the unknown option.`, ); } else if (value.sameAs) { const hasPersonSpecificFields = Object.entries(value).some( ([field, fieldValue]) => field !== "sameAs" && fieldValue != null, ); if (hasPersonSpecificFields) { throw new BadRequestException( `${role} must contain either sameAs or identity fields, not both.`, ); } participantId = resolveRole(value.sameAs); } else { const participant = requiredIdentity(role, value); const duplicate = [...participants.values()].find( (item) => item.nationalCode === participant.nationalCode, ); if (duplicate) { throw new BadRequestException( `${role} duplicates an existing nationalCode; use sameAs instead.`, ); } participants.set(participant.participantId, participant); participantId = participant.participantId; } resolving.delete(role); resolvedRoleIds.set(role, participantId); return participantId; }; for (const role of requiredRoles) resolveRole(role); return { participants: [...participants.values()], roles: { driver: resolvedRoleIds.get(InquiryParticipantRole.DRIVER)!, vehicleOwner: resolvedRoleIds.get(InquiryParticipantRole.VEHICLE_OWNER)!, thirdPartyPolicyholder: resolvedRoleIds.get( InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER, )!, ...(caseType === BlameRequestType.CAR_BODY ? { carBodyPolicyholder: resolvedRoleIds.get( InquiryParticipantRole.CAR_BODY_POLICYHOLDER, )!, } : {}), }, legacy: false, }; } export function resolveInquiryVehicle( input: InquiryVehicleInputDto, ): ResolvedInquiryVehicle { if (!input) { throw new BadRequestException("vehicle is required."); } const registrationState = input.registrationState ?? VehicleRegistrationState.CURRENT; if (!Object.values(VehicleRegistrationState).includes(registrationState)) { throw new BadRequestException( "vehicle.registrationState must be CURRENT or RECENTLY_TRANSFERRED.", ); } if (input.isNewCar != null && typeof input.isNewCar !== "boolean") { throw new BadRequestException("vehicle.isNewCar must be a boolean."); } const previousPolicyholderNationalCode = String( input.previousPolicyholderNationalCode ?? "", ).trim(); if (!input.currentPlate) { throw new BadRequestException("vehicle.currentPlate is required."); } assertCompleteInquiryPlate(input.currentPlate, "vehicle.currentPlate"); const vin = String(input.vin ?? "").trim(); if (vin && vin.length !== 17) { throw new BadRequestException( "vehicle.vin must contain exactly 17 characters.", ); } if ( registrationState === VehicleRegistrationState.RECENTLY_TRANSFERRED && (!input.previousPlate || !vin || !previousPolicyholderNationalCode) ) { throw new BadRequestException( "RECENTLY_TRANSFERRED requires previousPlate, vin, and previousPolicyholderNationalCode.", ); } if ( registrationState !== VehicleRegistrationState.RECENTLY_TRANSFERRED && (input.previousPlate || input.previousPolicyholderNationalCode != null) ) { throw new BadRequestException( "previousPlate and previousPolicyholderNationalCode are only allowed for RECENTLY_TRANSFERRED vehicles.", ); } if (input.previousPlate) { assertCompleteInquiryPlate(input.previousPlate, "vehicle.previousPlate"); } return { ...input, registrationState, ...(vin ? { vin } : {}), ...(previousPolicyholderNationalCode ? { previousPolicyholderNationalCode } : {}), }; } export function vehiclePlateCandidates(input?: ResolvedInquiryVehicle): Array<{ kind: "CURRENT" | "PREVIOUS"; plate: InquiryVehicleInputDto["currentPlate"]; }> { if (!input) return []; return [ { kind: "CURRENT" as const, plate: input.currentPlate }, ...(input.registrationState === VehicleRegistrationState.RECENTLY_TRANSFERRED && input.previousPlate ? [{ kind: "PREVIOUS" as const, plate: input.previousPlate }] : []), ]; } function normalizeVehicleSerial(value: unknown): string { return String(value ?? "") .toUpperCase() .replace(/[^A-Z0-9]/g, ""); } /** A dated result is usable only while the returned policy has not expired. */ export function isMappedPolicyCurrent( mapped: Record, todayGregorian: string = gregorianDateInIran(new Date()), ): boolean { const rawEndDate = mapped?.EndDate ?? mapped?.HEndDte ?? mapped?.persianEndDate ?? mapped?.PolicyEndDate ?? mapped?.endDate; if (rawEndDate == null || String(rawEndDate).trim() === "") return true; const endDate = jalaliToGregorianDate(rawEndDate); return endDate != null && endDate >= todayGregorian; } function normalizePlateForComparison( plate: InquiryVehicleInputDto["currentPlate"], ): string { return [ plate?.ir, plate?.leftDigits, plate?.centerAlphabet, plate?.centerDigits, ] .map((part) => String(part ?? "").trim()) .join("|"); } const LEGACY_INQUIRY_FIELDS = [ "nationalCodeOfDriver", "driverBirthday", "driverLicense", "licenseType", "nationalCodeOfInsurer", "insurerBirthday", "insurerLicense", "driverIsInsurer", "userNoCertificate", "plate", "plateId", "vin", "isNewCar", ] as const; function assertStructuredInquiryInput(input: Record): void { const legacyFields = LEGACY_INQUIRY_FIELDS.filter((field) => Object.prototype.hasOwnProperty.call(input, field), ); if (legacyFields.length > 0) { throw new BadRequestException( `Legacy inquiry fields are not accepted: ${legacyFields.join(", ")}. Use driver, vehicleOwner, thirdPartyPolicyholder, carBodyPolicyholder, and vehicle.`, ); } } export function assertPreviousPlateInquiryMatchesVin( expectedVin: string, mapped: Record, ): void { const expected = normalizeVehicleSerial(expectedVin); const candidates = [ mapped?.VinNumberField, mapped?.vin, mapped?.VIN, mapped?.ChassisNumberField, mapped?.chassisNumber, mapped?.ChassisNo, mapped?.vehicle?.VIN, mapped?.vehicle?.ChassisNo, ] .map(normalizeVehicleSerial) .filter(Boolean); if (!expected || !candidates.includes(expected)) { throw new BadRequestException( "Previous-plate inquiry does not match the submitted VIN/chassis; manual review is required.", ); } } export function isPolicyNotFoundError(error: unknown): boolean { const candidate = error as Record | null; const status = candidate?.status ?? candidate?.response?.status; if (Number(status) === 404) return true; const code = String( candidate?.code ?? candidate?.response?.data?.code ?? "", ).toUpperCase(); if (["NOT_FOUND", "POLICY_NOT_FOUND", "NO_POLICY"].includes(code)) { return true; } const message = String( candidate?.message ?? candidate?.response?.data?.message ?? error ?? "", ); return /\bnot[ -]?found\b|\bno (?:relevant )?policy\b|یافت نشد|فاقد بیمه(?:نامه)?/i.test( message, ); } export async function runPlateInquiryWithFallback(options: { vehicle?: ResolvedInquiryVehicle; fallbackCurrentPlate: InquiryVehicleInputDto["currentPlate"]; query: ( plate: InquiryVehicleInputDto["currentPlate"], plateKind: "CURRENT" | "PREVIOUS", ) => Promise; isUsable: (value: T) => boolean; mappedValue: (value: T) => Record; shouldFallbackOnError?: (error: unknown) => boolean; }): Promise<{ value: T; plateKind: "CURRENT" | "PREVIOUS"; attempts: Array<{ plateKind: "CURRENT" | "PREVIOUS"; plate: InquiryVehicleInputDto["currentPlate"]; vin?: string; succeeded: boolean; usable?: boolean; error?: string; }>; }> { const candidates = options.vehicle ? vehiclePlateCandidates(options.vehicle) : [{ kind: "CURRENT" as const, plate: options.fallbackCurrentPlate }]; let lastError: unknown; const attempts: Array<{ plateKind: "CURRENT" | "PREVIOUS"; plate: InquiryVehicleInputDto["currentPlate"]; vin?: string; succeeded: boolean; usable?: boolean; error?: string; }> = []; for (let index = 0; index < candidates.length; index += 1) { const candidate = candidates[index]; const isLast = index === candidates.length - 1; try { const value = await options.query(candidate.plate, candidate.kind); const usable = options.isUsable(value); if (!usable) { attempts.push({ plateKind: candidate.kind, plate: candidate.plate, ...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}), succeeded: true, usable: false, }); if (!isLast) continue; const error = new BadRequestException( "No current usable policy was found for the submitted vehicle identifiers.", ) as BadRequestException & { attempts?: typeof attempts }; error.attempts = attempts; throw error; } if (candidate.kind === "PREVIOUS" && usable) { assertPreviousPlateInquiryMatchesVin( options.vehicle!.vin!, options.mappedValue(value), ); } attempts.push({ plateKind: candidate.kind, plate: candidate.plate, ...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}), succeeded: true, usable: true, }); return { value, plateKind: candidate.kind, attempts }; } catch (error) { lastError = error; const alreadyRecorded = typeof error === "object" && error != null && Array.isArray((error as { attempts?: unknown }).attempts); if (!alreadyRecorded) { attempts.push({ plateKind: candidate.kind, plate: candidate.plate, ...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}), succeeded: false, error: error instanceof Error ? error.message : String(error), }); } if ( !isLast && !(options.shouldFallbackOnError ?? isPolicyNotFoundError)(error) ) { if (typeof error === "object" && error != null) { (error as { attempts?: typeof attempts }).attempts = attempts; } throw error; } if (isLast) { if (typeof error === "object" && error != null) { (error as { attempts?: typeof attempts }).attempts = attempts; } throw error; } } } throw lastError ?? new BadRequestException("Inquiry failed for all plates."); } export function normalizeInquirySubmission>( caseType: BlameRequestType, input: T, ): NormalizedInquirySubmission { const participants = resolveInquiryParticipants(caseType, input as any); const driver = participantForRole( participants, InquiryParticipantRole.DRIVER, ); const thirdPartyPolicyholder = participantForRole( participants, InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER, ); if (!driver || !thirdPartyPolicyholder) { throw new BadRequestException( "Driver and third-party policyholder identities are required.", ); } const vehicleOwner = participantForRole( participants, InquiryParticipantRole.VEHICLE_OWNER, ); const carBodyPolicyholder = participantForRole( participants, InquiryParticipantRole.CAR_BODY_POLICYHOLDER, ); const vehicle = resolveInquiryVehicle( input.vehicle as InquiryVehicleInputDto, ); const sameDriverAndPolicyholder = participants.roles.driver === participants.roles.thirdPartyPolicyholder; return { dto: { ...input, plate: vehicle.currentPlate, vin: vehicle.vin, nationalCodeOfDriver: driver.nationalCode, driverBirthday: driver.birthday, driverLicense: driver.licenseNumber, licenseType: driver.licenseType, userNoCertificate: driver.hasDrivingLicense == null ? undefined : !driver.hasDrivingLicense, nationalCodeOfInsurer: thirdPartyPolicyholder.nationalCode, insurerBirthday: thirdPartyPolicyholder.birthday, driverIsInsurer: sameDriverAndPolicyholder, insurerLicense: sameDriverAndPolicyholder ? driver.licenseNumber : undefined, isNewCar: vehicle.isNewCar, } as NormalizedInquirySubmission["dto"], participants, driver, vehicleOwner, thirdPartyPolicyholder, carBodyPolicyholder, vehicle, }; }