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

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

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

View File

@@ -0,0 +1,77 @@
/**
* Car inquiry privacy: never tell a caller who a plate/chassis belongs to.
* Ownership mismatch must be indistinguishable from "no result".
*/
export function getPolicyOwnerNationalCode(
policy: Record<string, unknown> | undefined,
): string {
if (!policy) {
return '';
}
const raw =
policy.NtnlId ??
policy.ntnlId ??
policy.NationalId ??
policy.nationalId ??
policy.NationalCode ??
policy.nationalCode;
return typeof raw === 'string' || typeof raw === 'number' ? String(raw).trim() : '';
}
export function nationalCodesEqual(left: string, right: string): boolean {
return left.trim().padStart(10, '0') === right.trim().padStart(10, '0');
}
export function isCarOwnershipMatch(
requestedNationalCode: string,
policy: Record<string, unknown> | undefined,
): boolean {
const owner = getPolicyOwnerNationalCode(policy);
return Boolean(owner) && nationalCodesEqual(requestedNationalCode, owner);
}
/** Client-safe no-match: no owner national code, no conflict map, no "belongs to". */
export function createCarOwnershipMismatchError(
formatError: (
providerMessage?: string,
providerCode?: string,
fallbackMessage?: string,
extras?: Record<string, unknown>,
) => Error,
): Error {
return formatError(
undefined,
'INQUIRY_NO_MATCH',
'Inquiry returned no matching result',
);
}
export function assertCarPolicyOwnedBy(
requestedNationalCode: string,
policy: Record<string, unknown> | undefined,
formatError: (
providerMessage?: string,
providerCode?: string,
fallbackMessage?: string,
extras?: Record<string, unknown>,
) => Error,
): void {
if (!isCarOwnershipMatch(requestedNationalCode, policy)) {
throw createCarOwnershipMismatchError(formatError);
}
}
/** Fail a test/review if a public error payload contains another party's national code. */
export function publicErrorLeaksNationalCode(
error: unknown,
forbiddenNationalCode: string,
): boolean {
const needle = forbiddenNationalCode.trim();
if (!needle) {
return false;
}
return JSON.stringify(error).includes(needle);
}

View File

