fanavaran rate bugs fixed + sms of claimId and claimNo added

This commit is contained in:
2026-08-02 11:33:24 +03:30
parent 86f8b829fd
commit 8f66502c49
10 changed files with 1205 additions and 438 deletions

View File

@@ -0,0 +1,104 @@
import { FanavaranAuthService } from "./fanavaran-auth.service";
describe("FanavaranAuthService", () => {
it("detects Fanavaran transient try-later messages", () => {
expect(
FanavaranAuthService.isTransientTryLaterError(
"کد پیگیری خطا: 10573755\r\n1405/05/1016:56:16:276\r\n.لطفا پس از چند لحظه مجدد تلاش فرمایید.",
),
).toBe(true);
expect(
FanavaranAuthService.isTransientTryLaterError(
"فیلد شماره بيمه نامه ضروری میباشد",
),
).toBe(false);
});
it("caches token and single-flights concurrent logins", async () => {
const http = {
post: jest.fn(),
};
const audit = {
recordStep: jest.fn().mockResolvedValue(undefined),
extractErrorMessage: (e: unknown) =>
e instanceof Error ? e.message : String(e),
sanitizeErrorDetails: () => ({}),
formatErrorWithTrackingCode: (m: string) => m,
};
let loginCalls = 0;
http.post.mockImplementation((url: string) => {
if (url.includes("GetAppToken")) {
return {
toPromise: undefined,
pipe: undefined,
subscribe: undefined,
// firstValueFrom uses Observable — mock as Observable-like via rxjs
};
}
return {};
});
// Use real firstValueFrom path by mocking httpService.post to return an Observable
const { of, delay } = await import("rxjs");
http.post.mockImplementation((url: string) => {
if (url.includes("GetAppToken")) {
return of({
status: 200,
headers: { apptoken: "app-1" },
data: {},
});
}
loginCalls += 1;
return of({
status: 200,
headers: { authenticationtoken: "auth-1" },
data: {},
}).pipe(delay(20));
});
const service = new FanavaranAuthService(http as any, audit as any);
const [a, b, c] = await Promise.all([
service.getAuthenticationToken("parsian"),
service.getAuthenticationToken("parsian"),
service.getAuthenticationToken("parsian"),
]);
expect(a).toBe("auth-1");
expect(b).toBe("auth-1");
expect(c).toBe("auth-1");
expect(loginCalls).toBe(1);
// Cache hit — no extra login
await service.getAuthenticationToken("parsian");
expect(loginCalls).toBe(1);
});
it("enters tenant backoff on try-later errors", () => {
const service = new FanavaranAuthService({} as any, {} as any);
service.registerFailure(
"parsian",
"کد پیگیری خطا: 1\r\n.لطفا پس از چند لحظه مجدد تلاش فرمایید.",
);
expect(service.isInBackoff("parsian")).toBe(true);
expect(() => service.assertNotInBackoff("parsian")).toThrow(
/backoff/i,
);
});
it("computes next Asia/Tehran midnight expiry after now", () => {
// 2026-08-02 10:00:00 UTC ≈ 13:30 Tehran (UTC+3:30) → same calendar day midnight
const now = Date.parse("2026-08-02T10:00:00.000Z");
const expiry = FanavaranAuthService.getNextMidnightExpiryMs(now);
expect(expiry).toBeGreaterThan(now);
// Must land within ~14h (before next Tehran midnight)
expect(expiry - now).toBeLessThanOrEqual(24 * 60 * 60 * 1000);
expect(expiry - now).toBeGreaterThan(0);
// Just after Tehran midnight: 2026-08-01 20:30:01 UTC = 2026-08-02 00:00:01 Tehran
const justAfterMidnight = Date.parse("2026-08-01T20:30:01.000Z");
const next = FanavaranAuthService.getNextMidnightExpiryMs(justAfterMidnight);
expect(next - justAfterMidnight).toBeGreaterThan(23 * 60 * 60 * 1000);
});
});

View File

