HOTFIX: not allowing empty price when expert tries to submit

This commit is contained in:
SepehrYahyaee
2026-09-05 16:44:40 +03:30
parent 70fa49398d
commit feacad58ca
7 changed files with 200 additions and 1 deletions

View File

@@ -25,7 +25,12 @@ export class IsRepairLineAmountTomanConstraint
number, number,
boolean | undefined, 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); const amount = parseMoneyAmountToman(value);
if (amount == null) return false; if (amount == null) return false;
if (allowZero && amount === 0) return true; if (allowZero && amount === 0) return true;

View File

@@ -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);
});
});

View File

@@ -3,6 +3,7 @@ import {
IsString, IsString,
IsNotEmpty, IsNotEmpty,
IsArray, IsArray,
ArrayMinSize,
IsBoolean, IsBoolean,
IsOptional, IsOptional,
ValidateNested, ValidateNested,
@@ -56,6 +57,7 @@ export class PartPricingV2Dto {
description: "Part price in Toman (integer string). Use 0 if the full amount is in salary.", description: "Part price in Toman (integer string). Use 0 if the full amount is in salary.",
}) })
@IsString() @IsString()
@IsNotEmpty()
@IsRepairLineAmountToman({ allowZero: true }) @IsRepairLineAmountToman({ allowZero: true })
price: string; price: string;
@@ -64,6 +66,7 @@ export class PartPricingV2Dto {
description: "Labor in Toman (integer string). Use 0 if the full amount is in price.", description: "Labor in Toman (integer string). Use 0 if the full amount is in price.",
}) })
@IsString() @IsString()
@IsNotEmpty()
@IsRepairLineAmountToman({ allowZero: true }) @IsRepairLineAmountToman({ allowZero: true })
salary: string; salary: string;
@@ -72,6 +75,7 @@ export class PartPricingV2Dto {
description: "Line total in Toman (integer string).", description: "Line total in Toman (integer string).",
}) })
@IsString() @IsString()
@IsNotEmpty()
@IsRepairLineAmountToman() @IsRepairLineAmountToman()
totalPayment: string; totalPayment: string;
@@ -101,6 +105,7 @@ export class SubmitExpertReplyV2Dto {
"One line per damaged part (`partId` from claim detail `damagedParts[]`), plus pricing, `daghi`, and `factorNeeded`.", "One line per damaged part (`partId` from claim detail `damagedParts[]`), plus pricing, `daghi`, and `factorNeeded`.",
}) })
@IsArray() @IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => PartPricingV2Dto) @Type(() => PartPricingV2Dto)
parts: PartPricingV2Dto[]; parts: PartPricingV2Dto[];

View File

@@ -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();
});
});

View File

@@ -168,6 +168,7 @@ import {
} from "src/helpers/unified-file-status"; } from "src/helpers/unified-file-status";
import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher"; import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher";
import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys"; import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys";
import { getExpertReplyPricingValidationError } from "src/helpers/expert-reply-pricing";
@Injectable() @Injectable()
export class ExpertClaimService { 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. // Validate total price cap (priced lines sum), when enabled.
const priceCap = getClaimV2TotalPaymentCapToman(); const priceCap = getClaimV2TotalPaymentCapToman();
if (priceCap !== null && reply.parts && reply.parts.length > 0) { 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"); 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. // Price cap validation, when enabled.
const priceCap = getClaimV2TotalPaymentCapToman(); const priceCap = getClaimV2TotalPaymentCapToman();
if (priceCap !== null) { if (priceCap !== null) {

View File

@@ -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();
});
});

View File

@@ -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;
}