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

Reviewed-on: Shared/esg#3
This commit is contained in:
2026-06-14 12:19:37 +03:30
25 changed files with 1062 additions and 81 deletions

View File

@@ -19,12 +19,27 @@ THROTTLE_TTL=60
THROTTLE_LIMIT=100 THROTTLE_LIMIT=100
# Provider routing (per inquiry type) # Provider routing (per inquiry type)
PERSON_DEFAULT_PROVIDER=HAMTA PERSON_DEFAULT_PROVIDER=PARSIAN
PERSON_FALLBACK_ENABLED=true PERSON_FALLBACK_ENABLED=true
PERSON_FALLBACK_PROVIDERS=MOALLEM,TEJARATNOU PERSON_FALLBACK_PROVIDERS=HAMTA,TEJARATNOU
SHAHKAR_DEFAULT_PROVIDER=PARSIAN
SHAHKAR_FALLBACK_ENABLED=false
SHAHKAR_FALLBACK_PROVIDERS=
SHEBA_DEFAULT_PROVIDER=PARSIAN
SHEBA_FALLBACK_ENABLED=false
SHEBA_FALLBACK_PROVIDERS=
CAR_PLATE_DEFAULT_PROVIDER=HAMTA CAR_PLATE_DEFAULT_PROVIDER=HAMTA
CAR_PLATE_FALLBACK_ENABLED=true CAR_PLATE_FALLBACK_ENABLED=true
CAR_PLATE_FALLBACK_PROVIDERS= CAR_PLATE_FALLBACK_PROVIDERS=
POLICY_BY_CHASSIS_DEFAULT_PROVIDER=PARSIAN
POLICY_BY_CHASSIS_FALLBACK_ENABLED=false
POLICY_BY_CHASSIS_FALLBACK_PROVIDERS=
POLICY_BY_PLATE_DEFAULT_PROVIDER=PARSIAN
POLICY_BY_PLATE_FALLBACK_ENABLED=false
POLICY_BY_PLATE_FALLBACK_PROVIDERS=
POLICY_BY_NATIONAL_CODE_DEFAULT_PROVIDER=PARSIAN
POLICY_BY_NATIONAL_CODE_FALLBACK_ENABLED=false
POLICY_BY_NATIONAL_CODE_FALLBACK_PROVIDERS=
# Hamta provider # Hamta provider
HAMTA_BASE_URL=https://api.hamta.example.com HAMTA_BASE_URL=https://api.hamta.example.com
@@ -34,6 +49,10 @@ HAMTA_SECRET_KEY=
HAMTA_TIMEOUT=10000 HAMTA_TIMEOUT=10000
HAMTA_ENABLED=true HAMTA_ENABLED=true
HAMTA_MAX_RETRIES=2 HAMTA_MAX_RETRIES=2
HAMTA_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out
HAMTA_PERSON_USERNAME=
HAMTA_PERSON_PASSWORD=
HAMTA_PERSON_AUTH_METHOD=SOAP
# Moallem provider (identical API structure to Hamta) # Moallem provider (identical API structure to Hamta)
MOALLEM_BASE_URL=https://api.moallem.example.com MOALLEM_BASE_URL=https://api.moallem.example.com
@@ -44,6 +63,34 @@ MOALLEM_TIMEOUT=10000
MOALLEM_ENABLED=true MOALLEM_ENABLED=true
MOALLEM_MAX_RETRIES=2 MOALLEM_MAX_RETRIES=2
# Parsian provider
PARSIAN_ENABLED=true
PARSIAN_TIMEOUT=10000
PARSIAN_MAX_RETRIES=2
PARSIAN_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out
PARSIAN_PERSON_USERNAME=
PARSIAN_PERSON_PASSWORD=
PARSIAN_PERSON_AUTH_METHOD=SOAP
PARSIAN_SHAHKAR_URL=https://apigateway.parsianinsurance.com/shahkarinqOut
PARSIAN_SHAHKAR_API_KEY=
PARSIAN_SHAHKAR_AUTH_METHOD=NONE
PARSIAN_SHEBA_URL=https://sayah.services.centinsur.ir/api/Cisb/TatbighServiceAsync
PARSIAN_SHEBA_USERNAME=
PARSIAN_SHEBA_PASSWORD=
PARSIAN_SHEBA_AUTH_METHOD=AMITIS
PARSIAN_POLICY_BY_CHASSIS_URL=http://reinsure.centinsur.ir/CarAllPlcysV4
PARSIAN_POLICY_BY_CHASSIS_USERNAME=
PARSIAN_POLICY_BY_CHASSIS_PASSWORD=
PARSIAN_POLICY_BY_CHASSIS_AUTH_METHOD=SOAP
PARSIAN_POLICY_BY_PLATE_URL=http://reinsure.centinsur.ir/CarAllPlcysV4
PARSIAN_POLICY_BY_PLATE_USERNAME=
PARSIAN_POLICY_BY_PLATE_PASSWORD=
PARSIAN_POLICY_BY_PLATE_AUTH_METHOD=SOAP
PARSIAN_POLICY_BY_NATIONAL_CODE_URL=http://reinsure.centinsur.ir/CarAllPlcysV4
PARSIAN_POLICY_BY_NATIONAL_CODE_USERNAME=
PARSIAN_POLICY_BY_NATIONAL_CODE_PASSWORD=
PARSIAN_POLICY_BY_NATIONAL_CODE_AUTH_METHOD=SOAP
# TejaratNou provider # TejaratNou provider
TEJARATNOU_BASE_URL=https://accounts.tejaratnoins.ir TEJARATNOU_BASE_URL=https://accounts.tejaratnoins.ir
TEJARATNOU_INQUIRY_BASE_URL=https://gateway.tejaratnoins.ir TEJARATNOU_INQUIRY_BASE_URL=https://gateway.tejaratnoins.ir

View File

@@ -111,14 +111,14 @@ Per-inquiry configuration via environment variables:
```env ```env
# Default provider and fallback chain # Default provider and fallback chain
PERSON_DEFAULT_PROVIDER=HAMTA PERSON_DEFAULT_PROVIDER=PARSIAN
PERSON_FALLBACK_PROVIDERS=MOALLEM PERSON_FALLBACK_PROVIDERS=HAMTA,TEJARATNOU
# Per-inquiry credentials and authentication # Per-inquiry credentials and authentication
HAMTA_PERSON_URL=https://api.example.com/person PARSIAN_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out
HAMTA_PERSON_USERNAME=user123 PARSIAN_PERSON_USERNAME=user123
HAMTA_PERSON_PASSWORD=pass123 PARSIAN_PERSON_PASSWORD=pass123
HAMTA_PERSON_AUTH_METHOD=AMITIS PARSIAN_PERSON_AUTH_METHOD=SOAP
# AMITIS authentication service # AMITIS authentication service
AMITIS_LOGIN_URL=https://auth.services.centinsur.ir/api/security/login AMITIS_LOGIN_URL=https://auth.services.centinsur.ir/api/security/login
@@ -131,13 +131,16 @@ See [Environment Configuration Guide](docs/ENVIRONMENT_CONFIGURATION.md) for com
| Inquiry Type | Providers | Description | | Inquiry Type | Providers | Description |
|--------------|-----------|-------------| |--------------|-----------|-------------|
| `PERSON_INQUIRY` | HAMTA, MOALLEM | Person information by national code and birth date | | `PERSON_INQUIRY` | PARSIAN, HAMTA, TEJARATNOU, MOALLEM | Person information by national code and birth date |
| `REAL_ESTATE_INQUIRY` | HAMTA | Real estate ownership information | | `REAL_ESTATE_INQUIRY` | HAMTA | Real estate ownership information |
| `POSTAL_CODE_INQUIRY` | HAMTA, MOALLEM | Address validation by postal code | | `POSTAL_CODE_INQUIRY` | HAMTA, MOALLEM | Address validation by postal code |
| `SHAHKAR_INQUIRY` | HAMTA, MOALLEM | Mobile number verification | | `SHAHKAR_INQUIRY` | PARSIAN, HAMTA, MOALLEM | Mobile number verification |
| `CIVIL_REGISTRATION_INQUIRY` | MOALLEM | Civil registration data | | `CIVIL_REGISTRATION_INQUIRY` | MOALLEM | Civil registration data |
| `SHEBA_INQUIRY` | MOALLEM | Bank account (SHEBA) validation | | `SHEBA_INQUIRY` | PARSIAN, HAMTA, MOALLEM | Bank account (SHEBA) validation |
| `SAYAH_INQUIRY` | TEJARATNOU | Sayah system inquiry | | `SAYAH_INQUIRY` | TEJARATNOU | Sayah system inquiry |
| `POLICY_BY_CHASSIS_INQUIRY` | PARSIAN | Car policy history by chassis number |
| `POLICY_BY_PLATE_INQUIRY` | PARSIAN | Car policy history by national plate |
| `POLICY_BY_NATIONAL_CODE_INQUIRY` | PARSIAN | Car policy history by national code |
See [API Documentation](docs/API_DOCUMENTATION.md) for detailed request/response formats. See [API Documentation](docs/API_DOCUMENTATION.md) for detailed request/response formats.

