forked from Shared/esg
feat: add car inquiries with resilience, ownership privacy, and retry visibility
Introduce plate, chassis, and third-party car endpoints, a wall-clock inquiry deadline with transport-only retries, and ownership-safe no-match errors so another person's identity never leaks. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
192
src/providers/shared/centinsur-car-policy.client.ts
Normal file
192
src/providers/shared/centinsur-car-policy.client.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { mergeOutboundAxiosConfig } from '../../common/helpers/http-client.helper';
|
||||
import {
|
||||
getCarPolicyProviderError,
|
||||
parseFirstCarPolicy,
|
||||
} from '../../common/helpers/soap-car-policy.helper';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderEnvConfig, InquiryConfig } from '../../config/configuration';
|
||||
|
||||
export interface CarPlateSoapFields {
|
||||
plk1: string;
|
||||
plk2: string;
|
||||
plk3: string;
|
||||
plksrl: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CentInsurCarPolicyClient {
|
||||
async inquireByChassis(
|
||||
config: ProviderEnvConfig,
|
||||
inquiryType: InquiryType,
|
||||
chassisNo: string,
|
||||
): Promise<Record<string, string>> {
|
||||
return this.callSoap(
|
||||
config,
|
||||
inquiryType,
|
||||
InquiryType.POLICY_BY_CHASSIS,
|
||||
'CIIWSPolicyChassis',
|
||||
{ ChassisNo: chassisNo },
|
||||
);
|
||||
}
|
||||
|
||||
async inquireByPlate(
|
||||
config: ProviderEnvConfig,
|
||||
inquiryType: InquiryType,
|
||||
plate: CarPlateSoapFields,
|
||||
): Promise<Record<string, string>> {
|
||||
return this.callSoap(
|
||||
config,
|
||||
inquiryType,
|
||||
InquiryType.POLICY_BY_PLATE,
|
||||
'CIIWSPolicyVehicleMeli',
|
||||
{
|
||||
Plk1: plate.plk1,
|
||||
Plk2: plate.plk2,
|
||||
Plk3: plate.plk3,
|
||||
PlkSrl: plate.plksrl,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async inquireByNationalCode(
|
||||
config: ProviderEnvConfig,
|
||||
inquiryType: InquiryType,
|
||||
nationalCode: string,
|
||||
): Promise<Record<string, string>> {
|
||||
return this.callSoap(
|
||||
config,
|
||||
inquiryType,
|
||||
InquiryType.POLICY_BY_NATIONAL_CODE,
|
||||
'CIIWSPolicyNationalId',
|
||||
{ NationalId: nationalCode },
|
||||
);
|
||||
}
|
||||
|
||||
private async callSoap(
|
||||
config: ProviderEnvConfig,
|
||||
inquiryType: InquiryType,
|
||||
fallbackInquiryType: InquiryType,
|
||||
methodName: string,
|
||||
fields: Record<string, string>,
|
||||
): Promise<Record<string, string>> {
|
||||
const inquiryConfig = this.resolveInquiryConfig(config, inquiryType, fallbackInquiryType);
|
||||
|
||||
if (!this.hasSoapCredentials(inquiryConfig)) {
|
||||
throw new Error(`${inquiryType} is not configured for CentInsur car policy SOAP`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post<string>(
|
||||
inquiryConfig.url,
|
||||
this.buildCarPolicyEnvelope(
|
||||
methodName,
|
||||
fields,
|
||||
inquiryConfig.username,
|
||||
inquiryConfig.password,
|
||||
),
|
||||
mergeOutboundAxiosConfig({
|
||||
headers: {
|
||||
'Content-Type': 'text/xml; charset=utf-8',
|
||||
SOAPAction: `"http://tempuri.org/ICarAllPlcys/${methodName}"`,
|
||||
},
|
||||
timeout: config.timeout,
|
||||
responseType: 'text',
|
||||
}),
|
||||
);
|
||||
|
||||
const policy = parseFirstCarPolicy(response.data);
|
||||
const providerError = getCarPolicyProviderError(response.data, policy);
|
||||
if (providerError) {
|
||||
const error = new Error(providerError.message);
|
||||
(error as Error & { normalizedError: { code: string; message: string } }).normalizedError = {
|
||||
code: providerError.code,
|
||||
message: providerError.message,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return policy;
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
const wrapped = new Error(error.message);
|
||||
(wrapped as Error & { normalizedError: { code: string; message: string } }).normalizedError =
|
||||
{
|
||||
code: String(error.response?.status ?? 'NETWORK_ERROR'),
|
||||
message: error.message,
|
||||
};
|
||||
throw wrapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
resolveInquiryConfig(
|
||||
config: ProviderEnvConfig,
|
||||
inquiryType: InquiryType,
|
||||
fallbackInquiryType: InquiryType,
|
||||
): InquiryConfig {
|
||||
const candidates = [
|
||||
inquiryType,
|
||||
fallbackInquiryType,
|
||||
InquiryType.POLICY_BY_PLATE,
|
||||
InquiryType.POLICY_BY_NATIONAL_CODE,
|
||||
InquiryType.POLICY_BY_CHASSIS,
|
||||
InquiryType.CAR_BY_PLATE,
|
||||
InquiryType.THIRD_PARTY_CAR,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const inquiryConfig = config.inquiries[candidate];
|
||||
if (inquiryConfig?.url) {
|
||||
return inquiryConfig;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url: '',
|
||||
username: '',
|
||||
password: '',
|
||||
apiKey: '',
|
||||
authMethod: 'SOAP',
|
||||
};
|
||||
}
|
||||
|
||||
hasSoapCredentials(inquiryConfig: InquiryConfig | undefined): boolean {
|
||||
return Boolean(inquiryConfig?.url && inquiryConfig.username && inquiryConfig.password);
|
||||
}
|
||||
|
||||
private buildCarPolicyEnvelope(
|
||||
methodName: string,
|
||||
fields: Record<string, string>,
|
||||
username: string,
|
||||
password: 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>
|
||||
<PassWrod>${this.escapeXml(password)}</PassWrod>
|
||||
</${methodName}>
|
||||
</soap:Body>
|
||||
</soap:Envelope>`;
|
||||
}
|
||||
|
||||
private escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
149
src/providers/shared/centinsur-car-provider.support.ts
Normal file
149
src/providers/shared/centinsur-car-provider.support.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { assertCarPolicyOwnedBy } from '../../common/helpers/car-inquiry-safety.helper';
|
||||
import { resolveThirdPartyCarPolicy } from '../../common/helpers/third-party-car-rules.helper';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderEnvConfig } from '../../config/configuration';
|
||||
import { LegacyInquiryPayload } from './legacy-api.provider.abstract';
|
||||
import { CentInsurCarPolicyClient } from './centinsur-car-policy.client';
|
||||
|
||||
export interface CarPlatePayload extends LegacyInquiryPayload {
|
||||
nationalCode?: string;
|
||||
plk1?: string;
|
||||
plk2?: string;
|
||||
plk3?: string;
|
||||
plksrl?: string;
|
||||
}
|
||||
|
||||
export interface CarChassisPayload extends LegacyInquiryPayload {
|
||||
nationalCode?: string;
|
||||
chassisNo?: string;
|
||||
}
|
||||
|
||||
type ErrorFormatter = (
|
||||
providerMessage?: string,
|
||||
providerCode?: string,
|
||||
fallbackMessage?: string,
|
||||
extras?: Record<string, unknown>,
|
||||
) => Error;
|
||||
|
||||
@Injectable()
|
||||
export class CentInsurCarProviderSupport {
|
||||
constructor(private readonly carPolicyClient: CentInsurCarPolicyClient) {}
|
||||
|
||||
async inquireCarByPlate(
|
||||
config: ProviderEnvConfig,
|
||||
payload: CarPlatePayload,
|
||||
formatError: ErrorFormatter,
|
||||
): Promise<{ raw: unknown }> {
|
||||
const nationalCode = this.getRequiredString(payload.nationalCode, 'nationalCode', formatError);
|
||||
const plate = {
|
||||
plk1: this.getRequiredString(payload.plk1, 'plk1', formatError),
|
||||
plk2: this.getRequiredString(payload.plk2, 'plk2', formatError),
|
||||
plk3: this.getRequiredString(payload.plk3, 'plk3', formatError),
|
||||
plksrl: this.getRequiredString(payload.plksrl, 'plksrl', formatError),
|
||||
};
|
||||
|
||||
const policy = await this.carPolicyClient.inquireByPlate(
|
||||
config,
|
||||
InquiryType.CAR_BY_PLATE,
|
||||
plate,
|
||||
);
|
||||
|
||||
assertCarPolicyOwnedBy(nationalCode, policy, formatError);
|
||||
|
||||
return {
|
||||
raw: {
|
||||
nationalCode,
|
||||
...plate,
|
||||
...policy,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async inquireCarByChassis(
|
||||
config: ProviderEnvConfig,
|
||||
payload: CarChassisPayload,
|
||||
formatError: ErrorFormatter,
|
||||
): Promise<{ raw: unknown }> {
|
||||
const nationalCode = this.getRequiredString(payload.nationalCode, 'nationalCode', formatError);
|
||||
const chassisNo = this.getRequiredString(payload.chassisNo, 'chassisNo', formatError);
|
||||
const policy = await this.carPolicyClient.inquireByChassis(
|
||||
config,
|
||||
InquiryType.CAR_BY_CHASSIS,
|
||||
chassisNo,
|
||||
);
|
||||
|
||||
assertCarPolicyOwnedBy(nationalCode, policy, formatError);
|
||||
|
||||
return {
|
||||
raw: {
|
||||
nationalCode,
|
||||
chassisNo,
|
||||
...policy,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async inquireThirdPartyCar(
|
||||
config: ProviderEnvConfig,
|
||||
payload: CarPlatePayload,
|
||||
formatError: ErrorFormatter,
|
||||
): Promise<{ raw: unknown }> {
|
||||
const input = {
|
||||
nationalCode: this.getRequiredString(payload.nationalCode, 'nationalCode', formatError),
|
||||
plk1: this.getRequiredString(payload.plk1, 'plk1', formatError),
|
||||
plk2: this.getRequiredString(payload.plk2, 'plk2', formatError),
|
||||
plk3: this.getRequiredString(payload.plk3, 'plk3', formatError),
|
||||
plksrl: this.getRequiredString(payload.plksrl, 'plksrl', formatError),
|
||||
};
|
||||
|
||||
const resolved = await resolveThirdPartyCarPolicy(input, {
|
||||
byPlate: async () =>
|
||||
this.carPolicyClient.inquireByPlate(config, InquiryType.THIRD_PARTY_CAR, input),
|
||||
byNationalCode: async () =>
|
||||
this.carPolicyClient.inquireByNationalCode(
|
||||
config,
|
||||
InquiryType.THIRD_PARTY_CAR,
|
||||
input.nationalCode,
|
||||
),
|
||||
});
|
||||
|
||||
if (!resolved) {
|
||||
throw formatError(
|
||||
undefined,
|
||||
'INQUIRY_NO_MATCH',
|
||||
'Inquiry returned no matching result',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
raw: {
|
||||
...input,
|
||||
...resolved.policy.raw,
|
||||
selection: {
|
||||
sources: resolved.sources,
|
||||
vehicleGroup: resolved.policy.vehicleGroup,
|
||||
isActive: resolved.policy.isActive,
|
||||
isZeroKm: resolved.policy.isZeroKm,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
hasCarPolicyConfig(config: ProviderEnvConfig, inquiryType: InquiryType): boolean {
|
||||
return this.carPolicyClient.hasSoapCredentials(
|
||||
this.carPolicyClient.resolveInquiryConfig(config, inquiryType, InquiryType.POLICY_BY_PLATE),
|
||||
);
|
||||
}
|
||||
|
||||
private getRequiredString(
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
formatError: ErrorFormatter,
|
||||
): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw formatError(undefined, undefined, `${fieldName} is required`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user