Merge pull request 'main' (#2) from s.hajizadeh/esg:main into main

Reviewed-on: Shared/esg#2
This commit is contained in:
2026-06-10 15:27:34 +03:30
5 changed files with 286 additions and 36 deletions

View File

@@ -2,9 +2,9 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsNotEmpty, IsOptional, IsString, Length, Matches } from 'class-validator'; import { IsIn, IsNotEmpty, IsOptional, IsString, Length, Matches } from 'class-validator';
export class SayahRequestDto { export class SayahRequestDto {
@ApiProperty({ example: '0', description: '0 for natural person' }) @ApiProperty({ example: '1', description: '1 for natural person (always use "1")' })
@IsString() @IsString()
@IsIn(['0']) @IsIn(['1'])
accountOwnerType!: string; accountOwnerType!: string;
@ApiProperty({ example: '4311402422', description: 'Iranian national code (10 digits)' }) @ApiProperty({ example: '4311402422', description: 'Iranian national code (10 digits)' })
@@ -19,7 +19,7 @@ export class SayahRequestDto {
@IsString() @IsString()
legalId?: string | null; legalId?: string | null;
@ApiProperty({ example: 'IR000000000000000000000000', description: 'Iranian Sheba ID' }) @ApiProperty({ example: 'IR800560611828005105117001', description: 'Iranian Sheba ID (26 characters)' })
@IsString() @IsString()
@Length(26, 26) @Length(26, 26)
@Matches(/^IR\d{24}$/, { message: 'shebaId must start with IR and contain 24 digits' }) @Matches(/^IR\d{24}$/, { message: 'shebaId must start with IR and contain 24 digits' })

View File

@@ -61,7 +61,9 @@ export class AmitisProvider {
/** /**
* Get access token for a specific provider and inquiry type * 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( async getAccessToken(
providerName: ProviderName, providerName: ProviderName,
@@ -72,13 +74,13 @@ export class AmitisProvider {
const tokenKey = this.getTokenKey(providerName, inquiryType); const tokenKey = this.getTokenKey(providerName, inquiryType);
const latestToken = await this.generalTokenService.getLatestToken(tokenKey); const latestToken = await this.generalTokenService.getLatestToken(tokenKey);
// Use cached token if valid and from same day // Use cached token if valid and within 24-hour window
if (latestToken && !this.isExpired(latestToken) && this.isSameTokenDay(latestToken)) { if (latestToken && !this.isExpired(latestToken) && this.isWithin24Hours(latestToken)) {
return latestToken.accessToken; return latestToken.accessToken;
} }
// Try to refresh if we have a refresh token and it's from the same day // Try to refresh if we have a refresh token and it's within 24 hours
if (latestToken?.refreshToken && this.isSameTokenDay(latestToken)) { if (latestToken?.refreshToken && this.isWithin24Hours(latestToken)) {
try { try {
return await this.refreshAccessToken(latestToken, providerName, inquiryType); return await this.refreshAccessToken(latestToken, providerName, inquiryType);
} catch (error) { } catch (error) {
@@ -100,18 +102,19 @@ export class AmitisProvider {
username: string, username: string,
password: string, password: string,
): Promise<string> { ): Promise<string> {
// Use URLSearchParams for application/x-www-form-urlencoded format // Use FormData for multipart/form-data format
const formData = new URLSearchParams(); const FormData = require('form-data');
const formData = new FormData();
formData.append('username', username); formData.append('username', username);
formData.append('password', password); formData.append('password', password);
try { try {
const response = await this.httpClient.post<CentInsurTokenResponse>( const response = await this.httpClient.post<CentInsurTokenResponse>(
this.config.loginPath, this.config.loginPath,
formData.toString(), formData,
{ {
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', ...formData.getHeaders(),
}, },
}, },
); );
@@ -198,25 +201,25 @@ export class AmitisProvider {
} }
private isExpired(token: GeneralTokenDocument): boolean { private isExpired(token: GeneralTokenDocument): boolean {
// Access token expires in 20 minutes, add 60 second buffer
const refreshBufferMs = 60 * 1000; const refreshBufferMs = 60 * 1000;
return !token.expiresAt || Date.now() + refreshBufferMs >= token.expiresAt.getTime(); 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) { if (!token.createdAt) {
return false; return false;
} }
return this.getDateKey(token.createdAt) === this.getDateKey(new Date()); const tokenCreationTime = token.createdAt.getTime();
} const currentTime = Date.now();
const twentyFourHoursMs = 24 * 60 * 60 * 1000;
private getDateKey(date: Date): string { return currentTime - tokenCreationTime < twentyFourHoursMs;
return new Intl.DateTimeFormat('en-CA', {
timeZone: this.config.tokenTimeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
} }
private getTokenKey(providerName: ProviderName, inquiryType: InquiryType): string { private getTokenKey(providerName: ProviderName, inquiryType: InquiryType): string {

View File

@@ -4,15 +4,28 @@ import axios, { AxiosError } from 'axios';
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 { ProviderEnvConfig } from '../../config/configuration'; import { ProviderEnvConfig } from '../../config/configuration';
import { AmitisProvider } from './amitis.provider';
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface'; import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
import { AmitisProvider } from './amitis.provider';
import { import {
LegacyApiProvider,
LegacyInquiryPayload, LegacyInquiryPayload,
LegacyInquiryResult, LegacyInquiryResult,
LegacyApiProvider,
} from '../shared/legacy-api.provider.abstract'; } 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; nationalCode?: string;
nationalCod?: string; nationalCod?: string;
NationalCod?: string; NationalCod?: string;
@@ -20,20 +33,24 @@ interface ShahkarInquiryPayload extends LegacyInquiryPayload {
MobileNo?: string; MobileNo?: string;
} }
interface SayahPayload extends LegacyInquiryPayload {
accountOwnerType?: string;
nationalId?: string;
legalId?: string | null;
shebaId?: string;
}
/** /**
* Hamta provider — uses shared LegacyApiProvider base. * Hamta provider — implements Hamta/CentInsur REST and SOAP inquiry services.
* Supports multiple inquiry types with different authentication methods.
*/ */
@Injectable() @Injectable()
export class HamtaProvider extends LegacyApiProvider { export class HamtaProvider extends LegacyApiProvider {
readonly name = ProviderName.HAMTA; readonly name = ProviderName.HAMTA;
readonly supportedInquiryTypes = [ readonly supportedInquiryTypes = [
InquiryType.PERSON, InquiryType.PERSON,
InquiryType.REAL_ESTATE,
InquiryType.SHEBA, InquiryType.SHEBA,
InquiryType.SHAHKAR, InquiryType.SHAHKAR,
InquiryType.POSTAL_CODE, InquiryType.POSTAL_CODE,
InquiryType.LEGAL_PERSON,
]; ];
private readonly hamtaConfig: ProviderEnvConfig; private readonly hamtaConfig: ProviderEnvConfig;
@@ -53,15 +70,113 @@ export class HamtaProvider extends LegacyApiProvider {
payload: LegacyInquiryPayload, payload: LegacyInquiryPayload,
context: ProviderExecutionContext, context: ProviderExecutionContext,
): Promise<LegacyInquiryResult> { ): Promise<LegacyInquiryResult> {
// 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) { 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); 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<Record<string, unknown>>(
`${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<string>(
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( const nationalCode = this.getRequiredString(
payload.nationalCode ?? payload.nationalCod ?? payload.NationalCod, payload.nationalCode ?? payload.nationalCod ?? payload.NationalCod,
'nationalCode', 'nationalCode',
@@ -106,11 +221,86 @@ export class HamtaProvider extends LegacyApiProvider {
String(error.response?.status ?? 'NETWORK_ERROR'), String(error.response?.status ?? 'NETWORK_ERROR'),
); );
} }
throw 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 `<?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>
<SubmitInqDteStsWithPstCod xmlns="http://tempuri.org/">
<NIN>${this.escapeXml(nationalCode)}</NIN>
<BirthDate>${this.escapeXml(birthDate)}</BirthDate>
<Username>${this.escapeXml(username)}</Username>
<Password>${this.escapeXml(password)}</Password>
</SubmitInqDteStsWithPstCod>
</soap:Body>
</soap:Envelope>`;
}
private buildShahkarEnvelope( private buildShahkarEnvelope(
nationalCode: string, nationalCode: string,
mobileNo: string, mobileNo: string,
@@ -141,10 +331,13 @@ export class HamtaProvider extends LegacyApiProvider {
if (typeof value !== 'string' || !value.trim()) { if (typeof value !== 'string' || !value.trim()) {
throw this.formatProviderError(undefined, undefined, `${fieldName} is required`); throw this.formatProviderError(undefined, undefined, `${fieldName} is required`);
} }
return value.trim(); 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 { private escapeXml(value: string): string {
return value return value
.replace(/&/g, '&') .replace(/&/g, '&')
@@ -163,3 +356,5 @@ export class HamtaProvider extends LegacyApiProvider {
.replace(/&/g, '&'); .replace(/&/g, '&');
} }
} }
// Made with Bob

View File

@@ -14,6 +14,10 @@ export interface LegacyProviderApiResponse {
[key: string]: unknown; [key: string]: unknown;
}; };
TrackingCode?: string; TrackingCode?: string;
// Sayah/Sheba API format
ReturnValue?: boolean;
HasError?: boolean;
Errors?: Record<string, string> | null;
} }
export type ProviderConfigSlice = ProviderEnvConfig; export type ProviderConfigSlice = ProviderEnvConfig;

View File

@@ -117,15 +117,33 @@ export abstract class LegacyApiProvider extends BaseProvider<
): Promise<{ raw: unknown }> { ): Promise<{ raw: unknown }> {
const inquiryConfig = this.getInquiryConfig(inquiryType); const inquiryConfig = this.getInquiryConfig(inquiryType);
// Transform payload for Real Estate inquiry to match CentInsur API format
const requestPayload = this.transformPayloadForInquiryType(inquiryType, payload);
try { try {
const response = await this.httpClient.post<LegacyProviderApiResponse>( const response = await this.httpClient.post<LegacyProviderApiResponse>(
inquiryConfig.url, inquiryConfig.url,
payload, requestPayload,
await this.buildRequestConfig(inquiryType, inquiryConfig), await this.buildRequestConfig(inquiryType, inquiryConfig),
); );
const body = response.data; 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) // Handle CentInsur API format (IsSucceed/Result)
if ('IsSucceed' in body) { if ('IsSucceed' in body) {
if (!body.IsSucceed) { if (!body.IsSucceed) {
@@ -149,6 +167,17 @@ export abstract class LegacyApiProvider extends BaseProvider<
if (error instanceof AxiosError) { if (error instanceof AxiosError) {
const data = error.response?.data as LegacyProviderApiResponse | undefined; 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 // Check for CentInsur format error
if (data && 'IsSucceed' in data && !data.IsSucceed) { if (data && 'IsSucceed' in data && !data.IsSucceed) {
throw this.formatProviderError( 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 { protected getInquiryConfig(inquiryType: InquiryType): InquiryConfig {
const inquiryConfig = this.providerConfig.inquiries[inquiryType]; const inquiryConfig = this.providerConfig.inquiries[inquiryType];