@@ -1,3 +1,4 @@
import { AttemptSummaryDto } from '../dto/attempt-summary.dto';
import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
import { translateError } from './translate-error.helper';
@@ -10,6 +11,7 @@ export function buildInquiryResponse<T = Record<string, unknown>>(params: {
duration: number;
data?: T | null;
error?: NormalizedErrorDto | null;
attemptSummary?: AttemptSummaryDto;
}): BaseInquiryResponseDto<T> {
const error = params.success ? null : (params.error != null ? translateError(params.error) : null);
@@ -22,6 +24,7 @@ export function buildInquiryResponse<T = Record<string, unknown>>(params: {
duration: params.duration,
data: params.success ? (params.data ?? null) : null,
error,
...(params.attemptSummary ? { attemptSummary: params.attemptSummary } : {}),
};
}

View File

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

View File

@@ -0,0 +1,277 @@
import { AxiosError } from 'axios';
import { AttemptErrorDto, AttemptSummaryDto } from '../dto/attempt-summary.dto';
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
import { ProviderExecutionContext } from '../interfaces/inquiry-provider.interface';
import { buildNormalizedError } from '../constants/error-messages';
import { translateError } from './translate-error.helper';
import { withRetry, RetryAttemptInfo } from './retry.helper';
import { withTimeout } from './timeout.helper';
/**
* Hard wall-clock budget for one inquiry (all attempts + fallbacks).
* Must exceed a single slow CentInsur SOAP over proxy (~25–30s).
* thirdPartyCar may need two SOAP calls, so default is 90s.
* Override with INQUIRY_DEADLINE_MS env.
*/
export const INQUIRY_DEADLINE_MS = (() => {
const fromEnv = Number.parseInt(process.env.INQUIRY_DEADLINE_MS ?? '', 10);
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 90_000;
})();
/** Default max HTTP attempts per provider execution. */
export const PROVIDER_DEFAULT_MAX_ATTEMPTS = 3;
/** Initial backoff before the second attempt (then ×2). */
export const PROVIDER_RETRY_DELAY_MS = 400;
/** Auth login/refresh: one gentle retry only. */
export const AUTH_MAX_ATTEMPTS = 2;
export const AUTH_RETRY_DELAY_MS = 500;
/** Do not start another attempt if remaining budget is below this. */
export const MIN_ATTEMPT_BUDGET_MS = 100;
export interface AttemptTrail {
totalAttempts: number;
failedAttempts: AttemptErrorDto[];
}
export function createAttemptTrail(): AttemptTrail {
return { totalAttempts: 0, failedAttempts: [] };
}
export function ensureAttemptTrail(context: ProviderExecutionContext): AttemptTrail {
if (!context.attemptTrail) {
context.attemptTrail = createAttemptTrail();
}
return context.attemptTrail;
}
export function buildAttemptSummary(
trail: AttemptTrail | undefined,
durationMs: number,
): AttemptSummaryDto {
const totalAttempts = trail?.totalAttempts ?? 0;
return {
totalAttempts,
retried: totalAttempts > 1,
durationMs,
attempts: trail?.failedAttempts ?? [],
};
}
/** Attach summary on failure always; on success only when retried. */
export function shouldExposeAttemptSummary(
success: boolean,
summary: AttemptSummaryDto | undefined,
): boolean {
if (!summary || summary.totalAttempts === 0) {
return false;
}
return !success || summary.retried;
}
export function createDeadlineError(label = 'Inquiry'): Error {
const normalized = buildNormalizedError('INQUIRY_DEADLINE_EXCEEDED', {
message: `${label} exceeded the ${INQUIRY_DEADLINE_MS}ms deadline`,
});
const error = new Error(normalized.message);
(error as Error & { normalizedError: NormalizedErrorDto; code: string }).normalizedError =
normalized;
(error as NodeJS.ErrnoException).code = 'ETIMEDOUT';
return error;
}
/**
* Transport / transient failures only.
* Does NOT retry 429 (rate limit / ban risk) or business rejects.
*/
export function isTransportRetryable(error: unknown): boolean {
if (!error || typeof error !== 'object') {
return false;
}
if ('code' in error) {
const code = (error as { code?: string }).code;
if (code === 'ETIMEDOUT' || code === 'ECONNABORTED' || code === 'ECONNRESET') {
return true;
}
}
if ('response' in error) {
const status = (error as { response?: { status?: number } }).response?.status;
if (typeof status === 'number') {
if (status === 408 || status === 499 || (status >= 500 && status < 600)) {
return true;
}
}
}
if ('normalizedError' in error) {
const normalized = (error as { normalizedError: NormalizedErrorDto }).normalizedError;
if (
normalized.code === 'PROVIDER_TIMEOUT' ||
normalized.code === 'PROVIDER_NETWORK_ERROR' ||
normalized.code === 'INQUIRY_DEADLINE_EXCEEDED'
) {
return normalized.code !== 'INQUIRY_DEADLINE_EXCEEDED';
}
const providerCode = normalized.providerCode?.trim();
if (providerCode && /^(408|499|5\d\d)$/.test(providerCode)) {
return true;
}
}
return false;
}
/** Auth: timeout / connection blips only — never 401/403/429/business rejects. */
export function isAuthTransportRetryable(error: unknown): boolean {
if (!error || typeof error !== 'object') {
return false;
}
if (error instanceof AxiosError) {
if (error.response) {
return false;
}
const code = error.code;
return code === 'ETIMEDOUT' || code === 'ECONNABORTED' || code === 'ECONNRESET';
}
if ('code' in error) {
const code = (error as { code?: string }).code;
return code === 'ETIMEDOUT' || code === 'ECONNABORTED' || code === 'ECONNRESET';
}
return false;
}
export function toAttemptErrorFields(error: unknown): {
code: string;
message: string;
messageFa?: string;
} {
if (error && typeof error === 'object' && 'normalizedError' in error) {
const normalized = translateError(
(error as { normalizedError: NormalizedErrorDto }).normalizedError,
);
return {
code: normalized.code,
message: normalized.message,
messageFa: normalized.messageFa,
};
}
if (error instanceof AxiosError) {
const status = error.response?.status;
const code =
error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED'
? 'PROVIDER_TIMEOUT'
: status && status >= 500
? 'PROVIDER_ERROR'
: 'PROVIDER_NETWORK_ERROR';
const normalized = translateError(
buildNormalizedError(code as 'PROVIDER_TIMEOUT' | 'PROVIDER_ERROR' | 'PROVIDER_NETWORK_ERROR', {
message: error.message,
}),
);
return {
code: normalized.code,
message: normalized.message,
messageFa: normalized.messageFa,
};
}
const errno = (error as NodeJS.ErrnoException | undefined)?.code;
const message = error instanceof Error ? error.message : 'Provider request failed';
const looksLikeTimeout =
errno === 'ETIMEDOUT' ||
errno === 'ECONNABORTED' ||
/timed?\s*out|deadline exceeded/i.test(message);
const code = looksLikeTimeout
? /deadline exceeded/i.test(message)
? 'INQUIRY_DEADLINE_EXCEEDED'
: 'PROVIDER_TIMEOUT'
: errno === 'ECONNRESET' || errno === 'NETWORK_ERROR'
? 'PROVIDER_NETWORK_ERROR'
: 'PROVIDER_ERROR';
const normalized = translateError(buildNormalizedError(code, { message }));
return {
code: normalized.code,
message: normalized.message,
messageFa: normalized.messageFa,
};
}
export function recordResilienceAttempt(
context: ProviderExecutionContext,
provider: string,
info: RetryAttemptInfo,
): void {
const trail = ensureAttemptTrail(context);
trail.totalAttempts += 1;
if (!info.succeeded && info.error !== undefined) {
const fields = toAttemptErrorFields(info.error);
trail.failedAttempts.push({
attempt: trail.totalAttempts,
provider,
code: fields.code,
durationMs: info.durationMs,
message: fields.message,
messageFa: fields.messageFa,
});
}
}
export interface RunWithResilienceOptions {
providerName: string;
maxAttempts: number;
timeoutMs: number;
context: ProviderExecutionContext;
label?: string;
shouldRetry?: (error: unknown) => boolean;
}
/**
* Shared retry + per-attempt timeout + inquiry deadline for all inquiry providers.
*/
export async function runWithProviderResilience<T>(
fn: () => Promise<T>,
options: RunWithResilienceOptions,
): Promise<T> {
const {
providerName,
maxAttempts,
timeoutMs,
context,
label = `${providerName} request`,
shouldRetry = isTransportRetryable,
} = options;
ensureAttemptTrail(context);
return withRetry(
async () => {
const remaining = context.deadlineAt
? context.deadlineAt - Date.now()
: timeoutMs;
if (remaining < MIN_ATTEMPT_BUDGET_MS) {
throw createDeadlineError(label);
}
const attemptTimeout = Math.min(timeoutMs, remaining);
return withTimeout(fn(), attemptTimeout, label);
},
{
maxAttempts,
delayMs: PROVIDER_RETRY_DELAY_MS,
backoffMultiplier: 2,
deadlineAt: context.deadlineAt,
minRemainingMs: MIN_ATTEMPT_BUDGET_MS,
shouldRetry,
onAttempt: (info) => recordResilienceAttempt(context, providerName, info),
},
);
}

View File

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

View File

@@ -0,0 +1,323 @@
import { CarPlateSoapFields } from '../../providers/shared/centinsur-car-policy.client';
export interface NormalizedCarPolicy {
raw: Record<string, string>;
nationalCode: string;
plateKey: string;
chassisNo: string;
motorNo: string;
vin: string;
startDate?: Date;
endDate?: Date;
vehicleGroup: string;
insuranceType: string;
isZeroKm: boolean;
isActive: boolean;
}
export interface CarPlateInput extends CarPlateSoapFields {
nationalCode: string;
}
export function getCurrentJalaliYear(now = new Date()): number {
const parts = new Intl.DateTimeFormat('en-u-ca-persian', { year: 'numeric' }).formatToParts(now);
const yearPart = parts.find((part) => part.type === 'year');
return Number(yearPart?.value ?? new Date().getFullYear() - 621);
}
/** Persian plate letter must be present and not a placeholder. */
export function isValidPlateLetter(letter: string): boolean {
const trimmed = letter.trim();
if (!trimmed || trimmed === '-' || trimmed === '0') {
return false;
}
return /[\u0600-\u06FF]/.test(trimmed);
}
export function nationalIdsMatch(requested: string, fromPolicy: string): boolean {
return requested.trim().padStart(10, '0') === fromPolicy.trim().padStart(10, '0');
}
export function normalizeSoapCarPolicy(
policy: Record<string, string>,
nationalCode: string,
): NormalizedCarPolicy {
const plk1 = pickField(policy, ['Plk1', 'PLK1']);
const plk2 = pickField(policy, ['Plk2', 'PLK2']);
const plk3 = pickField(policy, ['Plk3', 'PLK3']);
const plksrl = pickField(policy, ['PlkSrl', 'PlkSRL', 'Plk4']);
const chassisNo = pickField(policy, ['ChassisNo', 'ShasiNo', 'VIN', 'Vin']);
const motorNo = pickField(policy, ['MotorNo', 'Motor']);
const vin = pickField(policy, ['VIN', 'Vin', 'ChassisNo']);
const vehicleGroup = pickField(policy, [
'CarGrpCod',
'VhcleGrpCd',
'VhcleGrp',
'KbTyp',
'VehicleGroup',
]);
const insuranceType = pickField(policy, ['InsurTy', 'InsurTyp', 'PlcyTyp', 'InsuranceType']);
const policyNationalCode =
pickField(policy, ['NtnlId', 'NationalId', 'NationalCode']) || nationalCode;
const startDate = parsePolicyDate(
pickField(policy, [
'HBgnDte',
'PlcyBegnDt',
'BgnDt',
'PolicyBeginDate',
'StartDate',
]),
);
const endDate = parsePolicyDate(
pickField(policy, ['HEndDte', 'PlcyEndDt', 'EndDt', 'PolicyEndDate', 'EndDate']),
);
const plateKey = [plk1, plk2, plk3, plksrl].filter(Boolean).join('-');
const isZeroKm = isZeroKmPlate(plk1, plk2, plk3, plksrl);
const now = new Date();
return {
raw: policy,
nationalCode: policyNationalCode,
plateKey,
chassisNo,
motorNo,
vin,
startDate,
endDate,
vehicleGroup,
insuranceType,
isZeroKm,
isActive: Boolean(startDate && endDate && startDate <= now && endDate >= now),
};
}
export function isPassengerVehicleGroup(group: string): boolean {
const normalized = group.trim();
return normalized === '2' || normalized === '3' || normalized === '02' || normalized === '03';
}
export function overlapsCurrentJalaliYear(
policy: NormalizedCarPolicy,
jalaliYear = getCurrentJalaliYear(),
): boolean {
if (!policy.startDate || !policy.endDate) {
return true;
}
const yearStart = jalaliYearStartGregorian(jalaliYear);
const yearEnd = jalaliYearStartGregorian(jalaliYear + 1);
return policy.startDate < yearEnd && policy.endDate >= yearStart;
}
export function isAcceptableThirdPartyPolicy(
policy: NormalizedCarPolicy,
requestedNationalCode: string,
): boolean {
if (!nationalIdsMatch(requestedNationalCode, policy.nationalCode)) {
return false;
}
if (policy.vehicleGroup && !isPassengerVehicleGroup(policy.vehicleGroup)) {
return false;
}
if (!overlapsCurrentJalaliYear(policy)) {
return false;
}
return true;
}
export function sameVehicleIdentity(a: NormalizedCarPolicy, b: NormalizedCarPolicy): boolean {
const aChassis = normalizeIdentity(a.chassisNo || a.vin);
const bChassis = normalizeIdentity(b.chassisNo || b.vin);
if (aChassis && bChassis) {
return aChassis === bChassis;
}
const aMotor = normalizeIdentity(a.motorNo);
const bMotor = normalizeIdentity(b.motorNo);
if (aMotor && bMotor) {
return aMotor === bMotor;
}
return a.plateKey !== '' && a.plateKey === b.plateKey;
}
export function selectBestThirdPartyPolicy(
policies: NormalizedCarPolicy[],
requestedNationalCode: string,
): NormalizedCarPolicy | null {
const acceptable = policies.filter((policy) =>
isAcceptableThirdPartyPolicy(policy, requestedNationalCode),
);
if (acceptable.length === 0) {
return null;
}
const active = acceptable.filter((policy) => policy.isActive);
const pool = active.length > 0 ? active : acceptable;
return pool.sort((left, right) => {
const leftStart = left.startDate?.getTime() ?? 0;
const rightStart = right.startDate?.getTime() ?? 0;
return rightStart - leftStart;
})[0]!;
}
export function mergePlateAndNationalPolicies(
platePolicy: NormalizedCarPolicy | null,
nationalPolicy: NormalizedCarPolicy | null,
): NormalizedCarPolicy | null {
if (!platePolicy && !nationalPolicy) {
return null;
}
if (!platePolicy) {
return nationalPolicy;
}
if (!nationalPolicy) {
return platePolicy;
}
if (sameVehicleIdentity(platePolicy, nationalPolicy)) {
return platePolicy.startDate && nationalPolicy.startDate
? platePolicy.startDate >= nationalPolicy.startDate
? platePolicy
: nationalPolicy
: platePolicy;
}
if (nationalPolicy.isZeroKm) {
return nationalPolicy.startDate && platePolicy.startDate
? nationalPolicy.startDate >= platePolicy.startDate
? nationalPolicy
: platePolicy
: nationalPolicy;
}
return platePolicy;
}
export interface ThirdPartyCarLookup {
byPlate?: () => Promise<Record<string, string> | null>;
byNationalCode?: () => Promise<Record<string, string> | null>;
}
export async function resolveThirdPartyCarPolicy(
input: CarPlateInput,
lookup: ThirdPartyCarLookup,
): Promise<{ policy: NormalizedCarPolicy; sources: string[] } | null> {
const plateLetterValid = isValidPlateLetter(input.plk2);
const sources: string[] = [];
const platePromise =
plateLetterValid && lookup.byPlate
? lookup.byPlate().catch(() => null)
: Promise.resolve(null);
const nationalPromise = lookup.byNationalCode
? lookup.byNationalCode().catch(() => null)
: Promise.resolve(null);
// Parallel so dual SOAP inquiries share wall-clock time under the inquiry deadline.
const [plateRaw, nationalRaw] = await Promise.all([platePromise, nationalPromise]);
const platePolicy = plateRaw
? normalizeSoapCarPolicy(plateRaw, input.nationalCode)
: null;
const nationalPolicy = nationalRaw
? normalizeSoapCarPolicy(nationalRaw, input.nationalCode)
: null;
if (platePolicy) {
sources.push('plate');
}
if (nationalPolicy) {
sources.push('nationalCode');
}
let merged = mergePlateAndNationalPolicies(
platePolicy && isAcceptableThirdPartyPolicy(platePolicy, input.nationalCode) ? platePolicy : null,
nationalPolicy && isAcceptableThirdPartyPolicy(nationalPolicy, input.nationalCode)
? nationalPolicy
: null,
);
if (!merged && plateLetterValid && lookup.byPlate && !platePolicy) {
try {
const raw = await lookup.byPlate();
if (raw) {
const fallback = normalizeSoapCarPolicy(raw, input.nationalCode);
merged = selectBestThirdPartyPolicy([fallback], input.nationalCode);
if (merged) {
sources.push('plateFallback');
}
}
} catch {
merged = null;
}
}
if (!merged) {
return null;
}
return { policy: merged, sources };
}
function pickField(policy: Record<string, string>, keys: string[]): string {
for (const key of keys) {
const value = policy[key]?.trim();
if (value) {
return value;
}
}
return '';
}
function parsePolicyDate(value: string): Date | undefined {
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
// Jalali dates from CentInsur: 1405/05/23
const jalaliSlash = /^(\d{4})\/(\d{1,2})\/(\d{1,2})$/.exec(trimmed);
if (jalaliSlash) {
const year = Number(jalaliSlash[1]);
const month = Number(jalaliSlash[2]);
const day = Number(jalaliSlash[3]);
// Approximate Gregorian for overlap checks (Jalali year + 621, month/day as-is).
// Good enough for "overlaps current Jalali year" when both ends use the same scheme.
if (year > 1300 && year < 1600) {
const date = new Date(year + 621, month - 1, day);
return Number.isNaN(date.getTime()) ? undefined : date;
}
}
const compact = trimmed.replace(/[^\d]/g, '');
if (compact.length === 8) {
const year = Number(compact.slice(0, 4));
const month = Number(compact.slice(4, 6));
const day = Number(compact.slice(6, 8));
if (year > 1300 && year < 1600) {
const date = new Date(year + 621, month - 1, day);
return Number.isNaN(date.getTime()) ? undefined : date;
}
const date = new Date(year, month - 1, day);
return Number.isNaN(date.getTime()) ? undefined : date;
}
const parsed = new Date(trimmed);
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
}
function isZeroKmPlate(plk1: string, plk2: string, plk3: string, plksrl: string): boolean {
return !plk1.trim() && !plk2.trim() && !plk3.trim() && !plksrl.trim();
}
function normalizeIdentity(value: string): string {
return value.trim().toUpperCase();
}
function jalaliYearStartGregorian(jalaliYear: number): Date {
const march = 20;
const gregorianYear = jalaliYear + 621;
return new Date(gregorianYear, 2, march);
}