From feacad58ca968e1d1f953481f1d3412b986f5a95 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sat, 5 Sep 2026 16:44:40 +0330 Subject: [PATCH] HOTFIX: not allowing empty price when expert tries to submit --- .../repair-line-amount-toman.validator.ts | 7 +- .../dto/expert-claim-v2.dto.spec.ts | 27 ++++++++ src/expert-claim/dto/expert-claim-v2.dto.ts | 5 ++ src/expert-claim/expert-claim.service.spec.ts | 69 +++++++++++++++++++ src/expert-claim/expert-claim.service.ts | 15 ++++ src/helpers/expert-reply-pricing.spec.ts | 19 +++++ src/helpers/expert-reply-pricing.ts | 59 ++++++++++++++++ 7 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 src/expert-claim/dto/expert-claim-v2.dto.spec.ts create mode 100644 src/expert-claim/expert-claim.service.spec.ts create mode 100644 src/helpers/expert-reply-pricing.spec.ts create mode 100644 src/helpers/expert-reply-pricing.ts diff --git a/src/common/validators/repair-line-amount-toman.validator.ts b/src/common/validators/repair-line-amount-toman.validator.ts index f5a2db2..051568e 100644 --- a/src/common/validators/repair-line-amount-toman.validator.ts +++ b/src/common/validators/repair-line-amount-toman.validator.ts @@ -25,7 +25,12 @@ export class IsRepairLineAmountTomanConstraint number, boolean | undefined, ]; - if (value == null || value === "") return true; + // Optional fields are skipped by @IsOptional. A required amount must not + // accept an omitted or blank value, otherwise an empty expert pricing line + // can be submitted as a zero-cost repair. + if (value == null || (typeof value === "string" && value.trim() === "")) { + return false; + } const amount = parseMoneyAmountToman(value); if (amount == null) return false; if (allowZero && amount === 0) return true; diff --git a/src/expert-claim/dto/expert-claim-v2.dto.spec.ts b/src/expert-claim/dto/expert-claim-v2.dto.spec.ts new file mode 100644 index 0000000..ffc0bbc --- /dev/null +++ b/src/expert-claim/dto/expert-claim-v2.dto.spec.ts @@ -0,0 +1,27 @@ +import { plainToInstance } from "class-transformer"; +import { validate } from "class-validator"; +import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum"; +import { SubmitExpertReplyV2Dto } from "./expert-claim-v2.dto"; + +describe("SubmitExpertReplyV2Dto", () => { + it("rejects a selected damaged part with blank pricing", async () => { + const dto = plainToInstance(SubmitExpertReplyV2Dto, { + description: "Damage assessment", + parts: [ + { + partId: 201, + typeOfDamage: "Minor", + price: "", + salary: "", + totalPayment: "", + factorNeeded: false, + daghi: { option: DaghiOption.NO_VALUE }, + }, + ], + }); + + const errors = await validate(dto); + + expect(errors).not.toHaveLength(0); + }); +}); diff --git a/src/expert-claim/dto/expert-claim-v2.dto.ts b/src/expert-claim/dto/expert-claim-v2.dto.ts index 7d1c51b..d68e38d 100644 --- a/src/expert-claim/dto/expert-claim-v2.dto.ts +++ b/src/expert-claim/dto/expert-claim-v2.dto.ts @@ -3,6 +3,7 @@ import { IsString, IsNotEmpty, IsArray, + ArrayMinSize, IsBoolean, IsOptional, ValidateNested, @@ -56,6 +57,7 @@ export class PartPricingV2Dto { description: "Part price in Toman (integer string). Use 0 if the full amount is in salary.", }) @IsString() + @IsNotEmpty() @IsRepairLineAmountToman({ allowZero: true }) price: string; @@ -64,6 +66,7 @@ export class PartPricingV2Dto { description: "Labor in Toman (integer string). Use 0 if the full amount is in price.", }) @IsString() + @IsNotEmpty() @IsRepairLineAmountToman({ allowZero: true }) salary: string; @@ -72,6 +75,7 @@ export class PartPricingV2Dto { description: "Line total in Toman (integer string).", }) @IsString() + @IsNotEmpty() @IsRepairLineAmountToman() totalPayment: string; @@ -101,6 +105,7 @@ export class SubmitExpertReplyV2Dto { "One line per damaged part (`partId` from claim detail `damagedParts[]`), plus pricing, `daghi`, and `factorNeeded`.", }) @IsArray() + @ArrayMinSize(1) @ValidateNested({ each: true }) @Type(() => PartPricingV2Dto) parts: PartPricingV2Dto[]; diff --git a/src/expert-claim/expert-claim.service.spec.ts b/src/expert-claim/expert-claim.service.spec.ts new file mode 100644 index 0000000..9309c3a --- /dev/null +++ b/src/expert-claim/expert-claim.service.spec.ts @@ -0,0 +1,69 @@ +import { BadRequestException } from "@nestjs/common"; +import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; +import { RoleEnum } from "src/Types&Enums/role.enum"; +import { ExpertClaimService } from "./expert-claim.service"; + +const blankPricingReply = { + description: "Damage assessment", + parts: [ + { + partId: 201, + price: "", + salary: "", + totalPayment: "", + factorNeeded: false, + }, + ], +}; + +function createService() { + return new (ExpertClaimService as any)( + ...new Array(21).fill(undefined), + ) as ExpertClaimService; +} + +describe("ExpertClaimService expert-reply pricing", () => { + it("rejects a blank pricing line on the legacy submit endpoint before changing status", async () => { + const service = createService() as any; + const findAndUpdate = jest.fn(); + service.claimRequestManagementDbService = { + findOne: jest.fn().mockResolvedValue({ + _id: "legacy-claim", + actorLocked: { actorId: "expert-1" }, + unlockTime: new Date(Date.now() + 60_000), + lockFile: true, + }), + findAndUpdate, + }; + + await expect( + service.submitReplyRequest("legacy-claim", blankPricingReply, { + sub: "expert-1", + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(findAndUpdate).not.toHaveBeenCalled(); + }); + + it("rejects a blank pricing line on the V2 submit endpoint before completion", async () => { + const service = createService() as any; + const findByIdAndUpdate = jest.fn(); + service.claimCaseDbService = { + findById: jest.fn().mockResolvedValue({ + status: ClaimCaseStatus.EXPERT_REVIEWING, + workflow: { locked: true, lockedBy: { actorId: "expert-1" } }, + }), + findByIdAndUpdate, + }; + service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined); + + await expect( + service.submitExpertReplyV2("v2-claim", blankPricingReply, { + sub: "expert-1", + role: RoleEnum.FIELD_EXPERT, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(findByIdAndUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 31afa10..9418dce 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -168,6 +168,7 @@ import { } from "src/helpers/unified-file-status"; import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher"; import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys"; +import { getExpertReplyPricingValidationError } from "src/helpers/expert-reply-pricing"; @Injectable() export class ExpertClaimService { @@ -1658,6 +1659,13 @@ export class ExpertClaimService { ); } + const pricingValidationError = getExpertReplyPricingValidationError( + reply.parts, + ); + if (pricingValidationError) { + throw new BadRequestException(pricingValidationError); + } + // Validate total price cap (priced lines sum), when enabled. const priceCap = getClaimV2TotalPaymentCapToman(); if (priceCap !== null && reply.parts && reply.parts.length > 0) { @@ -3261,6 +3269,13 @@ export class ExpertClaimService { throw new ForbiddenException("This claim is locked by another expert"); } + const pricingValidationError = getExpertReplyPricingValidationError( + reply.parts, + ); + if (pricingValidationError) { + throw new BadRequestException(pricingValidationError); + } + // Price cap validation, when enabled. const priceCap = getClaimV2TotalPaymentCapToman(); if (priceCap !== null) { diff --git a/src/helpers/expert-reply-pricing.spec.ts b/src/helpers/expert-reply-pricing.spec.ts new file mode 100644 index 0000000..fb43724 --- /dev/null +++ b/src/helpers/expert-reply-pricing.spec.ts @@ -0,0 +1,19 @@ +import { getExpertReplyPricingValidationError } from "./expert-reply-pricing"; + +describe("getExpertReplyPricingValidationError", () => { + it("rejects a blank pricing line", () => { + expect( + getExpertReplyPricingValidationError([ + { partId: 201, price: "", salary: "", totalPayment: "" }, + ]), + ).toMatch(/requires valid price, salary, and totalPayment/); + }); + + it("accepts a fully priced line with a zero-valued split", () => { + expect( + getExpertReplyPricingValidationError([ + { partId: 201, price: "0", salary: "10000", totalPayment: "10000" }, + ]), + ).toBeNull(); + }); +}); diff --git a/src/helpers/expert-reply-pricing.ts b/src/helpers/expert-reply-pricing.ts new file mode 100644 index 0000000..6b21604 --- /dev/null +++ b/src/helpers/expert-reply-pricing.ts @@ -0,0 +1,59 @@ +import { REPAIR_LINE_AMOUNT_TOMAN } from "src/constants/repair-amount-limits"; +import { parseMoneyAmountToman } from "src/utils/unicode-digits"; + +type ExpertReplyPricingPart = { + partId?: unknown; + price?: unknown; + salary?: unknown; + totalPayment?: unknown; +}; + +/** + * Validates the pricing that an expert must provide for every submitted + * damaged-part line. This is deliberately independent of DTO validation: + * legacy endpoints and deployments without a global ValidationPipe call the + * service directly. + */ +export function getExpertReplyPricingValidationError( + parts: unknown, +): string | null { + if (!Array.isArray(parts) || parts.length === 0) { + return "At least one damaged part with pricing is required to submit an expert reply."; + } + + for (const [index, rawPart] of parts.entries()) { + const part = rawPart as ExpertReplyPricingPart | null; + const label = + part && part.partId != null && String(part.partId).trim() !== "" + ? `Part ${String(part.partId)}` + : `Damaged part ${index + 1}`; + + if (!part || typeof part !== "object") { + return `${label} must include price, salary, and totalPayment.`; + } + + const price = parseMoneyAmountToman(part.price); + const salary = parseMoneyAmountToman(part.salary); + const totalPayment = parseMoneyAmountToman(part.totalPayment); + + const splitAmountIsValid = (amount: number | null) => + amount !== null && + (amount === 0 || + (amount >= REPAIR_LINE_AMOUNT_TOMAN.MIN && + amount <= REPAIR_LINE_AMOUNT_TOMAN.MAX)); + const totalIsValid = + totalPayment !== null && + totalPayment >= REPAIR_LINE_AMOUNT_TOMAN.MIN && + totalPayment <= REPAIR_LINE_AMOUNT_TOMAN.MAX; + + if ( + !splitAmountIsValid(price) || + !splitAmountIsValid(salary) || + !totalIsValid + ) { + return `${label} requires valid price, salary, and totalPayment. Price and salary may be 0; totalPayment must be between ${REPAIR_LINE_AMOUNT_TOMAN.MIN.toLocaleString("en-US")} and ${REPAIR_LINE_AMOUNT_TOMAN.MAX.toLocaleString("en-US")} Toman.`; + } + } + + return null; +}