forked from Shared/esg
Merge pull request 'feat: add car inquiries with resilience, ownership privacy, and retry visibility' (#14) from s.hajizadeh/esg:main into main
Reviewed-on: Shared/esg#14
This commit is contained in:
39
CHANGELOG.md
Normal file
39
CHANGELOG.md
Normal 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
|
||||||
@@ -48,6 +48,11 @@ export const ERROR_CATALOG = {
|
|||||||
message: 'All providers failed',
|
message: 'All providers failed',
|
||||||
messageFa: 'تمامی سرویسدهندهها با خطا مواجه شدند',
|
messageFa: 'تمامی سرویسدهندهها با خطا مواجه شدند',
|
||||||
},
|
},
|
||||||
|
INQUIRY_DEADLINE_EXCEEDED: {
|
||||||
|
status: HttpStatus.GATEWAY_TIMEOUT,
|
||||||
|
message: 'Inquiry exceeded the allowed time budget',
|
||||||
|
messageFa: 'مهلت زمانی استعلام به پایان رسید',
|
||||||
|
},
|
||||||
UNKNOWN_ERROR: {
|
UNKNOWN_ERROR: {
|
||||||
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
message: 'Unknown provider error',
|
message: 'Unknown provider error',
|
||||||
|
|||||||
42
src/common/dto/attempt-summary.dto.ts
Normal file
42
src/common/dto/attempt-summary.dto.ts
Normal 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[];
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { AttemptSummaryDto } from './attempt-summary.dto';
|
||||||
import { NormalizedErrorDto } from './normalized-error.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' })
|
@ApiProperty({ example: 342, description: 'Duration in milliseconds' })
|
||||||
duration!: number;
|
duration!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: AttemptSummaryDto,
|
||||||
|
description:
|
||||||
|
'Retry trail: always on failure; on success only when at least one retry occurred',
|
||||||
|
})
|
||||||
|
attemptSummary?: AttemptSummaryDto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ export class NormalizedErrorDto {
|
|||||||
details?: Array<{ field: string; constraints: string[] }>;
|
details?: Array<{ field: string; constraints: string[] }>;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: { nationalCode: '4311402422', NtnlId: '0015790231' },
|
example: { sheba: 'IR800560611828005105117001' },
|
||||||
description: 'Conflicting values when submitted data does not match provider result',
|
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>;
|
conflict?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ export enum InquiryType {
|
|||||||
SHAHKAR = 'SHAHKAR_INQUIRY',
|
SHAHKAR = 'SHAHKAR_INQUIRY',
|
||||||
POSTAL_CODE = 'POSTAL_CODE_INQUIRY',
|
POSTAL_CODE = 'POSTAL_CODE_INQUIRY',
|
||||||
LEGAL_PERSON = 'LEGAL_PERSON_INQUIRY',
|
LEGAL_PERSON = 'LEGAL_PERSON_INQUIRY',
|
||||||
|
/** @deprecated Use THIRD_PARTY_CAR for smart third-party car inquiry */
|
||||||
CAR_PLATE = 'CAR_PLATE_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_CHASSIS = 'POLICY_BY_CHASSIS_INQUIRY',
|
||||||
POLICY_BY_PLATE = 'POLICY_BY_PLATE_INQUIRY',
|
POLICY_BY_PLATE = 'POLICY_BY_PLATE_INQUIRY',
|
||||||
POLICY_BY_NATIONAL_CODE = 'POLICY_BY_NATIONAL_CODE_INQUIRY',
|
POLICY_BY_NATIONAL_CODE = 'POLICY_BY_NATIONAL_CODE_INQUIRY',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import { AttemptSummaryDto } from '../dto/attempt-summary.dto';
|
||||||
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
|
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
|
||||||
|
|
||||||
export class InquiryException extends HttpException {
|
export class InquiryException extends HttpException {
|
||||||
@@ -7,6 +8,7 @@ export class InquiryException extends HttpException {
|
|||||||
public readonly normalizedError: NormalizedErrorDto,
|
public readonly normalizedError: NormalizedErrorDto,
|
||||||
status: HttpStatus = HttpStatus.UNPROCESSABLE_ENTITY,
|
status: HttpStatus = HttpStatus.UNPROCESSABLE_ENTITY,
|
||||||
public readonly provider?: string,
|
public readonly provider?: string,
|
||||||
|
public readonly attemptSummary?: AttemptSummaryDto,
|
||||||
) {
|
) {
|
||||||
super({ message, error: normalizedError }, status);
|
super({ message, error: normalizedError }, status);
|
||||||
}
|
}
|
||||||
|
|||||||
77
src/common/helpers/car-inquiry-safety.helper.ts
Normal file
77
src/common/helpers/car-inquiry-safety.helper.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AttemptSummaryDto } from '../dto/attempt-summary.dto';
|
||||||
import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
|
import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
|
||||||
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
|
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
|
||||||
import { translateError } from './translate-error.helper';
|
import { translateError } from './translate-error.helper';
|
||||||
@@ -10,6 +11,7 @@ export function buildInquiryResponse<T = Record<string, unknown>>(params: {
|
|||||||
duration: number;
|
duration: number;
|
||||||
data?: T | null;
|
data?: T | null;
|
||||||
error?: NormalizedErrorDto | null;
|
error?: NormalizedErrorDto | null;
|
||||||
|
attemptSummary?: AttemptSummaryDto;
|
||||||
}): BaseInquiryResponseDto<T> {
|
}): BaseInquiryResponseDto<T> {
|
||||||
const error = params.success ? null : (params.error != null ? translateError(params.error) : null);
|
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,
|
duration: params.duration,
|
||||||
data: params.success ? (params.data ?? null) : null,
|
data: params.success ? (params.data ?? null) : null,
|
||||||
error,
|
error,
|
||||||
|
...(params.attemptSummary ? { attemptSummary: params.attemptSummary } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ const SENSITIVE_KEYS = [
|
|||||||
'token',
|
'token',
|
||||||
'authorization',
|
'authorization',
|
||||||
'nationalCode',
|
'nationalCode',
|
||||||
|
'nationalId',
|
||||||
|
'ntnlid',
|
||||||
|
'policyOwnerNationalCode',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
277
src/common/helpers/provider-resilience.helper.ts
Normal file
277
src/common/helpers/provider-resilience.helper.ts
Normal 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),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,24 @@
|
|||||||
|
export interface RetryAttemptInfo {
|
||||||
|
attempt: number;
|
||||||
|
durationMs: number;
|
||||||
|
succeeded: boolean;
|
||||||
|
error?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RetryOptions {
|
export interface RetryOptions {
|
||||||
maxAttempts: number;
|
maxAttempts: number;
|
||||||
delayMs: number;
|
delayMs: number;
|
||||||
backoffMultiplier?: number;
|
backoffMultiplier?: number;
|
||||||
shouldRetry?: (error: unknown) => boolean;
|
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 => {
|
const DEFAULT_SHOULD_RETRY = (error: unknown): boolean => {
|
||||||
if (error && typeof error === 'object') {
|
if (error && typeof error === 'object') {
|
||||||
// Check for network error codes
|
|
||||||
if ('code' in error) {
|
if ('code' in error) {
|
||||||
const code = (error as { code?: string }).code;
|
const code = (error as { code?: string }).code;
|
||||||
if (code === 'ECONNABORTED' || code === 'ETIMEDOUT' || code === 'ECONNRESET') {
|
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) {
|
if ('response' in error) {
|
||||||
const response = (error as { response?: { status?: number } }).response;
|
const response = (error as { response?: { status?: number } }).response;
|
||||||
if (response?.status && response.status >= 500 && response.status < 600) {
|
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.
|
* 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>(
|
export async function withRetry<T>(
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
@@ -39,20 +49,55 @@ export async function withRetry<T>(
|
|||||||
delayMs,
|
delayMs,
|
||||||
backoffMultiplier = 2,
|
backoffMultiplier = 2,
|
||||||
shouldRetry = DEFAULT_SHOULD_RETRY,
|
shouldRetry = DEFAULT_SHOULD_RETRY,
|
||||||
|
deadlineAt,
|
||||||
|
minRemainingMs = 0,
|
||||||
|
onAttempt,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
let currentDelay = delayMs;
|
let currentDelay = delayMs;
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
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 {
|
try {
|
||||||
return await fn();
|
const result = await fn();
|
||||||
|
onAttempt?.({
|
||||||
|
attempt,
|
||||||
|
durationMs: Date.now() - attemptStart,
|
||||||
|
succeeded: true,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
|
onAttempt?.({
|
||||||
|
attempt,
|
||||||
|
durationMs: Date.now() - attemptStart,
|
||||||
|
succeeded: false,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
const isLastAttempt = attempt === maxAttempts;
|
const isLastAttempt = attempt === maxAttempts;
|
||||||
if (isLastAttempt || !shouldRetry(error)) {
|
if (isLastAttempt || !shouldRetry(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deadlineAt !== undefined) {
|
||||||
|
const remainingAfterDelay = deadlineAt - Date.now() - currentDelay;
|
||||||
|
if (remainingAfterDelay < minRemainingMs) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await sleep(currentDelay);
|
await sleep(currentDelay);
|
||||||
currentDelay *= backoffMultiplier;
|
currentDelay *= backoffMultiplier;
|
||||||
}
|
}
|
||||||
|
|||||||
323
src/common/helpers/third-party-car-rules.helper.ts
Normal file
323
src/common/helpers/third-party-car-rules.helper.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { InquiryType } from '../enums/inquiry-type.enum';
|
import { InquiryType } from '../enums/inquiry-type.enum';
|
||||||
import { ProviderName } from '../enums/provider-name.enum';
|
import { ProviderName } from '../enums/provider-name.enum';
|
||||||
|
import { AttemptTrail } from '../helpers/provider-resilience.helper';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contract every external provider adapter must fulfill.
|
* Contract every external provider adapter must fulfill.
|
||||||
@@ -19,4 +20,8 @@ export interface InquiryProvider<TRequest = unknown, TResponse = unknown> {
|
|||||||
export interface ProviderExecutionContext {
|
export interface ProviderExecutionContext {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
trackingCode: 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ export interface TejaratNouConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
timeout: number;
|
timeout: number;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
authUrl: string;
|
baseUrl: string;
|
||||||
|
inquiryBaseUrl: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
inquiries: Partial<Record<InquiryType, InquiryConfig>>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InquiryRoutingConfig {
|
export interface InquiryRoutingConfig {
|
||||||
@@ -84,15 +84,14 @@ export default () => ({
|
|||||||
tejaratnou: {
|
tejaratnou: {
|
||||||
enabled: process.env.TEJARATNOU_ENABLED !== 'false',
|
enabled: process.env.TEJARATNOU_ENABLED !== 'false',
|
||||||
timeout: parseInt(process.env.TEJARATNOU_TIMEOUT ?? '15000', 10),
|
timeout: parseInt(process.env.TEJARATNOU_TIMEOUT ?? '15000', 10),
|
||||||
maxRetries: parseInt(process.env.TEJARATNOU_MAX_RETRIES ?? '2', 10),
|
maxRetries: parseInt(process.env.TEJARATNOU_MAX_RETRIES ?? '3', 10),
|
||||||
authUrl: process.env.TEJARATNOU_AUTH_URL ?? 'https://accounts.tejaratnoins.ir',
|
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',
|
clientId: process.env.TEJARATNOU_CLIENT_ID ?? 'api-gateway',
|
||||||
clientSecret: process.env.TEJARATNOU_CLIENT_SECRET ?? '',
|
clientSecret: process.env.TEJARATNOU_CLIENT_SECRET ?? '',
|
||||||
username: process.env.TEJARATNOU_USERNAME ?? '',
|
username: process.env.TEJARATNOU_USERNAME ?? '',
|
||||||
password: process.env.TEJARATNOU_PASSWORD ?? '',
|
password: process.env.TEJARATNOU_PASSWORD ?? '',
|
||||||
inquiries: {
|
|
||||||
[InquiryType.PERSON]: buildInquiryConfig('TEJARATNOU', 'PERSON'),
|
|
||||||
},
|
|
||||||
} satisfies TejaratNouConfig,
|
} satisfies TejaratNouConfig,
|
||||||
inquiryRouting: {
|
inquiryRouting: {
|
||||||
[InquiryType.PERSON]: buildInquiryRoutingConfig('PERSON', ProviderName.PARSIAN, [
|
[InquiryType.PERSON]: buildInquiryRoutingConfig('PERSON', ProviderName.PARSIAN, [
|
||||||
@@ -101,12 +100,14 @@ export default () => ({
|
|||||||
]),
|
]),
|
||||||
[InquiryType.REAL_ESTATE]: buildInquiryRoutingConfig('REAL_ESTATE', ProviderName.HAMTA, [
|
[InquiryType.REAL_ESTATE]: buildInquiryRoutingConfig('REAL_ESTATE', ProviderName.HAMTA, [
|
||||||
ProviderName.MOALLEM,
|
ProviderName.MOALLEM,
|
||||||
|
ProviderName.TEJARATNOU,
|
||||||
]),
|
]),
|
||||||
[InquiryType.SHEBA]: buildInquiryRoutingConfig('SHEBA', ProviderName.PARSIAN, [
|
[InquiryType.SHEBA]: buildInquiryRoutingConfig('SHEBA', ProviderName.PARSIAN, [
|
||||||
ProviderName.HAMTA,
|
ProviderName.HAMTA,
|
||||||
]),
|
]),
|
||||||
[InquiryType.SHAHKAR]: buildInquiryRoutingConfig('SHAHKAR', ProviderName.PARSIAN, [
|
[InquiryType.SHAHKAR]: buildInquiryRoutingConfig('SHAHKAR', ProviderName.PARSIAN, [
|
||||||
ProviderName.HAMTA,
|
ProviderName.HAMTA,
|
||||||
|
ProviderName.TEJARATNOU,
|
||||||
]),
|
]),
|
||||||
[InquiryType.POSTAL_CODE]: buildInquiryRoutingConfig('POSTAL_CODE', ProviderName.MOALLEM, [
|
[InquiryType.POSTAL_CODE]: buildInquiryRoutingConfig('POSTAL_CODE', ProviderName.MOALLEM, [
|
||||||
ProviderName.HAMTA,
|
ProviderName.HAMTA,
|
||||||
@@ -114,7 +115,10 @@ export default () => ({
|
|||||||
[InquiryType.LEGAL_PERSON]: buildInquiryRoutingConfig('LEGAL_PERSON', ProviderName.HAMTA, [
|
[InquiryType.LEGAL_PERSON]: buildInquiryRoutingConfig('LEGAL_PERSON', ProviderName.HAMTA, [
|
||||||
ProviderName.MOALLEM,
|
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(
|
[InquiryType.POLICY_BY_CHASSIS]: buildInquiryRoutingConfig(
|
||||||
'POLICY_BY_CHASSIS',
|
'POLICY_BY_CHASSIS',
|
||||||
ProviderName.PARSIAN,
|
ProviderName.PARSIAN,
|
||||||
@@ -142,6 +146,9 @@ function buildProviderConfig(prefix: string): ProviderEnvConfig {
|
|||||||
InquiryType.POSTAL_CODE,
|
InquiryType.POSTAL_CODE,
|
||||||
InquiryType.LEGAL_PERSON,
|
InquiryType.LEGAL_PERSON,
|
||||||
InquiryType.CAR_PLATE,
|
InquiryType.CAR_PLATE,
|
||||||
|
InquiryType.CAR_BY_PLATE,
|
||||||
|
InquiryType.CAR_BY_CHASSIS,
|
||||||
|
InquiryType.THIRD_PARTY_CAR,
|
||||||
InquiryType.POLICY_BY_CHASSIS,
|
InquiryType.POLICY_BY_CHASSIS,
|
||||||
InquiryType.POLICY_BY_PLATE,
|
InquiryType.POLICY_BY_PLATE,
|
||||||
InquiryType.POLICY_BY_NATIONAL_CODE,
|
InquiryType.POLICY_BY_NATIONAL_CODE,
|
||||||
@@ -159,7 +166,7 @@ function buildProviderConfig(prefix: string): ProviderEnvConfig {
|
|||||||
return {
|
return {
|
||||||
enabled: process.env[`${prefix}_ENABLED`] !== 'false',
|
enabled: process.env[`${prefix}_ENABLED`] !== 'false',
|
||||||
timeout: parseInt(process.env[`${prefix}_TIMEOUT`] ?? '10000', 10),
|
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,
|
inquiries,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -183,6 +190,9 @@ function inquiryTypeToEnvName(inquiryType: InquiryType): string {
|
|||||||
[InquiryType.POSTAL_CODE]: 'POSTAL_CODE',
|
[InquiryType.POSTAL_CODE]: 'POSTAL_CODE',
|
||||||
[InquiryType.LEGAL_PERSON]: 'LEGAL_PERSON',
|
[InquiryType.LEGAL_PERSON]: 'LEGAL_PERSON',
|
||||||
[InquiryType.CAR_PLATE]: 'CAR_PLATE',
|
[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_CHASSIS]: 'POLICY_BY_CHASSIS',
|
||||||
[InquiryType.POLICY_BY_PLATE]: 'POLICY_BY_PLATE',
|
[InquiryType.POLICY_BY_PLATE]: 'POLICY_BY_PLATE',
|
||||||
[InquiryType.POLICY_BY_NATIONAL_CODE]: 'POLICY_BY_NATIONAL_CODE',
|
[InquiryType.POLICY_BY_NATIONAL_CODE]: 'POLICY_BY_NATIONAL_CODE',
|
||||||
|
|||||||
18
src/inquiry/dto/car-by-chassis-request.dto.ts
Normal file
18
src/inquiry/dto/car-by-chassis-request.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
30
src/inquiry/dto/car-by-plate-request.dto.ts
Normal file
30
src/inquiry/dto/car-by-plate-request.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@ import { RealEstateRequestDto } from './dto/real-estate-request.dto';
|
|||||||
import { PolicyByChassisRequestDto } from './dto/policy-by-chassis-request.dto';
|
import { PolicyByChassisRequestDto } from './dto/policy-by-chassis-request.dto';
|
||||||
import { PolicyByNationalCodeRequestDto } from './dto/policy-by-national-code-request.dto';
|
import { PolicyByNationalCodeRequestDto } from './dto/policy-by-national-code-request.dto';
|
||||||
import { PolicyByPlateRequestDto } from './dto/policy-by-plate-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';
|
import { InquiryService } from './inquiry.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,6 +137,57 @@ export class InquiryController {
|
|||||||
return this.inquireGeneric(InquiryType.LEGAL_PERSON, payload, req);
|
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')
|
@Post('policyByChassis')
|
||||||
@InquiryAccess(InquiryType.POLICY_BY_CHASSIS)
|
@InquiryAccess(InquiryType.POLICY_BY_CHASSIS)
|
||||||
@Throttle({ default: { limit: 30, ttl: 60000 } })
|
@Throttle({ default: { limit: 30, ttl: 60000 } })
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { InquiryType } from '../common/enums/inquiry-type.enum';
|
import { InquiryType } from '../common/enums/inquiry-type.enum';
|
||||||
import { InquiryStatus } from '../common/enums/inquiry-status.enum';
|
import { InquiryStatus } from '../common/enums/inquiry-status.enum';
|
||||||
import { ProviderName } from '../common/enums/provider-name.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 { BaseInquiryResponseDto } from '../common/dto/base-inquiry-response.dto';
|
||||||
import { NormalizedErrorDto } from '../common/dto/normalized-error.dto';
|
import { NormalizedErrorDto } from '../common/dto/normalized-error.dto';
|
||||||
import { generateTrackingCode } from '../common/helpers/tracking-code.helper';
|
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 { InquiryLogService } from '../logging/inquiry-log.service';
|
||||||
import { translateError } from '../common/helpers/translate-error.helper';
|
import { translateError } from '../common/helpers/translate-error.helper';
|
||||||
import { buildNormalizedError } from '../common/constants/error-messages';
|
import { buildNormalizedError } from '../common/constants/error-messages';
|
||||||
|
import { shouldExposeAttemptSummary } from '../common/helpers/provider-resilience.helper';
|
||||||
import {
|
import {
|
||||||
LegacyInquiryPayload,
|
LegacyInquiryPayload,
|
||||||
PersonInquiryPayload,
|
|
||||||
PersonInquiryResult,
|
PersonInquiryResult,
|
||||||
} from '../providers/shared/legacy-api.provider.abstract';
|
} from '../providers/shared/legacy-api.provider.abstract';
|
||||||
import { ProviderOrchestratorService } from '../providers/strategy/provider-orchestrator.service';
|
import { ProviderOrchestratorService } from '../providers/strategy/provider-orchestrator.service';
|
||||||
@@ -57,6 +58,8 @@ export class InquiryService {
|
|||||||
trackingCode,
|
trackingCode,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const attemptSummary = this.resolveExposedSummary(true, result.attemptSummary);
|
||||||
|
|
||||||
const response = buildInquiryResponse({
|
const response = buildInquiryResponse({
|
||||||
success: true,
|
success: true,
|
||||||
provider: result.provider,
|
provider: result.provider,
|
||||||
@@ -64,6 +67,7 @@ export class InquiryService {
|
|||||||
message: `${this.getInquiryLabel(inquiryType)} completed successfully`,
|
message: `${this.getInquiryLabel(inquiryType)} completed successfully`,
|
||||||
duration: result.duration,
|
duration: result.duration,
|
||||||
data: this.toResponseData(result.data),
|
data: this.toResponseData(result.data),
|
||||||
|
attemptSummary,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.persistLog({
|
await this.persistLog({
|
||||||
@@ -75,12 +79,15 @@ export class InquiryService {
|
|||||||
duration: result.duration,
|
duration: result.duration,
|
||||||
requestId,
|
requestId,
|
||||||
trackingCode,
|
trackingCode,
|
||||||
|
attemptSummary: result.attemptSummary,
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const duration = Date.now() - start;
|
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({
|
const response = buildInquiryResponse({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -89,6 +96,7 @@ export class InquiryService {
|
|||||||
message: normalized.message,
|
message: normalized.message,
|
||||||
duration,
|
duration,
|
||||||
error: normalized,
|
error: normalized,
|
||||||
|
attemptSummary: exposedSummary,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.persistLog({
|
await this.persistLog({
|
||||||
@@ -100,6 +108,7 @@ export class InquiryService {
|
|||||||
requestId,
|
requestId,
|
||||||
trackingCode,
|
trackingCode,
|
||||||
error: normalized as unknown as Record<string, unknown>,
|
error: normalized as unknown as Record<string, unknown>,
|
||||||
|
attemptSummary,
|
||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
|
|
||||||
return response;
|
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> {
|
private toResponseData(result: unknown): Record<string, unknown> {
|
||||||
if (this.isPersonInquiryResult(result)) {
|
if (this.isPersonInquiryResult(result)) {
|
||||||
const raw =
|
const raw =
|
||||||
@@ -156,12 +172,25 @@ export class InquiryService {
|
|||||||
return inquiryType.replace(/_INQUIRY$/, '').toLowerCase().replace(/_/g, ' ');
|
return inquiryType.replace(/_INQUIRY$/, '').toLowerCase().replace(/_/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractFailure(error: unknown): {
|
private extractFailure(
|
||||||
|
error: unknown,
|
||||||
|
durationMs: number,
|
||||||
|
): {
|
||||||
normalized: NormalizedErrorDto;
|
normalized: NormalizedErrorDto;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
|
attemptSummary?: AttemptSummaryDto;
|
||||||
} {
|
} {
|
||||||
if (error instanceof InquiryException) {
|
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) {
|
if (error && typeof error === 'object' && 'normalizedError' in error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectModel } from '@nestjs/mongoose';
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
import { FilterQuery, Model } from 'mongoose';
|
import { FilterQuery, Model } from 'mongoose';
|
||||||
|
import { AttemptSummaryDto } from '../common/dto/attempt-summary.dto';
|
||||||
import { InquiryStatus } from '../common/enums/inquiry-status.enum';
|
import { InquiryStatus } from '../common/enums/inquiry-status.enum';
|
||||||
import { InquiryType } from '../common/enums/inquiry-type.enum';
|
import { InquiryType } from '../common/enums/inquiry-type.enum';
|
||||||
import { ProviderName } from '../common/enums/provider-name.enum';
|
import { ProviderName } from '../common/enums/provider-name.enum';
|
||||||
@@ -23,6 +24,7 @@ export interface CreateInquiryLogInput {
|
|||||||
requestId: string;
|
requestId: string;
|
||||||
trackingCode: string;
|
trackingCode: string;
|
||||||
error?: Record<string, unknown>;
|
error?: Record<string, unknown>;
|
||||||
|
attemptSummary?: AttemptSummaryDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InquiryLogListItem {
|
export interface InquiryLogListItem {
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ export class InquiryLog {
|
|||||||
@Prop({ type: Object })
|
@Prop({ type: Object })
|
||||||
error?: Record<string, unknown>;
|
error?: Record<string, unknown>;
|
||||||
|
|
||||||
|
@Prop({ type: Object })
|
||||||
|
attemptSummary?: Record<string, unknown>;
|
||||||
|
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import {
|
|||||||
InquiryProvider,
|
InquiryProvider,
|
||||||
ProviderExecutionContext,
|
ProviderExecutionContext,
|
||||||
} from '../../common/interfaces/inquiry-provider.interface';
|
} 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 { 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 { ProviderConfigSlice } from '../interfaces/provider-config.interface';
|
||||||
import {
|
import {
|
||||||
AppErrorCode,
|
AppErrorCode,
|
||||||
@@ -22,9 +25,9 @@ import {
|
|||||||
* Abstract base for all provider adapters.
|
* Abstract base for all provider adapters.
|
||||||
*
|
*
|
||||||
* Responsibilities:
|
* Responsibilities:
|
||||||
* - Retry + timeout orchestration
|
* - Retry + timeout + inquiry deadline orchestration
|
||||||
|
* - Attempt trail recording
|
||||||
* - Standardized error normalization
|
* - Standardized error normalization
|
||||||
* - Response formatting hooks
|
|
||||||
* - Structured logging
|
* - Structured logging
|
||||||
*
|
*
|
||||||
* Subclasses implement provider-specific HTTP/business logic only.
|
* Subclasses implement provider-specific HTTP/business logic only.
|
||||||
@@ -108,43 +111,19 @@ export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
|
|||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
context: ProviderExecutionContext,
|
context: ProviderExecutionContext,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const operation = () =>
|
const maxAttempts = Math.max(1, this.config.maxRetries || PROVIDER_DEFAULT_MAX_ATTEMPTS);
|
||||||
withTimeout(fn(), this.config.timeout, `${this.name} request`);
|
|
||||||
|
|
||||||
return withRetry(operation, {
|
return runWithProviderResilience(fn, {
|
||||||
maxAttempts: this.config.maxRetries,
|
providerName: this.name,
|
||||||
delayMs: 300,
|
maxAttempts,
|
||||||
|
timeoutMs: this.config.timeout,
|
||||||
|
context,
|
||||||
shouldRetry: (error) => this.isRetryable(error),
|
shouldRetry: (error) => this.isRetryable(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected isRetryable(error: unknown): boolean {
|
protected isRetryable(error: unknown): boolean {
|
||||||
if (!error || typeof error !== 'object') return false;
|
return isTransportRetryable(error);
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected normalizeError(partial: Partial<NormalizedErrorDto>): NormalizedErrorDto {
|
protected normalizeError(partial: Partial<NormalizedErrorDto>): NormalizedErrorDto {
|
||||||
@@ -184,9 +163,14 @@ export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
|
|||||||
): Error {
|
): Error {
|
||||||
const message = providerMessage?.trim() || fallbackMessage;
|
const message = providerMessage?.trim() || fallbackMessage;
|
||||||
const upstreamCode = providerCode?.trim();
|
const upstreamCode = providerCode?.trim();
|
||||||
|
const code = this.toProviderErrorCode(upstreamCode);
|
||||||
|
const catalogMessage =
|
||||||
|
code !== 'PROVIDER_ERROR' && fallbackMessage === 'Provider request failed'
|
||||||
|
? undefined
|
||||||
|
: fallbackMessage;
|
||||||
const normalized = this.normalizeError({
|
const normalized = this.normalizeError({
|
||||||
code: upstreamCode,
|
code: upstreamCode,
|
||||||
message: fallbackMessage,
|
message: catalogMessage,
|
||||||
providerMessage: message,
|
providerMessage: message,
|
||||||
providerCode: upstreamCode,
|
providerCode: upstreamCode,
|
||||||
...extras,
|
...extras,
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ import {
|
|||||||
AppErrorCode,
|
AppErrorCode,
|
||||||
buildNormalizedError,
|
buildNormalizedError,
|
||||||
} from '../../common/constants/error-messages';
|
} 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 { AmitisAuthServiceConfig } from '../../config/configuration';
|
||||||
import { GeneralTokenDocument } from '../schemas/general-token.schema';
|
import { GeneralTokenDocument } from '../schemas/general-token.schema';
|
||||||
import { GeneralTokenService } from '../services/general-token.service';
|
import { GeneralTokenService } from '../services/general-token.service';
|
||||||
@@ -124,7 +130,8 @@ export class AmitisProvider {
|
|||||||
`AMITIS login → POST ${loginUrl} | provider=${providerName} | inquiry=${inquiryType} | username=${username.toLowerCase()}`,
|
`AMITIS login → POST ${loginUrl} | provider=${providerName} | inquiry=${inquiryType} | username=${username.toLowerCase()}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await this.httpClient.post<CentInsurTokenResponse>(
|
const response = await this.withGentleAuthRetry(() =>
|
||||||
|
this.httpClient.post<CentInsurTokenResponse>(
|
||||||
this.config.loginPath,
|
this.config.loginPath,
|
||||||
loginBody.toString(),
|
loginBody.toString(),
|
||||||
{
|
{
|
||||||
@@ -132,6 +139,7 @@ export class AmitisProvider {
|
|||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isOutboundHttpDebugEnabled()) {
|
if (isOutboundHttpDebugEnabled()) {
|
||||||
@@ -142,7 +150,7 @@ export class AmitisProvider {
|
|||||||
|
|
||||||
const token = this.extractToken(response.data);
|
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;
|
return token.accessToken;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw this.toAuthError(`AMITIS login failed for ${providerName}/${inquiryType}`, error);
|
throw this.toAuthError(`AMITIS login failed for ${providerName}/${inquiryType}`, error);
|
||||||
@@ -155,10 +163,12 @@ export class AmitisProvider {
|
|||||||
inquiryType: InquiryType,
|
inquiryType: InquiryType,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const response = await this.httpClient.post<CentInsurTokenResponse>(this.config.refreshPath, {
|
const response = await this.withGentleAuthRetry(() =>
|
||||||
|
this.httpClient.post<CentInsurTokenResponse>(this.config.refreshPath, {
|
||||||
Token: latestToken.accessToken,
|
Token: latestToken.accessToken,
|
||||||
RefreshToken: latestToken.refreshToken,
|
RefreshToken: latestToken.refreshToken,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
const token = this.extractToken(response.data, latestToken.refreshToken);
|
const token = this.extractToken(response.data, latestToken.refreshToken);
|
||||||
|
|
||||||
await this.saveToken(
|
await this.saveToken(
|
||||||
@@ -166,7 +176,6 @@ export class AmitisProvider {
|
|||||||
providerName,
|
providerName,
|
||||||
inquiryType,
|
inquiryType,
|
||||||
latestToken.username,
|
latestToken.username,
|
||||||
latestToken.clientSecret,
|
|
||||||
'refresh',
|
'refresh',
|
||||||
);
|
);
|
||||||
return token.accessToken;
|
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(
|
private async saveToken(
|
||||||
token: AmitisAuthToken,
|
token: AmitisAuthToken,
|
||||||
providerName: ProviderName,
|
providerName: ProviderName,
|
||||||
inquiryType: InquiryType,
|
inquiryType: InquiryType,
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
|
||||||
scope: string,
|
scope: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.generalTokenService.create({
|
await this.generalTokenService.persistToken({
|
||||||
serviceProvider: this.getTokenKey(providerName, inquiryType),
|
serviceProvider: this.getTokenKey(providerName, inquiryType),
|
||||||
tokenType: token.tokenType,
|
tokenType: token.tokenType,
|
||||||
url: this.config.baseUrl,
|
url: this.config.baseUrl,
|
||||||
clientId: username.toLowerCase(),
|
clientId: username.toLowerCase(),
|
||||||
clientSecret: password,
|
|
||||||
username: username.toLowerCase(),
|
username: username.toLowerCase(),
|
||||||
scope,
|
scope,
|
||||||
accessToken: token.accessToken,
|
accessToken: token.accessToken,
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ import { ProviderName } from '../../common/enums/provider-name.enum';
|
|||||||
import { ProviderEnvConfig } from '../../config/configuration';
|
import { ProviderEnvConfig } from '../../config/configuration';
|
||||||
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
|
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
|
||||||
import { AmitisProvider } from './amitis.provider';
|
import { AmitisProvider } from './amitis.provider';
|
||||||
|
import {
|
||||||
|
CentInsurCarProviderSupport,
|
||||||
|
CarChassisPayload,
|
||||||
|
CarPlatePayload,
|
||||||
|
} from '../shared/centinsur-car-provider.support';
|
||||||
import {
|
import {
|
||||||
LegacyApiProvider,
|
LegacyApiProvider,
|
||||||
LegacyInquiryPayload,
|
LegacyInquiryPayload,
|
||||||
@@ -68,11 +73,18 @@ export class MoallemProvider extends LegacyApiProvider {
|
|||||||
InquiryType.SHAHKAR,
|
InquiryType.SHAHKAR,
|
||||||
InquiryType.POSTAL_CODE,
|
InquiryType.POSTAL_CODE,
|
||||||
InquiryType.REAL_ESTATE,
|
InquiryType.REAL_ESTATE,
|
||||||
|
InquiryType.CAR_BY_PLATE,
|
||||||
|
InquiryType.CAR_BY_CHASSIS,
|
||||||
|
InquiryType.THIRD_PARTY_CAR,
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly moallemConfig: ProviderEnvConfig;
|
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')!;
|
const config = configService.get<ProviderEnvConfig>('moallem')!;
|
||||||
super(
|
super(
|
||||||
config,
|
config,
|
||||||
@@ -103,6 +115,33 @@ export class MoallemProvider extends LegacyApiProvider {
|
|||||||
return this.inquireSayah(payload as SayahPayload);
|
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);
|
return super.callProvider(inquiryType, payload, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,18 @@ import {
|
|||||||
parseFirstCarPolicy,
|
parseFirstCarPolicy,
|
||||||
} from '../../common/helpers/soap-car-policy.helper';
|
} from '../../common/helpers/soap-car-policy.helper';
|
||||||
import { getSayahProviderError, SayahApiResponse } from '../../common/helpers/sayah-response.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 { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||||
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
|
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
|
||||||
import { ProviderEnvConfig } from '../../config/configuration';
|
import { ProviderEnvConfig } from '../../config/configuration';
|
||||||
import { BaseProvider } from '../base/base-provider.abstract';
|
import { BaseProvider } from '../base/base-provider.abstract';
|
||||||
import { AmitisProvider } from './amitis.provider';
|
import { AmitisProvider } from './amitis.provider';
|
||||||
|
import {
|
||||||
|
CentInsurCarProviderSupport,
|
||||||
|
CarChassisPayload,
|
||||||
|
CarPlatePayload,
|
||||||
|
} from '../shared/centinsur-car-provider.support';
|
||||||
import {
|
import {
|
||||||
LegacyInquiryPayload,
|
LegacyInquiryPayload,
|
||||||
LegacyInquiryResult,
|
LegacyInquiryResult,
|
||||||
@@ -95,6 +101,9 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
|
|||||||
InquiryType.PERSON,
|
InquiryType.PERSON,
|
||||||
InquiryType.SHAHKAR,
|
InquiryType.SHAHKAR,
|
||||||
InquiryType.SHEBA,
|
InquiryType.SHEBA,
|
||||||
|
InquiryType.CAR_BY_PLATE,
|
||||||
|
InquiryType.CAR_BY_CHASSIS,
|
||||||
|
InquiryType.THIRD_PARTY_CAR,
|
||||||
InquiryType.POLICY_BY_CHASSIS,
|
InquiryType.POLICY_BY_CHASSIS,
|
||||||
InquiryType.POLICY_BY_PLATE,
|
InquiryType.POLICY_BY_PLATE,
|
||||||
InquiryType.POLICY_BY_NATIONAL_CODE,
|
InquiryType.POLICY_BY_NATIONAL_CODE,
|
||||||
@@ -103,6 +112,7 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
|
|||||||
constructor(
|
constructor(
|
||||||
configService: ConfigService,
|
configService: ConfigService,
|
||||||
private readonly amitisProvider: AmitisProvider,
|
private readonly amitisProvider: AmitisProvider,
|
||||||
|
private readonly centInsurCarSupport: CentInsurCarProviderSupport,
|
||||||
) {
|
) {
|
||||||
super(configService.get<ProviderEnvConfig>('parsian')!);
|
super(configService.get<ProviderEnvConfig>('parsian')!);
|
||||||
}
|
}
|
||||||
@@ -125,7 +135,9 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
|
|||||||
Boolean(shebaConfig?.password)) ||
|
Boolean(shebaConfig?.password)) ||
|
||||||
this.hasSoapCredentials(policyByChassisConfig) ||
|
this.hasSoapCredentials(policyByChassisConfig) ||
|
||||||
this.hasSoapCredentials(policyByPlateConfig) ||
|
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);
|
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) {
|
if (inquiryType === InquiryType.POLICY_BY_CHASSIS) {
|
||||||
return this.inquirePolicyByChassis(payload as ParsianPolicyByChassisPayload);
|
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 },
|
{ Plk1: plk1, Plk2: plk2, Plk3: plk3, PlkSrl: plksrl },
|
||||||
);
|
);
|
||||||
|
|
||||||
const policyNationalId = policy.NtnlId?.trim() ?? '';
|
assertCarPolicyOwnedBy(nationalCode, policy, (message, code, fallback, extras) =>
|
||||||
if (!policyNationalId || !this.nationalIdsMatch(nationalCode, policyNationalId)) {
|
this.formatProviderError(message, code, fallback, extras as never),
|
||||||
throw this.formatProviderError(
|
|
||||||
'عدم تطابق اطلاعات',
|
|
||||||
'INQUIRY_NO_MATCH',
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
conflict: {
|
|
||||||
nationalCode,
|
|
||||||
NtnlId: policyNationalId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
raw: {
|
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(
|
private async callCarPolicySoap(
|
||||||
inquiryType: InquiryType,
|
inquiryType: InquiryType,
|
||||||
methodName: string,
|
methodName: string,
|
||||||
|
|||||||
@@ -1,19 +1,38 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import axios, { AxiosError, AxiosInstance } from 'axios';
|
import { AxiosError, AxiosInstance } from 'axios';
|
||||||
import { createOutboundAxiosInstance, mergeOutboundAxiosConfig } from '../../common/helpers/http-client.helper';
|
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 { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||||
import { jalaliDateToGregorianDate } from '../../common/helpers/jalali-date.helper';
|
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 { 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 { TejaratNouConfig } from '../interfaces/tejaratnou-config.interface';
|
||||||
import { GeneralTokenService } from '../services/general-token.service';
|
import { GeneralTokenService } from '../services/general-token.service';
|
||||||
import { CachedInquiryResultService } from '../services/cached-inquiry-result.service';
|
import {
|
||||||
import { PersonInquiryPayload, PersonInquiryResult } from '../shared/legacy-api.provider.abstract';
|
InquiryResultCacheService,
|
||||||
|
PersonInquiryCacheData,
|
||||||
|
} from '../services/inquiry-result-cache.service';
|
||||||
|
import {
|
||||||
|
LegacyInquiryPayload,
|
||||||
|
LegacyInquiryResult,
|
||||||
|
PersonInquiryPayload,
|
||||||
|
PersonInquiryResult,
|
||||||
|
} from '../shared/legacy-api.provider.abstract';
|
||||||
|
|
||||||
interface TejaratNouPersonInquiryResponse {
|
interface TejaratNouGatewayResponse<T = unknown> {
|
||||||
data?: TejaratNouPersonInquiryData;
|
data?: T;
|
||||||
isSuccess?: boolean;
|
isSuccess?: boolean;
|
||||||
statusCode?: number;
|
statusCode?: number;
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -39,10 +58,23 @@ interface TejaratNouPersonInquiryData {
|
|||||||
message?: unknown;
|
message?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tejarat No gateway provider.
|
||||||
|
*
|
||||||
|
* OAuth token (accounts host) + REST calls on inquiry gateway host.
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TejaratNouProvider implements InquiryProvider<PersonInquiryPayload, PersonInquiryResult> {
|
export class TejaratNouProvider implements InquiryProvider<LegacyInquiryPayload, LegacyInquiryResult> {
|
||||||
readonly name = ProviderName.TEJARATNOU;
|
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 logger = new Logger(TejaratNouProvider.name);
|
||||||
private readonly httpClient: AxiosInstance;
|
private readonly httpClient: AxiosInstance;
|
||||||
private readonly config: TejaratNouConfig;
|
private readonly config: TejaratNouConfig;
|
||||||
@@ -50,162 +82,509 @@ export class TejaratNouProvider implements InquiryProvider<PersonInquiryPayload,
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly generalTokenService: GeneralTokenService,
|
private readonly generalTokenService: GeneralTokenService,
|
||||||
private readonly cachedInquiryResultService: CachedInquiryResultService,
|
private readonly inquiryResultCacheService: InquiryResultCacheService,
|
||||||
) {
|
) {
|
||||||
this.config = this.configService.get<TejaratNouConfig>('tejaratnou')!;
|
this.config = this.configService.get<TejaratNouConfig>('tejaratnou')!;
|
||||||
this.httpClient = createOutboundAxiosInstance({
|
this.httpClient = createOutboundAxiosInstance({
|
||||||
baseURL: this.config.inquiryBaseUrl,
|
baseURL: this.config.inquiryBaseUrl,
|
||||||
timeout: this.config.timeout,
|
timeout: this.config.timeout,
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
isEnabled(): boolean {
|
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(
|
async execute(
|
||||||
inquiryType: InquiryType,
|
inquiryType: InquiryType,
|
||||||
payload: PersonInquiryPayload,
|
payload: LegacyInquiryPayload,
|
||||||
context: ProviderExecutionContext,
|
context: ProviderExecutionContext,
|
||||||
): Promise<PersonInquiryResult> {
|
): Promise<LegacyInquiryResult> {
|
||||||
if (inquiryType !== InquiryType.PERSON) {
|
switch (inquiryType) {
|
||||||
throw new Error(`Unsupported inquiry type: ${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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.inquirePerson(payload, context);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
private async inquirePerson(
|
||||||
payload: PersonInquiryPayload,
|
payload: PersonInquiryPayload,
|
||||||
_context: ProviderExecutionContext,
|
context: ProviderExecutionContext,
|
||||||
): Promise<PersonInquiryResult> {
|
): Promise<PersonInquiryResult> {
|
||||||
const cachedResult = await this.cachedInquiryResultService.findByNationalCodeAndBirthDate(
|
const cached = await this.inquiryResultCacheService.findPersonInquiry(
|
||||||
payload.nationalCode,
|
payload.nationalCode,
|
||||||
payload.birthDate,
|
payload.birthDate,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (cachedResult) {
|
if (cached) {
|
||||||
this.logger.log(`Cache hit for nationalCode: ${payload.nationalCode}`);
|
this.logger.log(
|
||||||
return {
|
`Cache hit for person inquiry | nationalCode=${payload.nationalCode} | requestId=${context.requestId}`,
|
||||||
nationalCode: cachedResult.nationalCode,
|
);
|
||||||
birthDate: cachedResult.birthDate,
|
return this.toPersonResult(cached, payload.birthDate);
|
||||||
fullName: `${cachedResult.name} ${cachedResult.family}`,
|
|
||||||
raw: cachedResult as any,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await this.getTniToken();
|
return this.withResilience(
|
||||||
if (!token) {
|
() => this.fetchPersonLive(payload, context),
|
||||||
throw new Error('Failed to retrieve TNI token');
|
context,
|
||||||
|
'person inquiry',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
private async fetchPersonLive(
|
||||||
|
payload: PersonInquiryPayload,
|
||||||
|
context: ProviderExecutionContext,
|
||||||
|
): Promise<PersonInquiryResult> {
|
||||||
const providerBirthDate = jalaliDateToGregorianDate(payload.birthDate);
|
const providerBirthDate = jalaliDateToGregorianDate(payload.birthDate);
|
||||||
const response = await this.httpClient.post<TejaratNouPersonInquiryResponse>(
|
const response = await this.authenticatedRequest<TejaratNouPersonInquiryData>(
|
||||||
|
'POST',
|
||||||
`/api/identity-inquiry/national-code/${payload.nationalCode}/birthdate/${encodeURIComponent(providerBirthDate)}`,
|
`/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 response = await this.httpClient.request<TejaratNouGatewayResponse<T>>({
|
||||||
|
method,
|
||||||
|
url: path,
|
||||||
|
data: method === 'POST' ? body : undefined,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
Accept: 'text/plain',
|
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(
|
throw this.createProviderError(
|
||||||
response.data.message ?? 'TejaratNou person inquiry failed',
|
envelope.message ?? 'TejaratNou inquiry failed',
|
||||||
String(response.data.statusCode ?? 'PROVIDER_ERROR'),
|
String(envelope.statusCode ?? 'PROVIDER_ERROR'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inquiryData = response.data.data;
|
if (envelope?.data !== undefined) {
|
||||||
const nationalCode = this.getNationalCode(inquiryData);
|
return envelope.data;
|
||||||
|
}
|
||||||
|
|
||||||
await this.cacheInquiryResult(inquiryData, payload.birthDate);
|
return envelope as T;
|
||||||
|
|
||||||
return {
|
|
||||||
nationalCode,
|
|
||||||
birthDate: payload.birthDate,
|
|
||||||
fullName: this.getFullName(inquiryData),
|
|
||||||
raw: inquiryData,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AxiosError) {
|
if (error instanceof AxiosError) {
|
||||||
this.logger.error(
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cacheInquiryResult(
|
private getCentInsurBusinessError(
|
||||||
inquiryData: TejaratNouPersonInquiryData,
|
data: CentInsurApiResponse | Record<string, unknown>,
|
||||||
gatewayBirthDate: string,
|
): { message: string; code: string } | null {
|
||||||
): Promise<void> {
|
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 {
|
try {
|
||||||
await this.cachedInquiryResultService.create({
|
await this.inquiryResultCacheService.savePersonInquiry(ProviderName.TEJARATNOU, data);
|
||||||
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,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`TejaratNou inquiry succeeded, but cache write failed: ${
|
`TejaratNou inquiry succeeded but cache write failed: ${
|
||||||
error instanceof Error ? error.message : String(error)
|
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 {
|
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);
|
const error = new Error(providerMessage);
|
||||||
(
|
(
|
||||||
error as Error & {
|
error as Error & {
|
||||||
normalizedError: {
|
normalizedError: {
|
||||||
code: string;
|
code: string;
|
||||||
message: string;
|
message: string;
|
||||||
messageFa: string;
|
messageFa?: string;
|
||||||
providerMessage: string;
|
providerMessage: string;
|
||||||
providerCode: string;
|
providerCode: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
).normalizedError = {
|
).normalizedError = {
|
||||||
code: 'PROVIDER_ERROR',
|
code,
|
||||||
message: publicMessage.message,
|
message: publicMessage.message,
|
||||||
messageFa: publicMessage.messageFa,
|
messageFa: publicMessage.messageFa,
|
||||||
providerMessage,
|
providerMessage,
|
||||||
@@ -213,55 +592,4 @@ export class TejaratNouProvider implements InquiryProvider<PersonInquiryPayload,
|
|||||||
};
|
};
|
||||||
return error;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
export interface TejaratNouConfig {
|
export interface TejaratNouConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
timeout: number;
|
||||||
|
maxRetries: number;
|
||||||
|
/** OAuth token endpoint host, e.g. https://accounts.tejaratnoins.ir */
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
/** Inquiry API gateway host, e.g. https://gateway.tejaratnoins.ir */
|
||||||
inquiryBaseUrl: string;
|
inquiryBaseUrl: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
timeout: number;
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ import { TejaratNouProvider } from './implementations/tejaratnou.provider';
|
|||||||
import { AmitisProvider } from './implementations/amitis.provider';
|
import { AmitisProvider } from './implementations/amitis.provider';
|
||||||
import { ProviderOrchestratorService } from './strategy/provider-orchestrator.service';
|
import { ProviderOrchestratorService } from './strategy/provider-orchestrator.service';
|
||||||
import { GeneralToken, GeneralTokenSchema } from './schemas/general-token.schema';
|
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 { 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.
|
* Provider adapters, factory, and orchestration strategy.
|
||||||
@@ -19,7 +24,7 @@ import { CachedInquiryResultService } from './services/cached-inquiry-result.ser
|
|||||||
imports: [
|
imports: [
|
||||||
MongooseModule.forFeature([
|
MongooseModule.forFeature([
|
||||||
{ name: GeneralToken.name, schema: GeneralTokenSchema },
|
{ name: GeneralToken.name, schema: GeneralTokenSchema },
|
||||||
{ name: CachedInquiryResult.name, schema: CachedInquiryResultSchema },
|
{ name: InquiryResultCache.name, schema: InquiryResultCacheSchema },
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -29,10 +34,17 @@ import { CachedInquiryResultService } from './services/cached-inquiry-result.ser
|
|||||||
TejaratNouProvider,
|
TejaratNouProvider,
|
||||||
AmitisProvider,
|
AmitisProvider,
|
||||||
GeneralTokenService,
|
GeneralTokenService,
|
||||||
CachedInquiryResultService,
|
InquiryResultCacheService,
|
||||||
|
CentInsurCarPolicyClient,
|
||||||
|
CentInsurCarProviderSupport,
|
||||||
ProviderFactory,
|
ProviderFactory,
|
||||||
ProviderOrchestratorService,
|
ProviderOrchestratorService,
|
||||||
],
|
],
|
||||||
exports: [ProviderFactory, ProviderOrchestratorService, GeneralTokenService, CachedInquiryResultService],
|
exports: [
|
||||||
|
ProviderFactory,
|
||||||
|
ProviderOrchestratorService,
|
||||||
|
GeneralTokenService,
|
||||||
|
InquiryResultCacheService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class ProvidersModule {}
|
export class ProvidersModule {}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -3,9 +3,10 @@ import { HydratedDocument } from 'mongoose';
|
|||||||
|
|
||||||
export type GeneralTokenDocument = HydratedDocument<GeneralToken>;
|
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' })
|
@Schema({ timestamps: { createdAt: true, updatedAt: false }, collection: 'general_tokens' })
|
||||||
export class GeneralToken {
|
export class GeneralToken {
|
||||||
@Prop({ required: true })
|
@Prop({ required: true, index: true })
|
||||||
serviceProvider!: string;
|
serviceProvider!: string;
|
||||||
|
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
@@ -17,9 +18,6 @@ export class GeneralToken {
|
|||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
clientId!: string;
|
clientId!: string;
|
||||||
|
|
||||||
@Prop({ required: true })
|
|
||||||
clientSecret!: string;
|
|
||||||
|
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
username!: string;
|
username!: string;
|
||||||
|
|
||||||
@@ -35,10 +33,12 @@ export class GeneralToken {
|
|||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
expiresIn!: number;
|
expiresIn!: number;
|
||||||
|
|
||||||
@Prop({ required: true })
|
@Prop({ required: true, index: true })
|
||||||
expiresAt!: Date;
|
expiresAt!: Date;
|
||||||
|
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GeneralTokenSchema = SchemaFactory.createForClass(GeneralToken);
|
export const GeneralTokenSchema = SchemaFactory.createForClass(GeneralToken);
|
||||||
|
|
||||||
|
GeneralTokenSchema.index({ serviceProvider: 1, createdAt: -1 });
|
||||||
|
|||||||
36
src/providers/schemas/inquiry-result-cache.schema.ts
Normal file
36
src/providers/schemas/inquiry-result-cache.schema.ts
Normal 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 });
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 { InjectModel } from '@nestjs/mongoose';
|
||||||
import { Model } from '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 { 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()
|
@Injectable()
|
||||||
export class GeneralTokenService {
|
export class GeneralTokenService {
|
||||||
|
private readonly logger = new Logger(GeneralTokenService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectModel(GeneralToken.name)
|
@InjectModel(GeneralToken.name)
|
||||||
private readonly generalTokenModel: Model<GeneralTokenDocument>,
|
private readonly generalTokenModel: Model<GeneralTokenDocument>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getLatestToken(serviceProvider = 'TejaratNou'): Promise<GeneralTokenDocument | null> {
|
async getLatestToken(serviceProvider: string): Promise<GeneralTokenDocument | null> {
|
||||||
return this.generalTokenModel
|
return this.generalTokenModel.findOne({ serviceProvider }).sort({ createdAt: -1 }).exec();
|
||||||
.findOne({ serviceProvider })
|
|
||||||
.sort({ createdAt: -1 })
|
|
||||||
.exec();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(
|
async isTokenExpired(
|
||||||
data: Omit<GeneralToken, 'createdAt'>,
|
token: GeneralTokenDocument,
|
||||||
): Promise<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);
|
return this.generalTokenModel.create(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async isTokenExpired(token: GeneralTokenDocument): Promise<boolean> {
|
/** OAuth2 password grant for Tejarat No identity gateway. */
|
||||||
if (!token.expiresAt) {
|
async getTejaratNouAccessToken(config: TejaratNouConfig): Promise<string> {
|
||||||
return true;
|
return this.getValidAccessToken(
|
||||||
}
|
'TejaratNou',
|
||||||
const now = new Date();
|
async () => {
|
||||||
return now >= token.expiresAt;
|
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,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
98
src/providers/services/inquiry-result-cache.service.ts
Normal file
98
src/providers/services/inquiry-result-cache.service.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
192
src/providers/shared/centinsur-car-policy.client.ts
Normal file
192
src/providers/shared/centinsur-car-policy.client.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
}
|
||||||
149
src/providers/shared/centinsur-car-provider.support.ts
Normal file
149
src/providers/shared/centinsur-car-provider.support.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,45 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||||
import { ProviderName } from '../../common/enums/provider-name.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 { NormalizedErrorDto } from '../../common/dto/normalized-error.dto';
|
||||||
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
|
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
|
||||||
import { InquiryException } from '../../common/exceptions/inquiry.exception';
|
import { InquiryException } from '../../common/exceptions/inquiry.exception';
|
||||||
import { ProviderFactory } from '../factory/provider.factory';
|
import { ProviderFactory } from '../factory/provider.factory';
|
||||||
import { translateError } from '../../common/helpers/translate-error.helper';
|
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> {
|
export interface OrchestrationResult<T> {
|
||||||
data: T;
|
data: T;
|
||||||
provider: ProviderName;
|
provider: ProviderName;
|
||||||
duration: number;
|
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()
|
@Injectable()
|
||||||
export class ProviderOrchestratorService {
|
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 errors: NormalizedErrorDto[] = [];
|
||||||
const start = Date.now();
|
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();
|
const attemptStart = Date.now();
|
||||||
try {
|
try {
|
||||||
const data = (await provider.execute(
|
const data = (await provider.execute(
|
||||||
inquiryType,
|
inquiryType,
|
||||||
payload,
|
payload,
|
||||||
context,
|
executionContext,
|
||||||
)) as TResponse;
|
)) 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 {
|
return {
|
||||||
data,
|
data,
|
||||||
provider: provider.name,
|
provider: provider.name,
|
||||||
duration: Date.now() - start,
|
duration,
|
||||||
|
attemptSummary,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const normalized = this.extractError(error);
|
const normalized = this.extractError(error);
|
||||||
errors.push(normalized);
|
errors.push(normalized);
|
||||||
const errorSummary = normalized.providerMessage ?? normalized.message;
|
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(
|
this.logger.warn(
|
||||||
hasNextProvider
|
mayFallback
|
||||||
? `Provider ${provider.name} failed for ${inquiryType}, trying next fallback | ${errorSummary}`
|
? `Provider ${provider.name} failed for ${inquiryType}, trying next fallback | ${errorSummary}`
|
||||||
: `Provider ${provider.name} failed for ${inquiryType}, no fallback available | ${errorSummary}`,
|
: `Provider ${provider.name} failed for ${inquiryType}, no fallback available | ${errorSummary}`,
|
||||||
);
|
);
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Attempt duration: ${Date.now() - attemptStart}ms | providerCode=${normalized.providerCode ?? normalized.code} | providerMessage=${normalized.providerMessage ?? normalized.message}`,
|
`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) {
|
if (!lastError) {
|
||||||
throw new InquiryException(lastError.message, lastError, undefined, providers[0]!.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastProvider = providers[providers.length - 1]!.name;
|
|
||||||
throw new InquiryException(
|
throw new InquiryException(
|
||||||
errors.map((error) => error.message).join('; '),
|
'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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InquiryException(
|
||||||
|
lastError.message,
|
||||||
buildNormalizedError('ALL_PROVIDERS_FAILED', {
|
buildNormalizedError('ALL_PROVIDERS_FAILED', {
|
||||||
message: errors.map((error) => error.message).join('; '),
|
message: lastError.message,
|
||||||
|
messageFa: lastError.messageFa,
|
||||||
providerMessage: lastError.providerMessage ?? lastError.message,
|
providerMessage: lastError.providerMessage ?? lastError.message,
|
||||||
providerCode: lastError.providerCode ?? lastError.code,
|
providerCode: lastError.providerCode ?? lastError.code,
|
||||||
}),
|
}),
|
||||||
undefined,
|
undefined,
|
||||||
lastProvider,
|
lastProvider,
|
||||||
|
attemptSummary,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractError(error: unknown): NormalizedErrorDto {
|
private shouldTryFallback(error: NormalizedErrorDto): boolean {
|
||||||
if (error && typeof error === 'object' && 'normalizedError' in error) {
|
return !NON_FALLBACK_ERROR_CODES.has(error.code);
|
||||||
return translateError((error as { normalizedError: NormalizedErrorDto }).normalizedError);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 instanceof InquiryException) {
|
if (error instanceof InquiryException) {
|
||||||
return translateError(error.normalizedError);
|
return translateError(error.normalizedError);
|
||||||
}
|
}
|
||||||
return buildNormalizedError('UNKNOWN_ERROR', {
|
if (error && typeof error === 'object' && 'normalizedError' in error) {
|
||||||
message: error instanceof Error ? error.message : 'Unknown provider 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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
67
test/unit/car-inquiry-safety.spec.ts
Normal file
67
test/unit/car-inquiry-safety.spec.ts
Normal 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('سهيل');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
113
test/unit/provider-resilience.spec.ts
Normal file
113
test/unit/provider-resilience.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
test/unit/timeout-deadline-bugs.spec.ts
Normal file
49
test/unit/timeout-deadline-bugs.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user