@@ -0,0 +1,433 @@
import { HttpService } from "@nestjs/axios";
import {
BadGatewayException,
HttpException,
Injectable,
Logger,
ServiceUnavailableException,
} from "@nestjs/common";
import { isAxiosError } from "axios";
import { firstValueFrom } from "rxjs";
import {
getFanavaranClientProfile,
type FanavaranAuthConfig,
type FanavaranClientKey,
} from "src/core/config/fanavaran-client.config";
import { FanavaranAuditService } from "./fanavaran-audit.service";
import type { FanavaranAuditSession } from "./fanavaran-audit.types";
import {
FanavaranAuditStatus,
FanavaranAuditStep,
} from "./schema/fanavaran-audit-log.schema";
interface CachedFanavaranAuth {
authenticationToken: string;
/** Epoch ms when the cached token should be refreshed. */
expiresAt: number;
}
interface TenantBackoffState {
until: number;
reason: string;
}
/** Shared Fanavaran auth: one AppToken+Login per tenant, reused until TTL / backoff. */
@Injectable()
export class FanavaranAuthService {
private readonly logger = new Logger(FanavaranAuthService.name);
private readonly getAppTokenUrl =
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken";
private readonly loginUrl =
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login";
/** When Fanavaran says "try again later", pause all tenant calls. */
static readonly TRANSIENT_BACKOFF_MS = 5 * 60 * 1000;
/**
* Fanavaran authenticationToken is valid until local midnight (Asia/Tehran).
* First call after 00:00 gets a fresh token; daytime calls reuse the cache.
*/
static readonly TOKEN_TIME_ZONE = "Asia/Tehran";
private readonly tokenCache = new Map<FanavaranClientKey, CachedFanavaranAuth>();
private readonly inflightLogin = new Map<
FanavaranClientKey,
Promise<string>
>();
private readonly backoffByTenant = new Map<
FanavaranClientKey,
TenantBackoffState
>();
constructor(
private readonly httpService: HttpService,
private readonly fanavaranAuditService: FanavaranAuditService,
) {}
/** True when Fanavaran asked us to wait (Persian “try again later” / tracking-code 500). */
static isTransientTryLaterError(errorOrMessage: unknown): boolean {
const message = FanavaranAuthService.extractMessage(errorOrMessage);
if (!message) return false;
return (
message.includes("لطفا پس از چند لحظه مجدد تلاش") ||
message.includes("مجدد تلاش فرمایید") ||
/try again later/i.test(message)
);
}
static extractMessage(errorOrMessage: unknown): string {
if (typeof errorOrMessage === "string") return errorOrMessage;
if (isAxiosError(errorOrMessage)) {
const data = errorOrMessage.response?.data as
| { Message?: string; message?: string }
| string
| undefined;
if (typeof data === "string") return data;
return (
data?.Message ||
data?.message ||
errorOrMessage.message ||
""
);
}
if (errorOrMessage instanceof Error) return errorOrMessage.message;
return errorOrMessage == null ? "" : String(errorOrMessage);
}
/**
* Epoch ms of the next 00:00:00 in Asia/Tehran after `now`.
* If `now` is exactly midnight Tehran, returns the following midnight.
*/
static getNextMidnightExpiryMs(
nowMs: number = Date.now(),
timeZone: string = FanavaranAuthService.TOKEN_TIME_ZONE,
): number {
const parts = Object.fromEntries(
new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
})
.formatToParts(new Date(nowMs))
.filter((p) => p.type !== "literal")
.map((p) => [p.type, p.value]),
) as Record<string, string>;
const hour = Number(parts.hour);
const minute = Number(parts.minute);
const second = Number(parts.second);
const msIntoDay = ((hour * 60 + minute) * 60 + second) * 1000;
const msPerDay = 24 * 60 * 60 * 1000;
const remaining = msPerDay - msIntoDay;
// Exactly at midnight → treat as expired for current day; expire at next midnight.
return nowMs + (remaining === 0 ? msPerDay : remaining);
}
getBackoffRemainingMs(clientKey: FanavaranClientKey): number {
const state = this.backoffByTenant.get(clientKey);
if (!state) return 0;
return Math.max(0, state.until - Date.now());
}
isInBackoff(clientKey: FanavaranClientKey): boolean {
return this.getBackoffRemainingMs(clientKey) > 0;
}
/**
* Call after any Fanavaran HTTP failure. Sets a tenant-wide pause when the
* error is the familiar “try again later” overload response.
*/
registerFailure(clientKey: FanavaranClientKey, error: unknown): void {
if (!FanavaranAuthService.isTransientTryLaterError(error)) return;
const until = Date.now() + FanavaranAuthService.TRANSIENT_BACKOFF_MS;
const reason = FanavaranAuthService.extractMessage(error).slice(0, 500);
this.backoffByTenant.set(clientKey, { until, reason });
// Token may still be valid, but Fanavaran is rejecting work — keep token,
// just stop hammering Login/business APIs.
this.logger.warn(
`[${clientKey}] Fanavaran transient backoff until ${new Date(until).toISOString()}: ${reason}`,
);
}
clearBackoff(clientKey: FanavaranClientKey): void {
this.backoffByTenant.delete(clientKey);
}
invalidateToken(clientKey: FanavaranClientKey): void {
this.tokenCache.delete(clientKey);
}
assertNotInBackoff(clientKey: FanavaranClientKey): void {
const remaining = this.getBackoffRemainingMs(clientKey);
if (remaining <= 0) return;
const state = this.backoffByTenant.get(clientKey);
throw new ServiceUnavailableException(
`Fanavaran tenant "${clientKey}" is in backoff for ${Math.ceil(remaining / 1000)}s after transient errors. ${state?.reason ?? ""}`.trim(),
);
}
async getAuthenticationToken(
clientKey: FanavaranClientKey,
options?: {
auditSession?: FanavaranAuditSession;
forceRefresh?: boolean;
},
): Promise<string> {
this.assertNotInBackoff(clientKey);
if (!options?.forceRefresh) {
const cached = this.tokenCache.get(clientKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.authenticationToken;
}
} else {
this.invalidateToken(clientKey);
}
const existing = this.inflightLogin.get(clientKey);
if (existing) {
return existing;
}
const loginPromise = this.loginFresh(clientKey, options?.auditSession);
this.inflightLogin.set(clientKey, loginPromise);
try {
return await loginPromise;
} finally {
this.inflightLogin.delete(clientKey);
}
}
async getRequestHeaders(
clientKey: FanavaranClientKey,
options?: {
auditSession?: FanavaranAuditSession;
forceRefresh?: boolean;
},
): Promise<{
authenticationToken: string;
CorpId: string;
ContractId: string;
Location: string;
}> {
const profile = getFanavaranClientProfile(clientKey);
const authenticationToken = await this.getAuthenticationToken(
clientKey,
options,
);
return {
authenticationToken,
CorpId: profile.auth.corpId,
ContractId: profile.auth.contractId,
Location: profile.auth.location,
};
}
private async loginFresh(
clientKey: FanavaranClientKey,
auditSession?: FanavaranAuditSession,
): Promise<string> {
const profile = getFanavaranClientProfile(clientKey);
const appToken = await this.fetchAppToken(profile.auth, auditSession);
const authenticationToken = await this.fetchLoginToken(
appToken,
profile.auth,
auditSession,
);
const expiresAt = FanavaranAuthService.getNextMidnightExpiryMs();
this.tokenCache.set(clientKey, {
authenticationToken,
expiresAt,
});
this.clearBackoff(clientKey);
this.logger.log(
`[${clientKey}] Cached Fanavaran authenticationToken until ${new Date(
expiresAt,
).toISOString()} (${FanavaranAuthService.TOKEN_TIME_ZONE} midnight)`,
);
return authenticationToken;
}
private emptyBodyTransformRequest() {
return [
(_data: unknown, headers?: Record<string, unknown>) => {
if (headers) {
delete headers["Content-Type"];
delete headers["content-type"];
}
return _data;
},
];
}
private async fetchAppToken(
config: Pick<FanavaranAuthConfig, "appName" | "secret">,
auditSession?: FanavaranAuditSession,
): Promise<string> {
const startedAt = Date.now();
if (auditSession) {
await this.fanavaranAuditService.recordStep({
session: auditSession,
step: FanavaranAuditStep.GET_APP_TOKEN,
status: FanavaranAuditStatus.STARTED,
requestUrl: this.getAppTokenUrl,
requestMeta: { appName: config.appName, cached: false },
});
}
try {
const response = await firstValueFrom(
this.httpService.post(this.getAppTokenUrl, "", {
headers: {
appname: config.appName,
secret: config.secret,
"Content-Length": "0",
},
transformRequest: this.emptyBodyTransformRequest(),
}),
);
const appToken =
response.headers.apptoken ||
response.headers.appToken ||
response.headers["apptoken"] ||
response.headers["appToken"];
if (!appToken) {
throw new BadGatewayException("Failed to get Fanavaran appToken");
}
if (auditSession) {
await this.fanavaranAuditService.recordStep({
session: auditSession,
step: FanavaranAuditStep.GET_APP_TOKEN,
status: FanavaranAuditStatus.SUCCESS,
requestUrl: this.getAppTokenUrl,
httpStatus: response.status,
responseMeta: { hasAppToken: true },
durationMs: Date.now() - startedAt,
});
}
return appToken;
} catch (error) {
if (auditSession) {
await this.fanavaranAuditService.recordStep({
session: auditSession,
step: FanavaranAuditStep.GET_APP_TOKEN,
status: FanavaranAuditStatus.FAILURE,
requestUrl: this.getAppTokenUrl,
httpStatus: isAxiosError(error) ? error.response?.status : undefined,
errorMessage: this.fanavaranAuditService.extractErrorMessage(error),
errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error),
durationMs: Date.now() - startedAt,
});
}
throw this.toGatewayError(error, "Failed to get appToken from external API", auditSession);
}
}
private async fetchLoginToken(
appToken: string,
config: Pick<FanavaranAuthConfig, "username" | "password">,
auditSession?: FanavaranAuditSession,
): Promise<string> {
const startedAt = Date.now();
if (auditSession) {
await this.fanavaranAuditService.recordStep({
session: auditSession,
step: FanavaranAuditStep.LOGIN,
status: FanavaranAuditStatus.STARTED,
requestUrl: this.loginUrl,
requestMeta: { userName: config.username, cached: false },
});
}
try {
const response = await firstValueFrom(
this.httpService.post(this.loginUrl, "", {
headers: {
appToken,
userName: config.username,
password: config.password,
"Content-Length": "0",
},
transformRequest: this.emptyBodyTransformRequest(),
}),
);
const authenticationToken =
response.headers.authenticationtoken ||
response.headers.authenticationToken ||
response.headers["authenticationtoken"] ||
response.headers["authenticationToken"] ||
response.data?.authenticationtoken ||
response.data?.authenticationToken ||
response.data?.authentication_token;
if (!authenticationToken) {
throw new BadGatewayException(
"Failed to get Fanavaran authenticationToken",
);
}
if (auditSession) {
await this.fanavaranAuditService.recordStep({
session: auditSession,
step: FanavaranAuditStep.LOGIN,
status: FanavaranAuditStatus.SUCCESS,
requestUrl: this.loginUrl,
httpStatus: response.status,
responseMeta: { hasAuthenticationToken: true },
durationMs: Date.now() - startedAt,
});
}
return authenticationToken;
} catch (error) {
if (auditSession) {
await this.fanavaranAuditService.recordStep({
session: auditSession,
step: FanavaranAuditStep.LOGIN,
status: FanavaranAuditStatus.FAILURE,
requestUrl: this.loginUrl,
httpStatus: isAxiosError(error) ? error.response?.status : undefined,
errorMessage: this.fanavaranAuditService.extractErrorMessage(error),
errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error),
durationMs: Date.now() - startedAt,
});
}
throw this.toGatewayError(error, "Failed to login to external API", auditSession);
}
}
private toGatewayError(
error: unknown,
fallback: string,
auditSession?: FanavaranAuditSession,
): HttpException {
if (error instanceof HttpException) {
return error;
}
const message = isAxiosError(error)
? error.response?.data?.Message ||
error.response?.data?.message ||
error.message ||
fallback
: fallback;
return new BadGatewayException(
this.fanavaranAuditService.formatErrorWithTrackingCode(
String(message),
auditSession?.trackingCode,
),
);
}
}