View File

@@ -352,9 +352,10 @@ POST /api/inquiry/sheba
### Request Body ### Request Body
```json ```json
{ {
"accountOwnerType": "REAL", "accountOwnerType": "1",
"nationalId": "0123456789", "nationalCode": "0123456789",
"shebaId": "IR123456789012345678901234" "legalId": null,
"sheba": "IR123456789012345678901234"
} }
``` ```
@@ -362,10 +363,10 @@ POST /api/inquiry/sheba
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|----------|-------------| |-------|------|----------|-------------|
| accountOwnerType | string | Yes | Account owner type: "REAL" (person) or "LEGAL" (company) | | accountOwnerType | string | No | Account owner type; defaults to `"1"` in providers |
| nationalId | string | Conditional | Required if accountOwnerType is "REAL" | | nationalCode | string | Yes | Iranian national code (10 digits) |
| legalId | string | Conditional | Required if accountOwnerType is "LEGAL" | | legalId | string | No | Legal identifier when needed by the selected account owner type |
| shebaId | string | Yes | SHEBA/IBAN number (26 characters starting with IR) | | sheba | string | Yes | SHEBA/IBAN number (26 characters starting with IR) |
### Success Response (200 OK) ### Success Response (200 OK)
@@ -398,12 +399,57 @@ curl -X POST https://api.example.com/api/inquiry/sheba \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \ -H "X-API-Key: your-api-key-here" \
-d '{ -d '{
"accountOwnerType": "REAL", "accountOwnerType": "1",
"nationalId": "0123456789", "nationalCode": "0123456789",
"shebaId": "IR123456789012345678901234" "sheba": "IR123456789012345678901234"
}' }'
``` ```
## Car Policy By Chassis
### Endpoint
```
POST /api/inquiry/policyByChassis
```
### Request Body
```json
{
"chassisNo": "NAAM01E15HK123456"
}
```
## Car Policy By Plate
### Endpoint
```
POST /api/inquiry/policyByPlate
```
### Request Body
```json
{
"plk1": "12",
"plk2": "ب",
"plk3": "345",
"plksrl": "67"
}
```
## Car Policy By National Code
### Endpoint
```
POST /api/inquiry/policyByNationalCode
```
### Request Body
```json
{
"nationalCode": "0123456789"
}
```
## Error Responses ## Error Responses
### 400 Bad Request - Invalid Input ### 400 Bad Request - Invalid Input

View File

@@ -112,11 +112,11 @@ HAMTA_ENABLED=true
HAMTA_TIMEOUT=10000 HAMTA_TIMEOUT=10000
HAMTA_MAX_RETRIES=3 HAMTA_MAX_RETRIES=3
# Person Inquiry (AMITIS auth) # Person Inquiry (SOAP auth)
HAMTA_PERSON_URL=https://api.hamta.example.com/api/inquiry/person HAMTA_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out
HAMTA_PERSON_USERNAME=pa6476 HAMTA_PERSON_USERNAME=hamta.sabtahval
HAMTA_PERSON_PASSWORD=ciiws@sabt92 HAMTA_PERSON_PASSWORD=your-password
HAMTA_PERSON_AUTH_METHOD=AMITIS HAMTA_PERSON_AUTH_METHOD=SOAP
# Real Estate Inquiry (AMITIS auth) # Real Estate Inquiry (AMITIS auth)
HAMTA_REAL_ESTATE_URL=https://apigw.services.centinsur.ir/amlakeskanservice/amlakeskan/inquiry HAMTA_REAL_ESTATE_URL=https://apigw.services.centinsur.ir/amlakeskanservice/amlakeskan/inquiry
@@ -147,6 +147,7 @@ HAMTA_LEGAL_PERSON_URL=https://api.hamta.example.com/api/inquiry/legalPerson
HAMTA_LEGAL_PERSON_USERNAME=SabtAsnadHamta HAMTA_LEGAL_PERSON_USERNAME=SabtAsnadHamta
HAMTA_LEGAL_PERSON_PASSWORD=HSY1f6?e@kSJ HAMTA_LEGAL_PERSON_PASSWORD=HSY1f6?e@kSJ
HAMTA_LEGAL_PERSON_AUTH_METHOD=AMITIS HAMTA_LEGAL_PERSON_AUTH_METHOD=AMITIS
``` ```
### MOALLEM Provider ### MOALLEM Provider
@@ -194,6 +195,50 @@ MOALLEM_LEGAL_PERSON_PASSWORD=
MOALLEM_LEGAL_PERSON_AUTH_METHOD=SOAP MOALLEM_LEGAL_PERSON_AUTH_METHOD=SOAP
``` ```
### PARSIAN Provider
```env
# Global Settings
PARSIAN_ENABLED=true
PARSIAN_TIMEOUT=10000
PARSIAN_MAX_RETRIES=2
# Person Inquiry (SOAP auth)
PARSIAN_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out
PARSIAN_PERSON_USERNAME=pa6476
PARSIAN_PERSON_PASSWORD=your-password
PARSIAN_PERSON_AUTH_METHOD=SOAP
# Shahkar Inquiry (X-PACKAGE-API-KEY header)
PARSIAN_SHAHKAR_URL=https://apigateway.parsianinsurance.com/shahkarinqOut
PARSIAN_SHAHKAR_API_KEY=your-package-api-key
PARSIAN_SHAHKAR_AUTH_METHOD=NONE
# Sheba/Sayah Inquiry (AMITIS auth)
PARSIAN_SHEBA_URL=https://sayah.services.centinsur.ir/api/Cisb/TatbighServiceAsync
PARSIAN_SHEBA_USERNAME=parsian.sayah
PARSIAN_SHEBA_PASSWORD=your-password
PARSIAN_SHEBA_AUTH_METHOD=AMITIS
# Car policy inquiry by chassis (SOAP auth)
PARSIAN_POLICY_BY_CHASSIS_URL=http://reinsure.centinsur.ir/CarAllPlcysV4
PARSIAN_POLICY_BY_CHASSIS_USERNAME=pa6476
PARSIAN_POLICY_BY_CHASSIS_PASSWORD=your-password
PARSIAN_POLICY_BY_CHASSIS_AUTH_METHOD=SOAP
# Car policy inquiry by national plate (SOAP auth)
PARSIAN_POLICY_BY_PLATE_URL=http://reinsure.centinsur.ir/CarAllPlcysV4
PARSIAN_POLICY_BY_PLATE_USERNAME=pa6476
PARSIAN_POLICY_BY_PLATE_PASSWORD=your-password
PARSIAN_POLICY_BY_PLATE_AUTH_METHOD=SOAP
# Car policy inquiry by national code (SOAP auth)
PARSIAN_POLICY_BY_NATIONAL_CODE_URL=http://reinsure.centinsur.ir/CarAllPlcysV4
PARSIAN_POLICY_BY_NATIONAL_CODE_USERNAME=pa6476
PARSIAN_POLICY_BY_NATIONAL_CODE_PASSWORD=your-password
PARSIAN_POLICY_BY_NATIONAL_CODE_AUTH_METHOD=SOAP
```
### TEJARATNOU Provider ### TEJARATNOU Provider
```env ```env
@@ -238,22 +283,22 @@ Routing determines which provider handles each inquiry type and whether to use f
#### Person Inquiry (Single Provider) #### Person Inquiry (Single Provider)
```env ```env
PERSON_DEFAULT_PROVIDER=TEJARATNOU PERSON_DEFAULT_PROVIDER=PARSIAN
PERSON_FALLBACK_ENABLED=false PERSON_FALLBACK_ENABLED=false
PERSON_FALLBACK_PROVIDERS= PERSON_FALLBACK_PROVIDERS=
``` ```
**Behavior**: Only TEJARATNOU is used. If it fails, request fails. **Behavior**: Only PARSIAN is used. If it fails, request fails.
#### Person Inquiry (With Fallback) #### Person Inquiry (With Fallback)
```env ```env
PERSON_DEFAULT_PROVIDER=TEJARATNOU PERSON_DEFAULT_PROVIDER=PARSIAN
PERSON_FALLBACK_ENABLED=true PERSON_FALLBACK_ENABLED=true
PERSON_FALLBACK_PROVIDERS=HAMTA,MOALLEM PERSON_FALLBACK_PROVIDERS=HAMTA,TEJARATNOU
``` ```
**Behavior**: **Behavior**:
1. Try TEJARATNOU first 1. Try PARSIAN first
2. If fails, try HAMTA 2. If fails, try HAMTA
3. If fails, try MOALLEM 3. If fails, try TEJARATNOU
4. If all fail, return error 4. If all fail, return error
#### Real Estate Inquiry #### Real Estate Inquiry
@@ -265,11 +310,18 @@ REAL_ESTATE_FALLBACK_PROVIDERS=
#### Shahkar Inquiry #### Shahkar Inquiry
```env ```env
SHAHKAR_DEFAULT_PROVIDER=MOALLEM SHAHKAR_DEFAULT_PROVIDER=PARSIAN
SHAHKAR_FALLBACK_ENABLED=false SHAHKAR_FALLBACK_ENABLED=false
SHAHKAR_FALLBACK_PROVIDERS= SHAHKAR_FALLBACK_PROVIDERS=
``` ```
#### Sheba/Sayah Inquiry
```env
SHEBA_DEFAULT_PROVIDER=PARSIAN
SHEBA_FALLBACK_ENABLED=false
SHEBA_FALLBACK_PROVIDERS=
```
## Provider Behavior Examples ## Provider Behavior Examples
### Scenario 1: All Providers Enabled ### Scenario 1: All Providers Enabled
@@ -451,9 +503,9 @@ AMITIS_HAMTA_PERSON_PASSWORD=ciiws@sabt92
### New Format (Current) ### New Format (Current)
```env ```env
HAMTA_PERSON_USERNAME=pa6476 PARSIAN_PERSON_USERNAME=pa6476
HAMTA_PERSON_PASSWORD=ciiws@sabt92 PARSIAN_PERSON_PASSWORD=your-password
HAMTA_PERSON_AUTH_METHOD=AMITIS PARSIAN_PERSON_AUTH_METHOD=SOAP
``` ```
**Key Changes:** **Key Changes:**

