From 884a9c7f113508dcf8faccc3a8e2ff278e2f581d Mon Sep 17 00:00:00 2001 From: "s.hajizadeh" Date: Wed, 10 Jun 2026 15:11:24 +0330 Subject: [PATCH] requests fix --- src/inquiry/dto/sayah-request.dto.ts | 6 +- .../implementations/amitis.provider.ts | 43 ++-- .../implementations/hamta.provider.ts | 219 +++++++++++++++++- .../interfaces/provider-config.interface.ts | 4 + .../shared/legacy-api.provider.abstract.ts | 50 +++- 5 files changed, 286 insertions(+), 36 deletions(-) diff --git a/src/inquiry/dto/sayah-request.dto.ts b/src/inquiry/dto/sayah-request.dto.ts index 9a76579..17a8631 100644 --- a/src/inquiry/dto/sayah-request.dto.ts +++ b/src/inquiry/dto/sayah-request.dto.ts @@ -2,9 +2,9 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsIn, IsNotEmpty, IsOptional, IsString, Length, Matches } from 'class-validator'; export class SayahRequestDto { - @ApiProperty({ example: '0', description: '0 for natural person' }) + @ApiProperty({ example: '1', description: '1 for natural person (always use "1")' }) @IsString() - @IsIn(['0']) + @IsIn(['1']) accountOwnerType!: string; @ApiProperty({ example: '4311402422', description: 'Iranian national code (10 digits)' }) @@ -19,7 +19,7 @@ export class SayahRequestDto { @IsString() legalId?: string | null; - @ApiProperty({ example: 'IR000000000000000000000000', description: 'Iranian Sheba ID' }) + @ApiProperty({ example: 'IR800560611828005105117001', description: 'Iranian Sheba ID (26 characters)' }) @IsString() @Length(26, 26) @Matches(/^IR\d{24}$/, { message: 'shebaId must start with IR and contain 24 digits' }) diff --git a/src/providers/implementations/amitis.provider.ts b/src/providers/implementations/amitis.provider.ts index 1485649..c2948c9 100644 --- a/src/providers/implementations/amitis.provider.ts +++ b/src/providers/implementations/amitis.provider.ts @@ -61,7 +61,9 @@ export class AmitisProvider { /** * Get access token for a specific provider and inquiry type - * Handles token caching, refresh, and daily expiration + * Handles token caching, refresh, and 24-hour expiration + * - Access token expires in 20 minutes + * - Refresh token expires in 24 hours from creation */ async getAccessToken( providerName: ProviderName, @@ -72,13 +74,13 @@ export class AmitisProvider { const tokenKey = this.getTokenKey(providerName, inquiryType); const latestToken = await this.generalTokenService.getLatestToken(tokenKey); - // Use cached token if valid and from same day - if (latestToken && !this.isExpired(latestToken) && this.isSameTokenDay(latestToken)) { + // Use cached token if valid and within 24-hour window + if (latestToken && !this.isExpired(latestToken) && this.isWithin24Hours(latestToken)) { return latestToken.accessToken; } - // Try to refresh if we have a refresh token and it's from the same day - if (latestToken?.refreshToken && this.isSameTokenDay(latestToken)) { + // Try to refresh if we have a refresh token and it's within 24 hours + if (latestToken?.refreshToken && this.isWithin24Hours(latestToken)) { try { return await this.refreshAccessToken(latestToken, providerName, inquiryType); } catch (error) { @@ -100,18 +102,19 @@ export class AmitisProvider { username: string, password: string, ): Promise { - // Use URLSearchParams for application/x-www-form-urlencoded format - const formData = new URLSearchParams(); + // Use FormData for multipart/form-data format + const FormData = require('form-data'); + const formData = new FormData(); formData.append('username', username); formData.append('password', password); try { const response = await this.httpClient.post( this.config.loginPath, - formData.toString(), + formData, { headers: { - 'Content-Type': 'application/x-www-form-urlencoded', + ...formData.getHeaders(), }, }, ); @@ -198,25 +201,25 @@ export class AmitisProvider { } private isExpired(token: GeneralTokenDocument): boolean { + // Access token expires in 20 minutes, add 60 second buffer const refreshBufferMs = 60 * 1000; return !token.expiresAt || Date.now() + refreshBufferMs >= token.expiresAt.getTime(); } - private isSameTokenDay(token: GeneralTokenDocument): boolean { + /** + * Check if token is within 24-hour window from creation + * Refresh token is valid for 24 hours after initial login + */ + private isWithin24Hours(token: GeneralTokenDocument): boolean { if (!token.createdAt) { return false; } - return this.getDateKey(token.createdAt) === this.getDateKey(new Date()); - } - - private getDateKey(date: Date): string { - return new Intl.DateTimeFormat('en-CA', { - timeZone: this.config.tokenTimeZone, - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).format(date); + const tokenCreationTime = token.createdAt.getTime(); + const currentTime = Date.now(); + const twentyFourHoursMs = 24 * 60 * 60 * 1000; + + return currentTime - tokenCreationTime < twentyFourHoursMs; } private getTokenKey(providerName: ProviderName, inquiryType: InquiryType): string { diff --git a/src/providers/implementations/hamta.provider.ts b/src/providers/implementations/hamta.provider.ts index 1d0f956..05937d2 100644 --- a/src/providers/implementations/hamta.provider.ts +++ b/src/providers/implementations/hamta.provider.ts @@ -4,15 +4,28 @@ import axios, { AxiosError } from 'axios'; import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { ProviderName } from '../../common/enums/provider-name.enum'; import { ProviderEnvConfig } from '../../config/configuration'; -import { AmitisProvider } from './amitis.provider'; import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface'; +import { AmitisProvider } from './amitis.provider'; import { + LegacyApiProvider, LegacyInquiryPayload, LegacyInquiryResult, - LegacyApiProvider, } from '../shared/legacy-api.provider.abstract'; -interface ShahkarInquiryPayload extends LegacyInquiryPayload { +interface CivilRegistrationPayload extends LegacyInquiryPayload { + nationalCode?: string; + NIN?: string; + birthDate?: string; + BirthDate?: string; + dateHasPostfix?: number; +} + +interface PostalCodePayload extends LegacyInquiryPayload { + postalCode?: string; + PostalCode?: string; +} + +interface ShahkarPayload extends LegacyInquiryPayload { nationalCode?: string; nationalCod?: string; NationalCod?: string; @@ -20,20 +33,24 @@ interface ShahkarInquiryPayload extends LegacyInquiryPayload { MobileNo?: string; } +interface SayahPayload extends LegacyInquiryPayload { + accountOwnerType?: string; + nationalId?: string; + legalId?: string | null; + shebaId?: string; +} + /** - * Hamta provider — uses shared LegacyApiProvider base. - * Supports multiple inquiry types with different authentication methods. + * Hamta provider — implements Hamta/CentInsur REST and SOAP inquiry services. */ @Injectable() export class HamtaProvider extends LegacyApiProvider { readonly name = ProviderName.HAMTA; readonly supportedInquiryTypes = [ InquiryType.PERSON, - InquiryType.REAL_ESTATE, InquiryType.SHEBA, InquiryType.SHAHKAR, InquiryType.POSTAL_CODE, - InquiryType.LEGAL_PERSON, ]; private readonly hamtaConfig: ProviderEnvConfig; @@ -53,15 +70,113 @@ export class HamtaProvider extends LegacyApiProvider { payload: LegacyInquiryPayload, context: ProviderExecutionContext, ): Promise { - // Shahkar uses SOAP authentication, handled separately + if (inquiryType === InquiryType.PERSON) { + return this.inquireCivilRegistration(payload as CivilRegistrationPayload); + } + + if (inquiryType === InquiryType.POSTAL_CODE) { + return this.inquirePostalCode(payload as PostalCodePayload); + } + if (inquiryType === InquiryType.SHAHKAR) { - return this.inquireShahkar(payload as ShahkarInquiryPayload); + return this.inquireShahkar(payload as ShahkarPayload); + } + + if (inquiryType === InquiryType.SHEBA) { + return this.inquireSayah(payload as SayahPayload); } return super.callProvider(inquiryType, payload, context); } - private async inquireShahkar(payload: ShahkarInquiryPayload): Promise<{ raw: unknown }> { + private async inquirePostalCode(payload: PostalCodePayload): Promise<{ raw: unknown }> { + const postalCode = this.getRequiredString( + payload.postalCode ?? payload.PostalCode, + 'postalCode', + ); + + const inquiryConfig = this.getInquiryConfig(InquiryType.POSTAL_CODE); + const token = await this.amitisProvider.getAccessToken( + ProviderName.HAMTA, + InquiryType.POSTAL_CODE, + inquiryConfig.username, + inquiryConfig.password, + ); + + try { + const response = await axios.get>( + `${inquiryConfig.url}/AddressByPostcode`, + { + params: { PostalCode: postalCode }, + headers: { Authorization: `Bearer ${token}` }, + timeout: this.hamtaConfig.timeout, + }, + ); + + return { raw: response.data }; + } catch (error) { + if (error instanceof AxiosError) { + throw this.formatProviderError( + error.message, + String(error.response?.status ?? 'NETWORK_ERROR'), + ); + } + throw error; + } + } + + private async inquireCivilRegistration( + payload: CivilRegistrationPayload, + ): Promise<{ raw: unknown }> { + const nationalCode = this.getRequiredString( + payload.nationalCode ?? payload.NIN, + 'nationalCode', + ); + const birthDate = this.getRequiredString(payload.birthDate ?? payload.BirthDate, 'birthDate'); + + const inquiryConfig = this.getInquiryConfig(InquiryType.PERSON); + + try { + const response = await axios.post( + inquiryConfig.url, + this.buildCivilRegistrationEnvelope( + nationalCode, + birthDate, + inquiryConfig.username, + inquiryConfig.password, + ), + { + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + SOAPAction: '"http://tempuri.org/ISabtInq/SubmitInqDteStsWithPstCod"', + }, + timeout: this.hamtaConfig.timeout, + responseType: 'text', + }, + ); + + const result = this.extractSoapValue(response.data, 'SubmitInqDteStsWithPstCodResult'); + + return { + raw: { + nationalCode, + birthDate, + result, + soap: response.data, + }, + }; + } catch (error) { + if (error instanceof AxiosError) { + throw this.formatProviderError( + error.message, + String(error.response?.status ?? 'NETWORK_ERROR'), + ); + } + throw error; + } + } + + private async inquireShahkar(payload: ShahkarPayload): Promise<{ raw: unknown }> { const nationalCode = this.getRequiredString( payload.nationalCode ?? payload.nationalCod ?? payload.NationalCod, 'nationalCode', @@ -106,11 +221,86 @@ export class HamtaProvider extends LegacyApiProvider { String(error.response?.status ?? 'NETWORK_ERROR'), ); } - throw error; } } + private async inquireSayah(payload: SayahPayload): Promise<{ raw: unknown }> { + const accountOwnerType = this.getRequiredString(payload.accountOwnerType, 'accountOwnerType'); + const nationalId = payload.nationalId ?? ''; + const legalId = payload.legalId ?? null; + const shebaId = this.getRequiredString(payload.shebaId, 'shebaId'); + + const inquiryConfig = this.getInquiryConfig(InquiryType.SHEBA); + const token = await this.amitisProvider.getAccessToken( + ProviderName.HAMTA, + InquiryType.SHEBA, + inquiryConfig.username, + inquiryConfig.password, + ); + + try { + const response = await axios.post<{ + IsSucceed?: boolean; + Errors?: Array<{ Code?: string; Message?: string }>; + Result?: unknown; + }>( + inquiryConfig.url, + { + AccountOwnerType: accountOwnerType, + NationalId: nationalId, + LegalId: legalId, + ShebaId: shebaId, + }, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + timeout: this.hamtaConfig.timeout, + }, + ); + + if (!response.data.IsSucceed && response.data.Errors) { + throw this.formatProviderError( + this.formatErrors(response.data.Errors), + 'SAYAH_HAS_ERROR', + ); + } + + return { raw: response.data }; + } catch (error) { + if (error instanceof AxiosError) { + throw this.formatProviderError( + error.message, + String(error.response?.status ?? 'NETWORK_ERROR'), + ); + } + throw error; + } + } + + private buildCivilRegistrationEnvelope( + nationalCode: string, + birthDate: string, + username: string, + password: string, + ): string { + return ` + + + + ${this.escapeXml(nationalCode)} + ${this.escapeXml(birthDate)} + ${this.escapeXml(username)} + ${this.escapeXml(password)} + + +`; + } + private buildShahkarEnvelope( nationalCode: string, mobileNo: string, @@ -141,10 +331,13 @@ export class HamtaProvider extends LegacyApiProvider { if (typeof value !== 'string' || !value.trim()) { throw this.formatProviderError(undefined, undefined, `${fieldName} is required`); } - return value.trim(); } + private formatErrors(errors: Array<{ Code?: string; Message?: string }>): string { + return errors.map((e) => `${e.Code}: ${e.Message}`).join(', '); + } + private escapeXml(value: string): string { return value .replace(/&/g, '&') @@ -163,3 +356,5 @@ export class HamtaProvider extends LegacyApiProvider { .replace(/&/g, '&'); } } + +// Made with Bob diff --git a/src/providers/interfaces/provider-config.interface.ts b/src/providers/interfaces/provider-config.interface.ts index d9959f5..2620279 100644 --- a/src/providers/interfaces/provider-config.interface.ts +++ b/src/providers/interfaces/provider-config.interface.ts @@ -14,6 +14,10 @@ export interface LegacyProviderApiResponse { [key: string]: unknown; }; TrackingCode?: string; + // Sayah/Sheba API format + ReturnValue?: boolean; + HasError?: boolean; + Errors?: Record | null; } export type ProviderConfigSlice = ProviderEnvConfig; diff --git a/src/providers/shared/legacy-api.provider.abstract.ts b/src/providers/shared/legacy-api.provider.abstract.ts index 0befcbb..eb467ec 100644 --- a/src/providers/shared/legacy-api.provider.abstract.ts +++ b/src/providers/shared/legacy-api.provider.abstract.ts @@ -117,15 +117,33 @@ export abstract class LegacyApiProvider extends BaseProvider< ): Promise<{ raw: unknown }> { const inquiryConfig = this.getInquiryConfig(inquiryType); + // Transform payload for Real Estate inquiry to match CentInsur API format + const requestPayload = this.transformPayloadForInquiryType(inquiryType, payload); + try { const response = await this.httpClient.post( inquiryConfig.url, - payload, + requestPayload, await this.buildRequestConfig(inquiryType, inquiryConfig), ); const body = response.data; + // Handle Sayah/Sheba API format (ReturnValue/HasError) + if ('ReturnValue' in body || 'HasError' in body) { + if (body.HasError) { + // Format error message from Errors object + const errorMessages = body.Errors + ? Object.entries(body.Errors).map(([code, field]) => `${field} (code: ${code})`).join(', ') + : 'Request failed'; + throw this.formatProviderError( + errorMessages, + 'SAYAH_ERROR', + ); + } + return { raw: body }; + } + // Handle CentInsur API format (IsSucceed/Result) if ('IsSucceed' in body) { if (!body.IsSucceed) { @@ -149,6 +167,17 @@ export abstract class LegacyApiProvider extends BaseProvider< if (error instanceof AxiosError) { const data = error.response?.data as LegacyProviderApiResponse | undefined; + // Check for Sayah/Sheba format error + if (data && ('ReturnValue' in data || 'HasError' in data) && data.HasError) { + const errorMessages = data.Errors + ? Object.entries(data.Errors).map(([code, field]) => `${field} (code: ${code})`).join(', ') + : error.message; + throw this.formatProviderError( + errorMessages, + String(error.response?.status ?? 'SAYAH_ERROR'), + ); + } + // Check for CentInsur format error if (data && 'IsSucceed' in data && !data.IsSucceed) { throw this.formatProviderError( @@ -166,6 +195,25 @@ export abstract class LegacyApiProvider extends BaseProvider< } } + /** + * Transform payload to match provider-specific API format + * Real Estate inquiry uses NationalId/PostalCode instead of nationalCode/postalCode + */ + protected transformPayloadForInquiryType( + inquiryType: InquiryType, + payload: LegacyInquiryPayload, + ): LegacyInquiryPayload { + if (inquiryType === InquiryType.REAL_ESTATE) { + return { + NationalId: payload.nationalCode ?? payload.nationalId ?? payload.NationalId, + PostalCode: payload.postalCode ?? payload.PostalCode, + ...payload, + }; + } + + return payload; + } + protected getInquiryConfig(inquiryType: InquiryType): InquiryConfig { const inquiryConfig = this.providerConfig.inquiries[inquiryType];