diff --git a/src/common/helpers/soap-car-policy.helper.ts b/src/common/helpers/soap-car-policy.helper.ts new file mode 100644 index 0000000..b8516f1 --- /dev/null +++ b/src/common/helpers/soap-car-policy.helper.ts @@ -0,0 +1,85 @@ +function decodeXml(value: string): string { + return value + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/>/g, '>') + .replace(/</g, '<') + .replace(/&/g, '&'); +} + +function normalizeSoapTextValue(value: string): string { + if (/i:nil\s*=\s*["']true["']/i.test(value)) { + return ''; + } + + const nestedStrings = [ + ...value.matchAll(/<(?:[\w]+:)?string[^>]*>([\s\S]*?)<\/(?:[\w]+:)?string>/gi), + ] + .map((match) => decodeXml(match[1].trim())) + .filter(Boolean); + + if (nestedStrings.length > 0) { + return nestedStrings.join(', '); + } + + return decodeXml(value.trim()); +} + +function parseSoapFields(block: string): Record { + const fields: Record = {}; + const tagRegex = /<(?:[\w]+:)?(\w+)(?:[^>]*)>([\s\S]*?)<\/(?:[\w]+:)?\1>/gi; + let match: RegExpExecArray | null; + + while ((match = tagRegex.exec(block)) !== null) { + fields[match[1]] = normalizeSoapTextValue(match[2]); + } + + return fields; +} + +export function parseFirstCarPolicy(soapXml: string): Record { + const resultMatch = soapXml.match( + /<(?:[\w]+:)?(?:CIIWSPolicyChassis|CIIWSPolicyVehicleMeli|CIIWSPolicyNationalId)Result[^>]*>([\s\S]*?)<\/(?:[\w]+:)?(?:CIIWSPolicyChassis|CIIWSPolicyVehicleMeli|CIIWSPolicyNationalId)Result>/i, + ); + + if (!resultMatch) { + return {}; + } + + const firstPolicyMatch = resultMatch[1].match( + /<(?:[\w]+:)?Policy(?:\s[^>]*)?>\s*<(?:[\w]+:)?Policy(?:\s[^>]*)?>([\s\S]*?)<\/(?:[\w]+:)?Policy>/i, + ); + + if (!firstPolicyMatch) { + return {}; + } + + return parseSoapFields(firstPolicyMatch[1]); +} + +export function getCarPolicyProviderError( + soapXml: string, + policy: Record, +): { message: string; code: string } | null { + const resultMatch = soapXml.match( + /<(?:[\w]+:)?(?:CIIWSPolicyChassis|CIIWSPolicyVehicleMeli|CIIWSPolicyNationalId)Result[^>]*>([\s\S]*?)<\/(?:[\w]+:)?(?:CIIWSPolicyChassis|CIIWSPolicyVehicleMeli|CIIWSPolicyNationalId)Result>/i, + ); + + if (resultMatch) { + const errorMatch = resultMatch[1].match( + /<(?:[\w]+:)?Error(?![^>]*i:nil\s*=\s*["']true["'])[^>]*>([\s\S]*?)<\/(?:[\w]+:)?Error>/i, + ); + if (errorMatch) { + const errorText = normalizeSoapTextValue(errorMatch[1]).trim(); + if (errorText) { + return { message: errorText, code: 'PROVIDER_ERROR' }; + } + } + } + + if (Object.keys(policy).length === 0) { + return { message: 'No policy record found', code: 'RECORD_NOT_FOUND' }; + } + + return null; +} diff --git a/src/providers/implementations/amitis.provider.ts b/src/providers/implementations/amitis.provider.ts index 0a85bd1..3a6284e 100644 --- a/src/providers/implementations/amitis.provider.ts +++ b/src/providers/implementations/amitis.provider.ts @@ -7,6 +7,7 @@ import { } from '../../common/helpers/http-client.helper'; import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { ProviderName } from '../../common/enums/provider-name.enum'; +import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto'; import { AmitisAuthServiceConfig } from '../../config/configuration'; import { GeneralTokenDocument } from '../schemas/general-token.schema'; import { GeneralTokenService } from '../services/general-token.service'; @@ -201,10 +202,21 @@ export class AmitisProvider { fallbackRefreshToken?: string, ): AmitisAuthToken { const body = responseBody.data ?? responseBody; + const loginStatus = body.LoginStatus; - if (body.IsSucceed === false) { - throw new Error( - `AMITIS login rejected (LoginStatus=${body.LoginStatus ?? 'unknown'})`, + if (loginStatus === 2 || body.IsSucceed === false) { + throw this.createAuthError( + loginStatus === 2 + ? 'AMITIS username or password is invalid for this service account' + : `AMITIS login rejected (LoginStatus=${loginStatus ?? 'unknown'})`, + loginStatus === 2 ? 'AUTH_INVALID_CREDENTIALS' : 'AUTH_REJECTED', + ); + } + + if (loginStatus !== undefined && loginStatus !== 1 && body.IsSucceed !== true) { + throw this.createAuthError( + `AMITIS login rejected (LoginStatus=${loginStatus})`, + 'AUTH_REJECTED', ); } @@ -255,7 +267,23 @@ export class AmitisProvider { return `${ProviderName.AMITIS}:${providerName}:${inquiryType}`; } + private createAuthError(message: string, code: string): Error { + const normalized: NormalizedErrorDto = { + code, + message, + providerMessage: message, + providerCode: code, + }; + const err = new Error(message); + (err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized; + return err; + } + private toAuthError(message: string, error: unknown): Error { + if (error instanceof Error && 'normalizedError' in error) { + return error; + } + if (error instanceof AxiosError) { const status = error.response?.status ?? 'NETWORK_ERROR'; const body = diff --git a/src/providers/implementations/hamta.provider.ts b/src/providers/implementations/hamta.provider.ts index d3f1293..8fe2465 100644 --- a/src/providers/implementations/hamta.provider.ts +++ b/src/providers/implementations/hamta.provider.ts @@ -2,6 +2,11 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios, { AxiosError } from 'axios'; import { mergeOutboundAxiosConfig } from '../../common/helpers/http-client.helper'; +import { + buildCivilRegistrationFullName, + getCivilRegistrationProviderError, + parseCiiEstelamResult, +} from '../../common/helpers/soap-civil-registration.helper'; import { getSayahProviderError, SayahApiResponse, @@ -20,6 +25,7 @@ import { LegacyApiProvider, LegacyInquiryPayload, LegacyInquiryResult, + PersonInquiryResult, } from '../shared/legacy-api.provider.abstract'; interface CivilRegistrationPayload extends LegacyInquiryPayload { @@ -107,6 +113,7 @@ export class HamtaProvider extends LegacyApiProvider { ); const inquiryConfig = this.getInquiryConfig(InquiryType.POSTAL_CODE); + this.assertAmitisCredentials(InquiryType.POSTAL_CODE, inquiryConfig); const token = await this.amitisProvider.getAccessToken( ProviderName.HAMTA, InquiryType.POSTAL_CODE, @@ -138,7 +145,7 @@ export class HamtaProvider extends LegacyApiProvider { private async inquireCivilRegistration( payload: CivilRegistrationPayload, - ): Promise<{ raw: unknown }> { + ): Promise { const nationalCode = this.getRequiredString( payload.nationalCode ?? payload.NIN, 'nationalCode', @@ -151,6 +158,7 @@ export class HamtaProvider extends LegacyApiProvider { const response = await axios.post( inquiryConfig.url, this.buildCivilRegistrationEnvelope( + payload, nationalCode, birthDate, inquiryConfig.username, @@ -166,22 +174,23 @@ export class HamtaProvider extends LegacyApiProvider { }), ); - const result = this.extractSoapValue(response.data, 'SubmitInqDteStsWithPstCodResult'); + const civilRegistration = parseCiiEstelamResult(response.data); + const providerError = getCivilRegistrationProviderError(civilRegistration); + if (providerError) { + throw this.formatProviderError(providerError.message, providerError.code); + } + + const fullName = buildCivilRegistrationFullName(civilRegistration); return { - raw: { - nationalCode, - birthDate, - result, - soap: response.data, - }, + nationalCode, + birthDate, + fullName: fullName || undefined, + raw: civilRegistration, }; } catch (error) { if (error instanceof AxiosError) { - throw this.formatProviderError( - error.message, - String(error.response?.status ?? 'NETWORK_ERROR'), - ); + throw this.formatAxiosError(error); } throw error; } @@ -251,6 +260,7 @@ export class HamtaProvider extends LegacyApiProvider { const shebaId = this.getRequiredString(payload.sheba, 'sheba'); const inquiryConfig = this.getInquiryConfig(InquiryType.SHEBA); + this.assertAmitisCredentials(InquiryType.SHEBA, inquiryConfig); const token = await this.amitisProvider.getAccessToken( ProviderName.HAMTA, InquiryType.SHEBA, @@ -294,19 +304,35 @@ export class HamtaProvider extends LegacyApiProvider { } private buildCivilRegistrationEnvelope( + payload: CivilRegistrationPayload, nationalCode: string, birthDate: string, username: string, password: string, ): string { + const birthDateCompact = birthDate.replace(/-/g, ''); + const dateHasPostfix = payload.dateHasPostfix ?? 0; + return ` - ${this.escapeXml(nationalCode)} - ${this.escapeXml(birthDate)} + + ${this.escapeXml(nationalCode)} + 0 + 0 + ${this.escapeXml(birthDateCompact)} + ${dateHasPostfix} + 0 + 0 + 0 + 0 + 0 + 0 + 0 + ${this.escapeXml(username)} ${this.escapeXml(password)} @@ -335,9 +361,17 @@ export class HamtaProvider extends LegacyApiProvider { `; } - private extractSoapValue(xml: string, tagName: string): string | null { - const match = xml.match(new RegExp(`<(?:\\w+:)?${tagName}[^>]*>([\\s\\S]*?)`)); - return match ? this.decodeXml(match[1].trim()) : null; + private assertAmitisCredentials( + inquiryType: InquiryType, + inquiryConfig: { username: string; password: string }, + ): void { + const envPrefix = inquiryType.replace(/_INQUIRY$/, ''); + if (!inquiryConfig.username?.trim() || !inquiryConfig.password?.trim()) { + throw this.formatProviderError( + `${envPrefix} AMITIS credentials are missing. Set HAMTA_${envPrefix}_USERNAME and HAMTA_${envPrefix}_PASSWORD in .env`, + 'PROVIDER_NOT_CONFIGURED', + ); + } } private getRequiredString(value: unknown, fieldName: string): string { @@ -359,15 +393,6 @@ export class HamtaProvider extends LegacyApiProvider { .replace(/"/g, '"') .replace(/'/g, '''); } - - private decodeXml(value: string): string { - return value - .replace(/'/g, "'") - .replace(/"/g, '"') - .replace(/>/g, '>') - .replace(/</g, '<') - .replace(/&/g, '&'); - } } // Made with Bob diff --git a/src/providers/implementations/parsian.provider.ts b/src/providers/implementations/parsian.provider.ts index a75027a..0984cf7 100644 --- a/src/providers/implementations/parsian.provider.ts +++ b/src/providers/implementations/parsian.provider.ts @@ -2,12 +2,16 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios, { AxiosError } from 'axios'; import { mergeOutboundAxiosConfig } from '../../common/helpers/http-client.helper'; -import { getSayahProviderError, SayahApiResponse } from '../../common/helpers/sayah-response.helper'; import { - describeShahkarResponse, - getShahkarProviderError, - normalizeShahkarFields, -} from '../../common/helpers/shahkar-response.helper'; + buildCivilRegistrationFullName, + getCivilRegistrationProviderError, + parseCiiEstelamResult, +} from '../../common/helpers/soap-civil-registration.helper'; +import { + getCarPolicyProviderError, + parseFirstCarPolicy, +} from '../../common/helpers/soap-car-policy.helper'; +import { getSayahProviderError, SayahApiResponse } from '../../common/helpers/sayah-response.helper'; import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { ProviderName } from '../../common/enums/provider-name.enum'; import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface'; @@ -25,6 +29,7 @@ interface ParsianCivilRegistrationPayload extends LegacyInquiryPayload { birthDate?: string; NIN?: string; BirthDate?: string; + dateHasPostfix?: number; } interface ParsianShahkarPayload extends LegacyInquiryPayload { @@ -43,7 +48,7 @@ interface ParsianShahkarResponse { comment?: string | null; id?: string | null; requestId?: string | null; - response?: number; + response?: number | string; result?: string | null; } @@ -187,6 +192,7 @@ export class ParsianProvider extends BaseProvider); - const providerError = getShahkarProviderError(shahkarFields); - if (providerError) { - this.nestLogger.warn( - `SHAHKAR provider business failure | ${describeShahkarResponse(shahkarFields)}`, + const responseCode = Number(body.response); + + if (!Number.isFinite(responseCode) || responseCode !== 200) { + throw this.formatProviderError( + body.comment ?? body.errorNams ?? body.ErrorNams ?? 'Shahkar inquiry failed', + String(body.response ?? 'PROVIDER_ERROR'), + undefined, + { + providerTrackingCode: body.requestId ?? body.id ?? undefined, + }, ); - throw this.formatProviderError(providerError.message, providerError.code, undefined, { - providerTrackingCode: providerError.providerTrackingCode, - }); } return { raw: { nationalCode, mobileNumber, - ...shahkarFields, + ...body, }, }; } catch (error) { @@ -246,6 +254,7 @@ export class ParsianProvider extends BaseProvider( inquiryConfig.url, this.buildCivilRegistrationEnvelope( + payload, nationalCode, birthDate, inquiryConfig.username, @@ -261,15 +270,19 @@ export class ParsianProvider extends BaseProvider { const chassisNo = this.getRequiredString(payload.chassisNo, 'chassisNo'); - const response = await this.callCarPolicySoap( + const policy = await this.callCarPolicySoap( InquiryType.POLICY_BY_CHASSIS, 'CIIWSPolicyChassis', - { Chassisno: chassisNo }, + { ChassisNo: chassisNo }, ); return { raw: { chassisNo, - ...response, + ...policy, }, }; } @@ -304,16 +317,16 @@ export class ParsianProvider extends BaseProvider { const nationalCode = this.getRequiredString(payload.nationalCode, 'nationalCode'); - const response = await this.callCarPolicySoap( + const policy = await this.callCarPolicySoap( InquiryType.POLICY_BY_NATIONAL_CODE, 'CIIWSPolicyNationalId', - { nationalId: nationalCode }, + { NationalId: nationalCode }, ); return { raw: { nationalCode, - ...response, + ...policy, }, }; } @@ -325,11 +338,10 @@ export class ParsianProvider extends BaseProvider, - passwordFieldName = 'Password', - ): Promise<{ result: string | null; soap: string }> { + ): Promise> { const inquiryConfig = this.config.inquiries[inquiryType]; if (!this.hasSoapCredentials(inquiryConfig)) { @@ -367,22 +378,24 @@ export class ParsianProvider extends BaseProvider, username: string, password: string, - passwordFieldName: string, ): string { const fieldXml = Object.entries(fields) .map(([name, value]) => ` <${name}>${this.escapeXml(value)}`) @@ -483,27 +495,47 @@ export class ParsianProvider extends BaseProvider <${methodName} xmlns="http://tempuri.org/"> ${fieldXml} - ${this.escapeXml(username)} - <${passwordFieldName}>${this.escapeXml(password)} + ${this.escapeXml(username)} + ${this.escapeXml(password)} `; } private buildCivilRegistrationEnvelope( + payload: ParsianCivilRegistrationPayload, nationalCode: string, birthDate: string, username: string, password: string, ): string { + const birthDateCompact = birthDate.replace(/-/g, ''); + const dateHasPostfix = payload.dateHasPostfix ?? 0; + return ` - ${this.escapeXml(nationalCode)} - ${this.escapeXml(birthDate)} + + ${this.escapeXml(nationalCode)} + + + + + 0 + 0 + ${this.escapeXml(birthDateCompact)} + ${dateHasPostfix} + 0 + 0 + 0 + 0 + 0 + 0 + 0 + ${this.escapeXml(username)} ${this.escapeXml(password)} @@ -511,13 +543,6 @@ ${fieldXml} `; } - private extractSoapValue(xml: string, tagName: string): string | null { - const match = xml.match( - new RegExp(`<(?:\\w+:)?${tagName}[^>]*>([\\s\\S]*?)`), - ); - return match ? this.decodeXml(match[1].trim()) : null; - } - private escapeXml(value: string): string { return value .replace(/&/g, '&') @@ -527,15 +552,6 @@ ${fieldXml} .replace(/'/g, '''); } - private decodeXml(value: string): string { - return value - .replace(/'/g, "'") - .replace(/"/g, '"') - .replace(/>/g, '>') - .replace(/</g, '<') - .replace(/&/g, '&'); - } - private hasErrors( errors?: Record | Array<{ Code?: string; Message?: string }> | null, ): boolean { diff --git a/src/providers/shared/legacy-api.provider.abstract.ts b/src/providers/shared/legacy-api.provider.abstract.ts index cee4d47..c214a2a 100644 --- a/src/providers/shared/legacy-api.provider.abstract.ts +++ b/src/providers/shared/legacy-api.provider.abstract.ts @@ -216,7 +216,6 @@ export abstract class LegacyApiProvider extends BaseProvider< return { NationalId: payload.nationalCode ?? payload.nationalId ?? payload.NationalId, PostalCode: payload.postalCode ?? payload.PostalCode, - ...payload, }; }