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
# Provider routing (per inquiry type)
PERSON_DEFAULT_PROVIDER=HAMTA
PERSON_DEFAULT_PROVIDER=PARSIAN
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_FALLBACK_ENABLED=true
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_BASE_URL=https://api.hamta.example.com
@@ -34,6 +49,10 @@ HAMTA_SECRET_KEY=
HAMTA_TIMEOUT=10000
HAMTA_ENABLED=true
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_BASE_URL=https://api.moallem.example.com
@@ -44,6 +63,34 @@ MOALLEM_TIMEOUT=10000
MOALLEM_ENABLED=true
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_BASE_URL=https://accounts.tejaratnoins.ir
TEJARATNOU_INQUIRY_BASE_URL=https://gateway.tejaratnoins.ir

View File

@@ -111,14 +111,14 @@ Per-inquiry configuration via environment variables:
```env
# Default provider and fallback chain
PERSON_DEFAULT_PROVIDER=HAMTA
PERSON_FALLBACK_PROVIDERS=MOALLEM
PERSON_DEFAULT_PROVIDER=PARSIAN
PERSON_FALLBACK_PROVIDERS=HAMTA,TEJARATNOU
# Per-inquiry credentials and authentication
HAMTA_PERSON_URL=https://api.example.com/person
HAMTA_PERSON_USERNAME=user123
HAMTA_PERSON_PASSWORD=pass123
HAMTA_PERSON_AUTH_METHOD=AMITIS
PARSIAN_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out
PARSIAN_PERSON_USERNAME=user123
PARSIAN_PERSON_PASSWORD=pass123
PARSIAN_PERSON_AUTH_METHOD=SOAP
# AMITIS authentication service
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 |
|--------------|-----------|-------------|
| `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 |
| `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 |
| `SHEBA_INQUIRY` | MOALLEM | Bank account (SHEBA) validation |
| `SHEBA_INQUIRY` | PARSIAN, HAMTA, MOALLEM | Bank account (SHEBA) validation |
| `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.

View File

@@ -352,9 +352,10 @@ POST /api/inquiry/sheba
### Request Body
```json
{
"accountOwnerType": "REAL",
"nationalId": "0123456789",
"shebaId": "IR123456789012345678901234"
"accountOwnerType": "1",
"nationalCode": "0123456789",
"legalId": null,
"sheba": "IR123456789012345678901234"
}
```
@@ -362,10 +363,10 @@ POST /api/inquiry/sheba
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| accountOwnerType | string | Yes | Account owner type: "REAL" (person) or "LEGAL" (company) |
| nationalId | string | Conditional | Required if accountOwnerType is "REAL" |
| legalId | string | Conditional | Required if accountOwnerType is "LEGAL" |
| shebaId | string | Yes | SHEBA/IBAN number (26 characters starting with IR) |
| accountOwnerType | string | No | Account owner type; defaults to `"1"` in providers |
| nationalCode | string | Yes | Iranian national code (10 digits) |
| legalId | string | No | Legal identifier when needed by the selected account owner type |
| sheba | string | Yes | SHEBA/IBAN number (26 characters starting with IR) |
### Success Response (200 OK)
@@ -398,12 +399,57 @@ curl -X POST https://api.example.com/api/inquiry/sheba \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \
-d '{
"accountOwnerType": "REAL",
"nationalId": "0123456789",
"shebaId": "IR123456789012345678901234"
"accountOwnerType": "1",
"nationalCode": "0123456789",
"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
### 400 Bad Request - Invalid Input
@@ -625,4 +671,4 @@ For API support:
- **Documentation**: https://docs.example.com
- **Status Page**: https://status.example.com
- **Support Email**: support@example.com
- **Emergency**: +98-21-1234-5678
- **Emergency**: +98-21-1234-5678

View File

