| 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 7cf6036..b807e18 100644
--- a/src/request-management/inquiry-participant-resolver.spec.ts
+++ b/src/request-management/inquiry-participant-resolver.spec.ts
@@ -495,6 +495,26 @@ describe("inquiry participant resolver", () => {
});
});
+ 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",
diff --git a/src/request-management/inquiry-participant-resolver.ts b/src/request-management/inquiry-participant-resolver.ts
index b6adec4..de9e8e0 100644
--- a/src/request-management/inquiry-participant-resolver.ts
+++ b/src/request-management/inquiry-participant-resolver.ts
@@ -446,6 +446,7 @@ export async function runCurrentPlateInquiry(options: {
vin?: string;
query: (plate: InquiryVehicleInputDto["currentPlate"]) => Promise;
isUsable: (value: T) => boolean;
+ errorMessage?: (value: T) => string | undefined;
}): Promise<{
value: T;
plateKind: "CURRENT";
@@ -479,7 +480,8 @@ export async function runCurrentPlateInquiry(options: {
usable: false,
});
const error = new BadRequestException(
- "بیمهنامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
+ options.errorMessage?.(value) ||
+ "بیمهنامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
) as BadRequestException & { attempts?: typeof attempts };
error.attempts = attempts;
throw error;
diff --git a/src/request-management/request-management.policyholder-inquiry.spec.ts b/src/request-management/request-management.policyholder-inquiry.spec.ts
index f37149e..3682edc 100644
--- a/src/request-management/request-management.policyholder-inquiry.spec.ts
+++ b/src/request-management/request-management.policyholder-inquiry.spec.ts
@@ -78,6 +78,34 @@ describe("RequestManagementService policyholder inquiry routing", () => {
);
});
+ 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 = {
diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts
index 0dfb1ab..e856449 100644
--- a/src/request-management/request-management.service.ts
+++ b/src/request-management/request-management.service.ts
@@ -147,6 +147,13 @@ 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.
@@ -401,6 +408,7 @@ export class RequestManagementService {
!value?.mapped?.Error &&
!!value?.mapped?.CompanyName &&
isMappedPolicyCurrent(value.mapped),
+ errorMessage: mappedInquiryErrorMessage,
});
return {
...result.value,
@@ -435,6 +443,7 @@ export class RequestManagementService {
value.mapped.CompanyName ||
value.mapped.companyId
),
+ 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) و کد ملی واردشده یافت نشد.",
+ "نتیجهای مطابق با اطلاعات وارد شده یافت نشد",
);
});