diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f81cbb3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Added + +- Car inquiry endpoints: + - `POST /inquiry/carByPlate` — insurance inquiry by plate (provider passthrough) + - `POST /inquiry/carByChassis` — insurance inquiry by chassis/VIN + - `POST /inquiry/thirdPartyCar` — third-party car inquiry with provider-specific policy selection +- New inquiry types: `CAR_BY_PLATE`, `CAR_BY_CHASSIS`, `THIRD_PARTY_CAR` (legacy `CAR_PLATE` remains, marked deprecated) +- Shared CentInsur SOAP client and provider support for plate / chassis / national-code car policy lookups +- Third-party car selection rules: passenger vehicle groups, current Jalali-year overlap, active-policy preference, and plate + national-code merge (including zero-km plates) +- Provider resilience layer: wall-clock inquiry deadline (`INQUIRY_DEADLINE_MS`, default 90s), transport-only retries, per-attempt timeouts, and an attempt trail +- `attemptSummary` on inquiry responses — always on failure; on success only when a retry occurred +- `INQUIRY_DEADLINE_EXCEEDED` error (HTTP 504) with Persian translation +- Car ownership safety helpers: ownership mismatch is returned as a generic no-match (no owner national code, no conflict map) +- Generalized `inquiry_result_cache` collection and service (replaces person-only cached-inquiry-result) +- Unit tests for car privacy, provider resilience, and timeout/deadline behavior + +### Changed + +- Retry helper honors an absolute deadline and records per-attempt outcomes +- Orchestrator initializes a deadline and attempt trail, and does not fall through to another provider on business outcomes (`INQUIRY_NO_MATCH`, `RECORD_NOT_FOUND`, `SHEBA_MISMATCH`, validation) +- Base provider, Amitis, Moallem, Parsian, and Tejarat No use the shared resilience runner +- Tejarat No now covers person, Shahkar, real-estate, and the new car inquiry types (OAuth on accounts host, REST on inquiry gateway) +- General token service supports more resilient auth login/refresh +- Inquiry logs persist `attemptSummary` +- Payload masking also covers `nationalId`, `ntnlid`, and `policyOwnerNationalCode` +- Conflict payloads no longer include another person's national code or identity fields + +### Security + +- Car inquiry ownership mismatch is indistinguishable from "no result" so callers cannot discover who a plate or chassis belongs to +- Public error payloads are checked so another party's national code cannot leak diff --git a/src/common/constants/error-messages.ts b/src/common/constants/error-messages.ts index 496e830..2113643 100644 --- a/src/common/constants/error-messages.ts +++ b/src/common/constants/error-messages.ts @@ -48,6 +48,11 @@ export const ERROR_CATALOG = { message: 'All providers failed', messageFa: 'تمامی سرویس‌دهنده‌ها با خطا مواجه شدند', }, + INQUIRY_DEADLINE_EXCEEDED: { + status: HttpStatus.GATEWAY_TIMEOUT, + message: 'Inquiry exceeded the allowed time budget', + messageFa: 'مهلت زمانی استعلام به پایان رسید', + }, UNKNOWN_ERROR: { status: HttpStatus.INTERNAL_SERVER_ERROR, message: 'Unknown provider error', diff --git a/src/common/dto/attempt-summary.dto.ts b/src/common/dto/attempt-summary.dto.ts new file mode 100644 index 0000000..3fac4f3 --- /dev/null +++ b/src/common/dto/attempt-summary.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * One failed HTTP attempt in an inquiry's retry trail (client-facing). + */ +export class AttemptErrorDto { + @ApiProperty({ example: 1, description: '1-based attempt number across the whole inquiry' }) + attempt!: number; + + @ApiProperty({ example: 'HAMTA' }) + provider!: string; + + @ApiProperty({ example: 'PROVIDER_TIMEOUT' }) + code!: string; + + @ApiProperty({ example: 1042, description: 'Duration of this attempt in milliseconds' }) + durationMs!: number; + + @ApiProperty({ example: 'Provider request timed out' }) + message!: string; + + @ApiPropertyOptional({ example: 'درخواست به سرویس‌دهنده زمان‌بر شد' }) + messageFa?: string; +} + +/** + * Compact retry summary for API clients. + * Full upstream detail stays in logs/metrics. + */ +export class AttemptSummaryDto { + @ApiProperty({ example: 3, description: 'Total outbound HTTP attempts for this inquiry' }) + totalAttempts!: number; + + @ApiProperty({ example: true, description: 'True when more than one HTTP attempt was made' }) + retried!: boolean; + + @ApiProperty({ example: 12450, description: 'Wall-clock duration for the inquiry in milliseconds' }) + durationMs!: number; + + @ApiProperty({ type: [AttemptErrorDto], description: 'Failed attempts only (public messages)' }) + attempts!: AttemptErrorDto[]; +} diff --git a/src/common/dto/base-inquiry-response.dto.ts b/src/common/dto/base-inquiry-response.dto.ts index cf6ed11..75ec0a6 100644 --- a/src/common/dto/base-inquiry-response.dto.ts +++ b/src/common/dto/base-inquiry-response.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { AttemptSummaryDto } from './attempt-summary.dto'; import { NormalizedErrorDto } from './normalized-error.dto'; /** @@ -29,4 +30,11 @@ export class BaseInquiryResponseDto> { @ApiProperty({ example: 342, description: 'Duration in milliseconds' }) duration!: number; + + @ApiPropertyOptional({ + type: AttemptSummaryDto, + description: + 'Retry trail: always on failure; on success only when at least one retry occurred', + }) + attemptSummary?: AttemptSummaryDto; } diff --git a/src/common/dto/normalized-error.dto.ts b/src/common/dto/normalized-error.dto.ts index b708340..e8d6ad4 100644 --- a/src/common/dto/normalized-error.dto.ts +++ b/src/common/dto/normalized-error.dto.ts @@ -33,8 +33,9 @@ export class NormalizedErrorDto { details?: Array<{ field: string; constraints: string[] }>; @ApiPropertyOptional({ - example: { nationalCode: '4311402422', NtnlId: '0015790231' }, - description: 'Conflicting values when submitted data does not match provider result', + example: { sheba: 'IR800560611828005105117001' }, + description: + 'Field values from the request that failed a same-person check (e.g. sheba). Never includes another person\'s national code or identity.', }) conflict?: Record; } diff --git a/src/common/enums/inquiry-type.enum.ts b/src/common/enums/inquiry-type.enum.ts index 333c520..519d2f0 100644 --- a/src/common/enums/inquiry-type.enum.ts +++ b/src/common/enums/inquiry-type.enum.ts @@ -9,7 +9,11 @@ export enum InquiryType { SHAHKAR = 'SHAHKAR_INQUIRY', POSTAL_CODE = 'POSTAL_CODE_INQUIRY', LEGAL_PERSON = 'LEGAL_PERSON_INQUIRY', + /** @deprecated Use THIRD_PARTY_CAR for smart third-party car inquiry */ CAR_PLATE = 'CAR_PLATE_INQUIRY', + CAR_BY_PLATE = 'CAR_BY_PLATE_INQUIRY', + CAR_BY_CHASSIS = 'CAR_BY_CHASSIS_INQUIRY', + THIRD_PARTY_CAR = 'THIRD_PARTY_CAR_INQUIRY', POLICY_BY_CHASSIS = 'POLICY_BY_CHASSIS_INQUIRY', POLICY_BY_PLATE = 'POLICY_BY_PLATE_INQUIRY', POLICY_BY_NATIONAL_CODE = 'POLICY_BY_NATIONAL_CODE_INQUIRY', diff --git a/src/common/exceptions/inquiry.exception.ts b/src/common/exceptions/inquiry.exception.ts index 7c55dd2..21852d8 100644 --- a/src/common/exceptions/inquiry.exception.ts +++ b/src/common/exceptions/inquiry.exception.ts @@ -1,4 +1,5 @@ import { HttpException, HttpStatus } from '@nestjs/common'; +import { AttemptSummaryDto } from '../dto/attempt-summary.dto'; import { NormalizedErrorDto } from '../dto/normalized-error.dto'; export class InquiryException extends HttpException { @@ -7,6 +8,7 @@ export class InquiryException extends HttpException { public readonly normalizedError: NormalizedErrorDto, status: HttpStatus = HttpStatus.UNPROCESSABLE_ENTITY, public readonly provider?: string, + public readonly attemptSummary?: AttemptSummaryDto, ) { super({ message, error: normalizedError }, status); } diff --git a/src/common/helpers/car-inquiry-safety.helper.ts b/src/common/helpers/car-inquiry-safety.helper.ts new file mode 100644 index 0000000..71e797e --- /dev/null +++ b/src/common/helpers/car-inquiry-safety.helper.ts @@ -0,0 +1,77 @@ +/** + * Car inquiry privacy: never tell a caller who a plate/chassis belongs to. + * Ownership mismatch must be indistinguishable from "no result". + */ + +export function getPolicyOwnerNationalCode( + policy: Record | undefined, +): string { + if (!policy) { + return ''; + } + + const raw = + policy.NtnlId ?? + policy.ntnlId ?? + policy.NationalId ?? + policy.nationalId ?? + policy.NationalCode ?? + policy.nationalCode; + + return typeof raw === 'string' || typeof raw === 'number' ? String(raw).trim() : ''; +} + +export function nationalCodesEqual(left: string, right: string): boolean { + return left.trim().padStart(10, '0') === right.trim().padStart(10, '0'); +} + +export function isCarOwnershipMatch( + requestedNationalCode: string, + policy: Record | undefined, +): boolean { + const owner = getPolicyOwnerNationalCode(policy); + return Boolean(owner) && nationalCodesEqual(requestedNationalCode, owner); +} + +/** Client-safe no-match: no owner national code, no conflict map, no "belongs to". */ +export function createCarOwnershipMismatchError( + formatError: ( + providerMessage?: string, + providerCode?: string, + fallbackMessage?: string, + extras?: Record, + ) => Error, +): Error { + return formatError( + undefined, + 'INQUIRY_NO_MATCH', + 'Inquiry returned no matching result', + ); +} + +export function assertCarPolicyOwnedBy( + requestedNationalCode: string, + policy: Record | undefined, + formatError: ( + providerMessage?: string, + providerCode?: string, + fallbackMessage?: string, + extras?: Record, + ) => Error, +): void { + if (!isCarOwnershipMatch(requestedNationalCode, policy)) { + throw createCarOwnershipMismatchError(formatError); + } +} + +/** Fail a test/review if a public error payload contains another party's national code. */ +export function publicErrorLeaksNationalCode( + error: unknown, + forbiddenNationalCode: string, +): boolean { + const needle = forbiddenNationalCode.trim(); + if (!needle) { + return false; + } + return JSON.stringify(error).includes(needle); +} diff --git a/src/common/helpers/inquiry-response.helper.ts b/src/common/helpers/inquiry-response.helper.ts index af2a7c1..5790cb9 100644 --- a/src/common/helpers/inquiry-response.helper.ts +++ b/src/common/helpers/inquiry-response.helper.ts @@ -1,3 +1,4 @@ +import { AttemptSummaryDto } from '../dto/attempt-summary.dto'; import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto'; import { NormalizedErrorDto } from '../dto/normalized-error.dto'; import { translateError } from './translate-error.helper'; @@ -10,6 +11,7 @@ export function buildInquiryResponse>(params: { duration: number; data?: T | null; error?: NormalizedErrorDto | null; + attemptSummary?: AttemptSummaryDto; }): BaseInquiryResponseDto { const error = params.success ? null : (params.error != null ? translateError(params.error) : null); @@ -22,6 +24,7 @@ export function buildInquiryResponse>(params: { duration: params.duration, data: params.success ? (params.data ?? null) : null, error, + ...(params.attemptSummary ? { attemptSummary: params.attemptSummary } : {}), }; } diff --git a/src/common/helpers/mask-payload.helper.ts b/src/common/helpers/mask-payload.helper.ts index 0824891..5245534 100644 --- a/src/common/helpers/mask-payload.helper.ts +++ b/src/common/helpers/mask-payload.helper.ts @@ -5,6 +5,9 @@ const SENSITIVE_KEYS = [ 'token', 'authorization', 'nationalCode', + 'nationalId', + 'ntnlid', + 'policyOwnerNationalCode', ]; /** diff --git a/src/common/helpers/provider-resilience.helper.ts b/src/common/helpers/provider-resilience.helper.ts new file mode 100644 index 0000000..b5798af --- /dev/null +++ b/src/common/helpers/provider-resilience.helper.ts @@ -0,0 +1,277 @@ +import { AxiosError } from 'axios'; +import { AttemptErrorDto, AttemptSummaryDto } from '../dto/attempt-summary.dto'; +import { NormalizedErrorDto } from '../dto/normalized-error.dto'; +import { ProviderExecutionContext } from '../interfaces/inquiry-provider.interface'; +import { buildNormalizedError } from '../constants/error-messages'; +import { translateError } from './translate-error.helper'; +import { withRetry, RetryAttemptInfo } from './retry.helper'; +import { withTimeout } from './timeout.helper'; + +/** + * Hard wall-clock budget for one inquiry (all attempts + fallbacks). + * Must exceed a single slow CentInsur SOAP over proxy (~25–30s). + * thirdPartyCar may need two SOAP calls, so default is 90s. + * Override with INQUIRY_DEADLINE_MS env. + */ +export const INQUIRY_DEADLINE_MS = (() => { + const fromEnv = Number.parseInt(process.env.INQUIRY_DEADLINE_MS ?? '', 10); + return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 90_000; +})(); + +/** Default max HTTP attempts per provider execution. */ +export const PROVIDER_DEFAULT_MAX_ATTEMPTS = 3; + +/** Initial backoff before the second attempt (then ×2). */ +export const PROVIDER_RETRY_DELAY_MS = 400; + +/** Auth login/refresh: one gentle retry only. */ +export const AUTH_MAX_ATTEMPTS = 2; +export const AUTH_RETRY_DELAY_MS = 500; + +/** Do not start another attempt if remaining budget is below this. */ +export const MIN_ATTEMPT_BUDGET_MS = 100; + +export interface AttemptTrail { + totalAttempts: number; + failedAttempts: AttemptErrorDto[]; +} + +export function createAttemptTrail(): AttemptTrail { + return { totalAttempts: 0, failedAttempts: [] }; +} + +export function ensureAttemptTrail(context: ProviderExecutionContext): AttemptTrail { + if (!context.attemptTrail) { + context.attemptTrail = createAttemptTrail(); + } + return context.attemptTrail; +} + +export function buildAttemptSummary( + trail: AttemptTrail | undefined, + durationMs: number, +): AttemptSummaryDto { + const totalAttempts = trail?.totalAttempts ?? 0; + return { + totalAttempts, + retried: totalAttempts > 1, + durationMs, + attempts: trail?.failedAttempts ?? [], + }; +} + +/** Attach summary on failure always; on success only when retried. */ +export function shouldExposeAttemptSummary( + success: boolean, + summary: AttemptSummaryDto | undefined, +): boolean { + if (!summary || summary.totalAttempts === 0) { + return false; + } + return !success || summary.retried; +} + +export function createDeadlineError(label = 'Inquiry'): Error { + const normalized = buildNormalizedError('INQUIRY_DEADLINE_EXCEEDED', { + message: `${label} exceeded the ${INQUIRY_DEADLINE_MS}ms deadline`, + }); + const error = new Error(normalized.message); + (error as Error & { normalizedError: NormalizedErrorDto; code: string }).normalizedError = + normalized; + (error as NodeJS.ErrnoException).code = 'ETIMEDOUT'; + return error; +} + +/** + * Transport / transient failures only. + * Does NOT retry 429 (rate limit / ban risk) or business rejects. + */ +export function isTransportRetryable(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + + if ('code' in error) { + const code = (error as { code?: string }).code; + if (code === 'ETIMEDOUT' || code === 'ECONNABORTED' || code === 'ECONNRESET') { + return true; + } + } + + if ('response' in error) { + const status = (error as { response?: { status?: number } }).response?.status; + if (typeof status === 'number') { + if (status === 408 || status === 499 || (status >= 500 && status < 600)) { + return true; + } + } + } + + if ('normalizedError' in error) { + const normalized = (error as { normalizedError: NormalizedErrorDto }).normalizedError; + if ( + normalized.code === 'PROVIDER_TIMEOUT' || + normalized.code === 'PROVIDER_NETWORK_ERROR' || + normalized.code === 'INQUIRY_DEADLINE_EXCEEDED' + ) { + return normalized.code !== 'INQUIRY_DEADLINE_EXCEEDED'; + } + const providerCode = normalized.providerCode?.trim(); + if (providerCode && /^(408|499|5\d\d)$/.test(providerCode)) { + return true; + } + } + + return false; +} + +/** Auth: timeout / connection blips only — never 401/403/429/business rejects. */ +export function isAuthTransportRetryable(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + + if (error instanceof AxiosError) { + if (error.response) { + return false; + } + const code = error.code; + return code === 'ETIMEDOUT' || code === 'ECONNABORTED' || code === 'ECONNRESET'; + } + + if ('code' in error) { + const code = (error as { code?: string }).code; + return code === 'ETIMEDOUT' || code === 'ECONNABORTED' || code === 'ECONNRESET'; + } + + return false; +} + +export function toAttemptErrorFields(error: unknown): { + code: string; + message: string; + messageFa?: string; +} { + if (error && typeof error === 'object' && 'normalizedError' in error) { + const normalized = translateError( + (error as { normalizedError: NormalizedErrorDto }).normalizedError, + ); + return { + code: normalized.code, + message: normalized.message, + messageFa: normalized.messageFa, + }; + } + + if (error instanceof AxiosError) { + const status = error.response?.status; + const code = + error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED' + ? 'PROVIDER_TIMEOUT' + : status && status >= 500 + ? 'PROVIDER_ERROR' + : 'PROVIDER_NETWORK_ERROR'; + const normalized = translateError( + buildNormalizedError(code as 'PROVIDER_TIMEOUT' | 'PROVIDER_ERROR' | 'PROVIDER_NETWORK_ERROR', { + message: error.message, + }), + ); + return { + code: normalized.code, + message: normalized.message, + messageFa: normalized.messageFa, + }; + } + + const errno = (error as NodeJS.ErrnoException | undefined)?.code; + const message = error instanceof Error ? error.message : 'Provider request failed'; + const looksLikeTimeout = + errno === 'ETIMEDOUT' || + errno === 'ECONNABORTED' || + /timed?\s*out|deadline exceeded/i.test(message); + + const code = looksLikeTimeout + ? /deadline exceeded/i.test(message) + ? 'INQUIRY_DEADLINE_EXCEEDED' + : 'PROVIDER_TIMEOUT' + : errno === 'ECONNRESET' || errno === 'NETWORK_ERROR' + ? 'PROVIDER_NETWORK_ERROR' + : 'PROVIDER_ERROR'; + + const normalized = translateError(buildNormalizedError(code, { message })); + return { + code: normalized.code, + message: normalized.message, + messageFa: normalized.messageFa, + }; +} + +export function recordResilienceAttempt( + context: ProviderExecutionContext, + provider: string, + info: RetryAttemptInfo, +): void { + const trail = ensureAttemptTrail(context); + trail.totalAttempts += 1; + if (!info.succeeded && info.error !== undefined) { + const fields = toAttemptErrorFields(info.error); + trail.failedAttempts.push({ + attempt: trail.totalAttempts, + provider, + code: fields.code, + durationMs: info.durationMs, + message: fields.message, + messageFa: fields.messageFa, + }); + } +} + +export interface RunWithResilienceOptions { + providerName: string; + maxAttempts: number; + timeoutMs: number; + context: ProviderExecutionContext; + label?: string; + shouldRetry?: (error: unknown) => boolean; +} + +/** + * Shared retry + per-attempt timeout + inquiry deadline for all inquiry providers. + */ +export async function runWithProviderResilience( + fn: () => Promise, + options: RunWithResilienceOptions, +): Promise { + const { + providerName, + maxAttempts, + timeoutMs, + context, + label = `${providerName} request`, + shouldRetry = isTransportRetryable, + } = options; + + ensureAttemptTrail(context); + + return withRetry( + async () => { + const remaining = context.deadlineAt + ? context.deadlineAt - Date.now() + : timeoutMs; + if (remaining < MIN_ATTEMPT_BUDGET_MS) { + throw createDeadlineError(label); + } + const attemptTimeout = Math.min(timeoutMs, remaining); + return withTimeout(fn(), attemptTimeout, label); + }, + { + maxAttempts, + delayMs: PROVIDER_RETRY_DELAY_MS, + backoffMultiplier: 2, + deadlineAt: context.deadlineAt, + minRemainingMs: MIN_ATTEMPT_BUDGET_MS, + shouldRetry, + onAttempt: (info) => recordResilienceAttempt(context, providerName, info), + }, + ); +} diff --git a/src/common/helpers/retry.helper.ts b/src/common/helpers/retry.helper.ts index 3df29e7..083f662 100644 --- a/src/common/helpers/retry.helper.ts +++ b/src/common/helpers/retry.helper.ts @@ -1,21 +1,31 @@ +export interface RetryAttemptInfo { + attempt: number; + durationMs: number; + succeeded: boolean; + error?: unknown; +} + export interface RetryOptions { maxAttempts: number; delayMs: number; backoffMultiplier?: number; shouldRetry?: (error: unknown) => boolean; + /** Absolute timestamp; stop retrying when the budget is exhausted. */ + deadlineAt?: number; + /** Skip further retries when remaining time after delay is below this. */ + minRemainingMs?: number; + onAttempt?: (info: RetryAttemptInfo) => void; } const DEFAULT_SHOULD_RETRY = (error: unknown): boolean => { if (error && typeof error === 'object') { - // Check for network error codes if ('code' in error) { const code = (error as { code?: string }).code; if (code === 'ECONNABORTED' || code === 'ETIMEDOUT' || code === 'ECONNRESET') { return true; } } - - // Check for HTTP 500 errors (server errors that might be transient) + if ('response' in error) { const response = (error as { response?: { status?: number } }).response; if (response?.status && response.status >= 500 && response.status < 600) { @@ -28,7 +38,7 @@ const DEFAULT_SHOULD_RETRY = (error: unknown): boolean => { /** * Generic async retry with exponential backoff. - * Used by BaseProvider for transient network failures. + * Used by provider resilience for transient network failures. */ export async function withRetry( fn: () => Promise, @@ -39,20 +49,55 @@ export async function withRetry( delayMs, backoffMultiplier = 2, shouldRetry = DEFAULT_SHOULD_RETRY, + deadlineAt, + minRemainingMs = 0, + onAttempt, } = options; let lastError: unknown; let currentDelay = delayMs; for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (deadlineAt !== undefined && Date.now() >= deadlineAt) { + // Prefer the real last failure (e.g. provider timeout) over a synthetic deadline message. + if (lastError !== undefined) { + throw lastError; + } + const error = new Error('Inquiry deadline exceeded before attempt'); + (error as NodeJS.ErrnoException).code = 'ETIMEDOUT'; + throw error; + } + + const attemptStart = Date.now(); try { - return await fn(); + const result = await fn(); + onAttempt?.({ + attempt, + durationMs: Date.now() - attemptStart, + succeeded: true, + }); + return result; } catch (error) { lastError = error; + onAttempt?.({ + attempt, + durationMs: Date.now() - attemptStart, + succeeded: false, + error, + }); + const isLastAttempt = attempt === maxAttempts; if (isLastAttempt || !shouldRetry(error)) { throw error; } + + if (deadlineAt !== undefined) { + const remainingAfterDelay = deadlineAt - Date.now() - currentDelay; + if (remainingAfterDelay < minRemainingMs) { + throw error; + } + } + await sleep(currentDelay); currentDelay *= backoffMultiplier; } diff --git a/src/common/helpers/third-party-car-rules.helper.ts b/src/common/helpers/third-party-car-rules.helper.ts new file mode 100644 index 0000000..15b30b0 --- /dev/null +++ b/src/common/helpers/third-party-car-rules.helper.ts @@ -0,0 +1,323 @@ +import { CarPlateSoapFields } from '../../providers/shared/centinsur-car-policy.client'; + +export interface NormalizedCarPolicy { + raw: Record; + nationalCode: string; + plateKey: string; + chassisNo: string; + motorNo: string; + vin: string; + startDate?: Date; + endDate?: Date; + vehicleGroup: string; + insuranceType: string; + isZeroKm: boolean; + isActive: boolean; +} + +export interface CarPlateInput extends CarPlateSoapFields { + nationalCode: string; +} + +export function getCurrentJalaliYear(now = new Date()): number { + const parts = new Intl.DateTimeFormat('en-u-ca-persian', { year: 'numeric' }).formatToParts(now); + const yearPart = parts.find((part) => part.type === 'year'); + return Number(yearPart?.value ?? new Date().getFullYear() - 621); +} + +/** Persian plate letter must be present and not a placeholder. */ +export function isValidPlateLetter(letter: string): boolean { + const trimmed = letter.trim(); + if (!trimmed || trimmed === '-' || trimmed === '0') { + return false; + } + return /[\u0600-\u06FF]/.test(trimmed); +} + +export function nationalIdsMatch(requested: string, fromPolicy: string): boolean { + return requested.trim().padStart(10, '0') === fromPolicy.trim().padStart(10, '0'); +} + +export function normalizeSoapCarPolicy( + policy: Record, + nationalCode: string, +): NormalizedCarPolicy { + const plk1 = pickField(policy, ['Plk1', 'PLK1']); + const plk2 = pickField(policy, ['Plk2', 'PLK2']); + const plk3 = pickField(policy, ['Plk3', 'PLK3']); + const plksrl = pickField(policy, ['PlkSrl', 'PlkSRL', 'Plk4']); + const chassisNo = pickField(policy, ['ChassisNo', 'ShasiNo', 'VIN', 'Vin']); + const motorNo = pickField(policy, ['MotorNo', 'Motor']); + const vin = pickField(policy, ['VIN', 'Vin', 'ChassisNo']); + const vehicleGroup = pickField(policy, [ + 'CarGrpCod', + 'VhcleGrpCd', + 'VhcleGrp', + 'KbTyp', + 'VehicleGroup', + ]); + const insuranceType = pickField(policy, ['InsurTy', 'InsurTyp', 'PlcyTyp', 'InsuranceType']); + const policyNationalCode = + pickField(policy, ['NtnlId', 'NationalId', 'NationalCode']) || nationalCode; + const startDate = parsePolicyDate( + pickField(policy, [ + 'HBgnDte', + 'PlcyBegnDt', + 'BgnDt', + 'PolicyBeginDate', + 'StartDate', + ]), + ); + const endDate = parsePolicyDate( + pickField(policy, ['HEndDte', 'PlcyEndDt', 'EndDt', 'PolicyEndDate', 'EndDate']), + ); + const plateKey = [plk1, plk2, plk3, plksrl].filter(Boolean).join('-'); + const isZeroKm = isZeroKmPlate(plk1, plk2, plk3, plksrl); + const now = new Date(); + + return { + raw: policy, + nationalCode: policyNationalCode, + plateKey, + chassisNo, + motorNo, + vin, + startDate, + endDate, + vehicleGroup, + insuranceType, + isZeroKm, + isActive: Boolean(startDate && endDate && startDate <= now && endDate >= now), + }; +} + +export function isPassengerVehicleGroup(group: string): boolean { + const normalized = group.trim(); + return normalized === '2' || normalized === '3' || normalized === '02' || normalized === '03'; +} + +export function overlapsCurrentJalaliYear( + policy: NormalizedCarPolicy, + jalaliYear = getCurrentJalaliYear(), +): boolean { + if (!policy.startDate || !policy.endDate) { + return true; + } + + const yearStart = jalaliYearStartGregorian(jalaliYear); + const yearEnd = jalaliYearStartGregorian(jalaliYear + 1); + return policy.startDate < yearEnd && policy.endDate >= yearStart; +} + +export function isAcceptableThirdPartyPolicy( + policy: NormalizedCarPolicy, + requestedNationalCode: string, +): boolean { + if (!nationalIdsMatch(requestedNationalCode, policy.nationalCode)) { + return false; + } + if (policy.vehicleGroup && !isPassengerVehicleGroup(policy.vehicleGroup)) { + return false; + } + if (!overlapsCurrentJalaliYear(policy)) { + return false; + } + return true; +} + +export function sameVehicleIdentity(a: NormalizedCarPolicy, b: NormalizedCarPolicy): boolean { + const aChassis = normalizeIdentity(a.chassisNo || a.vin); + const bChassis = normalizeIdentity(b.chassisNo || b.vin); + if (aChassis && bChassis) { + return aChassis === bChassis; + } + + const aMotor = normalizeIdentity(a.motorNo); + const bMotor = normalizeIdentity(b.motorNo); + if (aMotor && bMotor) { + return aMotor === bMotor; + } + + return a.plateKey !== '' && a.plateKey === b.plateKey; +} + +export function selectBestThirdPartyPolicy( + policies: NormalizedCarPolicy[], + requestedNationalCode: string, +): NormalizedCarPolicy | null { + const acceptable = policies.filter((policy) => + isAcceptableThirdPartyPolicy(policy, requestedNationalCode), + ); + if (acceptable.length === 0) { + return null; + } + + const active = acceptable.filter((policy) => policy.isActive); + const pool = active.length > 0 ? active : acceptable; + + return pool.sort((left, right) => { + const leftStart = left.startDate?.getTime() ?? 0; + const rightStart = right.startDate?.getTime() ?? 0; + return rightStart - leftStart; + })[0]!; +} + +export function mergePlateAndNationalPolicies( + platePolicy: NormalizedCarPolicy | null, + nationalPolicy: NormalizedCarPolicy | null, +): NormalizedCarPolicy | null { + if (!platePolicy && !nationalPolicy) { + return null; + } + if (!platePolicy) { + return nationalPolicy; + } + if (!nationalPolicy) { + return platePolicy; + } + + if (sameVehicleIdentity(platePolicy, nationalPolicy)) { + return platePolicy.startDate && nationalPolicy.startDate + ? platePolicy.startDate >= nationalPolicy.startDate + ? platePolicy + : nationalPolicy + : platePolicy; + } + + if (nationalPolicy.isZeroKm) { + return nationalPolicy.startDate && platePolicy.startDate + ? nationalPolicy.startDate >= platePolicy.startDate + ? nationalPolicy + : platePolicy + : nationalPolicy; + } + + return platePolicy; +} + +export interface ThirdPartyCarLookup { + byPlate?: () => Promise | null>; + byNationalCode?: () => Promise | null>; +} + +export async function resolveThirdPartyCarPolicy( + input: CarPlateInput, + lookup: ThirdPartyCarLookup, +): Promise<{ policy: NormalizedCarPolicy; sources: string[] } | null> { + const plateLetterValid = isValidPlateLetter(input.plk2); + const sources: string[] = []; + + const platePromise = + plateLetterValid && lookup.byPlate + ? lookup.byPlate().catch(() => null) + : Promise.resolve(null); + const nationalPromise = lookup.byNationalCode + ? lookup.byNationalCode().catch(() => null) + : Promise.resolve(null); + + // Parallel so dual SOAP inquiries share wall-clock time under the inquiry deadline. + const [plateRaw, nationalRaw] = await Promise.all([platePromise, nationalPromise]); + + const platePolicy = plateRaw + ? normalizeSoapCarPolicy(plateRaw, input.nationalCode) + : null; + const nationalPolicy = nationalRaw + ? normalizeSoapCarPolicy(nationalRaw, input.nationalCode) + : null; + + if (platePolicy) { + sources.push('plate'); + } + if (nationalPolicy) { + sources.push('nationalCode'); + } + + let merged = mergePlateAndNationalPolicies( + platePolicy && isAcceptableThirdPartyPolicy(platePolicy, input.nationalCode) ? platePolicy : null, + nationalPolicy && isAcceptableThirdPartyPolicy(nationalPolicy, input.nationalCode) + ? nationalPolicy + : null, + ); + + if (!merged && plateLetterValid && lookup.byPlate && !platePolicy) { + try { + const raw = await lookup.byPlate(); + if (raw) { + const fallback = normalizeSoapCarPolicy(raw, input.nationalCode); + merged = selectBestThirdPartyPolicy([fallback], input.nationalCode); + if (merged) { + sources.push('plateFallback'); + } + } + } catch { + merged = null; + } + } + + if (!merged) { + return null; + } + + return { policy: merged, sources }; +} + +function pickField(policy: Record, keys: string[]): string { + for (const key of keys) { + const value = policy[key]?.trim(); + if (value) { + return value; + } + } + return ''; +} + +function parsePolicyDate(value: string): Date | undefined { + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + // Jalali dates from CentInsur: 1405/05/23 + const jalaliSlash = /^(\d{4})\/(\d{1,2})\/(\d{1,2})$/.exec(trimmed); + if (jalaliSlash) { + const year = Number(jalaliSlash[1]); + const month = Number(jalaliSlash[2]); + const day = Number(jalaliSlash[3]); + // Approximate Gregorian for overlap checks (Jalali year + 621, month/day as-is). + // Good enough for "overlaps current Jalali year" when both ends use the same scheme. + if (year > 1300 && year < 1600) { + const date = new Date(year + 621, month - 1, day); + return Number.isNaN(date.getTime()) ? undefined : date; + } + } + + const compact = trimmed.replace(/[^\d]/g, ''); + if (compact.length === 8) { + const year = Number(compact.slice(0, 4)); + const month = Number(compact.slice(4, 6)); + const day = Number(compact.slice(6, 8)); + if (year > 1300 && year < 1600) { + const date = new Date(year + 621, month - 1, day); + return Number.isNaN(date.getTime()) ? undefined : date; + } + const date = new Date(year, month - 1, day); + return Number.isNaN(date.getTime()) ? undefined : date; + } + + const parsed = new Date(trimmed); + return Number.isNaN(parsed.getTime()) ? undefined : parsed; +} + +function isZeroKmPlate(plk1: string, plk2: string, plk3: string, plksrl: string): boolean { + return !plk1.trim() && !plk2.trim() && !plk3.trim() && !plksrl.trim(); +} + +function normalizeIdentity(value: string): string { + return value.trim().toUpperCase(); +} + +function jalaliYearStartGregorian(jalaliYear: number): Date { + const march = 20; + const gregorianYear = jalaliYear + 621; + return new Date(gregorianYear, 2, march); +} diff --git a/src/common/interfaces/inquiry-provider.interface.ts b/src/common/interfaces/inquiry-provider.interface.ts index cb7b0e3..80acd98 100644 --- a/src/common/interfaces/inquiry-provider.interface.ts +++ b/src/common/interfaces/inquiry-provider.interface.ts @@ -1,5 +1,6 @@ import { InquiryType } from '../enums/inquiry-type.enum'; import { ProviderName } from '../enums/provider-name.enum'; +import { AttemptTrail } from '../helpers/provider-resilience.helper'; /** * Contract every external provider adapter must fulfill. @@ -19,4 +20,8 @@ export interface InquiryProvider { export interface ProviderExecutionContext { requestId: string; trackingCode: string; + /** Absolute timestamp when the inquiry wall-clock budget expires. */ + deadlineAt?: number; + /** Mutable trail of HTTP attempts for client summary + logs. */ + attemptTrail?: AttemptTrail; } diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 78d38ca..e3a90d1 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -31,12 +31,12 @@ export interface TejaratNouConfig { enabled: boolean; timeout: number; maxRetries: number; - authUrl: string; + baseUrl: string; + inquiryBaseUrl: string; clientId: string; clientSecret: string; username: string; password: string; - inquiries: Partial>; } export interface InquiryRoutingConfig { @@ -84,15 +84,14 @@ export default () => ({ tejaratnou: { enabled: process.env.TEJARATNOU_ENABLED !== 'false', timeout: parseInt(process.env.TEJARATNOU_TIMEOUT ?? '15000', 10), - maxRetries: parseInt(process.env.TEJARATNOU_MAX_RETRIES ?? '2', 10), - authUrl: process.env.TEJARATNOU_AUTH_URL ?? 'https://accounts.tejaratnoins.ir', + maxRetries: parseInt(process.env.TEJARATNOU_MAX_RETRIES ?? '3', 10), + baseUrl: process.env.TEJARATNOU_BASE_URL ?? 'https://accounts.tejaratnoins.ir', + inquiryBaseUrl: + process.env.TEJARATNOU_INQUIRY_BASE_URL ?? 'https://gateway.tejaratnoins.ir', clientId: process.env.TEJARATNOU_CLIENT_ID ?? 'api-gateway', clientSecret: process.env.TEJARATNOU_CLIENT_SECRET ?? '', username: process.env.TEJARATNOU_USERNAME ?? '', password: process.env.TEJARATNOU_PASSWORD ?? '', - inquiries: { - [InquiryType.PERSON]: buildInquiryConfig('TEJARATNOU', 'PERSON'), - }, } satisfies TejaratNouConfig, inquiryRouting: { [InquiryType.PERSON]: buildInquiryRoutingConfig('PERSON', ProviderName.PARSIAN, [ @@ -101,12 +100,14 @@ export default () => ({ ]), [InquiryType.REAL_ESTATE]: buildInquiryRoutingConfig('REAL_ESTATE', ProviderName.HAMTA, [ ProviderName.MOALLEM, + ProviderName.TEJARATNOU, ]), [InquiryType.SHEBA]: buildInquiryRoutingConfig('SHEBA', ProviderName.PARSIAN, [ ProviderName.HAMTA, ]), [InquiryType.SHAHKAR]: buildInquiryRoutingConfig('SHAHKAR', ProviderName.PARSIAN, [ ProviderName.HAMTA, + ProviderName.TEJARATNOU, ]), [InquiryType.POSTAL_CODE]: buildInquiryRoutingConfig('POSTAL_CODE', ProviderName.MOALLEM, [ ProviderName.HAMTA, @@ -114,7 +115,10 @@ export default () => ({ [InquiryType.LEGAL_PERSON]: buildInquiryRoutingConfig('LEGAL_PERSON', ProviderName.HAMTA, [ ProviderName.MOALLEM, ]), - [InquiryType.CAR_PLATE]: buildInquiryRoutingConfig('CAR_PLATE', ProviderName.HAMTA, []), + [InquiryType.CAR_PLATE]: buildInquiryRoutingConfig('CAR_PLATE', ProviderName.TEJARATNOU, []), + [InquiryType.CAR_BY_PLATE]: buildInquiryRoutingConfig('CAR_BY_PLATE', ProviderName.PARSIAN, []), + [InquiryType.CAR_BY_CHASSIS]: buildInquiryRoutingConfig('CAR_BY_CHASSIS', ProviderName.PARSIAN, []), + [InquiryType.THIRD_PARTY_CAR]: buildInquiryRoutingConfig('THIRD_PARTY_CAR', ProviderName.PARSIAN, []), [InquiryType.POLICY_BY_CHASSIS]: buildInquiryRoutingConfig( 'POLICY_BY_CHASSIS', ProviderName.PARSIAN, @@ -142,6 +146,9 @@ function buildProviderConfig(prefix: string): ProviderEnvConfig { InquiryType.POSTAL_CODE, InquiryType.LEGAL_PERSON, InquiryType.CAR_PLATE, + InquiryType.CAR_BY_PLATE, + InquiryType.CAR_BY_CHASSIS, + InquiryType.THIRD_PARTY_CAR, InquiryType.POLICY_BY_CHASSIS, InquiryType.POLICY_BY_PLATE, InquiryType.POLICY_BY_NATIONAL_CODE, @@ -159,7 +166,7 @@ function buildProviderConfig(prefix: string): ProviderEnvConfig { return { enabled: process.env[`${prefix}_ENABLED`] !== 'false', timeout: parseInt(process.env[`${prefix}_TIMEOUT`] ?? '10000', 10), - maxRetries: parseInt(process.env[`${prefix}_MAX_RETRIES`] ?? '2', 10), + maxRetries: parseInt(process.env[`${prefix}_MAX_RETRIES`] ?? '3', 10), inquiries, }; } @@ -183,6 +190,9 @@ function inquiryTypeToEnvName(inquiryType: InquiryType): string { [InquiryType.POSTAL_CODE]: 'POSTAL_CODE', [InquiryType.LEGAL_PERSON]: 'LEGAL_PERSON', [InquiryType.CAR_PLATE]: 'CAR_PLATE', + [InquiryType.CAR_BY_PLATE]: 'CAR_BY_PLATE', + [InquiryType.CAR_BY_CHASSIS]: 'CAR_BY_CHASSIS', + [InquiryType.THIRD_PARTY_CAR]: 'THIRD_PARTY_CAR', [InquiryType.POLICY_BY_CHASSIS]: 'POLICY_BY_CHASSIS', [InquiryType.POLICY_BY_PLATE]: 'POLICY_BY_PLATE', [InquiryType.POLICY_BY_NATIONAL_CODE]: 'POLICY_BY_NATIONAL_CODE', diff --git a/src/inquiry/dto/car-by-chassis-request.dto.ts b/src/inquiry/dto/car-by-chassis-request.dto.ts new file mode 100644 index 0000000..24246ca --- /dev/null +++ b/src/inquiry/dto/car-by-chassis-request.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, Length, Matches } from 'class-validator'; + +export class CarByChassisRequestDto { + @ApiProperty({ example: '4311402422', description: 'Owner national code (10 digits)' }) + @IsString() + @Length(10, 10) + @Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' }) + nationalCode!: string; + + @ApiProperty({ + example: 'IRFC89R2VSY976455', + description: 'Vehicle chassis number or VIN', + }) + @IsString() + @IsNotEmpty() + chassisNo!: string; +} diff --git a/src/inquiry/dto/car-by-plate-request.dto.ts b/src/inquiry/dto/car-by-plate-request.dto.ts new file mode 100644 index 0000000..57caa55 --- /dev/null +++ b/src/inquiry/dto/car-by-plate-request.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, Length, Matches } from 'class-validator'; + +export class CarByPlateRequestDto { + @ApiProperty({ example: '4311402422', description: 'Owner national code (10 digits)' }) + @IsString() + @Length(10, 10) + @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' }) + plk1!: string; + + @ApiProperty({ example: 'ب', description: 'Middle plate letter' }) + @IsString() + @IsNotEmpty() + plk2!: string; + + @ApiProperty({ example: '345', description: 'Three digits on the right side of the plate' }) + @IsString() + @Matches(/^\d{3}$/, { message: 'plk3 must be exactly 3 digits' }) + plk3!: string; + + @ApiProperty({ example: '67', description: 'Plate serial / region code' }) + @IsString() + @IsNotEmpty() + plksrl!: string; +} diff --git a/src/inquiry/inquiry.controller.ts b/src/inquiry/inquiry.controller.ts index fed20a6..b048333 100644 --- a/src/inquiry/inquiry.controller.ts +++ b/src/inquiry/inquiry.controller.ts @@ -32,6 +32,8 @@ import { RealEstateRequestDto } from './dto/real-estate-request.dto'; import { PolicyByChassisRequestDto } from './dto/policy-by-chassis-request.dto'; import { PolicyByNationalCodeRequestDto } from './dto/policy-by-national-code-request.dto'; import { PolicyByPlateRequestDto } from './dto/policy-by-plate-request.dto'; +import { CarByPlateRequestDto } from './dto/car-by-plate-request.dto'; +import { CarByChassisRequestDto } from './dto/car-by-chassis-request.dto'; import { InquiryService } from './inquiry.service'; /** @@ -135,6 +137,57 @@ export class InquiryController { return this.inquireGeneric(InquiryType.LEGAL_PERSON, payload, req); } + @Post('carByPlate') + @InquiryAccess(InquiryType.CAR_BY_PLATE) + @Throttle({ default: { limit: 30, ttl: 60000 } }) + @ApiOperation({ summary: 'Car insurance inquiry by plate (provider passthrough)' }) + @ApiResponse({ status: 200, type: GenericInquiryResponseDto }) + async inquireCarByPlate( + @Body() payload: CarByPlateRequestDto, + @Req() req: Request & { requestId?: string; trackingCode?: string }, + ): Promise>> { + return this.inquireGeneric( + InquiryType.CAR_BY_PLATE, + payload as unknown as Record, + req, + ); + } + + @Post('carByChassis') + @InquiryAccess(InquiryType.CAR_BY_CHASSIS) + @Throttle({ default: { limit: 30, ttl: 60000 } }) + @ApiOperation({ summary: 'Car insurance inquiry by chassis/VIN (provider passthrough)' }) + @ApiResponse({ status: 200, type: GenericInquiryResponseDto }) + async inquireCarByChassis( + @Body() payload: CarByChassisRequestDto, + @Req() req: Request & { requestId?: string; trackingCode?: string }, + ): Promise>> { + return this.inquireGeneric( + InquiryType.CAR_BY_CHASSIS, + payload as unknown as Record, + req, + ); + } + + @Post('thirdPartyCar') + @InquiryAccess(InquiryType.THIRD_PARTY_CAR) + @Throttle({ default: { limit: 30, ttl: 60000 } }) + @ApiOperation({ + summary: + 'Third-party car insurance inquiry with provider-specific selection (Tejarat passthrough, CentInsur partial rules)', + }) + @ApiResponse({ status: 200, type: GenericInquiryResponseDto }) + async inquireThirdPartyCar( + @Body() payload: CarByPlateRequestDto, + @Req() req: Request & { requestId?: string; trackingCode?: string }, + ): Promise>> { + return this.inquireGeneric( + InquiryType.THIRD_PARTY_CAR, + payload as unknown as Record, + req, + ); + } + @Post('policyByChassis') @InquiryAccess(InquiryType.POLICY_BY_CHASSIS) @Throttle({ default: { limit: 30, ttl: 60000 } }) diff --git a/src/inquiry/inquiry.service.ts b/src/inquiry/inquiry.service.ts index bceaf38..6e6f930 100644 --- a/src/inquiry/inquiry.service.ts +++ b/src/inquiry/inquiry.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InquiryType } from '../common/enums/inquiry-type.enum'; import { InquiryStatus } from '../common/enums/inquiry-status.enum'; import { ProviderName } from '../common/enums/provider-name.enum'; +import { AttemptSummaryDto } from '../common/dto/attempt-summary.dto'; import { BaseInquiryResponseDto } from '../common/dto/base-inquiry-response.dto'; import { NormalizedErrorDto } from '../common/dto/normalized-error.dto'; import { generateTrackingCode } from '../common/helpers/tracking-code.helper'; @@ -10,9 +11,9 @@ import { InquiryException } from '../common/exceptions/inquiry.exception'; import { InquiryLogService } from '../logging/inquiry-log.service'; import { translateError } from '../common/helpers/translate-error.helper'; import { buildNormalizedError } from '../common/constants/error-messages'; +import { shouldExposeAttemptSummary } from '../common/helpers/provider-resilience.helper'; import { LegacyInquiryPayload, - PersonInquiryPayload, PersonInquiryResult, } from '../providers/shared/legacy-api.provider.abstract'; import { ProviderOrchestratorService } from '../providers/strategy/provider-orchestrator.service'; @@ -57,6 +58,8 @@ export class InquiryService { trackingCode, ); + const attemptSummary = this.resolveExposedSummary(true, result.attemptSummary); + const response = buildInquiryResponse({ success: true, provider: result.provider, @@ -64,6 +67,7 @@ export class InquiryService { message: `${this.getInquiryLabel(inquiryType)} completed successfully`, duration: result.duration, data: this.toResponseData(result.data), + attemptSummary, }); await this.persistLog({ @@ -75,12 +79,15 @@ export class InquiryService { duration: result.duration, requestId, trackingCode, + attemptSummary: result.attemptSummary, }); return response; } catch (error) { const duration = Date.now() - start; - const { normalized, provider } = this.extractFailure(error); + const { normalized, provider, attemptSummary } = this.extractFailure(error, duration); + + const exposedSummary = this.resolveExposedSummary(false, attemptSummary); const response = buildInquiryResponse({ success: false, @@ -89,6 +96,7 @@ export class InquiryService { message: normalized.message, duration, error: normalized, + attemptSummary: exposedSummary, }); await this.persistLog({ @@ -100,6 +108,7 @@ export class InquiryService { requestId, trackingCode, error: normalized as unknown as Record, + attemptSummary, }).catch(() => undefined); return response; @@ -118,6 +127,13 @@ export class InquiryService { }); } + private resolveExposedSummary( + success: boolean, + summary: AttemptSummaryDto | undefined, + ): AttemptSummaryDto | undefined { + return shouldExposeAttemptSummary(success, summary) ? summary : undefined; + } + private toResponseData(result: unknown): Record { if (this.isPersonInquiryResult(result)) { const raw = @@ -156,12 +172,25 @@ export class InquiryService { return inquiryType.replace(/_INQUIRY$/, '').toLowerCase().replace(/_/g, ' '); } - private extractFailure(error: unknown): { + private extractFailure( + error: unknown, + durationMs: number, + ): { normalized: NormalizedErrorDto; provider?: string; + attemptSummary?: AttemptSummaryDto; } { if (error instanceof InquiryException) { - return { normalized: translateError(error.normalizedError), provider: error.provider }; + return { + normalized: translateError(error.normalizedError), + provider: error.provider, + attemptSummary: error.attemptSummary ?? { + totalAttempts: 0, + retried: false, + durationMs, + attempts: [], + }, + }; } if (error && typeof error === 'object' && 'normalizedError' in error) { return { diff --git a/src/logging/inquiry-log.service.ts b/src/logging/inquiry-log.service.ts index bee86b1..618aa46 100644 --- a/src/logging/inquiry-log.service.ts +++ b/src/logging/inquiry-log.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { FilterQuery, Model } from 'mongoose'; +import { AttemptSummaryDto } from '../common/dto/attempt-summary.dto'; import { InquiryStatus } from '../common/enums/inquiry-status.enum'; import { InquiryType } from '../common/enums/inquiry-type.enum'; import { ProviderName } from '../common/enums/provider-name.enum'; @@ -23,6 +24,7 @@ export interface CreateInquiryLogInput { requestId: string; trackingCode: string; error?: Record; + attemptSummary?: AttemptSummaryDto; } export interface InquiryLogListItem { diff --git a/src/logging/schemas/inquiry-log.schema.ts b/src/logging/schemas/inquiry-log.schema.ts index c5ecf00..3a6c3f4 100644 --- a/src/logging/schemas/inquiry-log.schema.ts +++ b/src/logging/schemas/inquiry-log.schema.ts @@ -41,6 +41,9 @@ export class InquiryLog { @Prop({ type: Object }) error?: Record; + @Prop({ type: Object }) + attemptSummary?: Record; + createdAt?: Date; } diff --git a/src/providers/base/base-provider.abstract.ts b/src/providers/base/base-provider.abstract.ts index 906edc5..769a21b 100644 --- a/src/providers/base/base-provider.abstract.ts +++ b/src/providers/base/base-provider.abstract.ts @@ -7,9 +7,12 @@ import { InquiryProvider, ProviderExecutionContext, } from '../../common/interfaces/inquiry-provider.interface'; -import { withRetry } from '../../common/helpers/retry.helper'; -import { withTimeout } from '../../common/helpers/timeout.helper'; import { RequestLogger } from '../../common/helpers/request-logger.helper'; +import { + PROVIDER_DEFAULT_MAX_ATTEMPTS, + isTransportRetryable, + runWithProviderResilience, +} from '../../common/helpers/provider-resilience.helper'; import { ProviderConfigSlice } from '../interfaces/provider-config.interface'; import { AppErrorCode, @@ -22,9 +25,9 @@ import { * Abstract base for all provider adapters. * * Responsibilities: - * - Retry + timeout orchestration + * - Retry + timeout + inquiry deadline orchestration + * - Attempt trail recording * - Standardized error normalization - * - Response formatting hooks * - Structured logging * * Subclasses implement provider-specific HTTP/business logic only. @@ -108,43 +111,19 @@ export abstract class BaseProvider fn: () => Promise, context: ProviderExecutionContext, ): Promise { - const operation = () => - withTimeout(fn(), this.config.timeout, `${this.name} request`); + const maxAttempts = Math.max(1, this.config.maxRetries || PROVIDER_DEFAULT_MAX_ATTEMPTS); - return withRetry(operation, { - maxAttempts: this.config.maxRetries, - delayMs: 300, + return runWithProviderResilience(fn, { + providerName: this.name, + maxAttempts, + timeoutMs: this.config.timeout, + context, shouldRetry: (error) => this.isRetryable(error), }); } protected isRetryable(error: unknown): boolean { - if (!error || typeof error !== 'object') return false; - - // Network-level transient errors - if ('code' in error) { - const code = (error as { code?: string }).code; - if (code === 'ETIMEDOUT' || code === 'ECONNABORTED' || code === 'ECONNRESET') { - return true; - } - } - - // HTTP errors that indicate provider unavailability - if ('response' in error) { - const status = (error as { response?: { status?: number } }).response?.status; - if (typeof status === 'number') { - if ( - status === 408 || // Request Timeout — provider didn't answer in time - status === 429 || // Too Many Requests — provider rate-limited us - status === 499 || // Client Closed Request — provider hung up - (status >= 500 && status < 600) // 5xx server errors - ) { - return true; - } - } - } - - return false; + return isTransportRetryable(error); } protected normalizeError(partial: Partial): NormalizedErrorDto { @@ -184,9 +163,14 @@ export abstract class BaseProvider ): Error { const message = providerMessage?.trim() || fallbackMessage; const upstreamCode = providerCode?.trim(); + const code = this.toProviderErrorCode(upstreamCode); + const catalogMessage = + code !== 'PROVIDER_ERROR' && fallbackMessage === 'Provider request failed' + ? undefined + : fallbackMessage; const normalized = this.normalizeError({ code: upstreamCode, - message: fallbackMessage, + message: catalogMessage, providerMessage: message, providerCode: upstreamCode, ...extras, diff --git a/src/providers/implementations/amitis.provider.ts b/src/providers/implementations/amitis.provider.ts index 0c9a8a9..957c6a5 100644 --- a/src/providers/implementations/amitis.provider.ts +++ b/src/providers/implementations/amitis.provider.ts @@ -12,6 +12,12 @@ import { AppErrorCode, buildNormalizedError, } from '../../common/constants/error-messages'; +import { + AUTH_MAX_ATTEMPTS, + AUTH_RETRY_DELAY_MS, + isAuthTransportRetryable, +} from '../../common/helpers/provider-resilience.helper'; +import { withRetry } from '../../common/helpers/retry.helper'; import { AmitisAuthServiceConfig } from '../../config/configuration'; import { GeneralTokenDocument } from '../schemas/general-token.schema'; import { GeneralTokenService } from '../services/general-token.service'; @@ -124,14 +130,16 @@ export class AmitisProvider { `AMITIS login → POST ${loginUrl} | provider=${providerName} | inquiry=${inquiryType} | username=${username.toLowerCase()}`, ); - const response = await this.httpClient.post( - this.config.loginPath, - loginBody.toString(), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', + const response = await this.withGentleAuthRetry(() => + this.httpClient.post( + this.config.loginPath, + loginBody.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, }, - }, + ), ); if (isOutboundHttpDebugEnabled()) { @@ -142,7 +150,7 @@ export class AmitisProvider { const token = this.extractToken(response.data); - await this.saveToken(token, providerName, inquiryType, username, password, 'login'); + await this.saveToken(token, providerName, inquiryType, username, 'login'); return token.accessToken; } catch (error) { throw this.toAuthError(`AMITIS login failed for ${providerName}/${inquiryType}`, error); @@ -155,10 +163,12 @@ export class AmitisProvider { inquiryType: InquiryType, ): Promise { try { - const response = await this.httpClient.post(this.config.refreshPath, { - Token: latestToken.accessToken, - RefreshToken: latestToken.refreshToken, - }); + const response = await this.withGentleAuthRetry(() => + this.httpClient.post(this.config.refreshPath, { + Token: latestToken.accessToken, + RefreshToken: latestToken.refreshToken, + }), + ); const token = this.extractToken(response.data, latestToken.refreshToken); await this.saveToken( @@ -166,7 +176,6 @@ export class AmitisProvider { providerName, inquiryType, latestToken.username, - latestToken.clientSecret, 'refresh', ); return token.accessToken; @@ -178,20 +187,27 @@ export class AmitisProvider { } } + /** One gentle retry on timeout/network only — never on 401/403/429/auth rejects. */ + private async withGentleAuthRetry(fn: () => Promise): Promise { + return withRetry(fn, { + maxAttempts: AUTH_MAX_ATTEMPTS, + delayMs: AUTH_RETRY_DELAY_MS, + shouldRetry: isAuthTransportRetryable, + }); + } + private async saveToken( token: AmitisAuthToken, providerName: ProviderName, inquiryType: InquiryType, username: string, - password: string, scope: string, ): Promise { - await this.generalTokenService.create({ + await this.generalTokenService.persistToken({ serviceProvider: this.getTokenKey(providerName, inquiryType), tokenType: token.tokenType, url: this.config.baseUrl, clientId: username.toLowerCase(), - clientSecret: password, username: username.toLowerCase(), scope, accessToken: token.accessToken, diff --git a/src/providers/implementations/moallem.provider.ts b/src/providers/implementations/moallem.provider.ts index ac3028e..3bd9b03 100644 --- a/src/providers/implementations/moallem.provider.ts +++ b/src/providers/implementations/moallem.provider.ts @@ -21,6 +21,11 @@ import { ProviderName } from '../../common/enums/provider-name.enum'; import { ProviderEnvConfig } from '../../config/configuration'; import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface'; import { AmitisProvider } from './amitis.provider'; +import { + CentInsurCarProviderSupport, + CarChassisPayload, + CarPlatePayload, +} from '../shared/centinsur-car-provider.support'; import { LegacyApiProvider, LegacyInquiryPayload, @@ -68,11 +73,18 @@ export class MoallemProvider extends LegacyApiProvider { InquiryType.SHAHKAR, InquiryType.POSTAL_CODE, InquiryType.REAL_ESTATE, + InquiryType.CAR_BY_PLATE, + InquiryType.CAR_BY_CHASSIS, + InquiryType.THIRD_PARTY_CAR, ]; private readonly moallemConfig: ProviderEnvConfig; - constructor(configService: ConfigService, private readonly amitisProvider: AmitisProvider) { + constructor( + configService: ConfigService, + private readonly amitisProvider: AmitisProvider, + private readonly centInsurCarSupport: CentInsurCarProviderSupport, + ) { const config = configService.get('moallem')!; super( config, @@ -103,6 +115,33 @@ export class MoallemProvider extends LegacyApiProvider { return this.inquireSayah(payload as SayahPayload); } + if (inquiryType === InquiryType.CAR_BY_PLATE) { + return this.centInsurCarSupport.inquireCarByPlate( + this.moallemConfig, + payload as CarPlatePayload, + (message, code, fallback, extras) => + this.formatProviderError(message, code, fallback, extras as never), + ); + } + + if (inquiryType === InquiryType.CAR_BY_CHASSIS) { + return this.centInsurCarSupport.inquireCarByChassis( + this.moallemConfig, + payload as CarChassisPayload, + (message, code, fallback, extras) => + this.formatProviderError(message, code, fallback, extras as never), + ); + } + + if (inquiryType === InquiryType.THIRD_PARTY_CAR) { + return this.centInsurCarSupport.inquireThirdPartyCar( + this.moallemConfig, + payload as CarPlatePayload, + (message, code, fallback, extras) => + this.formatProviderError(message, code, fallback, extras as never), + ); + } + return super.callProvider(inquiryType, payload, context); } diff --git a/src/providers/implementations/parsian.provider.ts b/src/providers/implementations/parsian.provider.ts index e377296..dd9acc9 100644 --- a/src/providers/implementations/parsian.provider.ts +++ b/src/providers/implementations/parsian.provider.ts @@ -12,12 +12,18 @@ import { parseFirstCarPolicy, } from '../../common/helpers/soap-car-policy.helper'; import { getSayahProviderError, SayahApiResponse } from '../../common/helpers/sayah-response.helper'; +import { assertCarPolicyOwnedBy } from '../../common/helpers/car-inquiry-safety.helper'; import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { ProviderName } from '../../common/enums/provider-name.enum'; import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface'; import { ProviderEnvConfig } from '../../config/configuration'; import { BaseProvider } from '../base/base-provider.abstract'; import { AmitisProvider } from './amitis.provider'; +import { + CentInsurCarProviderSupport, + CarChassisPayload, + CarPlatePayload, +} from '../shared/centinsur-car-provider.support'; import { LegacyInquiryPayload, LegacyInquiryResult, @@ -95,6 +101,9 @@ export class ParsianProvider extends BaseProvider('parsian')!); } @@ -125,7 +135,9 @@ export class ParsianProvider extends BaseProvider + this.formatProviderError(message, code, fallback, extras as never), + ); + } + + if (inquiryType === InquiryType.CAR_BY_CHASSIS) { + return this.centInsurCarSupport.inquireCarByChassis( + this.config, + payload as CarChassisPayload, + (message, code, fallback, extras) => + this.formatProviderError(message, code, fallback, extras as never), + ); + } + + if (inquiryType === InquiryType.THIRD_PARTY_CAR) { + return this.centInsurCarSupport.inquireThirdPartyCar( + this.config, + payload as CarPlatePayload, + (message, code, fallback, extras) => + this.formatProviderError(message, code, fallback, extras as never), + ); + } + if (inquiryType === InquiryType.POLICY_BY_CHASSIS) { return this.inquirePolicyByChassis(payload as ParsianPolicyByChassisPayload); } @@ -346,20 +385,9 @@ export class ParsianProvider extends BaseProvider + this.formatProviderError(message, code, fallback, extras as never), + ); return { raw: { @@ -373,10 +401,6 @@ export class ParsianProvider extends BaseProvider { + data?: T; isSuccess?: boolean; statusCode?: number; message?: string; @@ -39,10 +58,23 @@ interface TejaratNouPersonInquiryData { message?: unknown; } +/** + * Tejarat No gateway provider. + * + * OAuth token (accounts host) + REST calls on inquiry gateway host. + */ @Injectable() -export class TejaratNouProvider implements InquiryProvider { +export class TejaratNouProvider implements InquiryProvider { readonly name = ProviderName.TEJARATNOU; - readonly supportedInquiryTypes = [InquiryType.PERSON]; + readonly supportedInquiryTypes = [ + InquiryType.PERSON, + InquiryType.SHAHKAR, + InquiryType.REAL_ESTATE, + InquiryType.CAR_BY_PLATE, + InquiryType.CAR_BY_CHASSIS, + InquiryType.THIRD_PARTY_CAR, + ]; + private readonly logger = new Logger(TejaratNouProvider.name); private readonly httpClient: AxiosInstance; private readonly config: TejaratNouConfig; @@ -50,162 +82,509 @@ export class TejaratNouProvider implements InquiryProvider('tejaratnou')!; this.httpClient = createOutboundAxiosInstance({ baseURL: this.config.inquiryBaseUrl, timeout: this.config.timeout, - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, }); } isEnabled(): boolean { - return this.config.enabled && Boolean(this.config.baseUrl) && Boolean(this.config.inquiryBaseUrl); + return ( + this.config.enabled && + Boolean(this.config.baseUrl) && + Boolean(this.config.inquiryBaseUrl) && + Boolean(this.config.clientSecret) && + Boolean(this.config.username) && + Boolean(this.config.password) + ); } async execute( inquiryType: InquiryType, - payload: PersonInquiryPayload, + payload: LegacyInquiryPayload, context: ProviderExecutionContext, - ): Promise { - if (inquiryType !== InquiryType.PERSON) { - throw new Error(`Unsupported inquiry type: ${inquiryType}`); + ): Promise { + switch (inquiryType) { + case InquiryType.PERSON: + return this.inquirePerson( + { + nationalCode: this.getRequiredString(payload.nationalCode, 'nationalCode'), + birthDate: this.getRequiredString(payload.birthDate, 'birthDate'), + dateHasPostfix: + typeof payload.dateHasPostfix === 'number' ? payload.dateHasPostfix : undefined, + }, + context, + ); + case InquiryType.SHAHKAR: + return this.inquireShahkar(payload, context); + case InquiryType.REAL_ESTATE: + return this.inquireRealEstate(payload, context); + case InquiryType.CAR_BY_PLATE: + return this.inquireCarByPlate(payload, context); + case InquiryType.CAR_BY_CHASSIS: + return this.inquireCarByChassis(payload, context); + case InquiryType.THIRD_PARTY_CAR: + return this.inquireThirdPartyCar(payload, context); + default: + throw this.createProviderError(`Unsupported inquiry type: ${inquiryType}`, 'UNSUPPORTED_INQUIRY'); + } + } + + private async inquireCarByPlate( + payload: LegacyInquiryPayload, + context: ProviderExecutionContext, + ): Promise<{ raw: unknown }> { + const input = this.parseCarPlatePayload(payload); + return this.withResilience( + async () => ({ + raw: await this.fetchCarByPlate(input, context), + }), + context, + 'car by plate inquiry', + ); + } + + private async inquireCarByChassis( + payload: LegacyInquiryPayload, + context: ProviderExecutionContext, + ): Promise<{ raw: unknown }> { + const nationalCode = this.getRequiredString(payload.nationalCode, 'nationalCode'); + const chassisNo = this.getRequiredString(payload.chassisNo, 'chassisNo'); + + return this.withResilience( + async () => { + const data = await this.authenticatedRequest( + 'GET', + `/api/central-insurance-car-inquiry/vin/${encodeURIComponent(chassisNo)}/national-code/${nationalCode}`, + undefined, + context, + ); + + return { + raw: { + nationalCode, + chassisNo, + ...(typeof data === 'object' && data !== null ? (data as Record) : { data }), + }, + }; + }, + context, + 'car by chassis inquiry', + ); + } + + /** Tejarat applies third-party selection rules server-side — passthrough car-by-plate API. */ + private async inquireThirdPartyCar( + payload: LegacyInquiryPayload, + context: ProviderExecutionContext, + ): Promise<{ raw: unknown }> { + const input = this.parseCarPlatePayload(payload); + return this.withResilience( + async () => ({ + raw: { + ...(await this.fetchCarByPlate(input, context)), + selectionMode: 'provider', + }, + }), + context, + 'third-party car inquiry', + ); + } + + private parseCarPlatePayload(payload: LegacyInquiryPayload): { + nationalCode: string; + plk1: string; + plk2: string; + plk3: string; + plksrl: string; + } { + return { + nationalCode: this.getRequiredString(payload.nationalCode, 'nationalCode'), + plk1: this.getRequiredString(payload.plk1, 'plk1'), + plk2: this.getRequiredString(payload.plk2, 'plk2'), + plk3: this.getRequiredString(payload.plk3, 'plk3'), + plksrl: this.getRequiredString(payload.plksrl, 'plksrl'), + }; + } + + private async fetchCarByPlate( + input: { + nationalCode: string; + plk1: string; + plk2: string; + plk3: string; + plksrl: string; + }, + context: ProviderExecutionContext, + ): Promise> { + const data = await this.authenticatedRequest( + 'POST', + '/api/central-insurance-car-inquiry', + { + Part1: input.plk1, + Part2: input.plk2, + Part3: input.plk3, + Part4: input.plksrl, + NationalCode: input.nationalCode, + }, + context, + ); + + const raw = { + nationalCode: input.nationalCode, + plk1: input.plk1, + plk2: input.plk2, + plk3: input.plk3, + plksrl: input.plksrl, + ...(typeof data === 'object' && data !== null ? (data as Record) : { data }), + }; + + this.assertTejaratOwnerMatch(input.nationalCode, raw); + return raw; + } + + /** + * If Tejarat echoes a different owner national code, fail as generic no-match. + * Do not put that owner code on the error — the caller must not learn who the plate belongs to. + */ + private assertTejaratOwnerMatch(requestedNationalCode: string, raw: Record): void { + const owner = this.findOwnerNationalCode(raw, requestedNationalCode); + if (!owner) { + return; } - return this.inquirePerson(payload, context); + const pad = (value: string) => value.trim().padStart(10, '0'); + if (pad(requestedNationalCode) !== pad(owner)) { + throw this.createProviderError( + 'Inquiry returned no matching result', + 'INQUIRY_NO_MATCH', + ); + } + } + + private findOwnerNationalCode( + value: unknown, + requestedNationalCode: string, + depth = 0, + ): string | undefined { + if (depth > 4 || value == null) { + return undefined; + } + if (typeof value === 'string' || typeof value === 'number') { + return undefined; + } + if (Array.isArray(value)) { + for (const item of value) { + const found = this.findOwnerNationalCode(item, requestedNationalCode, depth + 1); + if (found) return found; + } + return undefined; + } + if (typeof value === 'object') { + const record = value as Record; + for (const key of ['NtnlId', 'ntnlId', 'NationalId', 'nationalId']) { + const candidate = record[key]; + if (typeof candidate === 'string' || typeof candidate === 'number') { + const text = String(candidate).trim(); + if (/^\d{10}$/.test(text)) { + return text; + } + } + } + for (const nested of Object.values(record)) { + const found = this.findOwnerNationalCode(nested, requestedNationalCode, depth + 1); + if (found) return found; + } + } + return undefined; } private async inquirePerson( payload: PersonInquiryPayload, - _context: ProviderExecutionContext, + context: ProviderExecutionContext, ): Promise { - const cachedResult = await this.cachedInquiryResultService.findByNationalCodeAndBirthDate( + const cached = await this.inquiryResultCacheService.findPersonInquiry( payload.nationalCode, payload.birthDate, ); - if (cachedResult) { - this.logger.log(`Cache hit for nationalCode: ${payload.nationalCode}`); - return { - nationalCode: cachedResult.nationalCode, - birthDate: cachedResult.birthDate, - fullName: `${cachedResult.name} ${cachedResult.family}`, - raw: cachedResult as any, - }; + if (cached) { + this.logger.log( + `Cache hit for person inquiry | nationalCode=${payload.nationalCode} | requestId=${context.requestId}`, + ); + return this.toPersonResult(cached, payload.birthDate); } - const token = await this.getTniToken(); - if (!token) { - throw new Error('Failed to retrieve TNI token'); - } + return this.withResilience( + () => this.fetchPersonLive(payload, context), + context, + 'person inquiry', + ); + } + + private async fetchPersonLive( + payload: PersonInquiryPayload, + context: ProviderExecutionContext, + ): Promise { + const providerBirthDate = jalaliDateToGregorianDate(payload.birthDate); + const response = await this.authenticatedRequest( + 'POST', + `/api/identity-inquiry/national-code/${payload.nationalCode}/birthdate/${encodeURIComponent(providerBirthDate)}`, + {}, + context, + ); + + const cacheData = this.toCacheData(response, payload); + await this.persistCache(cacheData); + + return this.toPersonResult(cacheData, payload.birthDate); + } + + private async inquireShahkar( + payload: LegacyInquiryPayload, + context: ProviderExecutionContext, + ): Promise<{ raw: unknown }> { + const nationalCode = this.getRequiredString( + payload.nationalCode ?? payload.nationalCod ?? payload.NationalCod, + 'nationalCode', + ); + const mobileNo = this.getRequiredString( + payload.mobileNo ?? payload.MobileNo ?? payload.mobileNumber ?? payload.MobileNumber, + 'mobileNo', + ); + + return this.withResilience( + async () => { + const data = await this.authenticatedRequest( + 'POST', + '/api/compliance-inquiry/compatibility', + { nationalCode, mobileNo }, + context, + ); + + return { + raw: { + nationalCode, + mobileNo, + ...(typeof data === 'object' && data !== null ? (data as Record) : { data }), + }, + }; + }, + context, + 'shahkar inquiry', + ); + } + + private async inquireRealEstate( + payload: LegacyInquiryPayload, + context: ProviderExecutionContext, + ): Promise<{ raw: unknown }> { + const nationalCode = this.getRequiredString( + payload.nationalCode ?? payload.nationalId ?? payload.NationalId, + 'nationalCode', + ); + const postalCode = this.getRequiredString( + payload.postalCode ?? payload.PostalCode, + 'postalCode', + ); + + return this.withResilience( + async () => { + const data = await this.authenticatedRequest>( + 'POST', + '/api/central-insurance-amlak-inquiry', + { + nationalId: nationalCode, + postalCode: Number(postalCode), + }, + context, + ); + + const centInsurError = this.getCentInsurBusinessError(data); + if (centInsurError) { + throw this.createProviderError(centInsurError.message, centInsurError.code); + } + + return { + raw: { + nationalCode, + postalCode, + ...(typeof data === 'object' && data !== null ? (data as Record) : { data }), + }, + }; + }, + context, + 'real estate inquiry', + ); + } + + private async withResilience( + fn: () => Promise, + context: ProviderExecutionContext, + label: string, + ): Promise { + const maxAttempts = Math.max(1, this.config.maxRetries || PROVIDER_DEFAULT_MAX_ATTEMPTS); + return runWithProviderResilience(fn, { + providerName: this.name, + maxAttempts, + timeoutMs: this.config.timeout, + context, + label: `${this.name} ${label}`, + }); + } + + private async authenticatedRequest( + method: 'GET' | 'POST', + path: string, + body: Record | undefined, + context: ProviderExecutionContext, + ): Promise { + const token = await this.generalTokenService.getTejaratNouAccessToken(this.config); try { - const providerBirthDate = jalaliDateToGregorianDate(payload.birthDate); - const response = await this.httpClient.post( - `/api/identity-inquiry/national-code/${payload.nationalCode}/birthdate/${encodeURIComponent(providerBirthDate)}`, - {}, - { - headers: { - Authorization: `Bearer ${token}`, - Accept: 'text/plain', - }, + const response = await this.httpClient.request>({ + method, + url: path, + data: method === 'POST' ? body : undefined, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'text/plain', }, - ); + }); - if (!response.data.isSuccess || !response.data.data) { + const envelope = response.data; + if (envelope?.isSuccess === false || (envelope?.statusCode && envelope.statusCode >= 400)) { throw this.createProviderError( - response.data.message ?? 'TejaratNou person inquiry failed', - String(response.data.statusCode ?? 'PROVIDER_ERROR'), + envelope.message ?? 'TejaratNou inquiry failed', + String(envelope.statusCode ?? 'PROVIDER_ERROR'), ); } - const inquiryData = response.data.data; - const nationalCode = this.getNationalCode(inquiryData); + if (envelope?.data !== undefined) { + return envelope.data; + } - await this.cacheInquiryResult(inquiryData, payload.birthDate); - - return { - nationalCode, - birthDate: payload.birthDate, - fullName: this.getFullName(inquiryData), - raw: inquiryData, - }; + return envelope as T; } catch (error) { if (error instanceof AxiosError) { this.logger.error( - `TejaratNou person inquiry failed: ${error.response?.status ?? 'NETWORK_ERROR'} ${error.message}`, + `TejaratNou ${path} failed | status=${error.response?.status ?? 'NETWORK'} | requestId=${context.requestId}`, + ); + const responseBody = error.response?.data as TejaratNouGatewayResponse | { message?: string } | undefined; + const message = responseBody?.message ?? error.message; + throw this.createProviderError( + message, + String(error.code ?? error.response?.status ?? 'NETWORK_ERROR'), ); - } else { - this.logger.error(`TejaratNou person inquiry failed: ${error}`); } throw error; } } - private async cacheInquiryResult( - inquiryData: TejaratNouPersonInquiryData, - gatewayBirthDate: string, - ): Promise { + private getCentInsurBusinessError( + data: CentInsurApiResponse | Record, + ): { message: string; code: string } | null { + if (!data || typeof data !== 'object' || !('IsSucceed' in data)) { + return null; + } + const providerError = getCentInsurProviderError(data as CentInsurApiResponse, InquiryType.REAL_ESTATE); + return providerError ? { message: providerError.message, code: providerError.code } : null; + } + + private getRequiredString(value: unknown, field: string): string { + if (typeof value !== 'string' || !value.trim()) { + throw this.createProviderError(`${field} is required`, 'VALIDATION_ERROR'); + } + return value.trim(); + } + + private toCacheData( + data: TejaratNouPersonInquiryData, + payload: PersonInquiryPayload, + ): PersonInquiryCacheData { + return { + nationalCode: String(data.nationalCodeString ?? data.nationalCode ?? payload.nationalCode), + birthDate: payload.birthDate, + birthDateGregorian: data.birthDateGregorian, + name: String(data.name ?? ''), + family: String(data.family ?? ''), + fatherName: String(data.fatherName ?? ''), + shenasnameSeri: String(data.shenasnameSeri ?? ''), + shenasnameSerial: String(data.shenasnameSerial ?? ''), + shenasnameNo: String(data.shenasnameNo ?? ''), + gender: data.gender, + deathStatus: data.deathStatus, + deathDate: data.deathDate ? String(data.deathDate) : undefined, + zipcode: String(data.zipcode ?? ''), + zipcodeDesc: data.zipcodeDesc, + exceptionMessage: data.exceptionMessage ? String(data.exceptionMessage) : undefined, + message: data.message, + }; + } + + private toPersonResult(data: PersonInquiryCacheData, birthDate: string): PersonInquiryResult { + return { + nationalCode: data.nationalCode, + birthDate, + fullName: [data.name, data.family].filter(Boolean).join(' '), + raw: data, + }; + } + + private async persistCache(data: PersonInquiryCacheData): Promise { try { - await this.cachedInquiryResultService.create({ - nationalCode: this.getNationalCode(inquiryData), - name: String(inquiryData.name ?? ''), - family: String(inquiryData.family ?? ''), - fatherName: String(inquiryData.fatherName ?? ''), - shenasnameSeri: String(inquiryData.shenasnameSeri ?? ''), - shenasnameSerial: String(inquiryData.shenasnameSerial ?? ''), - shenasnameNo: String(inquiryData.shenasnameNo ?? ''), - birthDate: gatewayBirthDate, - birthDateGregorian: inquiryData.birthDateGregorian, - gender: inquiryData.gender, - deathStatus: inquiryData.deathStatus, - deathDate: - inquiryData.deathDate === undefined || inquiryData.deathDate === null - ? undefined - : String(inquiryData.deathDate), - zipcode: String(inquiryData.zipcode ?? ''), - zipcodeDesc: inquiryData.zipcodeDesc, - exceptionMessage: - inquiryData.exceptionMessage === undefined || inquiryData.exceptionMessage === null - ? undefined - : String(inquiryData.exceptionMessage), - message: inquiryData.message, - }); + await this.inquiryResultCacheService.savePersonInquiry(ProviderName.TEJARATNOU, data); } catch (error) { this.logger.warn( - `TejaratNou inquiry succeeded, but cache write failed: ${ + `TejaratNou inquiry succeeded but cache write failed: ${ error instanceof Error ? error.message : String(error) }`, ); } } - private getNationalCode(inquiryData: TejaratNouPersonInquiryData): string { - return String(inquiryData.nationalCodeString ?? inquiryData.nationalCode ?? ''); - } - - private getFullName(inquiryData: TejaratNouPersonInquiryData): string { - return [inquiryData.name, inquiryData.family].filter(Boolean).join(' '); - } - private createProviderError(providerMessage: string, providerCode: string): Error { - const publicMessage = resolveProviderPublicMessage(providerMessage); + const code = + providerCode === 'ETIMEDOUT' || providerCode === 'ECONNABORTED' + ? 'PROVIDER_TIMEOUT' + : providerCode === 'NETWORK_ERROR' || providerCode === 'ECONNRESET' + ? 'PROVIDER_NETWORK_ERROR' + : providerCode === 'VALIDATION_ERROR' + ? 'VALIDATION_ERROR' + : providerCode === 'INQUIRY_NO_MATCH' + ? 'INQUIRY_NO_MATCH' + : providerCode === 'UNSUPPORTED_INQUIRY' + ? 'UNSUPPORTED_INQUIRY' + : 'PROVIDER_ERROR'; + + const publicMessage = + code === 'PROVIDER_ERROR' + ? resolveProviderPublicMessage(providerMessage) + : { + message: + code === 'INQUIRY_NO_MATCH' + ? 'Inquiry returned no matching result' + : providerMessage, + messageFa: undefined as string | undefined, + }; + const error = new Error(providerMessage); ( error as Error & { normalizedError: { code: string; message: string; - messageFa: string; + messageFa?: string; providerMessage: string; providerCode: string; }; } ).normalizedError = { - code: 'PROVIDER_ERROR', + code, message: publicMessage.message, messageFa: publicMessage.messageFa, providerMessage, @@ -213,55 +592,4 @@ export class TejaratNouProvider implements InquiryProvider { - const latestToken = await this.generalTokenService.getLatestToken(); - if (latestToken && !(await this.generalTokenService.isTokenExpired(latestToken))) { - this.logger.log(`Using existing token: ${latestToken._id}`); - return latestToken.accessToken; - } - - try { - const response = await axios.post( - `${this.config.baseUrl}/connect/token`, - new URLSearchParams({ - grant_type: 'password', - client_id: this.config.clientId, - client_secret: this.config.clientSecret, - username: this.config.username, - password: this.config.password, - scope: 'api-gateway access-management', - }), - mergeOutboundAxiosConfig({ - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Cookie: 'cookiesession1=678A8C5D2222753B93723D81AA53FF8C', - }, - timeout: this.config.timeout, - }), - ); - - const jsonData = response.data; - const expiresIn = jsonData.expires_in; - const expiresAt = new Date(Date.now() + expiresIn * 1000); - - await this.generalTokenService.create({ - serviceProvider: 'TejaratNou', - tokenType: jsonData.token_type, - url: this.config.baseUrl, - clientId: this.config.clientId, - clientSecret: this.config.clientSecret, - username: this.config.username, - scope: jsonData.scope, - accessToken: jsonData.access_token, - expiresIn: expiresIn, - expiresAt: expiresAt, - }); - - return jsonData.access_token; - } catch (err) { - this.logger.error(`Failed to get TNI token: ${err}`); - return null; - } - } } diff --git a/src/providers/interfaces/tejaratnou-config.interface.ts b/src/providers/interfaces/tejaratnou-config.interface.ts index bfa0b02..8fba0ee 100644 --- a/src/providers/interfaces/tejaratnou-config.interface.ts +++ b/src/providers/interfaces/tejaratnou-config.interface.ts @@ -1,10 +1,13 @@ export interface TejaratNouConfig { + enabled: boolean; + timeout: number; + maxRetries: number; + /** OAuth token endpoint host, e.g. https://accounts.tejaratnoins.ir */ baseUrl: string; + /** Inquiry API gateway host, e.g. https://gateway.tejaratnoins.ir */ inquiryBaseUrl: string; clientId: string; clientSecret: string; username: string; password: string; - timeout: number; - enabled: boolean; } diff --git a/src/providers/providers.module.ts b/src/providers/providers.module.ts index be005cb..0ec49a5 100644 --- a/src/providers/providers.module.ts +++ b/src/providers/providers.module.ts @@ -8,9 +8,14 @@ import { TejaratNouProvider } from './implementations/tejaratnou.provider'; import { AmitisProvider } from './implementations/amitis.provider'; import { ProviderOrchestratorService } from './strategy/provider-orchestrator.service'; import { GeneralToken, GeneralTokenSchema } from './schemas/general-token.schema'; -import { CachedInquiryResult, CachedInquiryResultSchema } from './schemas/cached-inquiry-result.schema'; +import { + InquiryResultCache, + InquiryResultCacheSchema, +} from './schemas/inquiry-result-cache.schema'; +import { CentInsurCarPolicyClient } from './shared/centinsur-car-policy.client'; +import { CentInsurCarProviderSupport } from './shared/centinsur-car-provider.support'; import { GeneralTokenService } from './services/general-token.service'; -import { CachedInquiryResultService } from './services/cached-inquiry-result.service'; +import { InquiryResultCacheService } from './services/inquiry-result-cache.service'; /** * Provider adapters, factory, and orchestration strategy. @@ -19,7 +24,7 @@ import { CachedInquiryResultService } from './services/cached-inquiry-result.ser imports: [ MongooseModule.forFeature([ { name: GeneralToken.name, schema: GeneralTokenSchema }, - { name: CachedInquiryResult.name, schema: CachedInquiryResultSchema }, + { name: InquiryResultCache.name, schema: InquiryResultCacheSchema }, ]), ], providers: [ @@ -29,10 +34,17 @@ import { CachedInquiryResultService } from './services/cached-inquiry-result.ser TejaratNouProvider, AmitisProvider, GeneralTokenService, - CachedInquiryResultService, + InquiryResultCacheService, + CentInsurCarPolicyClient, + CentInsurCarProviderSupport, ProviderFactory, ProviderOrchestratorService, ], - exports: [ProviderFactory, ProviderOrchestratorService, GeneralTokenService, CachedInquiryResultService], + exports: [ + ProviderFactory, + ProviderOrchestratorService, + GeneralTokenService, + InquiryResultCacheService, + ], }) export class ProvidersModule {} diff --git a/src/providers/schemas/cached-inquiry-result.schema.ts b/src/providers/schemas/cached-inquiry-result.schema.ts deleted file mode 100644 index f268bc2..0000000 --- a/src/providers/schemas/cached-inquiry-result.schema.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { HydratedDocument, Schema as MongooseSchema } from 'mongoose'; - -export type CachedInquiryResultDocument = HydratedDocument; - -@Schema({ timestamps: { createdAt: true, updatedAt: false }, collection: 'cached_inquiry_results' }) -export class CachedInquiryResult { - @Prop({ required: true, index: true }) - nationalCode!: string; - - @Prop({ required: true, index: true }) - birthDate!: string; - - @Prop() - birthDateGregorian?: string; - - @Prop({ required: true }) - name!: string; - - @Prop({ required: true }) - family!: string; - - @Prop({ required: true }) - fatherName!: string; - - @Prop({ required: true }) - shenasnameSeri!: string; - - @Prop({ required: true }) - shenasnameSerial!: string; - - @Prop({ required: true }) - shenasnameNo!: string; - - @Prop({ type: MongooseSchema.Types.Mixed }) - gender?: unknown; - - @Prop({ type: MongooseSchema.Types.Mixed }) - deathStatus?: unknown; - - @Prop() - deathDate?: string; - - @Prop({ required: true }) - zipcode!: string; - - @Prop() - zipcodeDesc?: string; - - @Prop() - exceptionMessage?: string; - - @Prop({ type: MongooseSchema.Types.Mixed }) - message?: unknown; - - createdAt?: Date; -} - -export const CachedInquiryResultSchema = SchemaFactory.createForClass(CachedInquiryResult); diff --git a/src/providers/schemas/general-token.schema.ts b/src/providers/schemas/general-token.schema.ts index ad6c0d6..2c0e3c2 100644 --- a/src/providers/schemas/general-token.schema.ts +++ b/src/providers/schemas/general-token.schema.ts @@ -3,9 +3,10 @@ import { HydratedDocument } from 'mongoose'; export type GeneralTokenDocument = HydratedDocument; +/** OAuth tokens for outbound provider APIs — credentials stay in env, not stored here. */ @Schema({ timestamps: { createdAt: true, updatedAt: false }, collection: 'general_tokens' }) export class GeneralToken { - @Prop({ required: true }) + @Prop({ required: true, index: true }) serviceProvider!: string; @Prop({ required: true }) @@ -17,9 +18,6 @@ export class GeneralToken { @Prop({ required: true }) clientId!: string; - @Prop({ required: true }) - clientSecret!: string; - @Prop({ required: true }) username!: string; @@ -35,10 +33,12 @@ export class GeneralToken { @Prop({ required: true }) expiresIn!: number; - @Prop({ required: true }) + @Prop({ required: true, index: true }) expiresAt!: Date; createdAt?: Date; } export const GeneralTokenSchema = SchemaFactory.createForClass(GeneralToken); + +GeneralTokenSchema.index({ serviceProvider: 1, createdAt: -1 }); diff --git a/src/providers/schemas/inquiry-result-cache.schema.ts b/src/providers/schemas/inquiry-result-cache.schema.ts new file mode 100644 index 0000000..b6cf762 --- /dev/null +++ b/src/providers/schemas/inquiry-result-cache.schema.ts @@ -0,0 +1,36 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument, Schema as MongooseSchema } from 'mongoose'; +import { InquiryType } from '../../common/enums/inquiry-type.enum'; +import { ProviderName } from '../../common/enums/provider-name.enum'; + +export type InquiryResultCacheDocument = HydratedDocument; + +/** + * Domain-level inquiry result cache — one collection for all inquiry types. + * + * Separate from inquiry_logs (operational audit per request) because: + * - Cache keys are inquiry-specific (nationalCode+birthDate, plate number, etc.) + * - Data is immutable for most inquiries (civil registration identity) + * - Retention and query patterns differ from audit trails + */ +@Schema({ timestamps: { createdAt: true, updatedAt: false }, collection: 'inquiry_result_cache' }) +export class InquiryResultCache { + @Prop({ required: true, enum: InquiryType, index: true }) + inquiryType!: InquiryType; + + /** Stable lookup key, e.g. "0012345678|1378-11-24" for person inquiry. */ + @Prop({ required: true, index: true }) + cacheKey!: string; + + @Prop({ required: true, enum: ProviderName }) + provider!: ProviderName; + + @Prop({ type: MongooseSchema.Types.Mixed, required: true }) + data!: Record; + + createdAt?: Date; +} + +export const InquiryResultCacheSchema = SchemaFactory.createForClass(InquiryResultCache); + +InquiryResultCacheSchema.index({ inquiryType: 1, cacheKey: 1 }, { unique: true }); diff --git a/src/providers/services/cached-inquiry-result.service.ts b/src/providers/services/cached-inquiry-result.service.ts deleted file mode 100644 index ab7bcc7..0000000 --- a/src/providers/services/cached-inquiry-result.service.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectModel } from '@nestjs/mongoose'; -import { Model } from 'mongoose'; -import { CachedInquiryResult, CachedInquiryResultDocument } from '../schemas/cached-inquiry-result.schema'; - -@Injectable() -export class CachedInquiryResultService { - constructor( - @InjectModel(CachedInquiryResult.name) - private readonly cachedInquiryResultModel: Model, - ) {} - - async findByNationalCodeAndBirthDate( - nationalCode: string, - birthDate: string, - ): Promise { - return this.cachedInquiryResultModel - .findOne({ nationalCode, birthDate }) - .exec(); - } - - async create( - data: Omit, - ): Promise { - return this.cachedInquiryResultModel.create(data); - } -} \ No newline at end of file diff --git a/src/providers/services/general-token.service.ts b/src/providers/services/general-token.service.ts index 11439c6..2110fbb 100644 --- a/src/providers/services/general-token.service.ts +++ b/src/providers/services/general-token.service.ts @@ -1,33 +1,151 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import axios from 'axios'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; +import { mergeOutboundAxiosConfig } from '../../common/helpers/http-client.helper'; +import { + AUTH_MAX_ATTEMPTS, + AUTH_RETRY_DELAY_MS, + isAuthTransportRetryable, +} from '../../common/helpers/provider-resilience.helper'; +import { withRetry } from '../../common/helpers/retry.helper'; import { GeneralToken, GeneralTokenDocument } from '../schemas/general-token.schema'; +import { TejaratNouConfig } from '../interfaces/tejaratnou-config.interface'; + +export interface OAuthTokenResponse { + accessToken: string; + tokenType: string; + scope: string; + expiresIn: number; +} + +/** Refresh tokens this many seconds before actual expiry. */ +const TOKEN_EXPIRY_BUFFER_SECONDS = 60; @Injectable() export class GeneralTokenService { + private readonly logger = new Logger(GeneralTokenService.name); + constructor( @InjectModel(GeneralToken.name) private readonly generalTokenModel: Model, ) {} - async getLatestToken(serviceProvider = 'TejaratNou'): Promise { - return this.generalTokenModel - .findOne({ serviceProvider }) - .sort({ createdAt: -1 }) - .exec(); + async getLatestToken(serviceProvider: string): Promise { + return this.generalTokenModel.findOne({ serviceProvider }).sort({ createdAt: -1 }).exec(); } - async create( - data: Omit, - ): Promise { + async isTokenExpired( + token: GeneralTokenDocument, + bufferSeconds = TOKEN_EXPIRY_BUFFER_SECONDS, + ): Promise { + if (!token.expiresAt) return true; + const refreshAt = token.expiresAt.getTime() - bufferSeconds * 1000; + return Date.now() >= refreshAt; + } + + /** + * Returns a valid access token — reuses MongoDB-stored token when not expired, + * otherwise fetches a new one via the supplied OAuth fetcher. + */ + async getValidAccessToken( + serviceProvider: string, + fetchNewToken: () => Promise, + persistContext: { + url: string; + clientId: string; + username: string; + }, + ): Promise { + const latest = await this.getLatestToken(serviceProvider); + if (latest && !(await this.isTokenExpired(latest))) { + this.logger.debug(`Reusing ${serviceProvider} token ${latest._id}`); + return latest.accessToken; + } + + const fresh = await fetchNewToken(); + const expiresAt = new Date(Date.now() + fresh.expiresIn * 1000); + + await this.generalTokenModel.create({ + serviceProvider, + tokenType: fresh.tokenType, + url: persistContext.url, + clientId: persistContext.clientId, + username: persistContext.username, + scope: fresh.scope, + accessToken: fresh.accessToken, + expiresIn: fresh.expiresIn, + expiresAt, + }); + + this.logger.log(`Stored new ${serviceProvider} token, expires at ${expiresAt.toISOString()}`); + return fresh.accessToken; + } + + /** Persist a provider token (AMITIS and other non-OAuth flows). Credentials stay in env. */ + async persistToken(data: { + serviceProvider: string; + tokenType: string; + url: string; + clientId: string; + username: string; + scope: string; + accessToken: string; + refreshToken?: string; + expiresIn: number; + expiresAt: Date; + }): Promise { return this.generalTokenModel.create(data); } - async isTokenExpired(token: GeneralTokenDocument): Promise { - if (!token.expiresAt) { - return true; - } - const now = new Date(); - return now >= token.expiresAt; + /** OAuth2 password grant for Tejarat No identity gateway. */ + async getTejaratNouAccessToken(config: TejaratNouConfig): Promise { + return this.getValidAccessToken( + 'TejaratNou', + async () => { + const response = await withRetry( + () => + axios.post( + `${config.baseUrl}/connect/token`, + new URLSearchParams({ + grant_type: 'password', + client_id: config.clientId, + client_secret: config.clientSecret, + username: config.username, + password: config.password, + scope: 'api-gateway access-management', + }), + mergeOutboundAxiosConfig({ + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + timeout: config.timeout, + }), + ), + { + maxAttempts: AUTH_MAX_ATTEMPTS, + delayMs: AUTH_RETRY_DELAY_MS, + shouldRetry: isAuthTransportRetryable, + }, + ); + + const body = response.data as { + access_token: string; + token_type: string; + scope: string; + expires_in: number; + }; + + return { + accessToken: body.access_token, + tokenType: body.token_type, + scope: body.scope, + expiresIn: body.expires_in, + }; + }, + { + url: config.baseUrl, + clientId: config.clientId, + username: config.username, + }, + ); } } diff --git a/src/providers/services/inquiry-result-cache.service.ts b/src/providers/services/inquiry-result-cache.service.ts new file mode 100644 index 0000000..ec0688b --- /dev/null +++ b/src/providers/services/inquiry-result-cache.service.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { InquiryType } from '../../common/enums/inquiry-type.enum'; +import { ProviderName } from '../../common/enums/provider-name.enum'; +import { + InquiryResultCache, + InquiryResultCacheDocument, +} from '../schemas/inquiry-result-cache.schema'; + +export interface PersonInquiryCacheData { + nationalCode: string; + birthDate: string; + birthDateGregorian?: string; + name: string; + family: string; + fatherName: string; + shenasnameSeri: string; + shenasnameSerial: string; + shenasnameNo: string; + gender?: unknown; + deathStatus?: unknown; + deathDate?: string; + zipcode: string; + zipcodeDesc?: string; + exceptionMessage?: string; + message?: unknown; +} + +@Injectable() +export class InquiryResultCacheService { + constructor( + @InjectModel(InquiryResultCache.name) + private readonly cacheModel: Model, + ) {} + + static personCacheKey(nationalCode: string, birthDate: string): string { + return `${nationalCode}|${birthDate}`; + } + + async findPersonInquiry( + nationalCode: string, + birthDate: string, + ): Promise { + const entry = await this.cacheModel + .findOne({ + inquiryType: InquiryType.PERSON, + cacheKey: InquiryResultCacheService.personCacheKey(nationalCode, birthDate), + }) + .exec(); + + if (!entry) return null; + return entry.data as unknown as PersonInquiryCacheData; + } + + async savePersonInquiry( + provider: ProviderName, + data: PersonInquiryCacheData, + ): Promise { + await this.cacheModel.updateOne( + { + inquiryType: InquiryType.PERSON, + cacheKey: InquiryResultCacheService.personCacheKey(data.nationalCode, data.birthDate), + }, + { + $set: { + inquiryType: InquiryType.PERSON, + cacheKey: InquiryResultCacheService.personCacheKey(data.nationalCode, data.birthDate), + provider, + data, + }, + }, + { upsert: true }, + ); + } + + /** Generic cache read for future inquiry types. */ + async find( + inquiryType: InquiryType, + cacheKey: string, + ): Promise | null> { + const entry = await this.cacheModel.findOne({ inquiryType, cacheKey }).exec(); + return entry?.data ?? null; + } + + async save( + inquiryType: InquiryType, + cacheKey: string, + provider: ProviderName, + data: Record, + ): Promise { + await this.cacheModel.updateOne( + { inquiryType, cacheKey }, + { $set: { inquiryType, cacheKey, provider, data } }, + { upsert: true }, + ); + } +} diff --git a/src/providers/shared/centinsur-car-policy.client.ts b/src/providers/shared/centinsur-car-policy.client.ts new file mode 100644 index 0000000..66df43d --- /dev/null +++ b/src/providers/shared/centinsur-car-policy.client.ts @@ -0,0 +1,192 @@ +import { Injectable } from '@nestjs/common'; +import axios, { AxiosError } from 'axios'; +import { mergeOutboundAxiosConfig } from '../../common/helpers/http-client.helper'; +import { + getCarPolicyProviderError, + parseFirstCarPolicy, +} from '../../common/helpers/soap-car-policy.helper'; +import { InquiryType } from '../../common/enums/inquiry-type.enum'; +import { ProviderEnvConfig, InquiryConfig } from '../../config/configuration'; + +export interface CarPlateSoapFields { + plk1: string; + plk2: string; + plk3: string; + plksrl: string; +} + +@Injectable() +export class CentInsurCarPolicyClient { + async inquireByChassis( + config: ProviderEnvConfig, + inquiryType: InquiryType, + chassisNo: string, + ): Promise> { + return this.callSoap( + config, + inquiryType, + InquiryType.POLICY_BY_CHASSIS, + 'CIIWSPolicyChassis', + { ChassisNo: chassisNo }, + ); + } + + async inquireByPlate( + config: ProviderEnvConfig, + inquiryType: InquiryType, + plate: CarPlateSoapFields, + ): Promise> { + return this.callSoap( + config, + inquiryType, + InquiryType.POLICY_BY_PLATE, + 'CIIWSPolicyVehicleMeli', + { + Plk1: plate.plk1, + Plk2: plate.plk2, + Plk3: plate.plk3, + PlkSrl: plate.plksrl, + }, + ); + } + + async inquireByNationalCode( + config: ProviderEnvConfig, + inquiryType: InquiryType, + nationalCode: string, + ): Promise> { + return this.callSoap( + config, + inquiryType, + InquiryType.POLICY_BY_NATIONAL_CODE, + 'CIIWSPolicyNationalId', + { NationalId: nationalCode }, + ); + } + + private async callSoap( + config: ProviderEnvConfig, + inquiryType: InquiryType, + fallbackInquiryType: InquiryType, + methodName: string, + fields: Record, + ): Promise> { + const inquiryConfig = this.resolveInquiryConfig(config, inquiryType, fallbackInquiryType); + + if (!this.hasSoapCredentials(inquiryConfig)) { + throw new Error(`${inquiryType} is not configured for CentInsur car policy SOAP`); + } + + try { + const response = await axios.post( + inquiryConfig.url, + this.buildCarPolicyEnvelope( + methodName, + fields, + inquiryConfig.username, + inquiryConfig.password, + ), + mergeOutboundAxiosConfig({ + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + SOAPAction: `"http://tempuri.org/ICarAllPlcys/${methodName}"`, + }, + timeout: config.timeout, + responseType: 'text', + }), + ); + + const policy = parseFirstCarPolicy(response.data); + const providerError = getCarPolicyProviderError(response.data, policy); + if (providerError) { + const error = new Error(providerError.message); + (error as Error & { normalizedError: { code: string; message: string } }).normalizedError = { + code: providerError.code, + message: providerError.message, + }; + throw error; + } + + return policy; + } catch (error) { + if (error instanceof AxiosError) { + const wrapped = new Error(error.message); + (wrapped as Error & { normalizedError: { code: string; message: string } }).normalizedError = + { + code: String(error.response?.status ?? 'NETWORK_ERROR'), + message: error.message, + }; + throw wrapped; + } + throw error; + } + } + + resolveInquiryConfig( + config: ProviderEnvConfig, + inquiryType: InquiryType, + fallbackInquiryType: InquiryType, + ): InquiryConfig { + const candidates = [ + inquiryType, + fallbackInquiryType, + InquiryType.POLICY_BY_PLATE, + InquiryType.POLICY_BY_NATIONAL_CODE, + InquiryType.POLICY_BY_CHASSIS, + InquiryType.CAR_BY_PLATE, + InquiryType.THIRD_PARTY_CAR, + ]; + + for (const candidate of candidates) { + const inquiryConfig = config.inquiries[candidate]; + if (inquiryConfig?.url) { + return inquiryConfig; + } + } + + return { + url: '', + username: '', + password: '', + apiKey: '', + authMethod: 'SOAP', + }; + } + + hasSoapCredentials(inquiryConfig: InquiryConfig | undefined): boolean { + return Boolean(inquiryConfig?.url && inquiryConfig.username && inquiryConfig.password); + } + + private buildCarPolicyEnvelope( + methodName: string, + fields: Record, + username: string, + password: string, + ): string { + const fieldXml = Object.entries(fields) + .map(([name, value]) => ` <${name}>${this.escapeXml(value)}`) + .join('\n'); + + return ` + + + <${methodName} xmlns="http://tempuri.org/"> +${fieldXml} + ${this.escapeXml(username)} + ${this.escapeXml(password)} + + +`; + } + + private escapeXml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +} diff --git a/src/providers/shared/centinsur-car-provider.support.ts b/src/providers/shared/centinsur-car-provider.support.ts new file mode 100644 index 0000000..6a30b68 --- /dev/null +++ b/src/providers/shared/centinsur-car-provider.support.ts @@ -0,0 +1,149 @@ +import { Injectable } from '@nestjs/common'; +import { assertCarPolicyOwnedBy } from '../../common/helpers/car-inquiry-safety.helper'; +import { resolveThirdPartyCarPolicy } from '../../common/helpers/third-party-car-rules.helper'; +import { InquiryType } from '../../common/enums/inquiry-type.enum'; +import { ProviderEnvConfig } from '../../config/configuration'; +import { LegacyInquiryPayload } from './legacy-api.provider.abstract'; +import { CentInsurCarPolicyClient } from './centinsur-car-policy.client'; + +export interface CarPlatePayload extends LegacyInquiryPayload { + nationalCode?: string; + plk1?: string; + plk2?: string; + plk3?: string; + plksrl?: string; +} + +export interface CarChassisPayload extends LegacyInquiryPayload { + nationalCode?: string; + chassisNo?: string; +} + +type ErrorFormatter = ( + providerMessage?: string, + providerCode?: string, + fallbackMessage?: string, + extras?: Record, +) => Error; + +@Injectable() +export class CentInsurCarProviderSupport { + constructor(private readonly carPolicyClient: CentInsurCarPolicyClient) {} + + async inquireCarByPlate( + config: ProviderEnvConfig, + payload: CarPlatePayload, + formatError: ErrorFormatter, + ): Promise<{ raw: unknown }> { + const nationalCode = this.getRequiredString(payload.nationalCode, 'nationalCode', formatError); + const plate = { + plk1: this.getRequiredString(payload.plk1, 'plk1', formatError), + plk2: this.getRequiredString(payload.plk2, 'plk2', formatError), + plk3: this.getRequiredString(payload.plk3, 'plk3', formatError), + plksrl: this.getRequiredString(payload.plksrl, 'plksrl', formatError), + }; + + const policy = await this.carPolicyClient.inquireByPlate( + config, + InquiryType.CAR_BY_PLATE, + plate, + ); + + assertCarPolicyOwnedBy(nationalCode, policy, formatError); + + return { + raw: { + nationalCode, + ...plate, + ...policy, + }, + }; + } + + async inquireCarByChassis( + config: ProviderEnvConfig, + payload: CarChassisPayload, + formatError: ErrorFormatter, + ): Promise<{ raw: unknown }> { + const nationalCode = this.getRequiredString(payload.nationalCode, 'nationalCode', formatError); + const chassisNo = this.getRequiredString(payload.chassisNo, 'chassisNo', formatError); + const policy = await this.carPolicyClient.inquireByChassis( + config, + InquiryType.CAR_BY_CHASSIS, + chassisNo, + ); + + assertCarPolicyOwnedBy(nationalCode, policy, formatError); + + return { + raw: { + nationalCode, + chassisNo, + ...policy, + }, + }; + } + + async inquireThirdPartyCar( + config: ProviderEnvConfig, + payload: CarPlatePayload, + formatError: ErrorFormatter, + ): Promise<{ raw: unknown }> { + const input = { + nationalCode: this.getRequiredString(payload.nationalCode, 'nationalCode', formatError), + plk1: this.getRequiredString(payload.plk1, 'plk1', formatError), + plk2: this.getRequiredString(payload.plk2, 'plk2', formatError), + plk3: this.getRequiredString(payload.plk3, 'plk3', formatError), + plksrl: this.getRequiredString(payload.plksrl, 'plksrl', formatError), + }; + + const resolved = await resolveThirdPartyCarPolicy(input, { + byPlate: async () => + this.carPolicyClient.inquireByPlate(config, InquiryType.THIRD_PARTY_CAR, input), + byNationalCode: async () => + this.carPolicyClient.inquireByNationalCode( + config, + InquiryType.THIRD_PARTY_CAR, + input.nationalCode, + ), + }); + + if (!resolved) { + throw formatError( + undefined, + 'INQUIRY_NO_MATCH', + 'Inquiry returned no matching result', + ); + } + + return { + raw: { + ...input, + ...resolved.policy.raw, + selection: { + sources: resolved.sources, + vehicleGroup: resolved.policy.vehicleGroup, + isActive: resolved.policy.isActive, + isZeroKm: resolved.policy.isZeroKm, + }, + }, + }; + } + + hasCarPolicyConfig(config: ProviderEnvConfig, inquiryType: InquiryType): boolean { + return this.carPolicyClient.hasSoapCredentials( + this.carPolicyClient.resolveInquiryConfig(config, inquiryType, InquiryType.POLICY_BY_PLATE), + ); + } + + private getRequiredString( + value: unknown, + fieldName: string, + formatError: ErrorFormatter, + ): string { + if (typeof value !== 'string' || !value.trim()) { + throw formatError(undefined, undefined, `${fieldName} is required`); + } + return value.trim(); + } +} diff --git a/src/providers/strategy/provider-orchestrator.service.ts b/src/providers/strategy/provider-orchestrator.service.ts index 8497aaf..40f11cf 100644 --- a/src/providers/strategy/provider-orchestrator.service.ts +++ b/src/providers/strategy/provider-orchestrator.service.ts @@ -1,21 +1,45 @@ import { Injectable, Logger } from '@nestjs/common'; import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { ProviderName } from '../../common/enums/provider-name.enum'; +import { AttemptSummaryDto } from '../../common/dto/attempt-summary.dto'; import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto'; import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface'; import { InquiryException } from '../../common/exceptions/inquiry.exception'; import { ProviderFactory } from '../factory/provider.factory'; import { translateError } from '../../common/helpers/translate-error.helper'; -import { buildNormalizedError } from '../../common/constants/error-messages'; +import { + AppErrorCode, + buildNormalizedError, + isAppErrorCode, +} from '../../common/constants/error-messages'; +import { + INQUIRY_DEADLINE_MS, + MIN_ATTEMPT_BUDGET_MS, + buildAttemptSummary, + createAttemptTrail, + toAttemptErrorFields, +} from '../../common/helpers/provider-resilience.helper'; export interface OrchestrationResult { data: T; provider: ProviderName; duration: number; + attemptSummary: AttemptSummaryDto; } +/** Business outcomes — do not fall through to another provider; client needs this answer. */ +const NON_FALLBACK_ERROR_CODES = new Set([ + 'INQUIRY_NO_MATCH', + 'RECORD_NOT_FOUND', + 'SHEBA_MISMATCH', + 'VALIDATION_ERROR', + 'UNSUPPORTED_INQUIRY', + 'NO_PROVIDERS', +]); + /** - * Strategy orchestrator — tries default provider, then fallbacks. + * Strategy orchestrator — tries default provider, then fallbacks (transport/outage only). + * Initializes the inquiry deadline and attempt trail for resilience. */ @Injectable() export class ProviderOrchestratorService { @@ -38,67 +62,143 @@ export class ProviderOrchestratorService { }); } + const deadlineAt = context.deadlineAt ?? Date.now() + INQUIRY_DEADLINE_MS; + const executionContext: ProviderExecutionContext = { + ...context, + deadlineAt, + attemptTrail: context.attemptTrail ?? createAttemptTrail(), + }; + const errors: NormalizedErrorDto[] = []; const start = Date.now(); - for (const provider of providers) { + for (let index = 0; index < providers.length; index++) { + const provider = providers[index]!; + const remainingMs = deadlineAt - Date.now(); + + if (remainingMs < MIN_ATTEMPT_BUDGET_MS) { + this.logger.warn( + `Skipping provider ${provider.name} for ${inquiryType}: inquiry deadline exhausted (${remainingMs}ms left)`, + ); + break; + } + const attemptStart = Date.now(); try { const data = (await provider.execute( inquiryType, payload, - context, + executionContext, )) as TResponse; + const duration = Date.now() - start; + const attemptSummary = buildAttemptSummary(executionContext.attemptTrail, duration); + + this.logger.log( + `Inquiry ${inquiryType} succeeded via ${provider.name} | attempts=${attemptSummary.totalAttempts} | retried=${attemptSummary.retried} | duration=${duration}ms`, + ); + return { data, provider: provider.name, - duration: Date.now() - start, + duration, + attemptSummary, }; } catch (error) { const normalized = this.extractError(error); errors.push(normalized); const errorSummary = normalized.providerMessage ?? normalized.message; - const hasNextProvider = providers.indexOf(provider) < providers.length - 1; + const mayFallback = + index < providers.length - 1 && + deadlineAt - Date.now() >= MIN_ATTEMPT_BUDGET_MS && + this.shouldTryFallback(normalized); + this.logger.warn( - hasNextProvider + mayFallback ? `Provider ${provider.name} failed for ${inquiryType}, trying next fallback | ${errorSummary}` : `Provider ${provider.name} failed for ${inquiryType}, no fallback available | ${errorSummary}`, ); this.logger.warn( `Attempt duration: ${Date.now() - attemptStart}ms | providerCode=${normalized.providerCode ?? normalized.code} | providerMessage=${normalized.providerMessage ?? normalized.message}`, ); + + // Business rejects stay on this provider — do not try Moallem "just because". + if (!mayFallback) { + break; + } } } - const lastError = errors[errors.length - 1]!; + const duration = Date.now() - start; + const attemptSummary = buildAttemptSummary(executionContext.attemptTrail, duration); + const lastError = errors[errors.length - 1]; + const lastProvider = + providers[Math.min(Math.max(errors.length, 1), providers.length) - 1]!.name; - if (providers.length === 1) { - throw new InquiryException(lastError.message, lastError, undefined, providers[0]!.name); + if (!lastError) { + throw new InquiryException( + 'Inquiry deadline exceeded', + buildNormalizedError('INQUIRY_DEADLINE_EXCEEDED'), + undefined, + providers[0]?.name, + attemptSummary, + ); + } + + this.logger.warn( + `Inquiry ${inquiryType} failed after ${attemptSummary.totalAttempts} HTTP attempt(s) | duration=${duration}ms`, + ); + + // Prefer the concrete business/transport error over ALL_PROVIDERS_FAILED noise. + if (errors.length === 1 || this.shouldSurfaceLastError(lastError)) { + throw new InquiryException( + lastError.message, + lastError, + undefined, + lastProvider, + attemptSummary, + ); } - const lastProvider = providers[providers.length - 1]!.name; throw new InquiryException( - errors.map((error) => error.message).join('; '), + lastError.message, buildNormalizedError('ALL_PROVIDERS_FAILED', { - message: errors.map((error) => error.message).join('; '), + message: lastError.message, + messageFa: lastError.messageFa, providerMessage: lastError.providerMessage ?? lastError.message, providerCode: lastError.providerCode ?? lastError.code, }), undefined, lastProvider, + attemptSummary, ); } + private shouldTryFallback(error: NormalizedErrorDto): boolean { + return !NON_FALLBACK_ERROR_CODES.has(error.code); + } + + /** Business no-match / validation should never become ALL_PROVIDERS_FAILED. */ + private shouldSurfaceLastError(error: NormalizedErrorDto): boolean { + return NON_FALLBACK_ERROR_CODES.has(error.code) || isAppErrorCode(error.code); + } + private extractError(error: unknown): NormalizedErrorDto { - if (error && typeof error === 'object' && 'normalizedError' in error) { - return translateError((error as { normalizedError: NormalizedErrorDto }).normalizedError); - } if (error instanceof InquiryException) { return translateError(error.normalizedError); } - return buildNormalizedError('UNKNOWN_ERROR', { - message: error instanceof Error ? error.message : 'Unknown provider error', - }); + if (error && typeof error === 'object' && 'normalizedError' in error) { + return translateError((error as { normalizedError: NormalizedErrorDto }).normalizedError); + } + + const fields = toAttemptErrorFields(error); + const code = isAppErrorCode(fields.code) ? fields.code : ('UNKNOWN_ERROR' as AppErrorCode); + return translateError( + buildNormalizedError(code, { + message: fields.message, + messageFa: fields.messageFa, + providerMessage: error instanceof Error ? error.message : fields.message, + }), + ); } } diff --git a/test/unit/car-inquiry-safety.spec.ts b/test/unit/car-inquiry-safety.spec.ts new file mode 100644 index 0000000..419db4c --- /dev/null +++ b/test/unit/car-inquiry-safety.spec.ts @@ -0,0 +1,67 @@ +import { + assertCarPolicyOwnedBy, + createCarOwnershipMismatchError, + isCarOwnershipMatch, + publicErrorLeaksNationalCode, +} from '../../src/common/helpers/car-inquiry-safety.helper'; + +const REQUESTED = '6269944419'; +const OWNER = '4311402422'; + +function formatError( + providerMessage?: string, + providerCode?: string, + fallbackMessage?: string, + extras?: Record, +): Error { + const error = new Error(fallbackMessage ?? providerMessage ?? 'failed'); + ( + error as Error & { + normalizedError: { + code?: string; + message: string; + providerMessage?: string; + conflict?: Record; + }; + } + ).normalizedError = { + code: providerCode, + message: fallbackMessage ?? 'failed', + providerMessage, + conflict: extras?.conflict as Record | undefined, + }; + return error; +} + +describe('car inquiry privacy', () => { + it('treats a plate/chassis record as a match only when owner national code equals the requester', () => { + expect(isCarOwnershipMatch(REQUESTED, { NtnlId: OWNER })).toBe(false); + expect(isCarOwnershipMatch(OWNER, { NtnlId: OWNER })).toBe(true); + expect(isCarOwnershipMatch(REQUESTED, {})).toBe(false); + }); + + it('never puts the real owner national code on a mismatch error', () => { + const error = createCarOwnershipMismatchError(formatError); + const payload = (error as Error & { normalizedError: unknown }).normalizedError; + + expect(publicErrorLeaksNationalCode(payload, OWNER)).toBe(false); + expect(payload).toMatchObject({ + code: 'INQUIRY_NO_MATCH', + message: 'Inquiry returned no matching result', + }); + expect((payload as { conflict?: unknown }).conflict).toBeUndefined(); + }); + + it('throws the same generic no-match when plate belongs to someone else', () => { + expect(() => + assertCarPolicyOwnedBy(REQUESTED, { NtnlId: OWNER, InsNam: 'سهيل حاجي زاده' }, formatError), + ).toThrow(); + + try { + assertCarPolicyOwnedBy(REQUESTED, { NtnlId: OWNER }, formatError); + } catch (error) { + expect(publicErrorLeaksNationalCode(error, OWNER)).toBe(false); + expect(JSON.stringify(error)).not.toContain('سهيل'); + } + }); +}); diff --git a/test/unit/provider-resilience.spec.ts b/test/unit/provider-resilience.spec.ts new file mode 100644 index 0000000..34db6ea --- /dev/null +++ b/test/unit/provider-resilience.spec.ts @@ -0,0 +1,113 @@ +import { + AUTH_MAX_ATTEMPTS, + buildAttemptSummary, + createAttemptTrail, + isAuthTransportRetryable, + isTransportRetryable, + recordResilienceAttempt, + shouldExposeAttemptSummary, +} from '../../src/common/helpers/provider-resilience.helper'; +import { withRetry } from '../../src/common/helpers/retry.helper'; +import { ProviderExecutionContext } from '../../src/common/interfaces/inquiry-provider.interface'; + +describe('provider resilience helpers', () => { + it('retries transport failures but not 429 or business rejects', () => { + expect(isTransportRetryable({ code: 'ETIMEDOUT' })).toBe(true); + expect(isTransportRetryable({ response: { status: 503 } })).toBe(true); + expect(isTransportRetryable({ response: { status: 429 } })).toBe(false); + expect( + isTransportRetryable({ + normalizedError: { code: 'INQUIRY_NO_MATCH', message: 'no match' }, + }), + ).toBe(false); + }); + + it('auth retry is timeout/network only', () => { + expect(isAuthTransportRetryable({ code: 'ECONNRESET' })).toBe(true); + expect(isAuthTransportRetryable({ response: { status: 401 } })).toBe(false); + expect(isAuthTransportRetryable({ response: { status: 500 } })).toBe(false); + expect(AUTH_MAX_ATTEMPTS).toBe(2); + }); + + it('records every HTTP attempt and exposes summary on failure or retried success', () => { + const context: ProviderExecutionContext = { + requestId: 'req-1', + trackingCode: 'trk-1', + attemptTrail: createAttemptTrail(), + }; + + recordResilienceAttempt(context, 'HAMTA', { + attempt: 1, + durationMs: 100, + succeeded: false, + error: { + normalizedError: { + code: 'PROVIDER_TIMEOUT', + message: 'Provider request timed out', + messageFa: 'درخواست به سرویس‌دهنده زمان‌بر شد', + }, + }, + }); + recordResilienceAttempt(context, 'HAMTA', { + attempt: 2, + durationMs: 80, + succeeded: true, + }); + + const summary = buildAttemptSummary(context.attemptTrail, 250); + expect(summary).toMatchObject({ + totalAttempts: 2, + retried: true, + durationMs: 250, + }); + expect(summary.attempts).toHaveLength(1); + expect(summary.attempts[0]).toMatchObject({ + attempt: 1, + provider: 'HAMTA', + code: 'PROVIDER_TIMEOUT', + }); + + expect(shouldExposeAttemptSummary(true, summary)).toBe(true); + expect( + shouldExposeAttemptSummary(true, { + totalAttempts: 1, + retried: false, + durationMs: 10, + attempts: [], + }), + ).toBe(false); + expect( + shouldExposeAttemptSummary(false, { + totalAttempts: 1, + retried: false, + durationMs: 10, + attempts: [], + }), + ).toBe(true); + }); + + it('stops retrying when the deadline budget is exhausted', async () => { + let calls = 0; + const deadlineAt = Date.now() + 50; + + await expect( + withRetry( + async () => { + calls += 1; + const error = new Error('timeout'); + (error as NodeJS.ErrnoException).code = 'ETIMEDOUT'; + throw error; + }, + { + maxAttempts: 3, + delayMs: 40, + deadlineAt, + minRemainingMs: 100, + shouldRetry: isTransportRetryable, + }, + ), + ).rejects.toMatchObject({ code: 'ETIMEDOUT' }); + + expect(calls).toBe(1); + }); +}); diff --git a/test/unit/timeout-deadline-bugs.spec.ts b/test/unit/timeout-deadline-bugs.spec.ts new file mode 100644 index 0000000..fe59329 --- /dev/null +++ b/test/unit/timeout-deadline-bugs.spec.ts @@ -0,0 +1,49 @@ +import { + INQUIRY_DEADLINE_MS, + createAttemptTrail, + runWithProviderResilience, + toAttemptErrorFields, +} from '../../src/common/helpers/provider-resilience.helper'; +import { ProviderExecutionContext } from '../../src/common/interfaces/inquiry-provider.interface'; + +describe('timeout / deadline resilience bugs', () => { + it('maps withTimeout ETIMEDOUT errors to PROVIDER_TIMEOUT (not PROVIDER_ERROR)', () => { + const error = new Error('PARSIAN request timed out after 20000ms'); + (error as NodeJS.ErrnoException).code = 'ETIMEDOUT'; + + expect(toAttemptErrorFields(error)).toMatchObject({ + code: 'PROVIDER_TIMEOUT', + messageFa: expect.stringMatching(/زمان/), + }); + }); + + it('caps attempt timeout by inquiry deadline even when provider timeout is higher', async () => { + const context: ProviderExecutionContext = { + requestId: 'req', + trackingCode: 'trk', + deadlineAt: Date.now() + 80, + attemptTrail: createAttemptTrail(), + }; + + const started = Date.now(); + await expect( + runWithProviderResilience( + () => new Promise((resolve) => setTimeout(() => resolve('late'), 500)), + { + providerName: 'PARSIAN', + maxAttempts: 1, + timeoutMs: 30_000, + context, + label: 'PARSIAN request', + }, + ), + ).rejects.toMatchObject({ code: 'ETIMEDOUT' }); + + expect(Date.now() - started).toBeLessThan(250); + }); + + it('default inquiry deadline must exceed a single slow CentInsur SOAP (~30s)', () => { + // thirdPartyCar may need two SOAP calls over a proxy; 20s was killing successful ~28s responses + expect(INQUIRY_DEADLINE_MS).toBeGreaterThanOrEqual(60_000); + }); +});