| Method | Route | What it does |
| GET | v2/expert-claim/requests | List claims in WAITING_FOR_DAMAGE_EXPERT queue + factor-validation queue. Query: search, sortBy, page, limit, unifiedStatus, fileType. |
- | GET | v2/expert-claim/request/:claimRequestId | Full claim detail: damaged parts, captured images, documents, priceDrop, blameCase party data, video URLs. Completed claims include Fanavaran claimNo / claimId when available. |
+ | GET | v2/expert-claim/request/:claimRequestId | Full claim detail: damaged parts, captured images, documents, priceDrop, blameCase party data, video URLs, and the effective priceCap (530,000,000 Rial for V1; null for V2–V6 or when disabled). Completed claims include Fanavaran claimNo / claimId when available. |
| POST | v2/expert-claim/assign/:claimRequestId | Lock claim to this expert. Returns assigned, already_assigned_to_you, or 409. |
| GET | v2/expert-claim/request/:claimRequestId/price-drop | Price-drop context: severity labels, coefficient catalog, damaged parts + mapping, suggested car year from blame inquiry. |
| PUT | v2/expert-claim/request/:claimRequestId/price-drop | Calculate and persist price-drop: carPrice × yearCoeff × sumOfCoeffs ÷ 400. |
- | PUT | v2/expert-claim/reply/submit/:claimRequestId | Submit damage assessment reply (priced parts list and daghi). daghi.branchId is used only with the تحویل داغی option. Cap: total ≤ 53 000 000 Toman. A priced-only claim completes immediately; factor claims continue through factor collection/validation. No final owner signature or automatic Fanavaran submission. |
+ | PUT | v2/expert-claim/reply/submit/:claimRequestId | Submit damage assessment reply (priced parts list and daghi). daghi.branchId is used only with the تحویل داغی option. V1 only: total ≤ 530,000,000 Rial; V2–V6 are uncapped. A priced-only claim completes immediately; factor claims continue through factor collection/validation. No final owner signature or automatic Fanavaran submission. |
| PUT | v2/expert-claim/reply/resend/:claimRequestId | Request user to resend documents/photos. One resend per claim lifecycle; returns 422 if already fulfilled. |
| PATCH | v2/expert-claim/:claimRequestId/visit | Ask user to come in person. Unlocks claim, sets claimStatus to NEEDS_REVISION. |
- | PATCH | v2/expert-claim/validate-factors/:claimRequestId | Validate uploaded repair factor invoices. Approve or reject each factor line with totalPayment. Cap applies across all lines (≤ 53 000 000 Toman). Auto-completes when all lines are decided. |
+ | PATCH | v2/expert-claim/validate-factors/:claimRequestId | Validate uploaded repair factor invoices. Approve or reject each factor line with totalPayment. The 530,000,000 Rial all-lines cap applies only to V1. Auto-completes when all lines are decided. |
| PATCH | v2/expert-claim/request/:claimRequestId/damaged-parts | Edit selected damaged parts while the claim is locked by this expert (EXPERT_REVIEWING). |
| GET | v2/expert-claim/outer-parts-catalog | Fanavaran outer car-components catalog (shared with user flow). |
| GET | v2/expert-claim/inner-parts-catalog | Static inner car-parts catalog JSON. |
diff --git a/src/common/utils/inquiry-error.spec.ts b/src/common/utils/inquiry-error.spec.ts
index 4ea2920..cce24da 100644
--- a/src/common/utils/inquiry-error.spec.ts
+++ b/src/common/utils/inquiry-error.spec.ts
@@ -4,13 +4,63 @@ import {
} from "./inquiry-error";
describe("inquiry error messages", () => {
- it("turns ESG not-found responses into a contextual plate message", () => {
+ it("preserves a Persian ESG not-found response", () => {
expect(
getInquiryErrorMessage(
{ success: false, message: "موردی یافت نشد" },
"thirdPartyPlate",
),
- ).toBe("بیمهنامه شخص ثالثی مطابق پلاک و کد ملی واردشده یافت نشد.");
+ ).toBe("موردی یافت نشد");
+ });
+
+ it.each([
+ ["RECORD_NOT_FOUND", "رکوردی یافت نشد", "Provider request failed"],
+ [
+ "INQUIRY_NO_MATCH",
+ "نتیجهای مطابق با اطلاعات وارد شده یافت نشد",
+ "Inquiry returned no matching result",
+ ],
+ ])(
+ "prefers ESG messageFa for %s over the technical message",
+ (code, messageFa, message) => {
+ expect(
+ getInquiryErrorMessage(
+ {
+ error: {
+ code,
+ message,
+ messageFa,
+ providerMessage: message,
+ providerCode: code,
+ },
+ attemptSummary: {
+ attempts: [{ code, message, messageFa }],
+ },
+ },
+ "thirdPartyPlate",
+ ),
+ ).toBe(messageFa);
+ },
+ );
+
+ it("finds messageFa inside an HTTP response envelope", () => {
+ expect(
+ getInquiryErrorMessage(
+ {
+ response: {
+ status: 404,
+ data: {
+ error: {
+ code: "RECORD_NOT_FOUND",
+ message: "Provider request failed",
+ messageFa: "رکوردی یافت نشد",
+ },
+ },
+ },
+ },
+ "thirdPartyPlate",
+ ),
+ ).toBe("رکوردی یافت نشد");
});
it("distinguishes VIN and car-body not-found failures", () => {
diff --git a/src/common/utils/inquiry-error.ts b/src/common/utils/inquiry-error.ts
index 2f08ee8..08ce261 100644
--- a/src/common/utils/inquiry-error.ts
+++ b/src/common/utils/inquiry-error.ts
@@ -87,7 +87,25 @@ export function inquiryErrorStatus(error: unknown): number | undefined {
}
export function extractInquiryProviderMessage(error: unknown): string {
- for (const record of errorRecords(error)) {
+ const records = errorRecords(error);
+
+ // The normalized ESG/Parsian envelope carries the safe user-facing text in
+ // messageFa while `message` and `providerMessage` may remain technical.
+ // Search every envelope level for that explicit Persian field before
+ // considering generic message fields on an outer object.
+ for (const record of records) {
+ for (const key of [
+ "messageFa",
+ "MessageFa",
+ "messageFA",
+ "persianMessage",
+ ] as const) {
+ const message = cleanMessage(record[key]);
+ if (message) return message;
+ }
+ }
+
+ for (const record of records) {
for (const key of ["message", "Message", "detail", "title"] as const) {
const message = cleanMessage(record[key]);
if (message) return message;
@@ -128,13 +146,21 @@ export function isInquiryTimeout(error: unknown): boolean {
const hasPersian = (value: string): boolean => /[\u0600-\u06ff]/.test(value);
const isNotFound = (error: unknown, message: string): boolean => {
- const root = asRecord(error);
- const responseData = asRecord(asRecord(root?.response)?.data);
- const code = String(root?.code ?? responseData?.code ?? "").toUpperCase();
+ const codes = errorRecords(error).flatMap((record) =>
+ [record.code, record.providerCode]
+ .map((code) => String(code ?? "").toUpperCase())
+ .filter(Boolean),
+ );
return (
inquiryErrorStatus(error) === 404 ||
- ["NOT_FOUND", "POLICY_NOT_FOUND", "NO_POLICY", "RECORD_NOT_FOUND"].includes(
- code,
+ codes.some((code) =>
+ [
+ "NOT_FOUND",
+ "POLICY_NOT_FOUND",
+ "NO_POLICY",
+ "RECORD_NOT_FOUND",
+ "INQUIRY_NO_MATCH",
+ ].includes(code),
) ||
/\bnot[ -]?found\b|\bno (?:active |relevant )?(?:record|policy|item)\b|record\.not\.found|موردی یافت نشد|یافت نشد|پیدا نشد|فاقد بیمه(?:| )?نامه/i.test(
message,
@@ -175,6 +201,11 @@ export function getInquiryErrorMessage(
): string {
const providerMessage = extractInquiryProviderMessage(error);
+ // Persian text supplied by the provider is already the intended client
+ // message. Preserve it verbatim instead of replacing it with a local
+ // contextual fallback such as "inquiry not found".
+ if (providerMessage && hasPersian(providerMessage)) return providerMessage;
+
if (isNotFound(error, providerMessage)) return NOT_FOUND_MESSAGES[context];
if (
@@ -208,10 +239,6 @@ export function getInquiryErrorMessage(
return "سرویس استعلام در دسترس نیست. لطفاً کمی بعد دوباره تلاش کنید.";
}
- // A provider's specific Persian validation/business message is already safe
- // and more useful than replacing it with a broad local validation message.
- if (providerMessage && hasPersian(providerMessage)) return providerMessage;
-
if (isInvalidInput(providerMessage) || inquiryErrorStatus(error) === 422) {
return INVALID_MESSAGES[context];
}
diff --git a/src/constants/repair-amount-limits.ts b/src/constants/repair-amount-limits.ts
index a9941eb..56d5665 100644
--- a/src/constants/repair-amount-limits.ts
+++ b/src/constants/repair-amount-limits.ts
@@ -8,13 +8,13 @@ export const REPAIR_LINE_AMOUNT_TOMAN = { // IT IS RIAL FROM NOW ON
MAX: 530_000_000,
} as const;
-/** Max sum of all priced + factor lines in one expert reply / validation (Toman). */
+/** Max sum of all priced + factor lines in one V1 expert reply / validation (Rial). */
export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX;
const ENABLED_VALUES = new Set(["1", "true", "yes", "on", "enabled"]);
/**
- * Returns null when the claim v2 total cap is disabled.
+ * Returns null when the V1 claim total cap is disabled.
*
* Set CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED=true to enforce the cap again.
* Optionally set CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN to override the amount.
diff --git a/src/expert-blame/expert-blame.participants.spec.ts b/src/expert-blame/expert-blame.participants.spec.ts
new file mode 100644
index 0000000..8ca8b4d
--- /dev/null
+++ b/src/expert-blame/expert-blame.participants.spec.ts
@@ -0,0 +1,66 @@
+import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
+import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
+import { ExpertBlameService } from "./expert-blame.service";
+
+describe("ExpertBlameService participant detail contract", () => {
+ it("returns normalized participants and their role assignments from the blame party", async () => {
+ const service = new (ExpertBlameService as any)(
+ ...new Array(13).fill(undefined),
+ ) as any;
+ const expertId = "66ec0e480e321873c0900001";
+
+ service.expireBlameCaseWorkflowLockV2IfStale = jest
+ .fn()
+ .mockResolvedValue(undefined);
+ service.blameRequestDbService = {
+ findByIdWithoutHistory: jest.fn().mockResolvedValue({
+ _id: "66ec0e480e321873c0900002",
+ type: BlameRequestType.THIRD_PARTY,
+ status: CaseStatus.WAITING_FOR_EXPERT,
+ expertInitiated: true,
+ initiatedByFieldExpertId: expertId,
+ workflow: {},
+ parties: [
+ {
+ role: "FIRST",
+ person: { fullName: "Legacy Party Name" },
+ participants: [
+ {
+ participantId: "PERSON_1",
+ nationalCode: "0012345678",
+ birthday: "1370/01/01",
+ unknown: true,
+ },
+ ],
+ participantRoles: {
+ driver: "PERSON_1",
+ vehicleOwner: "PERSON_1",
+ thirdPartyPolicyholder: "PERSON_1",
+ },
+ vehicle: { inquiry: { raw: { large: true } } },
+ },
+ ],
+ createdAt: new Date("2026-09-19T00:00:00.000Z"),
+ updatedAt: new Date("2026-09-19T00:00:00.000Z"),
+ }),
+ };
+
+ const result = await service.findOneV2("blame-1", { sub: expertId });
+ const party = (result.parties as any[])[0];
+
+ expect(party.participants).toEqual([
+ {
+ participantId: "PERSON_1",
+ nationalCode: "0012345678",
+ birthday: "1370/01/01",
+ },
+ ]);
+ expect(party.participantRoles).toEqual({
+ driver: "PERSON_1",
+ vehicleOwner: "PERSON_1",
+ thirdPartyPolicyholder: "PERSON_1",
+ });
+ expect(party.person.fullName).toBe("Legacy Party Name");
+ expect(party.vehicle.inquiry).toBeUndefined();
+ });
+});
diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts
index b682124..e840672 100644
--- a/src/expert-blame/expert-blame.service.ts
+++ b/src/expert-blame/expert-blame.service.ts
@@ -91,6 +91,7 @@ import {
ExpertFileActivityType,
ExpertFileKind,
} from "src/users/entities/schema/expert-file-activity.schema";
+import { sanitizeStoredInquiryParticipants } from "src/request-management/inquiry-participant-resolver";
interface CheckedRequestEntry {
CheckedRequest?: {
@@ -1250,8 +1251,17 @@ export class ExpertBlameService {
doc.createdAtFormatted = `${createdDate} ${createdTime}`;
doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
+ // Keep the normalized blame-party participant contract authoritative for
+ // every expert detail response. Legacy person fields remain for old files,
+ // but new UIs must read participants + participantRoles from the same party.
+ doc.parties = parties.map((party: Record) => ({
+ ...party,
+ participants: sanitizeStoredInquiryParticipants(party.participants),
+ participantRoles: party.participantRoles,
+ }));
+
// Strip heavy SandHub inquiry blob
- for (const party of parties as Array<{
+ for (const party of doc.parties as Array<{
vehicle?: Record;
}>) {
if (
diff --git a/src/expert-claim/dto/claim-detail-v2.dto.ts b/src/expert-claim/dto/claim-detail-v2.dto.ts
index 2b827e7..8221ce6 100644
--- a/src/expert-claim/dto/claim-detail-v2.dto.ts
+++ b/src/expert-claim/dto/claim-detail-v2.dto.ts
@@ -53,11 +53,19 @@ export class ClaimDetailV2ResponseDto {
blameRequestType?: BlameRequestType;
@ApiPropertyOptional({
- description: "How the blame file was initiated: IN_PERSON or LINK",
+ description: "How the blame file was initiated: NORMAL, IN_PERSON, or LINK",
example: "IN_PERSON",
})
creationMethod?: string;
+ @ApiProperty({
+ nullable: true,
+ description:
+ "Maximum expert-reply total for V1 user-created files. Null for V2-V6 flows or when the cap is disabled.",
+ example: 530000000,
+ })
+ priceCap: number | null;
+
@ApiPropertyOptional({
description:
"CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)",
@@ -278,7 +286,7 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({
description:
- "Linked blame case (`blameCases`), same shape as expert-blame detail: parties with video/voice URLs, workflow, expert, formatted dates.",
+ "Linked blame case (`blameCases`), same shape as expert-blame detail. Each party exposes its authoritative normalized `participants` and `participantRoles`, plus video/voice URLs, workflow, expert, and formatted dates.",
})
blameCase?: Record;
diff --git a/src/expert-claim/expert-claim.service.spec.ts b/src/expert-claim/expert-claim.service.spec.ts
index 96e443c..8ab91c8 100644
--- a/src/expert-claim/expert-claim.service.spec.ts
+++ b/src/expert-claim/expert-claim.service.spec.ts
@@ -168,6 +168,88 @@ describe("ExpertClaimService expert-reply pricing", () => {
);
});
+ it("enforces the configured total-payment cap for a V1 user-created file", async () => {
+ const previousEnabled = process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
+ process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = "true";
+
+ try {
+ const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
+ BlameRequestType.THIRD_PARTY,
+ );
+ (service as any).blameRequestDbService.findById.mockResolvedValue({
+ type: BlameRequestType.THIRD_PARTY,
+ creationMethod: "NORMAL",
+ });
+
+ await expect(
+ service.submitExpertReplyV2(
+ "v1-claim",
+ {
+ ...validV2Reply,
+ parts: [
+ {
+ ...validV2Reply.parts[0],
+ salary: "1000000",
+ totalPayment: "600000000",
+ },
+ ],
+ },
+ {
+ sub: V2_EXPERT_ID,
+ fullName: "Expert One",
+ role: RoleEnum.FIELD_EXPERT,
+ },
+ ),
+ ).rejects.toMatchObject({ response: { code: "PRICE_CAP_ERROR" } });
+
+ expect(findByIdAndUpdate).not.toHaveBeenCalled();
+ } finally {
+ if (previousEnabled === undefined) {
+ delete process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
+ } else {
+ process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = previousEnabled;
+ }
+ }
+ });
+
+ it("does not enforce the V1 cap for an expert-initiated flow", async () => {
+ const previousEnabled = process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
+ process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = "true";
+
+ try {
+ const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
+ BlameRequestType.THIRD_PARTY,
+ );
+
+ await service.submitExpertReplyV2(
+ "v3-claim",
+ {
+ ...validV2Reply,
+ parts: [
+ {
+ ...validV2Reply.parts[0],
+ salary: "1000000",
+ totalPayment: "600000000",
+ },
+ ],
+ },
+ {
+ sub: V2_EXPERT_ID,
+ fullName: "Expert One",
+ role: RoleEnum.FIELD_EXPERT,
+ },
+ );
+
+ expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
+ } finally {
+ if (previousEnabled === undefined) {
+ delete process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
+ } else {
+ process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = previousEnabled;
+ }
+ }
+ });
+
it("allows a repair line without daghi and removes a stray daghi payload", () => {
const service = createService() as any;
diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts
index 2d1d9aa..40e6753 100644
--- a/src/expert-claim/expert-claim.service.ts
+++ b/src/expert-claim/expert-claim.service.ts
@@ -156,7 +156,7 @@ import {
ExpertFileKind,
} from "src/users/entities/schema/expert-file-activity.schema";
-/** Maximum sum of line `totalPayment` across the claim (Toman; priced parts + factor lines after validation). */
+/** Configured V1 maximum sum of line `totalPayment` across the claim (Rial). */
import { getClaimV2TotalPaymentCapToman } from "src/constants/repair-amount-limits";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import {
@@ -180,6 +180,7 @@ import {
normalizeMoneyAmountString,
parseMoneyAmountToman,
} from "src/utils/unicode-digits";
+import { claimPriceCapAppliesToBlame } from "src/helpers/claim-price-cap";
@Injectable()
export class ExpertClaimService {
@@ -1694,8 +1695,13 @@ export class ExpertClaimService {
throw new BadRequestException(pricingValidationError);
}
- // Validate total price cap (priced lines sum), when enabled.
- const priceCap = getClaimV2TotalPaymentCapToman();
+ // The total cap is a V1-only rule. Legacy claims embed their blame file.
+ const configuredPriceCap = getClaimV2TotalPaymentCapToman();
+ const priceCap =
+ configuredPriceCap !== null &&
+ claimPriceCapAppliesToBlame(request.blameFile)
+ ? configuredPriceCap
+ : null;
if (priceCap !== null && reply.parts && reply.parts.length > 0) {
let totalPrice = 0;
@@ -1727,7 +1733,7 @@ export class ExpertClaimService {
if (totalPrice > priceCap) {
throw new BadRequestException({
- message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
+ message: `You have reached the maximum acceptable total price (Rial). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
error: "PRICE_CAP_ERROR",
code: "PRICE_CAP_ERROR",
totalPrice: totalPrice,
@@ -2293,7 +2299,8 @@ export class ExpertClaimService {
* Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION.
* — All approved → COMPLETED + APPROVED (expert-entered line totals; no extra owner signature).
* — Any rejected (repriced) → COMPLETED + APPROVED (auto-close for now; owner sign may be added later).
- * When enabled by env, total of all repair lines must be ≤ the claim v2 total payment cap.
+ * For V1 user-created files, when enabled by env, the total of all repair
+ * lines must be no greater than the configured total-payment cap.
* Response: `claimStatus` = `ClaimStatus`; `caseStatus` = `ClaimCaseStatus`.
*/
async validateClaimFactorsV2(
@@ -2433,7 +2440,12 @@ export class ExpertClaimService {
};
}
- const priceCap = getClaimV2TotalPaymentCapToman();
+ const configuredPriceCap = getClaimV2TotalPaymentCapToman();
+ const priceCap =
+ configuredPriceCap !== null &&
+ claimPriceCapAppliesToBlame(await this.loadBlameForClaim(claim))
+ ? configuredPriceCap
+ : null;
if (priceCap !== null) {
let totalPrice = 0;
for (const part of updatedReply.parts || []) {
@@ -2448,7 +2460,7 @@ export class ExpertClaimService {
}
if (totalPrice > priceCap) {
throw new BadRequestException({
- message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
+ message: `You have reached the maximum acceptable total price (Rial). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
error: "PRICE_CAP_ERROR",
totalPrice,
priceCap,
@@ -3259,7 +3271,7 @@ export class ExpertClaimService {
* - Claim must exist
* - Must be locked by this expert (workflow.lockedBy.actorId === actor.sub)
* - Must be in EXPERT_REVIEWING status
- * - Total payment across all parts must not exceed 53,000,000 (same cap as factor validation totals)
+ * - V1 only: total payment across all parts must not exceed the configured cap
* - Each part must include `daghi` (option + conditional price) like V1
*
* On success:
@@ -3364,8 +3376,12 @@ export class ExpertClaimService {
throw new BadRequestException(pricingValidationError);
}
- // Price cap validation, when enabled.
- const priceCap = getClaimV2TotalPaymentCapToman();
+ // The configured total cap is enforced only for V1 user-created files.
+ const configuredPriceCap = getClaimV2TotalPaymentCapToman();
+ const priceCap =
+ configuredPriceCap !== null && claimPriceCapAppliesToBlame(blame)
+ ? configuredPriceCap
+ : null;
if (priceCap !== null) {
let totalPrice = 0;
for (const part of reply.parts || []) {
@@ -3376,7 +3392,7 @@ export class ExpertClaimService {
}
if (totalPrice > priceCap) {
throw new BadRequestException({
- message: `مجموع مبلغ قطعات (${totalPrice.toLocaleString("fa-IR")}) از سقف مجاز (${priceCap.toLocaleString("fa-IR")}) تومان بیشتر است.`,
+ message: `مجموع مبلغ قطعات (${totalPrice.toLocaleString("fa-IR")}) از سقف مجاز (${priceCap.toLocaleString("fa-IR")}) ریال بیشتر است.`,
error: "PRICE_CAP_ERROR",
code: "PRICE_CAP_ERROR",
totalPrice,
@@ -5125,7 +5141,11 @@ export class ExpertClaimService {
claim.blameRequestId
? this.blameRequestDbService.find(
{ _id: new Types.ObjectId(claim.blameRequestId.toString()) },
- { lean: true, select: "type parties expert.decision" },
+ {
+ lean: true,
+ select:
+ "type parties expert.decision creationMethod expertInitiated registrarInitiated callCenterInitiated initiatedByFieldExpertId initiatedByRegistrarId initiatedByCallCenterId",
+ },
)
: Promise.resolve([]),
]);
@@ -5160,6 +5180,11 @@ export class ExpertClaimService {
const blameFileContext = blameLean
? this.blameFileContextForExpert(blameLean)
: {};
+ const configuredPriceCap = getClaimV2TotalPaymentCapToman();
+ const priceCap =
+ configuredPriceCap !== null && claimPriceCapAppliesToBlame(linkedBlame)
+ ? configuredPriceCap
+ : null;
let videoCapture: ClaimDetailV2ResponseDto["videoCapture"] = undefined;
if (videoCaptureRow) {
@@ -5269,6 +5294,7 @@ export class ExpertClaimService {
? this.sanitizeVehicleInquiryForApi(vehiclePayload)
: undefined,
...blameFileContext,
+ priceCap,
blameRequestId: claim.blameRequestId?.toString(),
blameRequestNo: claim.blameRequestNo,
money: moneyPayload,
diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts
index fcfdb99..6e41a1c 100644
--- a/src/expert-claim/expert-claim.v2.controller.ts
+++ b/src/expert-claim/expert-claim.v2.controller.ts
@@ -268,7 +268,7 @@ export class ExpertClaimV2Controller {
@ApiOperation({
summary: "Submit expert damage assessment reply",
description:
- "**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. Every supplied monetary field (`price`, `salary`, `totalPayment`, and `daghi.price`) must be 100,000–10,000,000,000 **Toman**. **Cap:** sum of line `totalPayment` values ≤ 53,000,000 **Toman** (same limit as factor-validation totals across priced + factor lines). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
+ "**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. Every supplied monetary field (`price`, `salary`, `totalPayment`, and `daghi.price`) must be 1,000,000–100,000,000,000 **Rial**. **V1-only cap:** for user-created V1 files, sum of line `totalPayment` values ≤ 530,000,000 **Rial** (same limit as factor-validation totals across priced + factor lines). V2–V6 files are uncapped. Claim detail exposes the effective `priceCap` (`null` when uncapped). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
"**Frontend routing by `ClaimCaseStatus` (`status`):**\n" +
"- **All parts `factorNeeded`:** `OWNER_REPAIR_FACTOR_UPLOAD_PENDING`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors; then `status` becomes **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `currentStep=EXPERT_COST_EVALUATION` for expert **validate-factors**.\n" +
"- **Mixed (some priced, some factorNeeded):** `INSURER_REVIEW_MIXED_FACTORS_PENDING`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance); `currentStep` then moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` (same case `status` until factors are done).\n" +
@@ -353,7 +353,7 @@ export class ExpertClaimV2Controller {
"**Response:** `claimStatus` = `ClaimStatus` (e.g. APPROVED). `caseStatus` = `ClaimCaseStatus` (e.g. COMPLETED vs insurer-review) — they are not interchangeable.\n\n" +
"**Preconditions:** `status=EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`), `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION`, every `factorNeeded` line has `factorLink`.\n\n" +
"**Decisions:** each factor line gets `APPROVED` or `REJECTED`. **Every** decided line must include expert-entered `totalPayment` **or** both `price` and `salary` (factor photos are not read for amounts).\n\n" +
- "**Cap (when every factor line is decided):** sum of **all** reply lines (priced parts + factor lines) must be ≤ **53,000,000 Toman**; otherwise `PRICE_CAP_ERROR` with message that the maximum acceptable total was exceeded.\n\n" +
+ "**V1-only cap (when every factor line is decided):** for user-created V1 files, sum of **all** reply lines (priced parts + factor lines) must be ≤ **530,000,000 Rial**; otherwise `PRICE_CAP_ERROR` is returned. V2–V6 files are uncapped.\n\n" +
"**Outcomes:**\n" +
"- **All approved:** `caseStatus=COMPLETED`, `claimStatus=APPROVED`, workflow `CLAIM_COMPLETED` — no owner signature. V5 instead waits for FileMaker approval.\n" +
"- **Any rejected (repriced):** same completion behavior for now (V5 waits for FileMaker approval).\n" +
diff --git a/src/helpers/claim-price-cap.spec.ts b/src/helpers/claim-price-cap.spec.ts
new file mode 100644
index 0000000..20f8eb4
--- /dev/null
+++ b/src/helpers/claim-price-cap.spec.ts
@@ -0,0 +1,25 @@
+import { CreationMethod } from "src/request-management/entities/schema/request-management.schema";
+import { claimPriceCapAppliesToBlame } from "./claim-price-cap";
+
+describe("claimPriceCapAppliesToBlame", () => {
+ it.each([
+ [{ creationMethod: CreationMethod.NORMAL }],
+ [{}],
+ ])("applies to V1 user-created files (%p)", (blame) => {
+ expect(claimPriceCapAppliesToBlame(blame)).toBe(true);
+ });
+
+ it.each([
+ [{ creationMethod: CreationMethod.LINK, expertInitiated: true }],
+ [{ creationMethod: CreationMethod.IN_PERSON, expertInitiated: true }],
+ [{ creationMethod: CreationMethod.IN_PERSON, registrarInitiated: true }],
+ [{ creationMethod: CreationMethod.LINK, callCenterInitiated: true }],
+ [{ creationMethod: CreationMethod.NORMAL, initiatedByCallCenterId: "agent" }],
+ ])("does not apply to non-V1 files (%p)", (blame) => {
+ expect(claimPriceCapAppliesToBlame(blame)).toBe(false);
+ });
+
+ it("does not apply when the linked blame origin is unavailable", () => {
+ expect(claimPriceCapAppliesToBlame(null)).toBe(false);
+ });
+});
diff --git a/src/helpers/claim-price-cap.ts b/src/helpers/claim-price-cap.ts
new file mode 100644
index 0000000..ecf41a2
--- /dev/null
+++ b/src/helpers/claim-price-cap.ts
@@ -0,0 +1,39 @@
+import { CreationMethod } from "src/request-management/entities/schema/request-management.schema";
+
+export type ClaimPriceCapBlameOrigin = {
+ creationMethod?: CreationMethod | string | null;
+ expertInitiated?: boolean | null;
+ registrarInitiated?: boolean | null;
+ callCenterInitiated?: boolean | null;
+ initiatedByFieldExpertId?: unknown;
+ initiatedByRegistrarId?: unknown;
+ initiatedByCallCenterId?: unknown;
+};
+
+/**
+ * The total-payment cap is a V1 business rule. V1 files are created directly
+ * by a user (`NORMAL`); expert/registrar LINK or IN_PERSON files and V6
+ * call-center files must not inherit it. Missing creationMethod is treated as
+ * NORMAL for older user-created records, while initiator markers take
+ * precedence so legacy non-V1 records cannot be misclassified.
+ */
+export function claimPriceCapAppliesToBlame(
+ blame?: ClaimPriceCapBlameOrigin | null,
+): boolean {
+ if (!blame) return false;
+
+ const hasNonV1Initiator =
+ blame.expertInitiated === true ||
+ blame.registrarInitiated === true ||
+ blame.callCenterInitiated === true ||
+ blame.initiatedByFieldExpertId != null ||
+ blame.initiatedByRegistrarId != null ||
+ blame.initiatedByCallCenterId != null;
+
+ if (hasNonV1Initiator) return false;
+
+ return (
+ blame.creationMethod == null ||
+ blame.creationMethod === CreationMethod.NORMAL
+ );
+}
diff --git a/src/request-management/inquiry-participant-resolver.spec.ts b/src/request-management/inquiry-participant-resolver.spec.ts
index 20553b9..b807e18 100644
--- a/src/request-management/inquiry-participant-resolver.spec.ts
+++ b/src/request-management/inquiry-participant-resolver.spec.ts
@@ -5,7 +5,6 @@ import {
VehicleRegistrationState,
} from "src/common/dto/inquiry-participants.dto";
import {
- assertPreviousPlateInquiryMatchesVin,
isMappedPolicyCurrent,
normalizeInquirySubmission,
participantForRole,
@@ -13,9 +12,8 @@ import {
resolveInquiryParticipants,
resolveInquirySubjects,
resolveInquiryVehicle,
- runPlateInquiryWithFallback,
+ runCurrentPlateInquiry,
sanitizeStoredInquiryParticipants,
- vehiclePlateCandidates,
} from "./inquiry-participant-resolver";
describe("inquiry participant resolver", () => {
@@ -309,42 +307,6 @@ describe("inquiry participant resolver", () => {
).toBe("0022222222");
});
- it("orders the current plate before the previous-plate fallback", () => {
- const currentPlate = {
- leftDigits: "44",
- centerAlphabet: "ب",
- centerDigits: "111",
- ir: "22",
- };
- const previousPlate = {
- leftDigits: "55",
- centerAlphabet: "ج",
- centerDigits: "222",
- ir: "33",
- };
-
- expect(
- vehiclePlateCandidates({
- registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
- currentPlate,
- previousPlate,
- vin: "NAAM01E15HK123456",
- previousPolicyholderNationalCode: "0098765432",
- }),
- ).toEqual([
- { kind: "CURRENT", plate: currentPlate },
- { kind: "PREVIOUS", plate: previousPlate },
- ]);
- });
-
- it("rejects a previous-plate result for another chassis", () => {
- expect(() =>
- assertPreviousPlateInquiryMatchesVin("NAAM01E15HK123456", {
- VinNumberField: "DIFFERENTVIN00001",
- }),
- ).toThrow(BadRequestException);
- });
-
it("requires the driver's licence status in the new contract", () => {
expect(() =>
resolveInquiryParticipants(BlameRequestType.THIRD_PARTY, {
@@ -357,58 +319,36 @@ describe("inquiry participant resolver", () => {
).toThrow(BadRequestException);
});
- it("falls back to the previous plate and accepts only a matching VIN", async () => {
+ it("runs only one inquiry for the current plate", async () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
};
- const previousPlate = {
- leftDigits: "55",
- centerAlphabet: "ج",
- centerDigits: "222",
- ir: "33",
- };
- const query = jest
- .fn()
- .mockRejectedValueOnce(new Error("not found"))
- .mockResolvedValueOnce({
- mapped: { VinNumberField: "NAAM01E15HK123456", CompanyName: "پارسیان" },
- });
-
- const result = await runPlateInquiryWithFallback<{
- mapped: { VinNumberField?: string; CompanyName?: string };
- }>({
- vehicle: {
- registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
- currentPlate,
- previousPlate,
- vin: "NAAM01E15HK123456",
- previousPolicyholderNationalCode: "0098765432",
- },
- fallbackCurrentPlate: currentPlate,
- query,
- isUsable: (value) => !!value.mapped.CompanyName,
- mappedValue: (value) => value.mapped,
+ const query = jest.fn().mockResolvedValue({
+ mapped: { CompanyName: "پارسیان" },
});
- expect(query).toHaveBeenCalledTimes(2);
- expect(query).toHaveBeenNthCalledWith(1, currentPlate, "CURRENT");
- expect(query).toHaveBeenNthCalledWith(2, previousPlate, "PREVIOUS");
- expect(result.plateKind).toBe("PREVIOUS");
+ const result = await runCurrentPlateInquiry<{
+ mapped: { CompanyName?: string };
+ }>({
+ currentPlate,
+ vin: "NAAM01E15HK123456",
+ query,
+ isUsable: (value) => !!value.mapped.CompanyName,
+ });
+
+ expect(query).toHaveBeenCalledTimes(1);
+ expect(query).toHaveBeenCalledWith(currentPlate);
+ expect(result.plateKind).toBe("CURRENT");
expect(result.attempts).toMatchObject([
- { plateKind: "CURRENT", succeeded: false, error: "not found" },
- { plateKind: "PREVIOUS", succeeded: true, usable: true },
+ { plateKind: "CURRENT", succeeded: true, usable: true },
]);
expect(result.attempts[0]).toMatchObject({
plate: currentPlate,
vin: "NAAM01E15HK123456",
});
- expect(result.attempts[1]).toMatchObject({
- plate: previousPlate,
- vin: "NAAM01E15HK123456",
- });
});
it("rejects a car-body policyholder on a third-party case", () => {
@@ -510,95 +450,72 @@ describe("inquiry participant resolver", () => {
);
});
- it("falls back from a stale current policy to a current previous-plate policy", async () => {
+ it("rejects a stale current policy without trying another plate", async () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
};
- const previousPlate = {
- leftDigits: "55",
- centerAlphabet: "ج",
- centerDigits: "222",
- ir: "33",
- };
- const query = jest
- .fn()
- .mockResolvedValueOnce({
- mapped: { CompanyName: "پارسیان", EndDate: "1404/01/01" },
- })
- .mockResolvedValueOnce({
- mapped: {
- CompanyName: "پارسیان",
- EndDate: "1406/01/01",
- VinNumberField: "NAAM01E15HK123456",
- },
- });
+ const query = jest.fn().mockResolvedValue({
+ mapped: { CompanyName: "پارسیان", EndDate: "1404/01/01" },
+ });
- const result = await runPlateInquiryWithFallback<{
- mapped: Record;
- }>({
- vehicle: resolveInquiryVehicle({
- registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
+ await expect(
+ runCurrentPlateInquiry<{ mapped: Record }>({
currentPlate,
- previousPlate,
- vin: "NAAM01E15HK123456",
- previousPolicyholderNationalCode: "0098765432",
+ query,
+ isUsable: (value) =>
+ !!value.mapped.CompanyName &&
+ isMappedPolicyCurrent(value.mapped, "2026-09-13"),
}),
- fallbackCurrentPlate: currentPlate,
- query,
- isUsable: (value) =>
- !!value.mapped.CompanyName &&
- isMappedPolicyCurrent(value.mapped, "2026-09-13"),
- mappedValue: (value) => value.mapped,
- });
-
- expect(result.plateKind).toBe("PREVIOUS");
- expect(result.attempts[0]).toMatchObject({
- plateKind: "CURRENT",
- succeeded: true,
- usable: false,
+ ).rejects.toMatchObject({
+ attempts: [{ plateKind: "CURRENT", succeeded: true, usable: false }],
});
+ expect(query).toHaveBeenCalledTimes(1);
});
- it("rejects the result and retains audit attempts when every plate is unusable", async () => {
+ it("retains the current-plate audit attempt when the result is unusable", async () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
centerDigits: "111",
ir: "22",
};
- const previousPlate = {
- leftDigits: "55",
- centerAlphabet: "ج",
- centerDigits: "222",
- ir: "33",
- };
await expect(
- runPlateInquiryWithFallback({
- vehicle: resolveInquiryVehicle({
- registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
- currentPlate,
- previousPlate,
- vin: "NAAM01E15HK123456",
- previousPolicyholderNationalCode: "0098765432",
- }),
- fallbackCurrentPlate: currentPlate,
+ runCurrentPlateInquiry({
+ currentPlate,
+ vin: "NAAM01E15HK123456",
query: async () => ({ mapped: { CompanyName: "پارسیان" } }),
isUsable: () => false,
- mappedValue: (value) => value.mapped,
}),
).rejects.toMatchObject({
- attempts: [
- { plateKind: "CURRENT", succeeded: true, usable: false },
- { plateKind: "PREVIOUS", succeeded: true, usable: false },
- ],
+ attempts: [{ plateKind: "CURRENT", succeeded: true, usable: false }],
});
});
- it("does not use the previous plate after a transport or provider outage", async () => {
+ it("preserves a mapped provider message when the current result is unusable", async () => {
+ const currentPlate = {
+ leftDigits: "44",
+ centerAlphabet: "ب",
+ centerDigits: "111",
+ ir: "22",
+ };
+
+ await expect(
+ runCurrentPlateInquiry({
+ currentPlate,
+ query: async () => ({
+ mapped: { Error: { Message: "رکوردی یافت نشد" } },
+ }),
+ isUsable: () => false,
+ errorMessage: (value) => value.mapped.Error.Message,
+ }),
+ ).rejects.toThrow("رکوردی یافت نشد");
+ });
+
+ it("retains one failed current-plate attempt after a provider outage", async () => {
const currentPlate = {
leftDigits: "44",
centerAlphabet: "ب",
@@ -612,23 +529,11 @@ describe("inquiry participant resolver", () => {
);
await expect(
- runPlateInquiryWithFallback({
- vehicle: resolveInquiryVehicle({
- registrationState: VehicleRegistrationState.RECENTLY_TRANSFERRED,
- currentPlate,
- previousPlate: {
- leftDigits: "55",
- centerAlphabet: "ج",
- centerDigits: "222",
- ir: "33",
- },
- vin: "NAAM01E15HK123456",
- previousPolicyholderNationalCode: "0098765432",
- }),
- fallbackCurrentPlate: currentPlate,
+ runCurrentPlateInquiry({
+ currentPlate,
+ vin: "NAAM01E15HK123456",
query,
isUsable: () => false,
- mappedValue: () => ({}),
}),
).rejects.toThrow("upstream timeout");
expect(query).toHaveBeenCalledTimes(1);
diff --git a/src/request-management/inquiry-participant-resolver.ts b/src/request-management/inquiry-participant-resolver.ts
index 550a352..de9e8e0 100644
--- a/src/request-management/inquiry-participant-resolver.ts
+++ b/src/request-management/inquiry-participant-resolver.ts
@@ -393,26 +393,6 @@ export function resolveInquiryVehicle(
};
}
-export function vehiclePlateCandidates(input?: ResolvedInquiryVehicle): Array<{
- kind: "CURRENT" | "PREVIOUS";
- plate: InquiryVehicleInputDto["currentPlate"];
-}> {
- if (!input) return [];
- return [
- { kind: "CURRENT" as const, plate: input.currentPlate },
- ...(input.registrationState ===
- VehicleRegistrationState.RECENTLY_TRANSFERRED && input.previousPlate
- ? [{ kind: "PREVIOUS" as const, plate: input.previousPlate }]
- : []),
- ];
-}
-
-function normalizeVehicleSerial(value: unknown): string {
- return String(value ?? "")
- .toUpperCase()
- .replace(/[^A-Z0-9]/g, "");
-}
-
/** A dated result is usable only while the returned policy has not expired. */
export function isMappedPolicyCurrent(
mapped: Record,
@@ -429,19 +409,6 @@ export function isMappedPolicyCurrent(
return endDate != null && endDate >= todayGregorian;
}
-function normalizePlateForComparison(
- plate: InquiryVehicleInputDto["currentPlate"],
-): string {
- return [
- plate?.ir,
- plate?.leftDigits,
- plate?.centerAlphabet,
- plate?.centerDigits,
- ]
- .map((part) => String(part ?? "").trim())
- .join("|");
-}
-
const LEGACY_INQUIRY_FIELDS = [
"nationalCodeOfDriver",
"driverBirthday",
@@ -469,63 +436,22 @@ function assertStructuredInquiryInput(input: Record): void {
}
}
-export function assertPreviousPlateInquiryMatchesVin(
- expectedVin: string,
- mapped: Record,
-): void {
- const expected = normalizeVehicleSerial(expectedVin);
- const candidates = [
- mapped?.VinNumberField,
- mapped?.vin,
- mapped?.VIN,
- mapped?.ChassisNumberField,
- mapped?.chassisNumber,
- mapped?.ChassisNo,
- mapped?.vehicle?.VIN,
- mapped?.vehicle?.ChassisNo,
- ]
- .map(normalizeVehicleSerial)
- .filter(Boolean);
- if (!expected || !candidates.includes(expected)) {
- throw new BadRequestException(
- "نتیجه استعلام پلاک قبلی با شماره شاسی (VIN) واردشده مطابقت ندارد و پرونده نیازمند بررسی دستی است.",
- );
- }
-}
-
-export function isPolicyNotFoundError(error: unknown): boolean {
- const candidate = error as Record | null;
- const status = candidate?.status ?? candidate?.response?.status;
- if (Number(status) === 404) return true;
- const code = String(
- candidate?.code ?? candidate?.response?.data?.code ?? "",
- ).toUpperCase();
- if (["NOT_FOUND", "POLICY_NOT_FOUND", "NO_POLICY"].includes(code)) {
- return true;
- }
- const message = String(
- candidate?.message ?? candidate?.response?.data?.message ?? error ?? "",
- );
- return /\bnot[ -]?found\b|\bno (?:relevant )?policy\b|یافت نشد|فاقد بیمه(?:نامه)?/i.test(
- message,
- );
-}
-
-export async function runPlateInquiryWithFallback(options: {
- vehicle?: ResolvedInquiryVehicle;
- fallbackCurrentPlate: InquiryVehicleInputDto["currentPlate"];
- query: (
- plate: InquiryVehicleInputDto["currentPlate"],
- plateKind: "CURRENT" | "PREVIOUS",
- ) => Promise;
+/**
+ * Run a policy inquiry only for the submitted current plate. Recent-transfer
+ * data is retained as case metadata, but must never trigger an inquiry for a
+ * previous plate or a previous policyholder.
+ */
+export async function runCurrentPlateInquiry(options: {
+ currentPlate: InquiryVehicleInputDto["currentPlate"];
+ vin?: string;
+ query: (plate: InquiryVehicleInputDto["currentPlate"]) => Promise;
isUsable: (value: T) => boolean;
- mappedValue: (value: T) => Record;
- shouldFallbackOnError?: (error: unknown) => boolean;
+ errorMessage?: (value: T) => string | undefined;
}): Promise<{
value: T;
- plateKind: "CURRENT" | "PREVIOUS";
+ plateKind: "CURRENT";
attempts: Array<{
- plateKind: "CURRENT" | "PREVIOUS";
+ plateKind: "CURRENT";
plate: InquiryVehicleInputDto["currentPlate"];
vin?: string;
succeeded: boolean;
@@ -533,12 +459,8 @@ export async function runPlateInquiryWithFallback(options: {
error?: string;
}>;
}> {
- const candidates = options.vehicle
- ? vehiclePlateCandidates(options.vehicle)
- : [{ kind: "CURRENT" as const, plate: options.fallbackCurrentPlate }];
- let lastError: unknown;
const attempts: Array<{
- plateKind: "CURRENT" | "PREVIOUS";
+ plateKind: "CURRENT";
plate: InquiryVehicleInputDto["currentPlate"];
vin?: string;
succeeded: boolean;
@@ -546,80 +468,51 @@ export async function runPlateInquiryWithFallback(options: {
error?: string;
}> = [];
- for (let index = 0; index < candidates.length; index += 1) {
- const candidate = candidates[index];
- const isLast = index === candidates.length - 1;
- try {
- const value = await options.query(candidate.plate, candidate.kind);
- const usable = options.isUsable(value);
- if (!usable) {
- attempts.push({
- plateKind: candidate.kind,
- plate: candidate.plate,
- ...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}),
- succeeded: true,
- usable: false,
- });
- if (!isLast) continue;
- const error = new BadRequestException(
- "بیمهنامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
- ) as BadRequestException & { attempts?: typeof attempts };
- error.attempts = attempts;
- throw error;
- }
- if (candidate.kind === "PREVIOUS" && usable) {
- assertPreviousPlateInquiryMatchesVin(
- options.vehicle!.vin!,
- options.mappedValue(value),
- );
- }
+ try {
+ const value = await options.query(options.currentPlate);
+ const usable = options.isUsable(value);
+ if (!usable) {
attempts.push({
- plateKind: candidate.kind,
- plate: candidate.plate,
- ...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}),
+ plateKind: "CURRENT",
+ plate: options.currentPlate,
+ ...(options.vin ? { vin: options.vin } : {}),
succeeded: true,
- usable: true,
+ usable: false,
});
- return { value, plateKind: candidate.kind, attempts };
- } catch (error) {
- lastError = error;
- const alreadyRecorded =
- typeof error === "object" &&
- error != null &&
- Array.isArray((error as { attempts?: unknown }).attempts);
- if (!alreadyRecorded) {
- attempts.push({
- plateKind: candidate.kind,
- plate: candidate.plate,
- ...(options.vehicle?.vin ? { vin: options.vehicle.vin } : {}),
- succeeded: false,
- error: error instanceof Error ? error.message : String(error),
- });
- }
- if (
- !isLast &&
- !(options.shouldFallbackOnError ?? isPolicyNotFoundError)(error)
- ) {
- if (typeof error === "object" && error != null) {
- (error as { attempts?: typeof attempts }).attempts = attempts;
- }
- throw error;
- }
- if (isLast) {
- if (typeof error === "object" && error != null) {
- (error as { attempts?: typeof attempts }).attempts = attempts;
- }
- throw error;
- }
+ const error = new BadRequestException(
+ options.errorMessage?.(value) ||
+ "بیمهنامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
+ ) as BadRequestException & { attempts?: typeof attempts };
+ error.attempts = attempts;
+ throw error;
}
+ attempts.push({
+ plateKind: "CURRENT",
+ plate: options.currentPlate,
+ ...(options.vin ? { vin: options.vin } : {}),
+ succeeded: true,
+ usable: true,
+ });
+ return { value, plateKind: "CURRENT", attempts };
+ } catch (error) {
+ const alreadyRecorded =
+ typeof error === "object" &&
+ error != null &&
+ Array.isArray((error as { attempts?: unknown }).attempts);
+ if (!alreadyRecorded) {
+ attempts.push({
+ plateKind: "CURRENT",
+ plate: options.currentPlate,
+ ...(options.vin ? { vin: options.vin } : {}),
+ succeeded: false,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ if (typeof error === "object" && error != null) {
+ (error as { attempts?: typeof attempts }).attempts = attempts;
+ }
+ throw error;
}
-
- throw (
- lastError ??
- new BadRequestException(
- "برای هیچیک از پلاکهای ثبتشده نتیجه معتبری یافت نشد.",
- )
- );
}
export function normalizeInquirySubmission>(
diff --git a/src/request-management/request-management.policyholder-inquiry.spec.ts b/src/request-management/request-management.policyholder-inquiry.spec.ts
new file mode 100644
index 0000000..3682edc
--- /dev/null
+++ b/src/request-management/request-management.policyholder-inquiry.spec.ts
@@ -0,0 +1,166 @@
+import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
+import { RequestManagementService } from "./request-management.service";
+
+describe("RequestManagementService policyholder inquiry routing", () => {
+ const currentPlate = {
+ leftDigits: "44",
+ centerAlphabet: "ب",
+ centerDigits: "111",
+ ir: "22",
+ };
+ const previousPlate = {
+ leftDigits: "55",
+ centerAlphabet: "ج",
+ centerDigits: "222",
+ ir: "33",
+ };
+ const vehicle = {
+ registrationState: "RECENTLY_TRANSFERRED",
+ currentPlate,
+ previousPlate,
+ previousPolicyholderNationalCode: "0098765432",
+ vin: "NAAM01E15HK123456",
+ };
+
+ function participantInput(caseType: BlameRequestType) {
+ return {
+ driver: {
+ nationalCode: "0011111111",
+ birthday: "1370/01/01",
+ hasDrivingLicense: false,
+ },
+ vehicleOwner: { sameAs: "DRIVER" },
+ thirdPartyPolicyholder: {
+ nationalCode: "0022222222",
+ birthday: "1360/02/02",
+ },
+ ...(caseType === BlameRequestType.CAR_BODY
+ ? {
+ carBodyPolicyholder: {
+ nationalCode: "0033333333",
+ birthday: "1350/03/03",
+ },
+ }
+ : {}),
+ vehicle,
+ };
+ }
+
+ it("does not fall back from the current plate for third-party insurance", async () => {
+ const service = Object.create(RequestManagementService.prototype) as any;
+ service.sandHubService = {
+ getTejaratBlockInquiry: jest
+ .fn()
+ .mockResolvedValueOnce({ raw: {}, mapped: {} })
+ .mockResolvedValueOnce({
+ raw: {},
+ mapped: { CompanyName: "پارسیان", VinNumberField: vehicle.vin },
+ }),
+ };
+ const submission = service.normalizeInquiryInput(
+ BlameRequestType.THIRD_PARTY,
+ participantInput(BlameRequestType.THIRD_PARTY),
+ );
+
+ await expect(service.getThirdPartyPlateInquiry(submission)).rejects.toThrow(
+ "بیمهنامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
+ );
+
+ expect(service.sandHubService.getTejaratBlockInquiry).toHaveBeenCalledTimes(
+ 1,
+ );
+ expect(service.sandHubService.getTejaratBlockInquiry).toHaveBeenCalledWith(
+ expect.objectContaining({
+ plate: currentPlate,
+ nationalCodeOfInsurer: "0022222222",
+ }),
+ undefined,
+ );
+ });
+
+ it("propagates the mapped ESG Persian error for third-party insurance", async () => {
+ const service = Object.create(RequestManagementService.prototype) as any;
+ service.sandHubService = {
+ getTejaratBlockInquiry: jest.fn().mockResolvedValue({
+ raw: {
+ success: false,
+ error: {
+ code: "RECORD_NOT_FOUND",
+ message: "Provider request failed",
+ messageFa: "رکوردی یافت نشد",
+ },
+ },
+ mapped: { Error: { Message: "رکوردی یافت نشد" } },
+ }),
+ };
+ const submission = service.normalizeInquiryInput(
+ BlameRequestType.THIRD_PARTY,
+ participantInput(BlameRequestType.THIRD_PARTY),
+ );
+
+ await expect(
+ service.getThirdPartyPlateInquiry(submission),
+ ).rejects.toThrow("رکوردی یافت نشد");
+ expect(service.sandHubService.getTejaratBlockInquiry).toHaveBeenCalledTimes(
+ 1,
+ );
+ });
+
+ it("does not fall back from the current plate for car-body insurance", async () => {
+ const service = Object.create(RequestManagementService.prototype) as any;
+ service.sandHubService = {
+ getCarBodyInquiry: jest
+ .fn()
+ .mockResolvedValueOnce({ raw: {}, mapped: {} })
+ .mockResolvedValueOnce({
+ raw: {},
+ mapped: { policyNumber: "BODY-1", VinNumberField: vehicle.vin },
+ }),
+ };
+ const submission = service.normalizeInquiryInput(
+ BlameRequestType.CAR_BODY,
+ participantInput(BlameRequestType.CAR_BODY),
+ );
+
+ await expect(service.getCarBodyPlateInquiry(submission)).rejects.toThrow(
+ "بیمهنامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
+ );
+
+ expect(service.sandHubService.getCarBodyInquiry).toHaveBeenCalledTimes(1);
+ expect(service.sandHubService.getCarBodyInquiry).toHaveBeenCalledWith(
+ expect.objectContaining({
+ plate: currentPlate,
+ nationalCodeOfInsurer: "0033333333",
+ }),
+ );
+ });
+
+ it("queries VIN with the third-party policyholder", async () => {
+ const service = Object.create(RequestManagementService.prototype) as any;
+ service.sandHubService = {
+ getPolicyByChassisInquiry: jest.fn().mockResolvedValue({
+ raw: {},
+ mapped: { CompanyName: "پارسیان" },
+ }),
+ };
+ const submission = service.normalizeInquiryInput(
+ BlameRequestType.THIRD_PARTY,
+ participantInput(BlameRequestType.THIRD_PARTY),
+ );
+
+ await service.getThirdPartyVinInquiry(submission);
+
+ expect(
+ service.sandHubService.getPolicyByChassisInquiry,
+ ).toHaveBeenCalledTimes(1);
+ expect(
+ service.sandHubService.getPolicyByChassisInquiry,
+ ).toHaveBeenCalledWith(
+ {
+ nationalCode: "0022222222",
+ chassis: vehicle.vin,
+ },
+ undefined,
+ );
+ });
+});
diff --git a/src/request-management/request-management.previous-policyholder.spec.ts b/src/request-management/request-management.previous-policyholder.spec.ts
deleted file mode 100644
index 53874d5..0000000
--- a/src/request-management/request-management.previous-policyholder.spec.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
-import { RequestManagementService } from "./request-management.service";
-
-describe("RequestManagementService previous policyholder routing", () => {
- const currentPlate = {
- leftDigits: "44",
- centerAlphabet: "ب",
- centerDigits: "111",
- ir: "22",
- };
- const previousPlate = {
- leftDigits: "55",
- centerAlphabet: "ج",
- centerDigits: "222",
- ir: "33",
- };
- const vehicle = {
- registrationState: "RECENTLY_TRANSFERRED",
- currentPlate,
- previousPlate,
- previousPolicyholderNationalCode: "0098765432",
- vin: "NAAM01E15HK123456",
- };
-
- function participantInput(caseType: BlameRequestType) {
- return {
- driver: {
- nationalCode: "0011111111",
- birthday: "1370/01/01",
- hasDrivingLicense: false,
- },
- vehicleOwner: { sameAs: "DRIVER" },
- thirdPartyPolicyholder: {
- nationalCode: "0022222222",
- birthday: "1360/02/02",
- },
- ...(caseType === BlameRequestType.CAR_BODY
- ? {
- carBodyPolicyholder: {
- nationalCode: "0033333333",
- birthday: "1350/03/03",
- },
- }
- : {}),
- vehicle,
- };
- }
-
- it("uses the previous code only for the previous-plate third-party lookup", async () => {
- const service = Object.create(RequestManagementService.prototype) as any;
- service.sandHubService = {
- getTejaratBlockInquiry: jest
- .fn()
- .mockResolvedValueOnce({ raw: {}, mapped: {} })
- .mockResolvedValueOnce({
- raw: {},
- mapped: {
- CompanyName: "پارسیان",
- VinNumberField: vehicle.vin,
- },
- }),
- };
- const submission = service.normalizeInquiryInput(
- BlameRequestType.THIRD_PARTY,
- participantInput(BlameRequestType.THIRD_PARTY),
- );
-
- const result = await service.getThirdPartyPlateInquiry(submission);
-
- expect(result.plateKind).toBe("PREVIOUS");
- expect(
- service.sandHubService.getTejaratBlockInquiry,
- ).toHaveBeenNthCalledWith(
- 1,
- expect.objectContaining({
- plate: currentPlate,
- nationalCodeOfInsurer: "0022222222",
- }),
- undefined,
- );
- expect(
- service.sandHubService.getTejaratBlockInquiry,
- ).toHaveBeenNthCalledWith(
- 2,
- expect.objectContaining({
- plate: previousPlate,
- nationalCodeOfInsurer: "0098765432",
- }),
- undefined,
- );
- });
-
- it("uses the previous code only for the previous-plate car-body lookup", async () => {
- const service = Object.create(RequestManagementService.prototype) as any;
- service.sandHubService = {
- getCarBodyInquiry: jest
- .fn()
- .mockResolvedValueOnce({ raw: {}, mapped: {} })
- .mockResolvedValueOnce({
- raw: {},
- mapped: {
- policyNumber: "BODY-1",
- VinNumberField: vehicle.vin,
- },
- }),
- };
- const submission = service.normalizeInquiryInput(
- BlameRequestType.CAR_BODY,
- participantInput(BlameRequestType.CAR_BODY),
- );
-
- const result = await service.getCarBodyPlateInquiry(submission);
-
- expect(result.plateKind).toBe("PREVIOUS");
- expect(service.sandHubService.getCarBodyInquiry).toHaveBeenNthCalledWith(
- 1,
- expect.objectContaining({
- plate: currentPlate,
- nationalCodeOfInsurer: "0033333333",
- }),
- );
- expect(service.sandHubService.getCarBodyInquiry).toHaveBeenNthCalledWith(
- 2,
- expect.objectContaining({
- plate: previousPlate,
- nationalCodeOfInsurer: "0098765432",
- }),
- );
- });
-});
diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts
index c3db7bf..e856449 100644
--- a/src/request-management/request-management.service.ts
+++ b/src/request-management/request-management.service.ts
@@ -140,13 +140,20 @@ import {
NormalizedInquirySubmission,
normalizeInquirySubmission,
resolveInquirySubjects,
- runPlateInquiryWithFallback,
+ runCurrentPlateInquiry,
sanitizeStoredInquiryParticipants,
} from "./inquiry-participant-resolver";
import {
getInquiryErrorMessage,
} from "src/common/utils/inquiry-error";
+function mappedInquiryErrorMessage(value: any): string | undefined {
+ const message = value?.mapped?.Error?.Message;
+ return typeof message === "string" && message.trim()
+ ? message.trim()
+ : undefined;
+}
+
/**
* Formats a compact Jalali date (number or string like 13780624) as YYYY/MM/DD.
* Returns the original value as a string if it cannot be parsed.
@@ -386,17 +393,14 @@ export class RequestManagementService {
options?: Record,
): Promise {
const subjects = resolveInquirySubjects(submission);
- const result = await runPlateInquiryWithFallback({
- vehicle: submission.vehicle,
- fallbackCurrentPlate: submission.dto.plate,
- query: (plate, plateKind) =>
+ const result = await runCurrentPlateInquiry({
+ currentPlate: submission.vehicle?.currentPlate ?? submission.dto.plate,
+ vin: submission.vehicle?.vin,
+ query: (plate) =>
this.sandHubService.getTejaratBlockInquiry(
{
plate: plate as any,
- nationalCodeOfInsurer:
- plateKind === "PREVIOUS"
- ? submission.vehicle!.previousPolicyholderNationalCode!
- : subjects.thirdPartyPolicyNationalCode,
+ nationalCodeOfInsurer: subjects.thirdPartyPolicyNationalCode,
},
options,
),
@@ -404,7 +408,7 @@ export class RequestManagementService {
!value?.mapped?.Error &&
!!value?.mapped?.CompanyName &&
isMappedPolicyCurrent(value.mapped),
- mappedValue: (value) => value?.mapped ?? {},
+ errorMessage: mappedInquiryErrorMessage,
});
return {
...result.value,
@@ -423,15 +427,12 @@ export class RequestManagementService {
"اطلاعات بیمهگذار برای استعلام بیمه بدنه الزامی است.",
);
}
- const result = await runPlateInquiryWithFallback({
- vehicle: submission.vehicle,
- fallbackCurrentPlate: submission.dto.plate,
- query: (plate, plateKind) =>
+ const result = await runCurrentPlateInquiry({
+ currentPlate: submission.vehicle?.currentPlate ?? submission.dto.plate,
+ vin: submission.vehicle?.vin,
+ query: (plate) =>
this.sandHubService.getCarBodyInquiry({
- nationalCodeOfInsurer:
- plateKind === "PREVIOUS"
- ? submission.vehicle!.previousPolicyholderNationalCode!
- : policyholderNationalCode,
+ nationalCodeOfInsurer: policyholderNationalCode,
plate: plate as any,
}),
isUsable: (value) =>
@@ -442,7 +443,7 @@ export class RequestManagementService {
value.mapped.CompanyName ||
value.mapped.companyId
),
- mappedValue: (value) => value?.mapped ?? {},
+ errorMessage: mappedInquiryErrorMessage,
});
return {
...result.value,
diff --git a/src/sand-hub/sand-hub.service.spec.ts b/src/sand-hub/sand-hub.service.spec.ts
index ef7b743..1ad9b20 100644
--- a/src/sand-hub/sand-hub.service.spec.ts
+++ b/src/sand-hub/sand-hub.service.spec.ts
@@ -155,28 +155,38 @@ describe("SandHubService inquiry mocks", () => {
expect(result.mapped.PrntPlcyCmpDocNo).toBe("REAL-ESG-POLICY");
});
- it("preserves ESG not-found semantics as a Persian plate-specific error", async () => {
+ it("preserves ESG messageFa for a plate inquiry", async () => {
process.env.CLIENT_ID = "8";
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
success: false,
- message: "موردی یافت نشد",
+ error: {
+ code: "RECORD_NOT_FOUND",
+ message: "Provider request failed",
+ messageFa: "رکوردی یافت نشد",
+ providerMessage: "err.record.not.found",
+ providerCode: "RECORD_NOT_FOUND",
+ },
});
const result = await service.getTejaratBlockInquiry(userDetail, {
enforceDeploymentClientMatch: true,
});
- expect(result.mapped.Error.Message).toBe(
- "بیمهنامه شخص ثالثی مطابق پلاک و کد ملی واردشده یافت نشد.",
- );
+ expect(result.mapped.Error.Message).toBe("رکوردی یافت نشد");
});
- it("uses a VIN-specific message for the same ESG not-found response", async () => {
+ it("preserves ESG messageFa for a VIN inquiry", async () => {
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
success: false,
- message: "موردی یافت نشد",
+ error: {
+ code: "INQUIRY_NO_MATCH",
+ message: "Inquiry returned no matching result",
+ messageFa: "نتیجهای مطابق با اطلاعات وارد شده یافت نشد",
+ providerMessage: "Inquiry returned no matching result",
+ providerCode: "INQUIRY_NO_MATCH",
+ },
});
const result = await service.getPolicyByChassisInquiry({
@@ -185,7 +195,7 @@ describe("SandHubService inquiry mocks", () => {
});
expect(result.mapped.Error.Message).toBe(
- "بیمهنامه شخص ثالثی مطابق شماره شاسی (VIN) و کد ملی واردشده یافت نشد.",
+ "نتیجهای مطابق با اطلاعات وارد شده یافت نشد",
);
});