forked from Yara724/api
Merge pull request 'HOTFIX: not allowing empty price when expert tries to submit' (#285) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#285
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
27
src/expert-claim/dto/expert-claim-v2.dto.spec.ts
Normal file
27
src/expert-claim/dto/expert-claim-v2.dto.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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[];
|
||||||
|
|||||||
69
src/expert-claim/expert-claim.service.spec.ts
Normal file
69
src/expert-claim/expert-claim.service.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
19
src/helpers/expert-reply-pricing.spec.ts
Normal file
19
src/helpers/expert-reply-pricing.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
59
src/helpers/expert-reply-pricing.ts
Normal file
59
src/helpers/expert-reply-pricing.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user