View File

@@ -35,10 +35,11 @@ export class InquiryAccessGuard implements CanActivate {
return true; return true;
} }
const inquiryKey = requiredInquiry as string; // Temporarily disabled: allow every authenticated user/client to call every inquiry.
if (!user.allowedInquiries.includes(inquiryKey)) { // const inquiryKey = requiredInquiry as string;
throw new ForbiddenException(`Access denied for inquiry: ${inquiryKey}`); // if (!user.allowedInquiries.includes(inquiryKey)) {
} // throw new ForbiddenException(`Access denied for inquiry: ${inquiryKey}`);
// }
return true; return true;
} }

View File

@@ -16,4 +16,10 @@ export class NormalizedErrorDto {
@ApiPropertyOptional({ example: '503' }) @ApiPropertyOptional({ example: '503' })
providerCode?: string; providerCode?: 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',
})
details?: Array<{ field: string; constraints: string[] }>;
} }

View File

@@ -10,4 +10,7 @@ export enum InquiryType {
POSTAL_CODE = 'POSTAL_CODE_INQUIRY', POSTAL_CODE = 'POSTAL_CODE_INQUIRY',
LEGAL_PERSON = 'LEGAL_PERSON_INQUIRY', LEGAL_PERSON = 'LEGAL_PERSON_INQUIRY',
CAR_PLATE = 'CAR_PLATE_INQUIRY', CAR_PLATE = 'CAR_PLATE_INQUIRY',
POLICY_BY_CHASSIS = 'POLICY_BY_CHASSIS_INQUIRY',
POLICY_BY_PLATE = 'POLICY_BY_PLATE_INQUIRY',
POLICY_BY_NATIONAL_CODE = 'POLICY_BY_NATIONAL_CODE_INQUIRY',
} }

View File

@@ -5,6 +5,7 @@
export enum ProviderName { export enum ProviderName {
HAMTA = 'HAMTA', HAMTA = 'HAMTA',
MOALLEM = 'MOALLEM', MOALLEM = 'MOALLEM',
PARSIAN = 'PARSIAN',
TEJARATNOU = 'TEJARATNOU', TEJARATNOU = 'TEJARATNOU',
AMITIS = 'AMITIS', AMITIS = 'AMITIS',
} }

View File

