feat: add car inquiries with resilience, ownership privacy, and retry visibility

Introduce plate, chassis, and third-party car endpoints, a wall-clock inquiry
deadline with transport-only retries, and ownership-safe no-match errors so
another person's identity never leaks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-05 17:21:16 +03:30
parent b4be98b156
commit 61098f1bf4
40 changed files with 2595 additions and 374 deletions

39
CHANGELOG.md Normal file
View File

@@ -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

View File

@@ -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',

View File

@@ -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[];
}

View File

@@ -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<T = Record<string, unknown>> {
@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;
}

View File

@@ -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<string, string>;
}

View File

@@ -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',

View File

@@ -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);
}

View File

@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown>,
) => Error,
): Error {
return formatError(
undefined,
'INQUIRY_NO_MATCH',
'Inquiry returned no matching result',
);
}
export function assertCarPolicyOwnedBy(
requestedNationalCode: string,
policy: Record<string, unknown> | undefined,
formatError: (
providerMessage?: string,
providerCode?: string,
fallbackMessage?: string,
extras?: Record<string, unknown>,
) => 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);
}

View File

@@ -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<T = Record<string, unknown>>(params: {
duration: number;
data?: T | null;
error?: NormalizedErrorDto | null;
attemptSummary?: AttemptSummaryDto;
}): BaseInquiryResponseDto<T> {
const error = params.success ? null : (params.error != null ? translateError(params.error) : null);
@@ -22,6 +24,7 @@ export function buildInquiryResponse<T = Record<string, unknown>>(params: {
duration: params.duration,
data: params.success ? (params.data ?? null) : null,
error,
...(params.attemptSummary ? { attemptSummary: params.attemptSummary } : {}),
};
}

View File

@@ -5,6 +5,9 @@ const SENSITIVE_KEYS = [
'token',
'authorization',
'nationalCode',
'nationalId',
'ntnlid',
'policyOwnerNationalCode',
];
/**

View File

@@ -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<T>(
fn: () => Promise<T>,
options: RunWithResilienceOptions,
): Promise<T> {
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),
},
);
}

View File

@@ -1,13 +1,24 @@
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') {
@@ -15,7 +26,6 @@ const DEFAULT_SHOULD_RETRY = (error: unknown): boolean => {
}
}
// 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<T>(
fn: () => Promise<T>,
@@ -39,20 +49,55 @@ export async function withRetry<T>(
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;
}

View File

@@ -0,0 +1,323 @@
import { CarPlateSoapFields } from '../../providers/shared/centinsur-car-policy.client';
export interface NormalizedCarPolicy {
raw: Record<string, string>;
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<string, string>,
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<Record<string, string> | null>;
byNationalCode?: () => Promise<Record<string, string> | 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<string, string>, 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);
}

View File

@@ -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<TRequest = unknown, TResponse = unknown> {
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;
}

View File

@@ -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<Record<InquiryType, InquiryConfig>>;
}
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',

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<BaseInquiryResponseDto<Record<string, unknown>>> {
return this.inquireGeneric(
InquiryType.CAR_BY_PLATE,
payload as unknown as Record<string, unknown>,
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<BaseInquiryResponseDto<Record<string, unknown>>> {
return this.inquireGeneric(
InquiryType.CAR_BY_CHASSIS,
payload as unknown as Record<string, unknown>,
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<BaseInquiryResponseDto<Record<string, unknown>>> {
return this.inquireGeneric(
InquiryType.THIRD_PARTY_CAR,
payload as unknown as Record<string, unknown>,
req,
);
}
@Post('policyByChassis')
@InquiryAccess(InquiryType.POLICY_BY_CHASSIS)
@Throttle({ default: { limit: 30, ttl: 60000 } })

View File

@@ -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<string, unknown>,
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<string, unknown> {
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 {

View File

@@ -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<string, unknown>;
attemptSummary?: AttemptSummaryDto;
}
export interface InquiryLogListItem {

View File

@@ -41,6 +41,9 @@ export class InquiryLog {
@Prop({ type: Object })
error?: Record<string, unknown>;
@Prop({ type: Object })
attemptSummary?: Record<string, unknown>;
createdAt?: Date;
}

View File

@@ -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<TRequest = unknown, TResponse = unknown>
fn: () => Promise<T>,
context: ProviderExecutionContext,
): Promise<T> {
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>): NormalizedErrorDto {
@@ -184,9 +163,14 @@ export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
): 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,

View File

@@ -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<CentInsurTokenResponse>(
this.config.loginPath,
loginBody.toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
const response = await this.withGentleAuthRetry(() =>
this.httpClient.post<CentInsurTokenResponse>(
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<string> {
try {
const response = await this.httpClient.post<CentInsurTokenResponse>(this.config.refreshPath, {
Token: latestToken.accessToken,
RefreshToken: latestToken.refreshToken,
});
const response = await this.withGentleAuthRetry(() =>
this.httpClient.post<CentInsurTokenResponse>(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<T>(fn: () => Promise<T>): Promise<T> {
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<void> {
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,

View File

@@ -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<ProviderEnvConfig>('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);
}

View File

@@ -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<LegacyInquiryPayload, LegacyIn
InquiryType.PERSON,
InquiryType.SHAHKAR,
InquiryType.SHEBA,
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,
@@ -103,6 +112,7 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
constructor(
configService: ConfigService,
private readonly amitisProvider: AmitisProvider,
private readonly centInsurCarSupport: CentInsurCarProviderSupport,
) {
super(configService.get<ProviderEnvConfig>('parsian')!);
}
@@ -125,7 +135,9 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
Boolean(shebaConfig?.password)) ||
this.hasSoapCredentials(policyByChassisConfig) ||
this.hasSoapCredentials(policyByPlateConfig) ||
this.hasSoapCredentials(policyByNationalCodeConfig))
this.hasSoapCredentials(policyByNationalCodeConfig) ||
this.centInsurCarSupport.hasCarPolicyConfig(this.config, InquiryType.CAR_BY_PLATE) ||
this.centInsurCarSupport.hasCarPolicyConfig(this.config, InquiryType.THIRD_PARTY_CAR))
);
}
@@ -146,6 +158,33 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
return this.inquireSayah(payload as ParsianSayahPayload);
}
if (inquiryType === InquiryType.CAR_BY_PLATE) {
return this.centInsurCarSupport.inquireCarByPlate(
this.config,
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.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<LegacyInquiryPayload, LegacyIn
{ Plk1: plk1, Plk2: plk2, Plk3: plk3, PlkSrl: plksrl },
);
const policyNationalId = policy.NtnlId?.trim() ?? '';
if (!policyNationalId || !this.nationalIdsMatch(nationalCode, policyNationalId)) {
throw this.formatProviderError(
'عدم تطابق اطلاعات',
'INQUIRY_NO_MATCH',
undefined,
{
conflict: {
nationalCode,
NtnlId: policyNationalId,
},
},
);
}
assertCarPolicyOwnedBy(nationalCode, policy, (message, code, fallback, extras) =>
this.formatProviderError(message, code, fallback, extras as never),
);
return {
raw: {
@@ -373,10 +401,6 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
};
}
private nationalIdsMatch(requested: string, fromPolicy: string): boolean {
return requested.trim().padStart(10, '0') === fromPolicy.trim().padStart(10, '0');
}
private async callCarPolicySoap(
inquiryType: InquiryType,
methodName: string,

View File

@@ -1,19 +1,38 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosError, AxiosInstance } from 'axios';
import { createOutboundAxiosInstance, mergeOutboundAxiosConfig } from '../../common/helpers/http-client.helper';
import { AxiosError, AxiosInstance } from 'axios';
import { createOutboundAxiosInstance } from '../../common/helpers/http-client.helper';
import {
CentInsurApiResponse,
getCentInsurProviderError,
} from '../../common/helpers/centinsur-response.helper';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
import { jalaliDateToGregorianDate } from '../../common/helpers/jalali-date.helper';
import { InquiryProvider, ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
import { resolveProviderPublicMessage } from '../../common/constants/error-messages';
import {
PROVIDER_DEFAULT_MAX_ATTEMPTS,
runWithProviderResilience,
} from '../../common/helpers/provider-resilience.helper';
import {
InquiryProvider,
ProviderExecutionContext,
} from '../../common/interfaces/inquiry-provider.interface';
import { TejaratNouConfig } from '../interfaces/tejaratnou-config.interface';
import { GeneralTokenService } from '../services/general-token.service';
import { CachedInquiryResultService } from '../services/cached-inquiry-result.service';
import { PersonInquiryPayload, PersonInquiryResult } from '../shared/legacy-api.provider.abstract';
import {
InquiryResultCacheService,
PersonInquiryCacheData,
} from '../services/inquiry-result-cache.service';
import {
LegacyInquiryPayload,
LegacyInquiryResult,
PersonInquiryPayload,
PersonInquiryResult,
} from '../shared/legacy-api.provider.abstract';
interface TejaratNouPersonInquiryResponse {
data?: TejaratNouPersonInquiryData;
interface TejaratNouGatewayResponse<T = unknown> {
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<PersonInquiryPayload, PersonInquiryResult> {
export class TejaratNouProvider implements InquiryProvider<LegacyInquiryPayload, LegacyInquiryResult> {
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<PersonInquiryPayload,
constructor(
private readonly configService: ConfigService,
private readonly generalTokenService: GeneralTokenService,
private readonly cachedInquiryResultService: CachedInquiryResultService,
private readonly inquiryResultCacheService: InquiryResultCacheService,
) {
this.config = this.configService.get<TejaratNouConfig>('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<PersonInquiryResult> {
if (inquiryType !== InquiryType.PERSON) {
throw new Error(`Unsupported inquiry type: ${inquiryType}`);
): Promise<LegacyInquiryResult> {
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<unknown>(
'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<string, unknown>) : { 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<Record<string, unknown>> {
const data = await this.authenticatedRequest<unknown>(
'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<string, unknown>) : { 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<string, unknown>): 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<string, unknown>;
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<PersonInquiryResult> {
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<PersonInquiryResult> {
const providerBirthDate = jalaliDateToGregorianDate(payload.birthDate);
const response = await this.authenticatedRequest<TejaratNouPersonInquiryData>(
'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<unknown>(
'POST',
'/api/compliance-inquiry/compatibility',
{ nationalCode, mobileNo },
context,
);
return {
raw: {
nationalCode,
mobileNo,
...(typeof data === 'object' && data !== null ? (data as Record<string, unknown>) : { 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<CentInsurApiResponse | Record<string, unknown>>(
'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<string, unknown>) : { data }),
},
};
},
context,
'real estate inquiry',
);
}
private async withResilience<T>(
fn: () => Promise<T>,
context: ProviderExecutionContext,
label: string,
): Promise<T> {
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<T>(
method: 'GET' | 'POST',
path: string,
body: Record<string, unknown> | undefined,
context: ProviderExecutionContext,
): Promise<T> {
const token = await this.generalTokenService.getTejaratNouAccessToken(this.config);
try {
const providerBirthDate = jalaliDateToGregorianDate(payload.birthDate);
const response = await this.httpClient.post<TejaratNouPersonInquiryResponse>(
`/api/identity-inquiry/national-code/${payload.nationalCode}/birthdate/${encodeURIComponent(providerBirthDate)}`,
{},
{
headers: {
Authorization: `Bearer ${token}`,
Accept: 'text/plain',
},
const response = await this.httpClient.request<TejaratNouGatewayResponse<T>>({
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<void> {
private getCentInsurBusinessError(
data: CentInsurApiResponse | Record<string, unknown>,
): { 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<void> {
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<PersonInquiryPayload,
};
return error;
}
private async getTniToken(): Promise<string | null> {
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;
}
}
}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -1,59 +0,0 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Schema as MongooseSchema } from 'mongoose';
export type CachedInquiryResultDocument = HydratedDocument<CachedInquiryResult>;
@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);

View File

@@ -3,9 +3,10 @@ import { HydratedDocument } from 'mongoose';
export type GeneralTokenDocument = HydratedDocument<GeneralToken>;
/** 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 });

View File

@@ -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<InquiryResultCache>;
/**
* 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<string, unknown>;
createdAt?: Date;
}
export const InquiryResultCacheSchema = SchemaFactory.createForClass(InquiryResultCache);
InquiryResultCacheSchema.index({ inquiryType: 1, cacheKey: 1 }, { unique: true });

View File

@@ -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<CachedInquiryResultDocument>,
) {}
async findByNationalCodeAndBirthDate(
nationalCode: string,
birthDate: string,
): Promise<CachedInquiryResultDocument | null> {
return this.cachedInquiryResultModel
.findOne({ nationalCode, birthDate })
.exec();
}
async create(
data: Omit<CachedInquiryResult, 'createdAt'>,
): Promise<CachedInquiryResultDocument> {
return this.cachedInquiryResultModel.create(data);
}
}

View File

@@ -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<GeneralTokenDocument>,
) {}
async getLatestToken(serviceProvider = 'TejaratNou'): Promise<GeneralTokenDocument | null> {
return this.generalTokenModel
.findOne({ serviceProvider })
.sort({ createdAt: -1 })
.exec();
async getLatestToken(serviceProvider: string): Promise<GeneralTokenDocument | null> {
return this.generalTokenModel.findOne({ serviceProvider }).sort({ createdAt: -1 }).exec();
}
async create(
data: Omit<GeneralToken, 'createdAt'>,
): Promise<GeneralTokenDocument> {
async isTokenExpired(
token: GeneralTokenDocument,
bufferSeconds = TOKEN_EXPIRY_BUFFER_SECONDS,
): Promise<boolean> {
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<OAuthTokenResponse>,
persistContext: {
url: string;
clientId: string;
username: string;
},
): Promise<string> {
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<GeneralTokenDocument> {
return this.generalTokenModel.create(data);
}
async isTokenExpired(token: GeneralTokenDocument): Promise<boolean> {
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<string> {
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,
},
);
}
}

View File

@@ -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<InquiryResultCacheDocument>,
) {}
static personCacheKey(nationalCode: string, birthDate: string): string {
return `${nationalCode}|${birthDate}`;
}
async findPersonInquiry(
nationalCode: string,
birthDate: string,
): Promise<PersonInquiryCacheData | null> {
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<void> {
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<Record<string, unknown> | null> {
const entry = await this.cacheModel.findOne({ inquiryType, cacheKey }).exec();
return entry?.data ?? null;
}
async save(
inquiryType: InquiryType,
cacheKey: string,
provider: ProviderName,
data: Record<string, unknown>,
): Promise<void> {
await this.cacheModel.updateOne(
{ inquiryType, cacheKey },
{ $set: { inquiryType, cacheKey, provider, data } },
{ upsert: true },
);
}
}

View File

@@ -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<Record<string, string>> {
return this.callSoap(
config,
inquiryType,
InquiryType.POLICY_BY_CHASSIS,
'CIIWSPolicyChassis',
{ ChassisNo: chassisNo },
);
}
async inquireByPlate(
config: ProviderEnvConfig,
inquiryType: InquiryType,
plate: CarPlateSoapFields,
): Promise<Record<string, string>> {
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<Record<string, string>> {
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<string, string>,
): Promise<Record<string, string>> {
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<string>(
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<string, string>,
username: string,
password: string,
): string {
const fieldXml = Object.entries(fields)
.map(([name, value]) => ` <${name}>${this.escapeXml(value)}</${name}>`)
.join('\n');
return `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<${methodName} xmlns="http://tempuri.org/">
${fieldXml}
<Username>${this.escapeXml(username)}</Username>
<PassWrod>${this.escapeXml(password)}</PassWrod>
</${methodName}>
</soap:Body>
</soap:Envelope>`;
}
private escapeXml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
}

View File

@@ -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<string, unknown>,
) => 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();
}
}

View File

@@ -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<T> {
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<string>([
'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,
}),
);
}
}

View File

@@ -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<string, unknown>,
): Error {
const error = new Error(fallbackMessage ?? providerMessage ?? 'failed');
(
error as Error & {
normalizedError: {
code?: string;
message: string;
providerMessage?: string;
conflict?: Record<string, string>;
};
}
).normalizedError = {
code: providerCode,
message: fallbackMessage ?? 'failed',
providerMessage,
conflict: extras?.conflict as Record<string, string> | 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('سهيل');
}
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});