From b3adb6f5832426cc9de0f64190b5dac6917223f8 Mon Sep 17 00:00:00 2001 From: "s.hajizadeh" Date: Sat, 20 Jun 2026 18:18:48 +0330 Subject: [PATCH] update the esg --- src/app.module.ts | 2 + src/claims/claims.controller.ts | 32 ++ src/claims/claims.module.ts | 31 ++ .../dto/submit-third-party-claim.dto.ts | 312 ++++++++++++++++++ src/claims/fanavaran/fanavaran-api.service.ts | 119 +++++++ .../fanavaran/fanavaran-auth.service.ts | 141 ++++++++ src/claims/fanavaran/fanavaran.types.ts | 81 +++++ .../schemas/evaluation-request.schema.ts | 30 ++ src/claims/schemas/field-expert.schema.ts | 18 + .../services/claim-expert-resolver.service.ts | 72 ++++ .../services/evaluation-request.service.ts | 19 ++ src/claims/third-party-claim.service.ts | 159 +++++++++ src/common/dto/normalized-error.dto.ts | 6 + src/config/configuration.ts | 35 ++ .../dto/policy-by-plate-request.dto.ts | 5 + src/providers/base/base-provider.abstract.ts | 3 +- .../implementations/parsian.provider.ts | 22 ++ 17 files changed, 1086 insertions(+), 1 deletion(-) create mode 100644 src/claims/claims.controller.ts create mode 100644 src/claims/claims.module.ts create mode 100644 src/claims/dto/submit-third-party-claim.dto.ts create mode 100644 src/claims/fanavaran/fanavaran-api.service.ts create mode 100644 src/claims/fanavaran/fanavaran-auth.service.ts create mode 100644 src/claims/fanavaran/fanavaran.types.ts create mode 100644 src/claims/schemas/evaluation-request.schema.ts create mode 100644 src/claims/schemas/field-expert.schema.ts create mode 100644 src/claims/services/claim-expert-resolver.service.ts create mode 100644 src/claims/services/evaluation-request.service.ts create mode 100644 src/claims/third-party-claim.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 247c375..d1acb27 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,6 +11,7 @@ import { RateLimitModule } from './rate-limit/rate-limit.module'; import { LoggingModule } from './logging/logging.module'; import { ProvidersModule } from './providers/providers.module'; import { InquiryModule } from './inquiry/inquiry.module'; +import { ClaimsModule } from './claims/claims.module'; import { DatabaseSeederModule } from './database/database-seeder.module'; /** @@ -34,6 +35,7 @@ import { DatabaseSeederModule } from './database/database-seeder.module'; DatabaseSeederModule, ProvidersModule, InquiryModule, + ClaimsModule, ], }) export class AppModule implements NestModule { diff --git a/src/claims/claims.controller.ts b/src/claims/claims.controller.ts new file mode 100644 index 0000000..8426c13 --- /dev/null +++ b/src/claims/claims.controller.ts @@ -0,0 +1,32 @@ +import { Body, Controller, Post, UseGuards, UseInterceptors } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { ResponseTransformInterceptor } from '../common/interceptors/response-transform.interceptor'; +import { UserRateLimitGuard } from '../rate-limit/user-rate-limit.guard'; +import { SubmitThirdPartyClaimDto } from './dto/submit-third-party-claim.dto'; +import { ThirdPartyClaimService, ThirdPartyClaimSubmitResult } from './third-party-claim.service'; + +@ApiTags('Claims') +@ApiBearerAuth() +@Controller('claims') +@UseGuards(JwtAuthGuard, UserRateLimitGuard) +@UseInterceptors(ResponseTransformInterceptor) +export class ClaimsController { + constructor(private readonly thirdPartyClaimService: ThirdPartyClaimService) {} + + @Post('third-party/submit') + @Throttle({ default: { limit: 20, ttl: 60000 } }) + @ApiOperation({ + summary: 'Submit a third-party car financial claim to Fanavaran', + description: + 'Authenticates with Fanavaran, resolves the latest third-party policy for the national code, ' + + 'resolves ClaimExpertId from evaluation history when provided, and submits the claim.', + }) + @ApiResponse({ status: 201, description: 'Claim submitted or structured failure returned' }) + async submitThirdPartyClaim( + @Body() dto: SubmitThirdPartyClaimDto, + ): Promise { + return this.thirdPartyClaimService.submit(dto); + } +} diff --git a/src/claims/claims.module.ts b/src/claims/claims.module.ts new file mode 100644 index 0000000..7f87136 --- /dev/null +++ b/src/claims/claims.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { ProvidersModule } from '../providers/providers.module'; +import { ClaimsController } from './claims.controller'; +import { FanavaranAuthService } from './fanavaran/fanavaran-auth.service'; +import { FanavaranApiService } from './fanavaran/fanavaran-api.service'; +import { EvaluationRequest, EvaluationRequestSchema } from './schemas/evaluation-request.schema'; +import { FieldExpert, FieldExpertSchema } from './schemas/field-expert.schema'; +import { ClaimExpertResolverService } from './services/claim-expert-resolver.service'; +import { EvaluationRequestService } from './services/evaluation-request.service'; +import { ThirdPartyClaimService } from './third-party-claim.service'; + +@Module({ + imports: [ + ProvidersModule, + MongooseModule.forFeature([ + { name: FieldExpert.name, schema: FieldExpertSchema }, + { name: EvaluationRequest.name, schema: EvaluationRequestSchema }, + ]), + ], + controllers: [ClaimsController], + providers: [ + FanavaranAuthService, + FanavaranApiService, + EvaluationRequestService, + ClaimExpertResolverService, + ThirdPartyClaimService, + ], + exports: [ThirdPartyClaimService, FanavaranApiService, ClaimExpertResolverService], +}) +export class ClaimsModule {} diff --git a/src/claims/dto/submit-third-party-claim.dto.ts b/src/claims/dto/submit-third-party-claim.dto.ts new file mode 100644 index 0000000..a61e38c --- /dev/null +++ b/src/claims/dto/submit-third-party-claim.dto.ts @@ -0,0 +1,312 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsEnum, + IsInt, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Length, + Matches, + Max, + Min, + ValidateIf, +} from 'class-validator'; +import { FanavaranInsuranceLineId } from '../fanavaran/fanavaran.types'; + +export class SubmitThirdPartyClaimDto { + @ApiProperty({ example: '0059132574', description: 'Policy holder national code (10 digits)' }) + @IsString() + @IsNotEmpty() + @Length(10, 10) + @Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' }) + nationalCode!: string; + + @ApiPropertyOptional({ + example: FanavaranInsuranceLineId.THIRD_PARTY, + enum: FanavaranInsuranceLineId, + default: FanavaranInsuranceLineId.THIRD_PARTY, + }) + @IsOptional() + @IsEnum(FanavaranInsuranceLineId) + @Type(() => Number) + insuranceLineId?: FanavaranInsuranceLineId; + + @ApiPropertyOptional({ + description: 'Override ClaimExpertId instead of resolving from evaluation history', + example: 4662, + }) + @IsOptional() + @IsInt() + @Type(() => Number) + claimExpertId?: number; + + @ApiPropertyOptional({ + description: 'Evaluation/claim case id — latest expert from history is used when claimExpertId is omitted', + example: 'CASE-2026-001', + }) + @IsOptional() + @IsString() + evaluationRequestId?: string; + + @ApiPropertyOptional({ + description: 'Use a specific policy id instead of resolving the latest policy from Fanavaran', + example: 14821240, + }) + @IsOptional() + @IsInt() + @Type(() => Number) + policyId?: number; + + @ApiProperty({ example: 701 }) + @IsInt() + @Type(() => Number) + AccidentCityId!: number; + + @ApiProperty({ example: 155 }) + @IsInt() + @Type(() => Number) + AccidentReportTypeId!: number; + + @ApiProperty({ example: 1 }) + @IsInt() + @Type(() => Number) + AccidentVehicleUsedId!: number; + + @ApiProperty({ example: 167 }) + @IsInt() + @Type(() => Number) + CompensationReferenceId!: number; + + @ApiProperty({ example: 2 }) + @IsInt() + @Type(() => Number) + CulpritLicenceTypeId!: number; + + @ApiProperty({ example: 337 }) + @IsInt() + @Type(() => Number) + CulpritTypeId!: number; + + @ApiProperty({ example: 6 }) + @IsInt() + @Type(() => Number) + AccidentCauseId!: number; + + @ApiProperty({ example: '1405/03/17' }) + @IsString() + @IsNotEmpty() + AccidentDate!: string; + + @ApiProperty({ example: '1405/03/17' }) + @IsString() + @IsNotEmpty() + AnnouncementDate!: string; + + @ApiProperty({ example: '1405/03/17' }) + @IsString() + @IsNotEmpty() + DocReceivedDate!: string; + + @ApiProperty({ example: '15:59' }) + @IsString() + @IsNotEmpty() + AccidentTime!: string; + + @ApiProperty({ example: 'تهران، محله کوی نصر، بزرگراه جلال آل احمد' }) + @IsString() + @IsNotEmpty() + AccidentLocationAddress!: string; + + @ApiProperty({ example: 143 }) + @IsNumber() + @Min(0) + @Type(() => Number) + EstimateAmount!: number; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @ValidateIf((_obj, value) => value !== null) + @IsInt() + @Type(() => Number) + AccidentCulpritId?: number | null; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + CouponNo?: string | null; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + CourtArchiveNo?: string | null; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @ValidateIf((_obj, value) => value !== null) + @IsInt() + @Type(() => Number) + CulpritLicenceCityId?: number | null; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @ValidateIf((_obj, value) => value !== null) + @IsInt() + @Type(() => Number) + CulpritLicenceCountryId?: number | null; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + CulpritLicenceForeignCityName?: string | null; + + @ApiProperty({ example: '1394/10/13' }) + @IsString() + @IsNotEmpty() + CulpritLicenceIssuDate!: string; + + @ApiProperty({ example: '1124242' }) + @IsString() + @IsNotEmpty() + CulpritLicenceNo!: string; + + @ApiProperty({ example: 1 }) + @IsInt() + @Min(1) + @Type(() => Number) + DamagedCount!: number; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + DmgAssessorFirstCreationTime?: string | null; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + EntryDate?: string | null; + + @ApiProperty({ example: 1 }) + @IsInt() + @Min(0) + @Max(1) + @Type(() => Number) + IsLicenseMatchWithVehicleKind!: number; + + @ApiProperty({ example: 0 }) + @IsInt() + @Min(0) + @Max(1) + @Type(() => Number) + HasOtherCulprit!: number; + + @ApiProperty({ example: 0 }) + @IsInt() + @Min(0) + @Max(1) + @Type(() => Number) + IsAccidentOutOfBorder!: number; + + @ApiProperty({ example: 0 }) + @IsInt() + @Min(0) + @Max(1) + @Type(() => Number) + IsFatalAccident!: number; + + @ApiProperty({ example: 0 }) + @IsInt() + @Min(0) + @Max(1) + @Type(() => Number) + IsPlaqueChanged!: number; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @ValidateIf((_obj, value) => value !== null) + @IsInt() + @Type(() => Number) + PlaqueCityId?: number | null; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @ValidateIf((_obj, value) => value !== null) + @IsInt() + @Type(() => Number) + PlaqueKindId?: number | null; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @ValidateIf((_obj, value) => value !== null) + @IsInt() + @Type(() => Number) + PlaqueLeftNo?: number | null; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @ValidateIf((_obj, value) => value !== null) + @IsInt() + @Type(() => Number) + PlaqueMiddleCodeId?: number | null; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + PlaqueNo?: string | null; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @ValidateIf((_obj, value) => value !== null) + @IsInt() + @Type(() => Number) + PlaqueRightNo?: number | null; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @ValidateIf((_obj, value) => value !== null) + @IsInt() + @Type(() => Number) + PlaqueSampleId?: number | null; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @ValidateIf((_obj, value) => value !== null) + @IsInt() + @Type(() => Number) + PlaqueSerial?: number | null; + + @ApiProperty({ example: 1 }) + @IsInt() + @Type(() => Number) + PoliceOfficerId!: number; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + PoliceReportDesc?: string | null; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + PoliceReportSeri?: string | null; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + PoliceReportSerial?: string | null; + + @ApiPropertyOptional({ example: '', default: '' }) + @IsOptional() + @IsString() + PreviousPolicyEndDate?: string; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + TrackingCode?: string | null; + + @ApiProperty({ example: 0 }) + @IsInt() + @Min(0) + @Max(1) + @Type(() => Number) + IsLicenseReplacement!: number; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @ValidateIf((_obj, value) => value !== null) + @IsInt() + @Type(() => Number) + UnknownCulpritCauseId?: number | null; +} diff --git a/src/claims/fanavaran/fanavaran-api.service.ts b/src/claims/fanavaran/fanavaran-api.service.ts new file mode 100644 index 0000000..add506f --- /dev/null +++ b/src/claims/fanavaran/fanavaran-api.service.ts @@ -0,0 +1,119 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AxiosError, AxiosInstance } from 'axios'; +import { createOutboundAxiosInstance } from '../../common/helpers/http-client.helper'; +import { FanavaranConfig } from '../../config/configuration'; +import { FanavaranAuthService } from './fanavaran-auth.service'; +import { + FanavaranInsuranceLineId, + FanavaranPolicySummary, + ThirdPartyCarFinancialClaimPayload, +} from './fanavaran.types'; + +@Injectable() +export class FanavaranApiService { + private readonly logger = new Logger(FanavaranApiService.name); + private readonly httpClient: AxiosInstance; + private readonly config: FanavaranConfig; + + constructor( + configService: ConfigService, + private readonly authService: FanavaranAuthService, + ) { + this.config = configService.get('fanavaran')!; + this.httpClient = createOutboundAxiosInstance({ + baseURL: this.config.baseUrl, + timeout: this.config.timeout, + headers: { + 'Content-Type': 'application/json', + }, + }); + } + + async inquiryLatestPolicy( + nationalCode: string, + insuranceLineId: FanavaranInsuranceLineId = FanavaranInsuranceLineId.THIRD_PARTY, + ): Promise { + const authenticationToken = await this.authService.getAuthenticationToken(); + const response = await this.httpClient.get( + '/api/BimeApi/v2.0/common/Policies/inquiry-my-policies', + { + params: { + InsuranceLineId: insuranceLineId, + NationalCode: nationalCode, + }, + headers: this.buildCommonHeaders(authenticationToken, this.config.policyInquiryLocation), + }, + ); + + const policies = Array.isArray(response.data) ? response.data : []; + if (policies.length === 0) { + throw new Error( + `No Fanavaran policies found for nationalCode=${nationalCode}, insuranceLineId=${insuranceLineId}`, + ); + } + + const latest = this.pickLatestPolicy(policies); + this.logger.log( + `Fanavaran policy selected | nationalCode=${nationalCode} | policyId=${latest.PolicyId} | issuDate=${latest.IssuDate}`, + ); + return latest; + } + + async submitThirdPartyCarFinancialClaim( + payload: ThirdPartyCarFinancialClaimPayload, + ): Promise { + const authenticationToken = await this.authService.getAuthenticationToken(); + + try { + const response = await this.httpClient.post( + '/api/BimeApi/v2.0/car/third-party-car-financial-claims', + payload, + { + headers: this.buildCommonHeaders(authenticationToken, this.config.claimSubmitLocation), + }, + ); + return response.data; + } catch (error) { + throw this.formatApiError(error); + } + } + + pickLatestPolicy(policies: FanavaranPolicySummary[]): FanavaranPolicySummary { + return [...policies].sort((left, right) => { + const dateCompare = right.IssuDate.localeCompare(left.IssuDate); + if (dateCompare !== 0) { + return dateCompare; + } + return right.PolicyId - left.PolicyId; + })[0]; + } + + private buildCommonHeaders( + authenticationToken: string, + location: number, + ): Record { + return { + authenticationToken, + CorpId: this.config.corpId, + ContractId: this.config.contractId, + Location: location, + }; + } + + private formatApiError(error: unknown): Error { + if (!(error instanceof AxiosError)) { + return error instanceof Error ? error : new Error(String(error)); + } + + const status = error.response?.status; + const body = + typeof error.response?.data === 'string' + ? error.response.data + : JSON.stringify(error.response?.data ?? {}); + + return new Error( + `Fanavaran API request failed${status ? ` (${status})` : ''}: ${error.message} | body=${body}`, + ); + } +} diff --git a/src/claims/fanavaran/fanavaran-auth.service.ts b/src/claims/fanavaran/fanavaran-auth.service.ts new file mode 100644 index 0000000..4897b3c --- /dev/null +++ b/src/claims/fanavaran/fanavaran-auth.service.ts @@ -0,0 +1,141 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AxiosInstance } from 'axios'; +import { createOutboundAxiosInstance } from '../../common/helpers/http-client.helper'; +import { FanavaranConfig } from '../../config/configuration'; +import { GeneralTokenService } from '../../providers/services/general-token.service'; +import { FanavaranLoginUserInfo } from './fanavaran.types'; + +const APP_TOKEN_PROVIDER = 'Fanavaran:AppToken'; +const AUTH_TOKEN_PROVIDER = 'Fanavaran:AuthToken'; + +@Injectable() +export class FanavaranAuthService { + private readonly logger = new Logger(FanavaranAuthService.name); + private readonly httpClient: AxiosInstance; + private readonly config: FanavaranConfig; + + constructor( + configService: ConfigService, + private readonly generalTokenService: GeneralTokenService, + ) { + this.config = configService.get('fanavaran')!; + this.httpClient = createOutboundAxiosInstance({ + baseURL: this.config.baseUrl, + timeout: this.config.timeout, + }); + } + + isEnabled(): boolean { + return ( + this.config.enabled && + Boolean(this.config.baseUrl) && + Boolean(this.config.appName) && + Boolean(this.config.appSecret) && + Boolean(this.config.userName) && + Boolean(this.config.password) + ); + } + + async getAuthenticationToken(): Promise { + const cached = await this.generalTokenService.getLatestToken(AUTH_TOKEN_PROVIDER); + if (cached && !(await this.generalTokenService.isTokenExpired(cached))) { + return cached.accessToken; + } + + const appToken = await this.getAppToken(); + const { authenticationToken } = await this.login(appToken); + return authenticationToken; + } + + private async getAppToken(): Promise { + const cached = await this.generalTokenService.getLatestToken(APP_TOKEN_PROVIDER); + if (cached && !(await this.generalTokenService.isTokenExpired(cached))) { + return cached.accessToken; + } + + const response = await this.httpClient.post( + '/api/EITAuthentication/GetAppToken', + undefined, + { + headers: { + appname: this.config.appName, + secret: this.config.appSecret, + }, + }, + ); + + const appToken = this.readHeader(response.headers, 'apptoken'); + if (!appToken) { + throw new Error('Fanavaran GetAppToken did not return appToken header'); + } + + await this.cacheToken(APP_TOKEN_PROVIDER, appToken, this.config.appTokenTtlSeconds); + return appToken; + } + + private async login(appToken: string): Promise<{ + authenticationToken: string; + user: FanavaranLoginUserInfo; + }> { + const response = await this.httpClient.post('/api/EITAuthentication/Login', undefined, { + headers: { + appToken, + userName: this.config.userName, + password: this.config.password, + }, + }); + + const authenticationToken = this.readHeader(response.headers, 'authenticationtoken'); + if (!authenticationToken) { + throw new Error('Fanavaran Login did not return authenticationToken header'); + } + + await this.cacheToken( + AUTH_TOKEN_PROVIDER, + authenticationToken, + this.config.authTokenTtlSeconds, + ); + + const user = response.data as FanavaranLoginUserInfo; + this.logger.log( + `Fanavaran login succeeded | user=${user?.UserName ?? this.config.userName}`, + ); + + return { authenticationToken, user }; + } + + private async cacheToken( + serviceProvider: string, + token: string, + ttlSeconds: number, + ): Promise { + const expiresAt = new Date(Date.now() + ttlSeconds * 1000); + await this.generalTokenService.create({ + serviceProvider, + tokenType: 'Bearer', + url: this.config.baseUrl, + clientId: this.config.appName, + clientSecret: '***', + username: this.config.userName, + scope: serviceProvider, + accessToken: token, + expiresIn: ttlSeconds, + expiresAt, + }); + } + + private readHeader( + headers: Record, + name: string, + ): string | undefined { + const value = headers[name]; + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + if (Array.isArray(value) && typeof value[0] === 'string') { + return value[0].trim(); + } + return undefined; + } +} diff --git a/src/claims/fanavaran/fanavaran.types.ts b/src/claims/fanavaran/fanavaran.types.ts new file mode 100644 index 0000000..49c1a9f --- /dev/null +++ b/src/claims/fanavaran/fanavaran.types.ts @@ -0,0 +1,81 @@ +/** Fanavaran (Iran EIT) insurance line identifiers. */ +export enum FanavaranInsuranceLineId { + CAR_BODY = 4, + THIRD_PARTY = 5, +} + +export interface FanavaranPolicySummary { + BeginDate: string; + CustomerId: number; + EffectiveDate: string | null; + EndDate: string; + EndoNo: number; + InsuranceLineId: number; + IsClosed: number; + IssuDate: string; + OpUnitId: number; + PayerCustomerId: number; + PersonRoleId: number; + PolicyGroupId: number; + PolicyId: number; + PolicyNo: number; + PolicyVerNo: number; + Status: number; + TranTypeId: number; +} + +export interface FanavaranLoginUserInfo { + UserName: string; + UserId: string; + FarsiName: string | null; +} + +export interface ThirdPartyCarFinancialClaimPayload { + AccidentCityId: number; + AccidentReportTypeId: number; + AccidentVehicleUsedId: number; + ClaimExpertId: number; + CompensationReferenceId: number; + CulpritLicenceTypeId: number; + CulpritTypeId: number; + AccidentCauseId: number; + AccidentDate: string; + AnnouncementDate: string; + DocReceivedDate: string; + AccidentTime: string; + AccidentLocationAddress: string; + EstimateAmount: number; + AccidentCulpritId: number | null; + CouponNo: string | null; + CourtArchiveNo: string | null; + CulpritLicenceCityId: number | null; + CulpritLicenceCountryId: number | null; + CulpritLicenceForeignCityName: string | null; + CulpritLicenceIssuDate: string; + CulpritLicenceNo: string; + DamagedCount: number; + DmgAssessorFirstCreationTime: string | null; + EntryDate: string | null; + IsLicenseMatchWithVehicleKind: number; + HasOtherCulprit: number; + IsAccidentOutOfBorder: number; + IsFatalAccident: number; + IsPlaqueChanged: number; + PlaqueCityId: number | null; + PlaqueKindId: number | null; + PlaqueLeftNo: number | null; + PlaqueMiddleCodeId: number | null; + PlaqueNo: string | null; + PlaqueRightNo: number | null; + PlaqueSampleId: number | null; + PlaqueSerial: number | null; + PoliceOfficerId: number; + PoliceReportDesc: string | null; + PoliceReportSeri: string | null; + PoliceReportSerial: string | null; + PolicyId: number; + PreviousPolicyEndDate: string; + TrackingCode: string | null; + IsLicenseReplacement: number; + UnknownCulpritCauseId: number | null; +} diff --git a/src/claims/schemas/evaluation-request.schema.ts b/src/claims/schemas/evaluation-request.schema.ts new file mode 100644 index 0000000..e724a2c --- /dev/null +++ b/src/claims/schemas/evaluation-request.schema.ts @@ -0,0 +1,30 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument, Types } from 'mongoose'; + +export type EvaluationRequestDocument = HydratedDocument; + +@Schema({ _id: false }) +export class EvaluationHistoryEntry { + @Prop({ type: Types.ObjectId, ref: 'FieldExpert', required: true }) + expertId!: Types.ObjectId; + + @Prop() + expertCode?: number; + + @Prop({ required: true, default: () => new Date() }) + evaluatedAt!: Date; +} + +export const EvaluationHistoryEntrySchema = + SchemaFactory.createForClass(EvaluationHistoryEntry); + +@Schema({ timestamps: true, collection: 'evaluation_requests' }) +export class EvaluationRequest { + @Prop({ required: true, unique: true, trim: true }) + requestId!: string; + + @Prop({ type: [EvaluationHistoryEntrySchema], default: [] }) + evaluations!: EvaluationHistoryEntry[]; +} + +export const EvaluationRequestSchema = SchemaFactory.createForClass(EvaluationRequest); diff --git a/src/claims/schemas/field-expert.schema.ts b/src/claims/schemas/field-expert.schema.ts new file mode 100644 index 0000000..1346104 --- /dev/null +++ b/src/claims/schemas/field-expert.schema.ts @@ -0,0 +1,18 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument, Types } from 'mongoose'; + +export type FieldExpertDocument = HydratedDocument; + +@Schema({ timestamps: true, collection: 'field_experts' }) +export class FieldExpert { + @Prop({ required: true, trim: true }) + name!: string; + + @Prop({ required: true, unique: true }) + expertCode!: number; + + @Prop({ default: true }) + active!: boolean; +} + +export const FieldExpertSchema = SchemaFactory.createForClass(FieldExpert); diff --git a/src/claims/services/claim-expert-resolver.service.ts b/src/claims/services/claim-expert-resolver.service.ts new file mode 100644 index 0000000..96a9ef9 --- /dev/null +++ b/src/claims/services/claim-expert-resolver.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { ConfigService } from '@nestjs/config'; +import { FanavaranConfig } from '../../config/configuration'; +import { FieldExpert, FieldExpertDocument } from '../schemas/field-expert.schema'; +import { EvaluationRequestService } from './evaluation-request.service'; + +export interface ClaimExpertResolutionInput { + claimExpertId?: number; + evaluationRequestId?: string; +} + +@Injectable() +export class ClaimExpertResolverService { + private readonly config: FanavaranConfig; + + constructor( + configService: ConfigService, + @InjectModel(FieldExpert.name) + private readonly fieldExpertModel: Model, + private readonly evaluationRequestService: EvaluationRequestService, + ) { + this.config = configService.get('fanavaran')!; + } + + async resolveClaimExpertId(input: ClaimExpertResolutionInput): Promise { + if (input.claimExpertId !== undefined && input.claimExpertId !== null) { + return input.claimExpertId; + } + + if (input.evaluationRequestId) { + const resolved = await this.resolveFromEvaluationHistory(input.evaluationRequestId); + if (resolved !== undefined) { + return resolved; + } + } + + return this.config.defaultClaimExpertId; + } + + private async resolveFromEvaluationHistory( + evaluationRequestId: string, + ): Promise { + const evaluationRequest = + await this.evaluationRequestService.findByRequestId(evaluationRequestId); + if (!evaluationRequest?.evaluations?.length) { + return undefined; + } + + const latestEvaluation = [...evaluationRequest.evaluations].sort( + (left, right) => right.evaluatedAt.getTime() - left.evaluatedAt.getTime(), + )[0]; + + if (latestEvaluation.expertCode !== undefined && latestEvaluation.expertCode !== null) { + return latestEvaluation.expertCode; + } + + if (!latestEvaluation.expertId) { + return undefined; + } + + const expert = await this.fieldExpertModel + .findOne({ + _id: new Types.ObjectId(latestEvaluation.expertId), + active: true, + }) + .exec(); + + return expert?.expertCode; + } +} diff --git a/src/claims/services/evaluation-request.service.ts b/src/claims/services/evaluation-request.service.ts new file mode 100644 index 0000000..45bde64 --- /dev/null +++ b/src/claims/services/evaluation-request.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { + EvaluationRequest, + EvaluationRequestDocument, +} from '../schemas/evaluation-request.schema'; + +@Injectable() +export class EvaluationRequestService { + constructor( + @InjectModel(EvaluationRequest.name) + private readonly evaluationRequestModel: Model, + ) {} + + async findByRequestId(requestId: string): Promise { + return this.evaluationRequestModel.findOne({ requestId }).exec(); + } +} diff --git a/src/claims/third-party-claim.service.ts b/src/claims/third-party-claim.service.ts new file mode 100644 index 0000000..b1df139 --- /dev/null +++ b/src/claims/third-party-claim.service.ts @@ -0,0 +1,159 @@ +import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { generateTrackingCode } from '../common/helpers/tracking-code.helper'; +import { SubmitThirdPartyClaimDto } from './dto/submit-third-party-claim.dto'; +import { FanavaranApiService } from './fanavaran/fanavaran-api.service'; +import { FanavaranAuthService } from './fanavaran/fanavaran-auth.service'; +import { + FanavaranInsuranceLineId, + FanavaranPolicySummary, + ThirdPartyCarFinancialClaimPayload, +} from './fanavaran/fanavaran.types'; +import { ClaimExpertResolverService } from './services/claim-expert-resolver.service'; + +export interface ThirdPartyClaimSubmitResult { + success: boolean; + trackingCode: string; + message: string; + duration: number; + data: { + policyId: number; + claimExpertId: number; + policy: FanavaranPolicySummary; + fanavaranResponse: unknown; + } | null; + error: { code: string; message: string } | null; +} + +@Injectable() +export class ThirdPartyClaimService { + private readonly logger = new Logger(ThirdPartyClaimService.name); + + constructor( + private readonly fanavaranAuthService: FanavaranAuthService, + private readonly fanavaranApiService: FanavaranApiService, + private readonly claimExpertResolver: ClaimExpertResolverService, + ) {} + + async submit(dto: SubmitThirdPartyClaimDto): Promise { + const trackingCode = generateTrackingCode(); + const start = Date.now(); + + if (!this.fanavaranAuthService.isEnabled()) { + throw new ServiceUnavailableException('Fanavaran integration is not configured'); + } + + try { + const insuranceLineId = + dto.insuranceLineId ?? FanavaranInsuranceLineId.THIRD_PARTY; + const claimExpertId = await this.claimExpertResolver.resolveClaimExpertId({ + claimExpertId: dto.claimExpertId, + evaluationRequestId: dto.evaluationRequestId, + }); + + const policy = + dto.policyId !== undefined + ? ({ PolicyId: dto.policyId } as FanavaranPolicySummary) + : await this.fanavaranApiService.inquiryLatestPolicy( + dto.nationalCode, + insuranceLineId, + ); + + const payload = this.buildClaimPayload(dto, policy.PolicyId, claimExpertId); + const fanavaranResponse = + await this.fanavaranApiService.submitThirdPartyCarFinancialClaim(payload); + + const duration = Date.now() - start; + this.logger.log( + `Third-party claim submitted | trackingCode=${trackingCode} | policyId=${policy.PolicyId} | claimExpertId=${claimExpertId} | durationMs=${duration}`, + ); + + return { + success: true, + trackingCode, + message: 'Third-party claim submitted successfully', + duration, + data: { + policyId: policy.PolicyId, + claimExpertId, + policy, + fanavaranResponse, + }, + error: null, + }; + } catch (error) { + const duration = Date.now() - start; + const message = error instanceof Error ? error.message : 'Third-party claim submission failed'; + this.logger.error( + `Third-party claim failed | trackingCode=${trackingCode} | durationMs=${duration} | ${message}`, + ); + + return { + success: false, + trackingCode, + message, + duration, + data: null, + error: { + code: 'THIRD_PARTY_CLAIM_FAILED', + message, + }, + }; + } + } + + private buildClaimPayload( + dto: SubmitThirdPartyClaimDto, + policyId: number, + claimExpertId: number, + ): ThirdPartyCarFinancialClaimPayload { + return { + AccidentCityId: dto.AccidentCityId, + AccidentReportTypeId: dto.AccidentReportTypeId, + AccidentVehicleUsedId: dto.AccidentVehicleUsedId, + ClaimExpertId: claimExpertId, + CompensationReferenceId: dto.CompensationReferenceId, + CulpritLicenceTypeId: dto.CulpritLicenceTypeId, + CulpritTypeId: dto.CulpritTypeId, + AccidentCauseId: dto.AccidentCauseId, + AccidentDate: dto.AccidentDate, + AnnouncementDate: dto.AnnouncementDate, + DocReceivedDate: dto.DocReceivedDate, + AccidentTime: dto.AccidentTime, + AccidentLocationAddress: dto.AccidentLocationAddress, + EstimateAmount: dto.EstimateAmount, + AccidentCulpritId: dto.AccidentCulpritId ?? null, + CouponNo: dto.CouponNo ?? null, + CourtArchiveNo: dto.CourtArchiveNo ?? null, + CulpritLicenceCityId: dto.CulpritLicenceCityId ?? null, + CulpritLicenceCountryId: dto.CulpritLicenceCountryId ?? null, + CulpritLicenceForeignCityName: dto.CulpritLicenceForeignCityName ?? null, + CulpritLicenceIssuDate: dto.CulpritLicenceIssuDate, + CulpritLicenceNo: dto.CulpritLicenceNo, + DamagedCount: dto.DamagedCount, + DmgAssessorFirstCreationTime: dto.DmgAssessorFirstCreationTime ?? null, + EntryDate: dto.EntryDate ?? null, + IsLicenseMatchWithVehicleKind: dto.IsLicenseMatchWithVehicleKind, + HasOtherCulprit: dto.HasOtherCulprit, + IsAccidentOutOfBorder: dto.IsAccidentOutOfBorder, + IsFatalAccident: dto.IsFatalAccident, + IsPlaqueChanged: dto.IsPlaqueChanged, + PlaqueCityId: dto.PlaqueCityId ?? null, + PlaqueKindId: dto.PlaqueKindId ?? null, + PlaqueLeftNo: dto.PlaqueLeftNo ?? null, + PlaqueMiddleCodeId: dto.PlaqueMiddleCodeId ?? null, + PlaqueNo: dto.PlaqueNo ?? null, + PlaqueRightNo: dto.PlaqueRightNo ?? null, + PlaqueSampleId: dto.PlaqueSampleId ?? null, + PlaqueSerial: dto.PlaqueSerial ?? null, + PoliceOfficerId: dto.PoliceOfficerId, + PoliceReportDesc: dto.PoliceReportDesc ?? null, + PoliceReportSeri: dto.PoliceReportSeri ?? null, + PoliceReportSerial: dto.PoliceReportSerial ?? null, + PolicyId: policyId, + PreviousPolicyEndDate: dto.PreviousPolicyEndDate ?? '', + TrackingCode: dto.TrackingCode ?? null, + IsLicenseReplacement: dto.IsLicenseReplacement, + UnknownCulpritCauseId: dto.UnknownCulpritCauseId ?? null, + }; + } +} diff --git a/src/common/dto/normalized-error.dto.ts b/src/common/dto/normalized-error.dto.ts index 816dc6b..2b2b9b9 100644 --- a/src/common/dto/normalized-error.dto.ts +++ b/src/common/dto/normalized-error.dto.ts @@ -28,4 +28,10 @@ export class NormalizedErrorDto { description: 'Field-level validation errors when code is VALIDATION_ERROR', }) details?: Array<{ field: string; constraints: string[] }>; + + @ApiPropertyOptional({ + example: { nationalCode: '4311402422', NtnlId: '0015790231' }, + description: 'Conflicting values when submitted data does not match provider result', + }) + conflict?: Record; } diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 78d38ca..8e179ae 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -27,6 +27,23 @@ export interface AmitisAuthServiceConfig { enabled: boolean; } +export interface FanavaranConfig { + enabled: boolean; + baseUrl: string; + timeout: number; + appName: string; + appSecret: string; + userName: string; + password: string; + corpId: number; + contractId: number; + policyInquiryLocation: number; + claimSubmitLocation: number; + defaultClaimExpertId: number; + appTokenTtlSeconds: number; + authTokenTtlSeconds: number; +} + export interface TejaratNouConfig { enabled: boolean; timeout: number; @@ -81,6 +98,24 @@ export default () => ({ hamta: buildProviderConfig('HAMTA'), moallem: buildProviderConfig('MOALLEM'), parsian: buildProviderConfig('PARSIAN'), + fanavaran: { + enabled: process.env.FANAVARAN_ENABLED !== 'false', + baseUrl: + process.env.FANAVARAN_BASE_URL ?? + 'https://apimanager.iraneit.com/BimeApiManager', + timeout: parseInt(process.env.FANAVARAN_TIMEOUT ?? '30000', 10), + appName: process.env.FANAVARAN_APP_NAME ?? '', + appSecret: process.env.FANAVARAN_APP_SECRET ?? '', + userName: process.env.FANAVARAN_USERNAME ?? '', + password: process.env.FANAVARAN_PASSWORD ?? '', + corpId: parseInt(process.env.FANAVARAN_CORP_ID ?? '543', 10), + contractId: parseInt(process.env.FANAVARAN_CONTRACT_ID ?? '28', 10), + policyInquiryLocation: parseInt(process.env.FANAVARAN_POLICY_INQUIRY_LOCATION ?? '100100', 10), + claimSubmitLocation: parseInt(process.env.FANAVARAN_CLAIM_SUBMIT_LOCATION ?? '210050', 10), + defaultClaimExpertId: parseInt(process.env.FANAVARAN_DEFAULT_CLAIM_EXPERT_ID ?? '4662', 10), + appTokenTtlSeconds: parseInt(process.env.FANAVARAN_APP_TOKEN_TTL_SECONDS ?? '3600', 10), + authTokenTtlSeconds: parseInt(process.env.FANAVARAN_AUTH_TOKEN_TTL_SECONDS ?? '3600', 10), + } satisfies FanavaranConfig, tejaratnou: { enabled: process.env.TEJARATNOU_ENABLED !== 'false', timeout: parseInt(process.env.TEJARATNOU_TIMEOUT ?? '15000', 10), diff --git a/src/inquiry/dto/policy-by-plate-request.dto.ts b/src/inquiry/dto/policy-by-plate-request.dto.ts index 2f79b3f..bce7b9c 100644 --- a/src/inquiry/dto/policy-by-plate-request.dto.ts +++ b/src/inquiry/dto/policy-by-plate-request.dto.ts @@ -2,6 +2,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString, Matches } from 'class-validator'; export class PolicyByPlateRequestDto { + @ApiProperty({ example: '4311402422', description: 'Owner national code (10 digits)' }) + @IsString() + @Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' }) + nationalCode!: string; + @ApiProperty({ example: '12', description: 'Two digits on the left side of the plate' }) @IsString() @Matches(/^\d{2}$/, { message: 'plk1 must be exactly 2 digits' }) diff --git a/src/providers/base/base-provider.abstract.ts b/src/providers/base/base-provider.abstract.ts index 470273c..2ae22ba 100644 --- a/src/providers/base/base-provider.abstract.ts +++ b/src/providers/base/base-provider.abstract.ts @@ -128,6 +128,7 @@ export abstract class BaseProvider providerCode: partial.providerCode, providerTrackingCode: partial.providerTrackingCode, details: partial.details, + conflict: partial.conflict, }; } @@ -135,7 +136,7 @@ export abstract class BaseProvider providerMessage?: string, providerCode?: string, fallbackMessage = 'Provider request failed', - extras?: Pick, + extras?: Pick, ): Error { const message = providerMessage?.trim() || fallbackMessage; const code = providerCode?.trim() || 'PROVIDER_ERROR'; diff --git a/src/providers/implementations/parsian.provider.ts b/src/providers/implementations/parsian.provider.ts index 0984cf7..e377296 100644 --- a/src/providers/implementations/parsian.provider.ts +++ b/src/providers/implementations/parsian.provider.ts @@ -81,6 +81,7 @@ interface ParsianPolicyByNationalCodePayload extends LegacyInquiryPayload { } interface ParsianPolicyByPlatePayload extends LegacyInquiryPayload { + nationalCode?: string; plk1?: string; plk2?: string; plk3?: string; @@ -334,6 +335,7 @@ export class ParsianProvider extends BaseProvider { + const nationalCode = this.getRequiredString(payload.nationalCode, 'nationalCode'); const plk1 = this.getRequiredString(payload.plk1, 'plk1'); const plk2 = this.getRequiredString(payload.plk2, 'plk2'); const plk3 = this.getRequiredString(payload.plk3, 'plk3'); @@ -344,8 +346,24 @@ export class ParsianProvider extends BaseProvider