@@ -112,11 +112,11 @@ HAMTA_ENABLED=true
HAMTA_TIMEOUT=10000
HAMTA_MAX_RETRIES=3
# Person Inquiry (AMITIS auth)
HAMTA_PERSON_URL=https://api.hamta.example.com/api/inquiry/person
HAMTA_PERSON_USERNAME=pa6476
HAMTA_PERSON_PASSWORD=ciiws@sabt92
HAMTA_PERSON_AUTH_METHOD=AMITIS
# Person Inquiry (SOAP auth)
HAMTA_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out
HAMTA_PERSON_USERNAME=hamta.sabtahval
HAMTA_PERSON_PASSWORD=your-password
HAMTA_PERSON_AUTH_METHOD=SOAP
# Real Estate Inquiry (AMITIS auth)
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_PASSWORD=HSY1f6?e@kSJ
HAMTA_LEGAL_PERSON_AUTH_METHOD=AMITIS
```
### MOALLEM Provider
@@ -194,6 +195,50 @@ MOALLEM_LEGAL_PERSON_PASSWORD=
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
```env
@@ -238,22 +283,22 @@ Routing determines which provider handles each inquiry type and whether to use f
#### Person Inquiry (Single Provider)
```env
PERSON_DEFAULT_PROVIDER=TEJARATNOU
PERSON_DEFAULT_PROVIDER=PARSIAN
PERSON_FALLBACK_ENABLED=false
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)
```env
PERSON_DEFAULT_PROVIDER=TEJARATNOU
PERSON_DEFAULT_PROVIDER=PARSIAN
PERSON_FALLBACK_ENABLED=true
PERSON_FALLBACK_PROVIDERS=HAMTA,MOALLEM
PERSON_FALLBACK_PROVIDERS=HAMTA,TEJARATNOU
```
**Behavior**:
1. Try TEJARATNOU first
1. Try PARSIAN first
2. If fails, try HAMTA
3. If fails, try MOALLEM
3. If fails, try TEJARATNOU
4. If all fail, return error
#### Real Estate Inquiry
@@ -265,11 +310,18 @@ REAL_ESTATE_FALLBACK_PROVIDERS=
#### Shahkar Inquiry
```env
SHAHKAR_DEFAULT_PROVIDER=MOALLEM
SHAHKAR_DEFAULT_PROVIDER=PARSIAN
SHAHKAR_FALLBACK_ENABLED=false
SHAHKAR_FALLBACK_PROVIDERS=
```
#### Sheba/Sayah Inquiry
```env
SHEBA_DEFAULT_PROVIDER=PARSIAN
SHEBA_FALLBACK_ENABLED=false
SHEBA_FALLBACK_PROVIDERS=
```
## Provider Behavior Examples
### Scenario 1: All Providers Enabled
@@ -451,13 +503,13 @@ AMITIS_HAMTA_PERSON_PASSWORD=ciiws@sabt92
### New Format (Current)
```env
HAMTA_PERSON_USERNAME=pa6476
HAMTA_PERSON_PASSWORD=ciiws@sabt92
HAMTA_PERSON_AUTH_METHOD=AMITIS
PARSIAN_PERSON_USERNAME=pa6476
PARSIAN_PERSON_PASSWORD=your-password
PARSIAN_PERSON_AUTH_METHOD=SOAP
```
**Key Changes:**
- Removed `AMITIS_` prefix from provider credentials
- Added `AUTH_METHOD` to specify authentication type
- Added per-inquiry `URL` configuration
- AMITIS is now a separate authentication service, not a provider
- AMITIS is now a separate authentication service, not a provider

View File

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

View File

@@ -16,4 +16,10 @@ export class NormalizedErrorDto {
@ApiPropertyOptional({ example: '503' })
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',
LEGAL_PERSON = 'LEGAL_PERSON_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 {
HAMTA = 'HAMTA',
MOALLEM = 'MOALLEM',
PARSIAN = 'PARSIAN',
TEJARATNOU = 'TEJARATNOU',
AMITIS = 'AMITIS',
}

View File

@@ -10,6 +10,7 @@ import { Request, Response } from 'express';
import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
import { ProviderException } from '../exceptions/provider.exception';
import { isNormalizedError } from '../helpers/validation-error.helper';
/**
* Global exception filter — normalizes all errors into BaseInquiryResponseDto
@@ -22,7 +23,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request & { trackingCode?: string }>();
const request = ctx.getRequest<Request & { requestId?: string; trackingCode?: string }>();
const status =
exception instanceof HttpException
@@ -31,9 +32,10 @@ export class AllExceptionsFilter implements ExceptionFilter {
const normalizedError = this.extractNormalizedError(exception);
const trackingCode = request.trackingCode ?? 'UNKNOWN';
const requestId = request.requestId ?? (request.headers['x-request-id'] as string | undefined) ?? 'unknown';
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,
);
@@ -56,13 +58,22 @@ export class AllExceptionsFilter implements ExceptionFilter {
if (exception instanceof HttpException) {
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 =
typeof res === 'string'
? res
: (res as { message?: string | string[] }).message;
return {
code: 'HTTP_ERROR',
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';
import { Observable, tap } from 'rxjs';
import { RequestLogger } from '../helpers/request-logger.helper';
import { formatHttpExceptionMessage } from '../helpers/validation-error.helper';
/**
* Logs HTTP request duration at the controller boundary.
@@ -31,9 +32,10 @@ export class LoggingInterceptor implements NestInterceptor {
);
},
error: (err: unknown) => {
const detail = formatHttpExceptionMessage(err);
this.requestLogger.logFailure(
{ requestId: req.requestId ?? 'unknown', durationMs: Date.now() - start },
`${req.method} ${req.url} failed`,
`${req.method} ${req.url} failed: ${detail}`,
err,
);
},

View File

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

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 { Transform } from 'class-transformer';
import { IsIn, IsNotEmpty, IsOptional, IsString, Length, Matches } from 'class-validator';
export class SayahRequestDto {
@ApiProperty({ example: '1', description: '1 for natural person (always use "1")' })
@ApiPropertyOptional({ example: '1', description: '1 for natural person' })
@IsOptional()
@IsString()
@IsIn(['1'])
accountOwnerType!: string;
accountOwnerType?: string;
@ApiProperty({ example: '4311402422', description: 'Iranian national code (10 digits)' })
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^\d{10}$/, { message: 'nationalId must be exactly 10 digits' })
nationalId!: string;
@Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
nationalCode!: string;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
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()
@Length(26, 26)
@Matches(/^IR\d{24}$/, { message: 'shebaId must start with IR and contain 24 digits' })
shebaId!: string;
@Matches(/^IR\d{24}$/, { message: 'sheba must start with IR and contain 24 digits' })
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 { ShahkarRequestDto } from './dto/shahkar-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';
/**
@@ -65,10 +69,14 @@ export class InquiryController {
@ApiOperation({ summary: 'Real estate inquiry' })
@ApiResponse({ status: 200, type: GenericInquiryResponseDto })
async inquireRealEstate(
@Body() payload: Record<string, unknown>,
@Body() payload: RealEstateRequestDto,
@Req() req: Request & { requestId?: string; trackingCode?: string },
): 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')
@@ -127,6 +135,54 @@ export class InquiryController {
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(
inquiryType: InquiryType,
payload: Record<string, unknown>,

View File

@@ -1,5 +1,5 @@
import './telemetry';
import { Logger, ValidationPipe } from '@nestjs/common';
import { BadRequestException, Logger, ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
@@ -7,6 +7,7 @@ import { NextFunction, Request, Response } from 'express';
import { AppModule } from './app.module';
import { API_KEY_HEADER } from './common/constants/app.constants';
import { OpenTelemetryNestLogger } from './otel-nest-logger';
import { buildValidationErrorResponse } from './common/helpers/validation-error.helper';
import {
recordHttpRequestEnd,
recordHttpRequestStart,
@@ -46,6 +47,13 @@ async function bootstrap(): Promise<void> {
forbidNonWhitelisted: true,
transform: 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 { HamtaProvider } from '../implementations/hamta.provider';
import { MoallemProvider } from '../implementations/moallem.provider';
import { ParsianProvider } from '../implementations/parsian.provider';
import { TejaratNouProvider } from '../implementations/tejaratnou.provider';
/**
@@ -22,11 +23,13 @@ export class ProviderFactory {
private readonly configService: ConfigService,
hamta: HamtaProvider,
moallem: MoallemProvider,
parsian: ParsianProvider,
tejaratnou: TejaratNouProvider,
) {
this.registry = new Map<ProviderName, InquiryProvider>([
[ProviderName.HAMTA, hamta],
[ProviderName.MOALLEM, moallem],
[ProviderName.PARSIAN, parsian],
[ProviderName.TEJARATNOU, tejaratnou],
]);
}

View File

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

View File

@@ -35,9 +35,9 @@ interface ShahkarPayload extends LegacyInquiryPayload {
interface SayahPayload extends LegacyInquiryPayload {
accountOwnerType?: string;
nationalId?: string;
nationalCode?: string;
legalId?: string | null;
shebaId?: string;
sheba?: string;
}
/**
@@ -51,6 +51,7 @@ export class MoallemProvider extends LegacyApiProvider {
InquiryType.SHEBA,
InquiryType.SHAHKAR,
InquiryType.POSTAL_CODE,
InquiryType.REAL_ESTATE,
];
private readonly moallemConfig: ProviderEnvConfig;
@@ -226,10 +227,10 @@ export class MoallemProvider extends LegacyApiProvider {
}
private async inquireSayah(payload: SayahPayload): Promise<{ raw: unknown }> {
const accountOwnerType = this.getRequiredString(payload.accountOwnerType, 'accountOwnerType');
const nationalId = payload.nationalId ?? '';
const accountOwnerType = payload.accountOwnerType ?? '1';
const nationalId = payload.nationalCode ?? '';
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 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 { HamtaProvider } from './implementations/hamta.provider';
import { MoallemProvider } from './implementations/moallem.provider';
import { ParsianProvider } from './implementations/parsian.provider';
import { TejaratNouProvider } from './implementations/tejaratnou.provider';
import { AmitisProvider } from './implementations/amitis.provider';
import { ProviderOrchestratorService } from './strategy/provider-orchestrator.service';
@@ -24,6 +25,7 @@ import { CachedInquiryResultService } from './services/cached-inquiry-result.ser
providers: [
HamtaProvider,
MoallemProvider,
ParsianProvider,
TejaratNouProvider,
AmitisProvider,
GeneralTokenService,

View File

@@ -94,7 +94,10 @@
"SHEBA_INQUIRY",
"SHAHKAR_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,
"requestLimitPerDay": 10000,
@@ -116,4 +119,4 @@
"lastLoginAt": {
"$date": "2026-06-07T12:35:13.319Z"
}
}]
}]