forked from Shared/esg
feat: add car inquiries with resilience, ownership privacy, and retry visibility
Introduce plate, chassis, and third-party car endpoints, a wall-clock inquiry deadline with transport-only retries, and ownership-safe no-match errors so another person's identity never leaks. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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 { NormalizedErrorDto } from '../dto/normalized-error.dto';
|
||||
import { translateError } from './translate-error.helper';
|
||||
@@ -10,6 +11,7 @@ export function buildInquiryResponse<T = Record<string, unknown>>(params: {
|
||||
duration: number;
|
||||
data?: T | null;
|
||||
error?: NormalizedErrorDto | null;
|
||||
attemptSummary?: AttemptSummaryDto;
|
||||
}): BaseInquiryResponseDto<T> {
|
||||
const error = params.success ? null : (params.error != null ? translateError(params.error) : null);
|
||||
|
||||
@@ -22,6 +24,7 @@ export function buildInquiryResponse<T = Record<string, unknown>>(params: {
|
||||
duration: params.duration,
|
||||
data: params.success ? (params.data ?? null) : null,
|
||||
error,
|
||||
...(params.attemptSummary ? { attemptSummary: params.attemptSummary } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ const SENSITIVE_KEYS = [
|
||||
'token',
|
||||
'authorization',
|
||||
'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,21 +1,31 @@
|
||||
export interface RetryAttemptInfo {
|
||||
attempt: number;
|
||||
durationMs: number;
|
||||
succeeded: boolean;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export interface RetryOptions {
|
||||
maxAttempts: number;
|
||||
delayMs: number;
|
||||
backoffMultiplier?: number;
|
||||
shouldRetry?: (error: unknown) => boolean;
|
||||
/** Absolute timestamp; stop retrying when the budget is exhausted. */
|
||||
deadlineAt?: number;
|
||||
/** Skip further retries when remaining time after delay is below this. */
|
||||
minRemainingMs?: number;
|
||||
onAttempt?: (info: RetryAttemptInfo) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_SHOULD_RETRY = (error: unknown): boolean => {
|
||||
if (error && typeof error === 'object') {
|
||||
// Check for network error codes
|
||||
if ('code' in error) {
|
||||
const code = (error as { code?: string }).code;
|
||||
if (code === 'ECONNABORTED' || code === 'ETIMEDOUT' || code === 'ECONNRESET') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for HTTP 500 errors (server errors that might be transient)
|
||||
|
||||
if ('response' in error) {
|
||||
const response = (error as { response?: { status?: number } }).response;
|
||||
if (response?.status && response.status >= 500 && response.status < 600) {
|
||||
@@ -28,7 +38,7 @@ const DEFAULT_SHOULD_RETRY = (error: unknown): boolean => {
|
||||
|
||||
/**
|
||||
* Generic async retry with exponential backoff.
|
||||
* Used by BaseProvider for transient network failures.
|
||||
* Used by provider resilience for transient network failures.
|
||||
*/
|
||||
export async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
@@ -39,20 +49,55 @@ export async function withRetry<T>(
|
||||
delayMs,
|
||||
backoffMultiplier = 2,
|
||||
shouldRetry = DEFAULT_SHOULD_RETRY,
|
||||
deadlineAt,
|
||||
minRemainingMs = 0,
|
||||
onAttempt,
|
||||
} = options;
|
||||
|
||||
let lastError: unknown;
|
||||
let currentDelay = delayMs;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
if (deadlineAt !== undefined && Date.now() >= deadlineAt) {
|
||||
// Prefer the real last failure (e.g. provider timeout) over a synthetic deadline message.
|
||||
if (lastError !== undefined) {
|
||||
throw lastError;
|
||||
}
|
||||
const error = new Error('Inquiry deadline exceeded before attempt');
|
||||
(error as NodeJS.ErrnoException).code = 'ETIMEDOUT';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const attemptStart = Date.now();
|
||||
try {
|
||||
return await fn();
|
||||
const result = await fn();
|
||||
onAttempt?.({
|
||||
attempt,
|
||||
durationMs: Date.now() - attemptStart,
|
||||
succeeded: true,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
onAttempt?.({
|
||||
attempt,
|
||||
durationMs: Date.now() - attemptStart,
|
||||
succeeded: false,
|
||||
error,
|
||||
});
|
||||
|
||||
const isLastAttempt = attempt === maxAttempts;
|
||||
if (isLastAttempt || !shouldRetry(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (deadlineAt !== undefined) {
|
||||
const remainingAfterDelay = deadlineAt - Date.now() - currentDelay;
|
||||
if (remainingAfterDelay < minRemainingMs) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(currentDelay);
|
||||
currentDelay *= backoffMultiplier;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user