@@ -10,6 +10,7 @@ import { Request, Response } from 'express';
import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto'; import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
import { NormalizedErrorDto } from '../dto/normalized-error.dto'; import { NormalizedErrorDto } from '../dto/normalized-error.dto';
import { ProviderException } from '../exceptions/provider.exception'; import { ProviderException } from '../exceptions/provider.exception';
import { isNormalizedError } from '../helpers/validation-error.helper';
/** /**
* Global exception filter — normalizes all errors into BaseInquiryResponseDto * Global exception filter — normalizes all errors into BaseInquiryResponseDto
@@ -22,7 +23,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void { catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request & { trackingCode?: string }>(); const request = ctx.getRequest<Request & { requestId?: string; trackingCode?: string }>();
const status = const status =
exception instanceof HttpException exception instanceof HttpException
@@ -31,9 +32,10 @@ export class AllExceptionsFilter implements ExceptionFilter {
const normalizedError = this.extractNormalizedError(exception); const normalizedError = this.extractNormalizedError(exception);
const trackingCode = request.trackingCode ?? 'UNKNOWN'; const trackingCode = request.trackingCode ?? 'UNKNOWN';
const requestId = request.requestId ?? (request.headers['x-request-id'] as string | undefined) ?? 'unknown';
this.logger.error( this.logger.error(
`requestId=${request.headers['x-request-id']} | trackingCode=${trackingCode} | ${normalizedError.message}`, `requestId=${requestId} | trackingCode=${trackingCode} | code=${normalizedError.code} | ${normalizedError.message}`,
exception instanceof Error ? exception.stack : undefined, exception instanceof Error ? exception.stack : undefined,
); );
@@ -56,13 +58,22 @@ export class AllExceptionsFilter implements ExceptionFilter {
if (exception instanceof HttpException) { if (exception instanceof HttpException) {
const res = exception.getResponse(); const res = exception.getResponse();
if (typeof res === 'object' && res !== null && 'error' in res) {
return (res as { error: NormalizedErrorDto }).error; if (typeof res === 'object' && res !== null) {
if ('error' in res && isNormalizedError((res as { error: unknown }).error)) {
return (res as { error: NormalizedErrorDto }).error;
}
if (isNormalizedError(res)) {
return res;
}
} }
const message = const message =
typeof res === 'string' typeof res === 'string'
? res ? res
: (res as { message?: string | string[] }).message; : (res as { message?: string | string[] }).message;
return { return {
code: 'HTTP_ERROR', code: 'HTTP_ERROR',
message: Array.isArray(message) ? message.join(', ') : String(message ?? exception.message), message: Array.isArray(message) ? message.join(', ') : String(message ?? exception.message),

View File

@@ -0,0 +1,97 @@
import { ValidationError } from 'class-validator';
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
export interface ValidationFieldError {
field: string;
constraints: string[];
}
export function flattenValidationErrors(
errors: ValidationError[],
parentPath = '',
): ValidationFieldError[] {
const result: ValidationFieldError[] = [];
for (const error of errors) {
const field = parentPath ? `${parentPath}.${error.property}` : error.property;
if (error.constraints) {
result.push({
field,
constraints: Object.values(error.constraints),
});
}
if (error.children?.length) {
result.push(...flattenValidationErrors(error.children, field));
}
}
return result;
}
export function buildValidationErrorResponse(errors: ValidationError[]): {
normalizedError: NormalizedErrorDto;
details: ValidationFieldError[];
} {
const details = flattenValidationErrors(errors);
const summary =
details.length > 0
? details.map((d) => `${d.field}: ${d.constraints.join(', ')}`).join('; ')
: 'Request validation failed';
return {
normalizedError: {
code: 'VALIDATION_ERROR',
message: summary,
details,
},
details,
};
}
export function formatHttpExceptionMessage(exception: unknown): string {
if (!(exception && typeof exception === 'object' && 'getResponse' in exception)) {
return exception instanceof Error ? exception.message : String(exception);
}
const response = (exception as { getResponse: () => unknown }).getResponse();
if (typeof response === 'string') {
return response;
}
if (typeof response !== 'object' || response === null) {
return exception instanceof Error ? exception.message : 'Request failed';
}
const body = response as {
message?: string | string[];
error?: unknown;
};
if (isNormalizedError(body.error)) {
return body.error.message;
}
if (Array.isArray(body.message)) {
return body.message.join('; ');
}
if (typeof body.message === 'string' && body.message.length > 0) {
return body.message;
}
return exception instanceof Error ? exception.message : 'Request failed';
}
export function isNormalizedError(value: unknown): value is NormalizedErrorDto {
return (
typeof value === 'object' &&
value !== null &&
'code' in value &&
'message' in value &&
typeof (value as NormalizedErrorDto).code === 'string' &&
typeof (value as NormalizedErrorDto).message === 'string'
);
}

View File

@@ -6,6 +6,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Observable, tap } from 'rxjs'; import { Observable, tap } from 'rxjs';
import { RequestLogger } from '../helpers/request-logger.helper'; import { RequestLogger } from '../helpers/request-logger.helper';
import { formatHttpExceptionMessage } from '../helpers/validation-error.helper';
/** /**
* Logs HTTP request duration at the controller boundary. * Logs HTTP request duration at the controller boundary.
@@ -31,9 +32,10 @@ export class LoggingInterceptor implements NestInterceptor {
); );
}, },
error: (err: unknown) => { error: (err: unknown) => {
const detail = formatHttpExceptionMessage(err);
this.requestLogger.logFailure( this.requestLogger.logFailure(
{ requestId: req.requestId ?? 'unknown', durationMs: Date.now() - start }, { requestId: req.requestId ?? 'unknown', durationMs: Date.now() - start },
`${req.method} ${req.url} failed`, `${req.method} ${req.url} failed: ${detail}`,
err, err,
); );
}, },

View File

@@ -7,6 +7,7 @@ export interface InquiryConfig {
url: string; url: string;
username: string; username: string;
password: string; password: string;
apiKey: string;
authMethod: AuthMethod; authMethod: AuthMethod;
} }
@@ -79,6 +80,7 @@ export default () => ({
} satisfies AmitisAuthServiceConfig, } satisfies AmitisAuthServiceConfig,
hamta: buildProviderConfig('HAMTA'), hamta: buildProviderConfig('HAMTA'),
moallem: buildProviderConfig('MOALLEM'), moallem: buildProviderConfig('MOALLEM'),
parsian: buildProviderConfig('PARSIAN'),
tejaratnou: { tejaratnou: {
enabled: process.env.TEJARATNOU_ENABLED !== 'false', enabled: process.env.TEJARATNOU_ENABLED !== 'false',
timeout: parseInt(process.env.TEJARATNOU_TIMEOUT ?? '15000', 10), timeout: parseInt(process.env.TEJARATNOU_TIMEOUT ?? '15000', 10),
@@ -93,14 +95,17 @@ export default () => ({
}, },
} satisfies TejaratNouConfig, } satisfies TejaratNouConfig,
inquiryRouting: { inquiryRouting: {
[InquiryType.PERSON]: buildInquiryRoutingConfig('PERSON', ProviderName.TEJARATNOU, []), [InquiryType.PERSON]: buildInquiryRoutingConfig('PERSON', ProviderName.PARSIAN, [
ProviderName.HAMTA,
ProviderName.TEJARATNOU,
]),
[InquiryType.REAL_ESTATE]: buildInquiryRoutingConfig('REAL_ESTATE', ProviderName.HAMTA, [ [InquiryType.REAL_ESTATE]: buildInquiryRoutingConfig('REAL_ESTATE', ProviderName.HAMTA, [
ProviderName.MOALLEM, ProviderName.MOALLEM,
]), ]),
[InquiryType.SHEBA]: buildInquiryRoutingConfig('SHEBA', ProviderName.MOALLEM, [ [InquiryType.SHEBA]: buildInquiryRoutingConfig('SHEBA', ProviderName.PARSIAN, [
ProviderName.HAMTA, ProviderName.HAMTA,
]), ]),
[InquiryType.SHAHKAR]: buildInquiryRoutingConfig('SHAHKAR', ProviderName.MOALLEM, [ [InquiryType.SHAHKAR]: buildInquiryRoutingConfig('SHAHKAR', ProviderName.PARSIAN, [
ProviderName.HAMTA, ProviderName.HAMTA,
]), ]),
[InquiryType.POSTAL_CODE]: buildInquiryRoutingConfig('POSTAL_CODE', ProviderName.MOALLEM, [ [InquiryType.POSTAL_CODE]: buildInquiryRoutingConfig('POSTAL_CODE', ProviderName.MOALLEM, [
@@ -110,6 +115,21 @@ export default () => ({
ProviderName.MOALLEM, ProviderName.MOALLEM,
]), ]),
[InquiryType.CAR_PLATE]: buildInquiryRoutingConfig('CAR_PLATE', ProviderName.HAMTA, []), [InquiryType.CAR_PLATE]: buildInquiryRoutingConfig('CAR_PLATE', ProviderName.HAMTA, []),
[InquiryType.POLICY_BY_CHASSIS]: buildInquiryRoutingConfig(
'POLICY_BY_CHASSIS',
ProviderName.PARSIAN,
[],
),
[InquiryType.POLICY_BY_PLATE]: buildInquiryRoutingConfig(
'POLICY_BY_PLATE',
ProviderName.PARSIAN,
[],
),
[InquiryType.POLICY_BY_NATIONAL_CODE]: buildInquiryRoutingConfig(
'POLICY_BY_NATIONAL_CODE',
ProviderName.PARSIAN,
[],
),
} satisfies Record<InquiryType, InquiryRoutingConfig>, } satisfies Record<InquiryType, InquiryRoutingConfig>,
}); });
@@ -122,6 +142,9 @@ function buildProviderConfig(prefix: string): ProviderEnvConfig {
InquiryType.POSTAL_CODE, InquiryType.POSTAL_CODE,
InquiryType.LEGAL_PERSON, InquiryType.LEGAL_PERSON,
InquiryType.CAR_PLATE, InquiryType.CAR_PLATE,
InquiryType.POLICY_BY_CHASSIS,
InquiryType.POLICY_BY_PLATE,
InquiryType.POLICY_BY_NATIONAL_CODE,
]; ];
const inquiries: Partial<Record<InquiryType, InquiryConfig>> = {}; const inquiries: Partial<Record<InquiryType, InquiryConfig>> = {};
@@ -146,6 +169,7 @@ function buildInquiryConfig(providerPrefix: string, inquiryPrefix: string): Inqu
url: process.env[`${providerPrefix}_${inquiryPrefix}_URL`] ?? '', url: process.env[`${providerPrefix}_${inquiryPrefix}_URL`] ?? '',
username: process.env[`${providerPrefix}_${inquiryPrefix}_USERNAME`] ?? '', username: process.env[`${providerPrefix}_${inquiryPrefix}_USERNAME`] ?? '',
password: process.env[`${providerPrefix}_${inquiryPrefix}_PASSWORD`] ?? '', password: process.env[`${providerPrefix}_${inquiryPrefix}_PASSWORD`] ?? '',
apiKey: process.env[`${providerPrefix}_${inquiryPrefix}_API_KEY`] ?? '',
authMethod: (process.env[`${providerPrefix}_${inquiryPrefix}_AUTH_METHOD`] ?? 'NONE') as AuthMethod, authMethod: (process.env[`${providerPrefix}_${inquiryPrefix}_AUTH_METHOD`] ?? 'NONE') as AuthMethod,
}; };
} }
@@ -159,6 +183,9 @@ function inquiryTypeToEnvName(inquiryType: InquiryType): string {
[InquiryType.POSTAL_CODE]: 'POSTAL_CODE', [InquiryType.POSTAL_CODE]: 'POSTAL_CODE',
[InquiryType.LEGAL_PERSON]: 'LEGAL_PERSON', [InquiryType.LEGAL_PERSON]: 'LEGAL_PERSON',
[InquiryType.CAR_PLATE]: 'CAR_PLATE', [InquiryType.CAR_PLATE]: 'CAR_PLATE',
[InquiryType.POLICY_BY_CHASSIS]: 'POLICY_BY_CHASSIS',
[InquiryType.POLICY_BY_PLATE]: 'POLICY_BY_PLATE',
[InquiryType.POLICY_BY_NATIONAL_CODE]: 'POLICY_BY_NATIONAL_CODE',
}; };
return mapping[inquiryType]; return mapping[inquiryType];
} }

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class PolicyByChassisRequestDto {
@ApiProperty({ example: 'NAAM01E15HK123456', description: 'Vehicle chassis number' })
@IsString()
@IsNotEmpty()
chassisNo!: string;
}

View File

@@ -0,0 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Length, Matches } from 'class-validator';
export class PolicyByNationalCodeRequestDto {
@ApiProperty({ example: '4311402422', description: 'Iranian national code (10 digits)' })
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
nationalCode!: string;
}

View File

@@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches } from 'class-validator';
export class PolicyByPlateRequestDto {
@ApiProperty({ example: '12', description: 'Two digits on the left side of the plate' })
@IsString()
@Matches(/^\d{2}$/, { message: 'plk1 must be exactly 2 digits' })
plk1!: string;
@ApiProperty({ example: 'ب', description: 'Middle plate letter' })
@IsString()
@IsNotEmpty()
plk2!: string;
@ApiProperty({ example: '345', description: 'Three digits on the right side of the plate' })
@IsString()
@Matches(/^\d{3}$/, { message: 'plk3 must be exactly 3 digits' })
plk3!: string;
@ApiProperty({ example: '67', description: 'Plate serial' })
@IsString()
@IsNotEmpty()
plksrl!: string;
}

View File

@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Length, Matches } from 'class-validator';
export class RealEstateRequestDto {
@ApiProperty({ example: '5098961130', description: 'Iranian national code (10 digits)' })
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
nationalCode!: string;
@ApiProperty({ example: '1349689554', description: 'Postal code (10 digits)' })
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^\d{10}$/, { message: 'postalCode must be exactly 10 digits' })
postalCode!: string;
}

View File

@@ -1,27 +1,33 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
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: '1', description: '1 for natural person (always use "1")' }) @ApiPropertyOptional({ example: '1', description: '1 for natural person' })
@IsOptional()
@IsString() @IsString()
@IsIn(['1']) @IsIn(['1'])
accountOwnerType!: string; accountOwnerType?: string;
@ApiProperty({ example: '4311402422', description: 'Iranian national code (10 digits)' }) @ApiProperty({ example: '4311402422', description: 'Iranian national code (10 digits)' })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@Length(10, 10) @Length(10, 10)
@Matches(/^\d{10}$/, { message: 'nationalId must be exactly 10 digits' }) @Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
nationalId!: string; nationalCode!: string;
@ApiPropertyOptional({ nullable: true }) @ApiPropertyOptional({ nullable: true })
@IsOptional() @IsOptional()
@IsString() @IsString()
legalId?: string | null; legalId?: string | null;
@ApiProperty({ example: 'IR800560611828005105117001', description: 'Iranian Sheba ID (26 characters)' }) @ApiProperty({
example: 'IR800560611828005105117001',
description: 'Iranian Sheba ID (26 characters)',
})
@Transform(({ value }: { value?: string }) => value?.toUpperCase())
@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: 'sheba must start with IR and contain 24 digits' })
shebaId!: string; sheba!: string;
} }

View File

@@ -28,6 +28,10 @@ import { GenericInquiryResponseDto } from './dto/generic-inquiry-response.dto';
import { PostalCodeRequestDto } from './dto/postal-code-request.dto'; import { PostalCodeRequestDto } from './dto/postal-code-request.dto';
import { ShahkarRequestDto } from './dto/shahkar-request.dto'; import { ShahkarRequestDto } from './dto/shahkar-request.dto';
import { SayahRequestDto } from './dto/sayah-request.dto'; import { SayahRequestDto } from './dto/sayah-request.dto';
import { RealEstateRequestDto } from './dto/real-estate-request.dto';
import { PolicyByChassisRequestDto } from './dto/policy-by-chassis-request.dto';
import { PolicyByNationalCodeRequestDto } from './dto/policy-by-national-code-request.dto';
import { PolicyByPlateRequestDto } from './dto/policy-by-plate-request.dto';
import { InquiryService } from './inquiry.service'; import { InquiryService } from './inquiry.service';
/** /**
@@ -65,10 +69,14 @@ export class InquiryController {
@ApiOperation({ summary: 'Real estate inquiry' }) @ApiOperation({ summary: 'Real estate inquiry' })
@ApiResponse({ status: 200, type: GenericInquiryResponseDto }) @ApiResponse({ status: 200, type: GenericInquiryResponseDto })
async inquireRealEstate( async inquireRealEstate(
@Body() payload: Record<string, unknown>, @Body() payload: RealEstateRequestDto,
@Req() req: Request & { requestId?: string; trackingCode?: string }, @Req() req: Request & { requestId?: string; trackingCode?: string },
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> { ): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
return this.inquireGeneric(InquiryType.REAL_ESTATE, payload, req); return this.inquireGeneric(
InquiryType.REAL_ESTATE,
payload as unknown as Record<string, unknown>,
req,
);
} }
@Post('sheba') @Post('sheba')
@@ -127,6 +135,54 @@ export class InquiryController {
return this.inquireGeneric(InquiryType.LEGAL_PERSON, payload, req); return this.inquireGeneric(InquiryType.LEGAL_PERSON, payload, req);
} }
@Post('policyByChassis')
@InquiryAccess(InquiryType.POLICY_BY_CHASSIS)
@Throttle({ default: { limit: 30, ttl: 60000 } })
@ApiOperation({ summary: 'Policy inquiry by chassis number' })
@ApiResponse({ status: 200, type: GenericInquiryResponseDto })
async inquirePolicyByChassis(
@Body() payload: PolicyByChassisRequestDto,
@Req() req: Request & { requestId?: string; trackingCode?: string },
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
return this.inquireGeneric(
InquiryType.POLICY_BY_CHASSIS,
payload as unknown as Record<string, unknown>,
req,
);
}
@Post('policyByPlate')
@InquiryAccess(InquiryType.POLICY_BY_PLATE)
@Throttle({ default: { limit: 30, ttl: 60000 } })
@ApiOperation({ summary: 'Policy inquiry by national vehicle plate' })
@ApiResponse({ status: 200, type: GenericInquiryResponseDto })
async inquirePolicyByPlate(
@Body() payload: PolicyByPlateRequestDto,
@Req() req: Request & { requestId?: string; trackingCode?: string },
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
return this.inquireGeneric(
InquiryType.POLICY_BY_PLATE,
payload as unknown as Record<string, unknown>,
req,
);
}
@Post('policyByNationalCode')
@InquiryAccess(InquiryType.POLICY_BY_NATIONAL_CODE)
@Throttle({ default: { limit: 30, ttl: 60000 } })
@ApiOperation({ summary: 'Policy inquiry by national code' })
@ApiResponse({ status: 200, type: GenericInquiryResponseDto })
async inquirePolicyByNationalCode(
@Body() payload: PolicyByNationalCodeRequestDto,
@Req() req: Request & { requestId?: string; trackingCode?: string },
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
return this.inquireGeneric(
InquiryType.POLICY_BY_NATIONAL_CODE,
payload as unknown as Record<string, unknown>,
req,
);
}
private async inquireGeneric( private async inquireGeneric(
inquiryType: InquiryType, inquiryType: InquiryType,
payload: Record<string, unknown>, payload: Record<string, unknown>,

View File

@@ -1,5 +1,5 @@
import './telemetry'; import './telemetry';
import { Logger, ValidationPipe } from '@nestjs/common'; import { BadRequestException, Logger, ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
@@ -7,6 +7,7 @@ import { NextFunction, Request, Response } from 'express';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { API_KEY_HEADER } from './common/constants/app.constants'; import { API_KEY_HEADER } from './common/constants/app.constants';
import { OpenTelemetryNestLogger } from './otel-nest-logger'; import { OpenTelemetryNestLogger } from './otel-nest-logger';
import { buildValidationErrorResponse } from './common/helpers/validation-error.helper';
import { import {
recordHttpRequestEnd, recordHttpRequestEnd,
recordHttpRequestStart, recordHttpRequestStart,
@@ -46,6 +47,13 @@ async function bootstrap(): Promise<void> {
forbidNonWhitelisted: true, forbidNonWhitelisted: true,
transform: true, transform: true,
transformOptions: { enableImplicitConversion: true }, transformOptions: { enableImplicitConversion: true },
exceptionFactory: (errors) => {
const { normalizedError } = buildValidationErrorResponse(errors);
return new BadRequestException({
message: normalizedError.message,
error: normalizedError,
});
},
}), }),
); );

View File

@@ -6,6 +6,7 @@ import { InquiryProvider } from '../../common/interfaces/inquiry-provider.interf
import { InquiryRoutingConfig } from '../../config/configuration'; import { InquiryRoutingConfig } from '../../config/configuration';
import { HamtaProvider } from '../implementations/hamta.provider'; import { HamtaProvider } from '../implementations/hamta.provider';
import { MoallemProvider } from '../implementations/moallem.provider'; import { MoallemProvider } from '../implementations/moallem.provider';
import { ParsianProvider } from '../implementations/parsian.provider';
import { TejaratNouProvider } from '../implementations/tejaratnou.provider'; import { TejaratNouProvider } from '../implementations/tejaratnou.provider';
/** /**
@@ -22,11 +23,13 @@ export class ProviderFactory {
private readonly configService: ConfigService, private readonly configService: ConfigService,
hamta: HamtaProvider, hamta: HamtaProvider,
moallem: MoallemProvider, moallem: MoallemProvider,
parsian: ParsianProvider,
tejaratnou: TejaratNouProvider, tejaratnou: TejaratNouProvider,
) { ) {
this.registry = new Map<ProviderName, InquiryProvider>([ this.registry = new Map<ProviderName, InquiryProvider>([
[ProviderName.HAMTA, hamta], [ProviderName.HAMTA, hamta],
[ProviderName.MOALLEM, moallem], [ProviderName.MOALLEM, moallem],
[ProviderName.PARSIAN, parsian],
[ProviderName.TEJARATNOU, tejaratnou], [ProviderName.TEJARATNOU, tejaratnou],
]); ]);
} }

View File

@@ -35,9 +35,9 @@ interface ShahkarPayload extends LegacyInquiryPayload {
interface SayahPayload extends LegacyInquiryPayload { interface SayahPayload extends LegacyInquiryPayload {
accountOwnerType?: string; accountOwnerType?: string;
nationalId?: string; nationalCode?: string;
legalId?: string | null; legalId?: string | null;
shebaId?: string; sheba?: string;
} }
/** /**
@@ -51,6 +51,7 @@ export class HamtaProvider extends LegacyApiProvider {
InquiryType.SHEBA, InquiryType.SHEBA,
InquiryType.SHAHKAR, InquiryType.SHAHKAR,
InquiryType.POSTAL_CODE, InquiryType.POSTAL_CODE,
InquiryType.REAL_ESTATE,
]; ];
private readonly hamtaConfig: ProviderEnvConfig; private readonly hamtaConfig: ProviderEnvConfig;
@@ -226,10 +227,10 @@ export class HamtaProvider extends LegacyApiProvider {
} }
private async inquireSayah(payload: SayahPayload): Promise<{ raw: unknown }> { private async inquireSayah(payload: SayahPayload): Promise<{ raw: unknown }> {
const accountOwnerType = this.getRequiredString(payload.accountOwnerType, 'accountOwnerType'); const accountOwnerType = payload.accountOwnerType ?? '1';
const nationalId = payload.nationalId ?? ''; const nationalId = payload.nationalCode ?? '';
const legalId = payload.legalId ?? null; const legalId = payload.legalId ?? null;
const shebaId = this.getRequiredString(payload.shebaId, 'shebaId'); const shebaId = this.getRequiredString(payload.sheba, 'sheba');
const inquiryConfig = this.getInquiryConfig(InquiryType.SHEBA); const inquiryConfig = this.getInquiryConfig(InquiryType.SHEBA);
const token = await this.amitisProvider.getAccessToken( const token = await this.amitisProvider.getAccessToken(
@@ -340,20 +341,20 @@ export class HamtaProvider extends LegacyApiProvider {
private escapeXml(value: string): string { private escapeXml(value: string): string {
return value return value
.replace(/&/g, '&') .replace(/&/g, '&amp;')
.replace(/</g, '<') .replace(/</g, '&lt;')
.replace(/>/g, '>') .replace(/>/g, '&gt;')
.replace(/"/g, '"') .replace(/"/g, '&quot;')
.replace(/'/g, '&apos;'); .replace(/'/g, '&apos;');
} }
private decodeXml(value: string): string { private decodeXml(value: string): string {
return value return value
.replace(/'/g, "'") .replace(/&apos;/g, "'")
.replace(/"/g, '"') .replace(/&quot;/g, '"')
.replace(/>/g, '>') .replace(/&gt;/g, '>')
.replace(/</g, '<') .replace(/&lt;/g, '<')
.replace(/&/g, '&'); .replace(/&amp;/g, '&');
} }
} }

View File

@@ -35,9 +35,9 @@ interface ShahkarPayload extends LegacyInquiryPayload {
interface SayahPayload extends LegacyInquiryPayload { interface SayahPayload extends LegacyInquiryPayload {
accountOwnerType?: string; accountOwnerType?: string;
nationalId?: string; nationalCode?: string;
legalId?: string | null; legalId?: string | null;
shebaId?: string; sheba?: string;
} }
/** /**
@@ -51,6 +51,7 @@ export class MoallemProvider extends LegacyApiProvider {
InquiryType.SHEBA, InquiryType.SHEBA,
InquiryType.SHAHKAR, InquiryType.SHAHKAR,
InquiryType.POSTAL_CODE, InquiryType.POSTAL_CODE,
InquiryType.REAL_ESTATE,
]; ];
private readonly moallemConfig: ProviderEnvConfig; private readonly moallemConfig: ProviderEnvConfig;
@@ -226,10 +227,10 @@ export class MoallemProvider extends LegacyApiProvider {
} }
private async inquireSayah(payload: SayahPayload): Promise<{ raw: unknown }> { private async inquireSayah(payload: SayahPayload): Promise<{ raw: unknown }> {
const accountOwnerType = this.getRequiredString(payload.accountOwnerType, 'accountOwnerType'); const accountOwnerType = payload.accountOwnerType ?? '1';
const nationalId = payload.nationalId ?? ''; const nationalId = payload.nationalCode ?? '';
const legalId = payload.legalId ?? null; const legalId = payload.legalId ?? null;
const shebaId = this.getRequiredString(payload.shebaId, 'shebaId'); const shebaId = this.getRequiredString(payload.sheba, 'sheba');
const inquiryConfig = this.getInquiryConfig(InquiryType.SHEBA); const inquiryConfig = this.getInquiryConfig(InquiryType.SHEBA);
const token = await this.amitisProvider.getAccessToken( const token = await this.amitisProvider.getAccessToken(

View File

@@ -0,0 +1,543 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosError } from 'axios';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
import { ProviderEnvConfig } from '../../config/configuration';
import { BaseProvider } from '../base/base-provider.abstract';
import { AmitisProvider } from './amitis.provider';
import {
LegacyInquiryPayload,
LegacyInquiryResult,
PersonInquiryResult,
} from '../shared/legacy-api.provider.abstract';
interface ParsianCivilRegistrationPayload extends LegacyInquiryPayload {
nationalCode?: string;
birthDate?: string;
NIN?: string;
BirthDate?: string;
}
interface ParsianShahkarPayload extends LegacyInquiryPayload {
nationalCode?: string;
nationalCod?: string;
NationalCod?: string;
mobileNo?: string;
MobileNo?: string;
mobileNumber?: string;
MobileNumber?: string;
}
interface ParsianShahkarResponse {
errorNams?: string | null;
ErrorNams?: string | null;
comment?: string | null;
id?: string | null;
requestId?: string | null;
response?: number;
result?: string | null;
}
interface ParsianSayahPayload extends LegacyInquiryPayload {
accountOwnerType?: string;
nationalCode?: string;
legalId?: string | null;
sheba?: string;
}
interface ParsianSayahResponse {
ReturnValue?: boolean;
returnValue?: boolean;
HasError?: boolean;
hasError?: boolean;
Errors?: Record<string, string> | Array<{ Code?: string; Message?: string }> | null;
errors?: Record<string, string> | Array<{ Code?: string; Message?: string }> | null;
IsSucceed?: boolean;
isSucceed?: boolean;
Result?: unknown;
result?: unknown;
}
interface ParsianPolicyByChassisPayload extends LegacyInquiryPayload {
chassisNo?: string;
}
interface ParsianPolicyByNationalCodePayload extends LegacyInquiryPayload {
nationalCode?: string;
}
interface ParsianPolicyByPlatePayload extends LegacyInquiryPayload {
plk1?: string;
plk2?: string;
plk3?: string;
plksrl?: string;
}
@Injectable()
export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyInquiryResult> {
readonly name = ProviderName.PARSIAN;
readonly supportedInquiryTypes = [
InquiryType.PERSON,
InquiryType.SHAHKAR,
InquiryType.SHEBA,
InquiryType.POLICY_BY_CHASSIS,
InquiryType.POLICY_BY_PLATE,
InquiryType.POLICY_BY_NATIONAL_CODE,
];
constructor(
configService: ConfigService,
private readonly amitisProvider: AmitisProvider,
) {
super(configService.get<ProviderEnvConfig>('parsian')!);
}
isEnabled(): boolean {
const personConfig = this.config.inquiries[InquiryType.PERSON];
const shahkarConfig = this.config.inquiries[InquiryType.SHAHKAR];
const shebaConfig = this.config.inquiries[InquiryType.SHEBA];
const policyByChassisConfig = this.config.inquiries[InquiryType.POLICY_BY_CHASSIS];
const policyByPlateConfig = this.config.inquiries[InquiryType.POLICY_BY_PLATE];
const policyByNationalCodeConfig =
this.config.inquiries[InquiryType.POLICY_BY_NATIONAL_CODE];
return (
this.config.enabled &&
(this.hasSoapCredentials(personConfig) ||
(Boolean(shahkarConfig?.url) && Boolean(shahkarConfig?.apiKey)) ||
(Boolean(shebaConfig?.url) &&
Boolean(shebaConfig?.username) &&
Boolean(shebaConfig?.password)) ||
this.hasSoapCredentials(policyByChassisConfig) ||
this.hasSoapCredentials(policyByPlateConfig) ||
this.hasSoapCredentials(policyByNationalCodeConfig))
);
}
protected async callProvider(
inquiryType: InquiryType,
payload: LegacyInquiryPayload,
_context: ProviderExecutionContext,
): Promise<LegacyInquiryResult> {
if (inquiryType === InquiryType.PERSON) {
return this.inquireCivilRegistration(payload as ParsianCivilRegistrationPayload);
}
if (inquiryType === InquiryType.SHAHKAR) {
return this.inquireShahkar(payload as ParsianShahkarPayload);
}
if (inquiryType === InquiryType.SHEBA) {
return this.inquireSayah(payload as ParsianSayahPayload);
}
if (inquiryType === InquiryType.POLICY_BY_CHASSIS) {
return this.inquirePolicyByChassis(payload as ParsianPolicyByChassisPayload);
}
if (inquiryType === InquiryType.POLICY_BY_PLATE) {
return this.inquirePolicyByPlate(payload as ParsianPolicyByPlatePayload);
}
if (inquiryType === InquiryType.POLICY_BY_NATIONAL_CODE) {
return this.inquirePolicyByNationalCode(payload as ParsianPolicyByNationalCodePayload);
}
throw this.formatProviderError(
undefined,
'UNSUPPORTED_INQUIRY',
`Parsian does not support ${inquiryType}`,
);
}
private async inquireShahkar(payload: ParsianShahkarPayload): Promise<{ raw: unknown }> {
const nationalCode = this.getRequiredString(
payload.nationalCode ?? payload.nationalCod ?? payload.NationalCod,
'nationalCode',
);
const mobileNumber = this.getRequiredString(
payload.mobileNumber ?? payload.MobileNumber ?? payload.mobileNo ?? payload.MobileNo,
'mobileNo',
);
const inquiryConfig = this.config.inquiries[InquiryType.SHAHKAR];
if (!inquiryConfig?.url || !inquiryConfig.apiKey) {
throw this.formatProviderError(
undefined,
undefined,
'Parsian Shahkar inquiry is not configured',
);
}
try {
const response = await axios.get<ParsianShahkarResponse>(inquiryConfig.url, {
params: {
nationalCode,
mobileNumber,
},
headers: {
'X-PACKAGE-API-KEY': inquiryConfig.apiKey,
},
timeout: this.config.timeout,
});
const body = response.data;
return {
raw: {
nationalCode,
mobileNumber,
...body,
},
};
} catch (error) {
if (error instanceof AxiosError) {
const data = error.response?.data as ParsianShahkarResponse | undefined;
throw this.formatProviderError(
data?.comment ?? data?.errorNams ?? data?.ErrorNams ?? error.message,
String(data?.response ?? error.response?.status ?? 'NETWORK_ERROR'),
);
}
throw error;
}
}
private async inquireCivilRegistration(
payload: ParsianCivilRegistrationPayload,
): Promise<PersonInquiryResult> {
const nationalCode = this.getRequiredString(
payload.nationalCode ?? payload.NIN,
'nationalCode',
);
const birthDate = this.getRequiredString(payload.birthDate ?? payload.BirthDate, 'birthDate');
const inquiryConfig = this.config.inquiries[InquiryType.PERSON];
if (!this.hasSoapCredentials(inquiryConfig)) {
throw this.formatProviderError(
undefined,
undefined,
'Parsian person inquiry is not configured',
);
}
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.config.timeout,
responseType: 'text',
},
);
return {
nationalCode,
birthDate,
raw: {
nationalCode,
birthDate,
result: this.extractSoapValue(response.data, 'SubmitInqDteStsWithPstCodResult'),
soap: response.data,
},
};
} catch (error) {
if (error instanceof AxiosError) {
throw this.formatProviderError(
error.message,
String(error.response?.status ?? 'NETWORK_ERROR'),
);
}
throw error;
}
}
private async inquirePolicyByChassis(
payload: ParsianPolicyByChassisPayload,
): Promise<{ raw: unknown }> {
const chassisNo = this.getRequiredString(payload.chassisNo, 'chassisNo');
const response = await this.callCarPolicySoap(
InquiryType.POLICY_BY_CHASSIS,
'CIIWSPolicyChassis',
{ Chassisno: chassisNo },
);
return {
raw: {
chassisNo,
...response,
},
};
}
private async inquirePolicyByNationalCode(
payload: ParsianPolicyByNationalCodePayload,
): Promise<{ raw: unknown }> {
const nationalCode = this.getRequiredString(payload.nationalCode, 'nationalCode');
const response = await this.callCarPolicySoap(
InquiryType.POLICY_BY_NATIONAL_CODE,
'CIIWSPolicyNationalId',
{ nationalId: nationalCode },
);
return {
raw: {
nationalCode,
...response,
},
};
}
private async inquirePolicyByPlate(
payload: ParsianPolicyByPlatePayload,
): Promise<{ raw: unknown }> {
const plk1 = this.getRequiredString(payload.plk1, 'plk1');
const plk2 = this.getRequiredString(payload.plk2, 'plk2');
const plk3 = this.getRequiredString(payload.plk3, 'plk3');
const plksrl = this.getRequiredString(payload.plksrl, 'plksrl');
const response = await this.callCarPolicySoap(
InquiryType.POLICY_BY_PLATE,
'CIIWSPolicyVehicleMeli',
{ Plk1: plk1, Plk2: plk2, Plk3: plk3, Plksrl: plksrl },
'PassWord',
);
return {
raw: {
plk1,
plk2,
plk3,
plksrl,
...response,
},
};
}
private async callCarPolicySoap(
inquiryType: InquiryType,
methodName: string,
fields: Record<string, string>,
passwordFieldName = 'Password',
): Promise<{ result: string | null; soap: string }> {
const inquiryConfig = this.config.inquiries[inquiryType];
if (!this.hasSoapCredentials(inquiryConfig)) {
throw this.formatProviderError(
undefined,
undefined,
`Parsian ${inquiryType} is not configured`,
);
}
try {
const response = await axios.post<string>(
inquiryConfig.url,
this.buildCarPolicyEnvelope(
methodName,
fields,
inquiryConfig.username,
inquiryConfig.password,
passwordFieldName,
),
{
headers: {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: `"http://tempuri.org/ICarAllPlcysV4/${methodName}"`,
},
timeout: this.config.timeout,
responseType: 'text',
},
);
return {
result: this.extractSoapValue(response.data, `${methodName}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 inquireSayah(payload: ParsianSayahPayload): Promise<{ raw: unknown }> {
const accountOwnerType = payload.accountOwnerType ?? '1';
const nationalId = payload.nationalCode ?? '';
const legalId = payload.legalId ?? null;
const shebaId = this.getRequiredString(payload.sheba, 'sheba');
const inquiryConfig = this.config.inquiries[InquiryType.SHEBA];
if (!inquiryConfig?.url || !inquiryConfig.username || !inquiryConfig.password) {
throw this.formatProviderError(
undefined,
undefined,
'Parsian Sayah inquiry is not configured',
);
}
const token = await this.amitisProvider.getAccessToken(
ProviderName.PARSIAN,
InquiryType.SHEBA,
inquiryConfig.username,
inquiryConfig.password,
);
try {
const response = await axios.post<ParsianSayahResponse>(
inquiryConfig.url,
{
AccountOwnerType: accountOwnerType,
NationalId: nationalId,
LegalId: legalId,
ShebaId: shebaId,
},
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
timeout: this.config.timeout,
},
);
if (
(response.data.IsSucceed === false || response.data.isSucceed === false) &&
this.hasErrors(response.data.Errors ?? response.data.errors)
) {
throw this.formatProviderError(
this.formatErrors(response.data.Errors ?? response.data.errors),
'SAYAH_HAS_ERROR',
);
}
return { raw: response.data };
} catch (error) {
if (error instanceof AxiosError) {
const data = error.response?.data as ParsianSayahResponse | undefined;
throw this.formatProviderError(
this.formatErrors(data?.Errors ?? data?.errors) ?? error.message,
String(error.response?.status ?? 'NETWORK_ERROR'),
);
}
throw error;
}
}
private getRequiredString(value: unknown, fieldName: string): string {
if (typeof value !== 'string' || !value.trim()) {
throw this.formatProviderError(undefined, undefined, `${fieldName} is required`);
}
return value.trim();
}
private hasSoapCredentials(
inquiryConfig: ProviderEnvConfig['inquiries'][InquiryType],
): inquiryConfig is NonNullable<ProviderEnvConfig['inquiries'][InquiryType]> {
return Boolean(inquiryConfig?.url && inquiryConfig.username && inquiryConfig.password);
}
private buildCarPolicyEnvelope(
methodName: string,
fields: Record<string, string>,
username: string,
password: string,
passwordFieldName: string,
): string {
const fieldXml = Object.entries(fields)
.map(([name, value]) => ` <${name}>${this.escapeXml(value)}</${name}>`)
.join('\n');
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>
<${methodName} xmlns="http://tempuri.org/">
${fieldXml}
<UserName>${this.escapeXml(username)}</UserName>
<${passwordFieldName}>${this.escapeXml(password)}</${passwordFieldName}>
</${methodName}>
</soap:Body>
</soap:Envelope>`;
}
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 extractSoapValue(xml: string, tagName: string): string | null {
const match = xml.match(
new RegExp(`<(?:\\w+:)?${tagName}[^>]*>([\\s\\S]*?)</(?:\\w+:)?${tagName}>`),
);
return match ? this.decodeXml(match[1].trim()) : null;
}
private escapeXml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
private decodeXml(value: string): string {
return value
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/g, '&');
}
private hasErrors(
errors?: Record<string, string> | Array<{ Code?: string; Message?: string }> | null,
): boolean {
if (!errors) return false;
return Array.isArray(errors) ? errors.length > 0 : Object.keys(errors).length > 0;
}
private formatErrors(
errors?: Record<string, string> | Array<{ Code?: string; Message?: string }> | null,
): string | undefined {
if (!this.hasErrors(errors)) return undefined;
if (Array.isArray(errors)) {
return errors.map((error) => `${error.Code}: ${error.Message}`).join(', ');
}
return Object.entries(errors ?? {})
.map(([code, field]) => `${field} (code: ${code})`)
.join(', ');
}
}

View File

@@ -3,6 +3,7 @@ import { MongooseModule } from '@nestjs/mongoose';
import { ProviderFactory } from './factory/provider.factory'; import { ProviderFactory } from './factory/provider.factory';
import { HamtaProvider } from './implementations/hamta.provider'; import { HamtaProvider } from './implementations/hamta.provider';
import { MoallemProvider } from './implementations/moallem.provider'; import { MoallemProvider } from './implementations/moallem.provider';
import { ParsianProvider } from './implementations/parsian.provider';
import { TejaratNouProvider } from './implementations/tejaratnou.provider'; import { TejaratNouProvider } from './implementations/tejaratnou.provider';
import { AmitisProvider } from './implementations/amitis.provider'; import { AmitisProvider } from './implementations/amitis.provider';
import { ProviderOrchestratorService } from './strategy/provider-orchestrator.service'; import { ProviderOrchestratorService } from './strategy/provider-orchestrator.service';
@@ -24,6 +25,7 @@ import { CachedInquiryResultService } from './services/cached-inquiry-result.ser
providers: [ providers: [
HamtaProvider, HamtaProvider,
MoallemProvider, MoallemProvider,
ParsianProvider,
TejaratNouProvider, TejaratNouProvider,
AmitisProvider, AmitisProvider,
GeneralTokenService, GeneralTokenService,

View File

@@ -94,7 +94,10 @@
"SHEBA_INQUIRY", "SHEBA_INQUIRY",
"SHAHKAR_INQUIRY", "SHAHKAR_INQUIRY",
"LEGAL_PERSON_INQUIRY", "LEGAL_PERSON_INQUIRY",
"CAR_PLATE_INQUIRY" "CAR_PLATE_INQUIRY",
"POLICY_BY_CHASSIS_INQUIRY",
"POLICY_BY_PLATE_INQUIRY",
"POLICY_BY_NATIONAL_CODE_INQUIRY"
], ],
"requestLimitPerMinute": 60, "requestLimitPerMinute": 60,
"requestLimitPerDay": 10000, "requestLimitPerDay": 10000,