final update on moallm client

This commit is contained in:
2026-06-15 10:23:00 +03:30
parent fac6142483
commit 1b7f678538
17 changed files with 394 additions and 70 deletions

View File

@@ -18,11 +18,11 @@ export class BaseInquiryResponseDto<T = Record<string, unknown>> {
@ApiPropertyOptional({ example: 'Inquiry completed successfully' })
message?: string;
@ApiPropertyOptional()
data?: T;
@ApiPropertyOptional({ nullable: true })
data?: T | null;
@ApiPropertyOptional({ type: NormalizedErrorDto })
error?: NormalizedErrorDto;
@ApiPropertyOptional({ type: NormalizedErrorDto, nullable: true })
error?: NormalizedErrorDto | null;
@ApiProperty({ example: 342, description: 'Duration in milliseconds' })
duration!: number;

View File

@@ -17,6 +17,12 @@ export class NormalizedErrorDto {
@ApiPropertyOptional({ example: '503' })
providerCode?: string;
@ApiPropertyOptional({
example: 'MjFiNDQwYjAtOTdkMy00YjFkLWEzN2UtMWEyOTk2NDE0MjRm',
description: 'Upstream provider reference code when available',
})
providerTrackingCode?: string;
@ApiPropertyOptional({
example: [{ field: 'sheba', constraints: ['sheba must start with IR and contain 24 digits'] }],
description: 'Field-level validation errors when code is VALIDATION_ERROR',

View File

@@ -6,6 +6,7 @@ export class InquiryException extends HttpException {
message: string,
public readonly normalizedError: NormalizedErrorDto,
status: HttpStatus = HttpStatus.UNPROCESSABLE_ENTITY,
public readonly provider?: string,
) {
super({ message, error: normalizedError }, status);
}

View File

@@ -7,8 +7,8 @@ import {
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
import { buildInquiryResponse } from '../helpers/inquiry-response.helper';
import { ProviderException } from '../exceptions/provider.exception';
import { isNormalizedError } from '../helpers/validation-error.helper';
@@ -39,14 +39,14 @@ export class AllExceptionsFilter implements ExceptionFilter {
exception instanceof Error ? exception.stack : undefined,
);
const body: BaseInquiryResponseDto = {
const body = buildInquiryResponse({
success: false,
provider: 'GATEWAY',
trackingCode,
message: normalizedError.message,
error: normalizedError,
duration: 0,
};
error: normalizedError,
});
response.status(status).json(body);
}

View File

@@ -0,0 +1,72 @@
import { InquiryType } from '../enums/inquiry-type.enum';
export interface CentInsurApiResponse {
IsSucceed?: boolean;
Result?: {
Result?: boolean;
ErrorMessage?: string | null;
[key: string]: unknown;
};
TrackingCode?: string;
message?: string;
code?: string;
success?: boolean;
}
export interface CentInsurProviderError {
message: string;
code: string;
providerTrackingCode?: string;
}
function getNoMatchMessage(inquiryType?: InquiryType): string {
switch (inquiryType) {
case InquiryType.REAL_ESTATE:
return 'No property ownership found for the provided national code and postal code';
default:
return 'Inquiry returned no matching result';
}
}
export function getCentInsurProviderError(
body: CentInsurApiResponse,
inquiryType?: InquiryType,
): CentInsurProviderError | null {
if (!('IsSucceed' in body)) {
return null;
}
const providerTrackingCode = body.TrackingCode?.trim() || undefined;
if (body.IsSucceed === false) {
return {
message: body.Result?.ErrorMessage?.trim() || 'Request failed',
code: 'API_ERROR',
providerTrackingCode,
};
}
if (body.Result?.Result === false) {
return {
message: body.Result?.ErrorMessage?.trim() || getNoMatchMessage(inquiryType),
code: 'INQUIRY_NO_MATCH',
providerTrackingCode,
};
}
return null;
}
export function describeCentInsurResponse(body: CentInsurApiResponse): string {
const innerResult =
body.Result && 'Result' in body.Result ? String(body.Result.Result) : 'undefined';
return [
`IsSucceed=${String(body.IsSucceed)}`,
`Result.Result=${innerResult}`,
`ErrorMessage=${body.Result?.ErrorMessage ?? 'none'}`,
body.TrackingCode ? `TrackingCode=${body.TrackingCode}` : null,
]
.filter(Boolean)
.join(' | ');
}

View File

@@ -0,0 +1,32 @@
import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
export function buildInquiryResponse<T = Record<string, unknown>>(params: {
success: boolean;
provider: string;
trackingCode: string;
message: string;
duration: number;
data?: T | null;
error?: NormalizedErrorDto | null;
}): BaseInquiryResponseDto<T> {
return {
success: params.success,
provider: params.provider,
trackingCode: params.trackingCode,
message: params.message,
duration: params.duration,
data: params.success ? (params.data ?? null) : null,
error: params.success ? null : (params.error ?? null),
};
}
export function normalizeInquiryResponse<T = Record<string, unknown>>(
response: BaseInquiryResponseDto<T>,
): BaseInquiryResponseDto<T> {
return {
...response,
data: response.success ? (response.data ?? null) : null,
error: response.success ? null : (response.error ?? null),
};
}

View File

@@ -0,0 +1,124 @@
function decodeXml(value: string): string {
return value
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/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());
}
export interface ShahkarInquiryFields {
response?: string;
result?: string;
comment?: string;
requestId?: string;
id?: string;
ErrorNams?: string;
}
export function parseShahkarInqueryResult(soapXml: string): ShahkarInquiryFields {
const blockMatch = soapXml.match(
/<(?:[\w]+:)?ShahkarInqueryResult[^>]*>([\s\S]*?)<\/(?:[\w]+:)?ShahkarInqueryResult>/i,
);
if (!blockMatch) {
return {};
}
const fields: Record<string, string> = {};
const tagRegex = /<(?:[\w]+:)?(\w+)(?:[^>]*)>([\s\S]*?)<\/(?:[\w]+:)?\1>/gi;
let match: RegExpExecArray | null;
while ((match = tagRegex.exec(blockMatch[1])) !== null) {
fields[match[1]] = normalizeSoapTextValue(match[2]);
}
return fields;
}
export function normalizeShahkarFields(raw: Record<string, unknown>): ShahkarInquiryFields {
const readString = (value: unknown): string | undefined => {
if (value === null || value === undefined) {
return undefined;
}
const text = String(value).trim();
return text.length > 0 ? text : undefined;
};
return {
response: readString(raw.response ?? raw.Response),
result: readString(raw.result ?? raw.Result),
comment: readString(raw.comment ?? raw.Comment),
requestId: readString(raw.requestId ?? raw.RequestId),
id: readString(raw.id ?? raw.Id),
ErrorNams: readString(raw.ErrorNams ?? raw.errorNams),
};
}
export function isShahkarSuccess(fields: ShahkarInquiryFields): boolean {
const responseCode = fields.response?.trim();
const resultText = fields.result?.trim().toUpperCase() ?? '';
return responseCode === '200' && resultText.startsWith('OK');
}
export function getShahkarProviderError(
fields: ShahkarInquiryFields,
): { message: string; code: string; providerCode?: string; providerTrackingCode?: string } | null {
if (isShahkarSuccess(fields)) {
return null;
}
const comment = fields.comment?.trim();
const errorNams = fields.ErrorNams?.trim();
const resultText = fields.result?.trim();
const responseCode = fields.response?.trim();
const providerTrackingCode = fields.requestId?.trim() || undefined;
if (resultText === 'NotIdentifiedException' || responseCode === '600') {
return {
message:
comment ||
'No match found between the provided national code and mobile number',
code: 'INQUIRY_NO_MATCH',
providerCode: resultText ?? responseCode,
providerTrackingCode,
};
}
return {
message: comment || errorNams || resultText || 'Shahkar inquiry failed',
code: 'PROVIDER_ERROR',
providerCode: resultText ?? responseCode,
providerTrackingCode,
};
}
export function describeShahkarResponse(fields: ShahkarInquiryFields): string {
return [
fields.response ? `response=${fields.response}` : null,
fields.result ? `result=${fields.result}` : null,
fields.comment ? `comment=${fields.comment}` : null,
fields.requestId ? `requestId=${fields.requestId}` : null,
]
.filter(Boolean)
.join(' | ');
}

View File

@@ -5,6 +5,7 @@ import {
NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
import { RequestLogger } from '../helpers/request-logger.helper';
import { formatHttpExceptionMessage } from '../helpers/validation-error.helper';
@@ -25,11 +26,21 @@ export class LoggingInterceptor implements NestInterceptor {
return next.handle().pipe(
tap({
next: () => {
this.requestLogger.logSuccess(
{ requestId: req.requestId ?? 'unknown' },
`${req.method} ${req.url} completed in ${Date.now() - start}ms`,
);
next: (body: unknown) => {
const durationMs = Date.now() - start;
const requestId = req.requestId ?? 'unknown';
const summary = `${req.method} ${req.url} completed in ${durationMs}ms`;
if (this.isBusinessFailure(body)) {
const response = body as BaseInquiryResponseDto;
this.requestLogger.logFailure(
{ requestId, durationMs },
`${summary} | businessSuccess=false | ${response.message ?? response.error?.code ?? 'failed'}`,
);
return;
}
this.requestLogger.logSuccess({ requestId }, summary);
},
error: (err: unknown) => {
const detail = formatHttpExceptionMessage(err);
@@ -42,4 +53,13 @@ export class LoggingInterceptor implements NestInterceptor {
}),
);
}
private isBusinessFailure(body: unknown): body is BaseInquiryResponseDto {
return Boolean(
body &&
typeof body === 'object' &&
'success' in body &&
(body as BaseInquiryResponseDto).success === false,
);
}
}

View File

@@ -7,6 +7,7 @@ import {
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
import { normalizeInquiryResponse } from '../helpers/inquiry-response.helper';
/**
* Ensures inquiry endpoints always emit BaseInquiryResponseDto shape.
@@ -15,7 +16,7 @@ import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
export class ResponseTransformInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(
map((data: BaseInquiryResponseDto) => data),
map((data: BaseInquiryResponseDto) => normalizeInquiryResponse(data)),
);
}
}

View File

@@ -16,9 +16,23 @@ export class SayahRequestDto {
@Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
nationalCode!: string;
@ApiPropertyOptional({ nullable: true })
@ApiPropertyOptional({
type: String,
example: '',
nullable: true,
description: 'Legal entity ID; use empty string for natural persons',
})
@IsOptional()
@IsString()
@Transform(({ value }: { value: unknown }) => {
if (value === null || value === undefined) {
return value;
}
if (typeof value === 'object') {
return '';
}
return String(value);
})
legalId?: string | null;
@ApiProperty({

View File

@@ -5,6 +5,7 @@ import { ProviderName } from '../common/enums/provider-name.enum';
import { BaseInquiryResponseDto } from '../common/dto/base-inquiry-response.dto';
import { NormalizedErrorDto } from '../common/dto/normalized-error.dto';
import { generateTrackingCode } from '../common/helpers/tracking-code.helper';
import { buildInquiryResponse } from '../common/helpers/inquiry-response.helper';
import { InquiryException } from '../common/exceptions/inquiry.exception';
import { InquiryLogService } from '../logging/inquiry-log.service';
import {
@@ -54,19 +55,20 @@ export class InquiryService {
trackingCode,
);
const response = this.buildSuccessResponse(
result.data,
result.provider,
const response = buildInquiryResponse({
success: true,
provider: result.provider,
trackingCode,
result.duration,
`${this.getInquiryLabel(inquiryType)} completed successfully`,
);
message: `${this.getInquiryLabel(inquiryType)} completed successfully`,
duration: result.duration,
data: this.toResponseData(result.data),
});
await this.persistLog({
inquiryType,
provider: result.provider,
requestPayload: payload,
responsePayload: response.data,
responsePayload: response.data ?? undefined,
status: InquiryStatus.SUCCESS,
duration: result.duration,
requestId,
@@ -76,20 +78,20 @@ export class InquiryService {
return response;
} catch (error) {
const duration = Date.now() - start;
const normalized = this.extractError(error);
const { normalized, provider } = this.extractFailure(error);
const response: BaseInquiryResponseDto<Record<string, unknown>> = {
const response = buildInquiryResponse({
success: false,
provider: 'GATEWAY',
provider: provider ?? 'GATEWAY',
trackingCode,
message: normalized.message,
error: normalized,
duration,
};
error: normalized,
});
await this.persistLog({
inquiryType,
provider: ProviderName.HAMTA,
provider: (provider as ProviderName | undefined) ?? ProviderName.HAMTA,
requestPayload: payload,
status: InquiryStatus.FAILURE,
duration,
@@ -114,23 +116,6 @@ export class InquiryService {
});
}
private buildSuccessResponse(
result: unknown,
provider: string,
trackingCode: string,
duration: number,
message: string,
): BaseInquiryResponseDto<Record<string, unknown>> {
return {
success: true,
provider,
trackingCode,
message,
duration,
data: this.toResponseData(result),
};
}
private toResponseData(result: unknown): Record<string, unknown> {
if (this.isPersonInquiryResult(result)) {
const raw =
@@ -169,16 +154,23 @@ export class InquiryService {
return inquiryType.replace(/_INQUIRY$/, '').toLowerCase().replace(/_/g, ' ');
}
private extractError(error: unknown): NormalizedErrorDto {
private extractFailure(error: unknown): {
normalized: NormalizedErrorDto;
provider?: string;
} {
if (error instanceof InquiryException) {
return error.normalizedError;
return { normalized: error.normalizedError, provider: error.provider };
}
if (error && typeof error === 'object' && 'normalizedError' in error) {
return (error as { normalizedError: NormalizedErrorDto }).normalizedError;
return {
normalized: (error as { normalizedError: NormalizedErrorDto }).normalizedError,
};
}
return {
code: 'INQUIRY_FAILED',
message: error instanceof Error ? error.message : 'Inquiry failed',
normalized: {
code: 'INQUIRY_FAILED',
message: error instanceof Error ? error.message : 'Inquiry failed',
},
};
}

View File

@@ -126,6 +126,8 @@ export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
message: partial.message ?? 'Provider request failed',
providerMessage: partial.providerMessage,
providerCode: partial.providerCode,
providerTrackingCode: partial.providerTrackingCode,
details: partial.details,
};
}
@@ -133,6 +135,7 @@ export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
providerMessage?: string,
providerCode?: string,
fallbackMessage = 'Provider request failed',
extras?: Pick<NormalizedErrorDto, 'providerTrackingCode' | 'details'>,
): Error {
const message = providerMessage?.trim() || fallbackMessage;
const code = providerCode?.trim() || 'PROVIDER_ERROR';
@@ -141,6 +144,7 @@ export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
message,
providerMessage: message,
providerCode: code,
...extras,
});
const err = new Error(message);
(err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized;

View File

@@ -6,6 +6,11 @@ import {
getSayahProviderError,
SayahApiResponse,
} from '../../common/helpers/sayah-response.helper';
import {
describeShahkarResponse,
getShahkarProviderError,
parseShahkarInqueryResult,
} from '../../common/helpers/shahkar-response.helper';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
import { ProviderEnvConfig } from '../../config/configuration';
@@ -210,14 +215,22 @@ export class HamtaProvider extends LegacyApiProvider {
}),
);
const result = this.extractSoapValue(response.data, 'ShahkarInqueryResult');
const shahkarFields = parseShahkarInqueryResult(response.data);
const providerError = getShahkarProviderError(shahkarFields);
if (providerError) {
this.nestLogger.warn(
`SHAHKAR provider business failure | ${describeShahkarResponse(shahkarFields)}`,
);
throw this.formatProviderError(providerError.message, providerError.code, undefined, {
providerTrackingCode: providerError.providerTrackingCode,
});
}
return {
raw: {
nationalCode,
mobileNo,
result,
soap: response.data,
...shahkarFields,
},
};
} catch (error) {

View File

@@ -7,6 +7,11 @@ import {
getCivilRegistrationProviderError,
parseCiiEstelamResult,
} from '../../common/helpers/soap-civil-registration.helper';
import {
getShahkarProviderError,
parseShahkarInqueryResult,
describeShahkarResponse,
} from '../../common/helpers/shahkar-response.helper';
import {
getSayahProviderError,
SayahApiResponse,
@@ -218,14 +223,22 @@ export class MoallemProvider extends LegacyApiProvider {
}),
);
const result = this.extractSoapValue(response.data, 'ShahkarInqueryResult');
const shahkarFields = parseShahkarInqueryResult(response.data);
const providerError = getShahkarProviderError(shahkarFields);
if (providerError) {
this.nestLogger.warn(
`SHAHKAR provider business failure | ${describeShahkarResponse(shahkarFields)}`,
);
throw this.formatProviderError(providerError.message, providerError.code, undefined, {
providerTrackingCode: providerError.providerTrackingCode,
});
}
return {
raw: {
nationalCode,
mobileNo,
result,
soap: response.data,
...shahkarFields,
},
};
} catch (error) {

View File

@@ -2,10 +2,12 @@ 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 {
getSayahProviderError,
SayahApiResponse,
} from '../../common/helpers/sayah-response.helper';
describeShahkarResponse,
getShahkarProviderError,
normalizeShahkarFields,
} from '../../common/helpers/shahkar-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';
@@ -192,11 +194,22 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
);
const body = response.data;
const shahkarFields = normalizeShahkarFields(body as unknown as Record<string, unknown>);
const providerError = getShahkarProviderError(shahkarFields);
if (providerError) {
this.nestLogger.warn(
`SHAHKAR provider business failure | ${describeShahkarResponse(shahkarFields)}`,
);
throw this.formatProviderError(providerError.message, providerError.code, undefined, {
providerTrackingCode: providerError.providerTrackingCode,
});
}
return {
raw: {
nationalCode,
mobileNumber,
...body,
...shahkarFields,
},
};
} catch (error) {

View File

@@ -1,5 +1,10 @@
import { AxiosError, AxiosInstance } from 'axios';
import { createOutboundAxiosInstance } from '../../common/helpers/http-client.helper';
import {
CentInsurApiResponse,
describeCentInsurResponse,
getCentInsurProviderError,
} from '../../common/helpers/centinsur-response.helper';
import { getSayahProviderError } from '../../common/helpers/sayah-response.helper';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
@@ -131,6 +136,12 @@ export abstract class LegacyApiProvider extends BaseProvider<
const body = response.data;
if ('IsSucceed' in body) {
this.nestLogger.log(
`${inquiryType} provider response | http=${response.status} | ${describeCentInsurResponse(body)}`,
);
}
// Handle Sayah/Sheba API format (ReturnValue/HasError)
if ('ReturnValue' in body || 'HasError' in body) {
const providerError = getSayahProviderError(body);
@@ -142,11 +153,14 @@ export abstract class LegacyApiProvider extends BaseProvider<
// Handle CentInsur API format (IsSucceed/Result)
if ('IsSucceed' in body) {
if (!body.IsSucceed) {
throw this.formatProviderError(
body.Result?.ErrorMessage ?? 'Request failed',
'API_ERROR',
const providerError = getCentInsurProviderError(body, inquiryType);
if (providerError) {
this.nestLogger.warn(
`${inquiryType} provider business failure | ${describeCentInsurResponse(body)}`,
);
throw this.formatProviderError(providerError.message, providerError.code, undefined, {
providerTrackingCode: providerError.providerTrackingCode,
});
}
return { raw: body };
}
@@ -172,11 +186,13 @@ export abstract class LegacyApiProvider extends BaseProvider<
}
// Check for CentInsur format error
if (data && 'IsSucceed' in data && !data.IsSucceed) {
throw this.formatProviderError(
data.Result?.ErrorMessage ?? error.message,
String(error.response?.status ?? 'API_ERROR'),
);
if (data && 'IsSucceed' in data) {
const providerError = getCentInsurProviderError(data, inquiryType);
if (providerError) {
throw this.formatProviderError(providerError.message, providerError.code, undefined, {
providerTrackingCode: providerError.providerTrackingCode,
});
}
}
throw this.formatProviderError(

View File

@@ -71,9 +71,10 @@ export class ProviderOrchestratorService {
const lastError = errors[errors.length - 1]!;
if (providers.length === 1) {
throw new InquiryException(lastError.message, lastError);
throw new InquiryException(lastError.message, lastError, undefined, providers[0]!.name);
}
const lastProvider = providers[providers.length - 1]!.name;
throw new InquiryException(
errors.map((error) => error.message).join('; '),
{
@@ -82,6 +83,8 @@ export class ProviderOrchestratorService {
providerMessage: lastError.providerMessage ?? lastError.message,
providerCode: lastError.providerCode ?? lastError.code,
},
undefined,
lastProvider,
);
}