View File

@@ -2,6 +2,8 @@ import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { createHttpModuleOptions } from "src/core/config/http-proxy.factory";
import { FanavaranAuditModule } from "./fanavaran-audit.module";
import { FanavaranAuthService } from "./fanavaran-auth.service";
import { FanavaranLookupService } from "./fanavaran-lookup.service";
@Module({
@@ -11,8 +13,9 @@ import { FanavaranLookupService } from "./fanavaran-lookup.service";
inject: [ConfigService],
useFactory: createHttpModuleOptions,
}),
FanavaranAuditModule,
],
providers: [FanavaranLookupService],
exports: [FanavaranLookupService],
providers: [FanavaranAuthService, FanavaranLookupService],
exports: [FanavaranAuthService, FanavaranLookupService],
})
export class FanavaranLookupModule {}

View File

@@ -9,26 +9,22 @@ import {
} from "@nestjs/common";
import { firstValueFrom } from "rxjs";
import { isAxiosError } from "axios";
import {
getFanavaranClientProfile,
type FanavaranClientKey,
} from "src/core/config/fanavaran-client.config";
import { type FanavaranClientKey } from "src/core/config/fanavaran-client.config";
import {
FANAVARAN_LOOKUP_BASE_URL,
fanavaranLookupCacheDir,
tejaratStaticAccidentFilePath,
} from "./fanavaran-lookup.config";
import { FanavaranAuthService } from "./fanavaran-auth.service";
@Injectable()
export class FanavaranLookupService {
private readonly logger = new Logger(FanavaranLookupService.name);
private readonly getAppTokenUrl =
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken";
private readonly loginUrl =
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login";
constructor(private readonly httpService: HttpService) {}
constructor(
private readonly httpService: HttpService,
private readonly fanavaranAuthService: FanavaranAuthService,
) {}
private cacheFilePath(clientKey: FanavaranClientKey, fileName: string): string {
return join(fanavaranLookupCacheDir(clientKey), fileName);
@@ -71,91 +67,12 @@ export class FanavaranLookupService {
return JSON.parse(cached) as T;
}
private async getAppToken(config: {
appName: string;
secret: string;
}): Promise<string> {
const response = await firstValueFrom(
this.httpService.post(this.getAppTokenUrl, "", {
headers: {
appname: config.appName,
secret: config.secret,
"Content-Length": "0",
},
transformRequest: [
(_data, headers) => {
if (headers) {
delete headers["Content-Type"];
delete headers["content-type"];
}
return _data;
},
],
}),
);
const appToken =
response.headers.apptoken ||
response.headers.appToken ||
response.headers["apptoken"] ||
response.headers["appToken"];
if (!appToken) {
throw new BadGatewayException("Failed to get Fanavaran appToken");
}
return appToken;
}
private async login(
appToken: string,
config: { username: string; password: string },
): Promise<string> {
const response = await firstValueFrom(
this.httpService.post(this.loginUrl, "", {
headers: {
appToken,
userName: config.username,
password: config.password,
"Content-Length": "0",
},
transformRequest: [
(_data, headers) => {
if (headers) {
delete headers["Content-Type"];
delete headers["content-type"];
}
return _data;
},
],
}),
);
const authenticationToken =
response.headers.authenticationtoken ||
response.headers.authenticationToken ||
response.headers["authenticationtoken"] ||
response.headers["authenticationToken"] ||
response.data?.authenticationtoken ||
response.data?.authenticationToken ||
response.data?.authentication_token;
if (!authenticationToken) {
throw new BadGatewayException("Failed to get Fanavaran authenticationToken");
}
return authenticationToken;
}
async fetchFromFanavaran(
clientKey: FanavaranClientKey,
url: string,
): Promise<unknown> {
const profile = getFanavaranClientProfile(clientKey);
try {
const appToken = await this.getAppToken(profile.auth);
const authenticationToken = await this.login(appToken, profile.auth);
const headers = await this.fanavaranAuthService.getRequestHeaders(clientKey);
this.logger.log(
`[${clientKey}] Calling Fanavaran lookup API: ${url}`,
@@ -164,10 +81,7 @@ export class FanavaranLookupService {
const response = await firstValueFrom(
this.httpService.get(url, {
headers: {
authenticationToken,
CorpId: profile.auth.corpId,
ContractId: profile.auth.contractId,
Location: profile.auth.location,
...headers,
"Content-Type": "application/json",
},
timeout: 20000,
@@ -184,8 +98,10 @@ export class FanavaranLookupService {
`[${clientKey}] Fanavaran lookup response status=${response.status} dataCount=${dataCount}`,
);
this.fanavaranAuthService.clearBackoff(clientKey);
return response.data;
} catch (error) {
this.fanavaranAuthService.registerFailure(clientKey, error);
const message = isAxiosError(error)
? error.response?.data?.Message ||
error.response?.data?.message ||

View File

@@ -55,7 +55,7 @@ export class FanavaranController {
@ApiOperation({
summary: "Preview Fanavaran base claim create payload",
description:
"Builds the GEN.03 third-party-car-financial-claims payload from local claimCases + blameCases without creating a Fanavaran claim.",
"Builds the GEN.03 payload from local claim/blame data. Fanavaran-sourced PolicyId is resolve-once: first call may inquire and caches on the claim; later calls reuse the cache. Auth token is shared until Asia/Tehran midnight. Pass forceRefreshPolicy=true to re-inquire.",
})
@ApiParam({
name: "client",
@@ -71,16 +71,32 @@ export class FanavaranController {
required: false,
description: "When true, returns payload plus mapping debug steps",
})
@ApiQuery({
name: "forceRefreshPolicy",
required: false,
description:
"When true, ignores cached PolicyId and performs a live Fanavaran policy inquiry again",
})
async preview(
@Param("client") client: string,
@Param("claimCaseId") claimCaseId: string,
@Query("debug") debug?: string,
@Query("forceRefreshPolicy") forceRefreshPolicy?: string,
@Query("resolvePolicy") resolvePolicy?: string,
) {
const clientKey = this.parseClientParam(client);
return await this.claimRequestManagementService.previewFanavaranSubmitV2(
claimCaseId,
clientKey,
{ debug: debug === "1" || debug === "true" },
{
debug: debug === "1" || debug === "true",
forceRefreshPolicy:
forceRefreshPolicy === "1" ||
forceRefreshPolicy === "true" ||
resolvePolicy === "1" ||
resolvePolicy === "true",
requirePolicyId: false,
},
);
}