From e4dfe7c57281007e0032095cd94a639165c5b315 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Wed, 20 May 2026 11:08:47 +0330 Subject: [PATCH] Validation for prices and objection --- src/app.module.ts | 19 +++- .../claim-request-management.service.ts | 50 ++++++---- .../dto/user-objection-v2.dto.ts | 12 ++- .../dto/user-objection.dto.ts | 96 ++++++++++++++++--- .../schema/claim-case.evaluation.schema.ts | 9 +- .../unicode-digits-normalize.interceptor.ts | 31 ++++++ .../has-objection-entries.validator.ts | 24 +++++ .../money-amount-string.validator.ts | 37 +++++++ .../objection-part-content.validator.ts | 29 ++++++ .../repair-line-amount-toman.validator.ts | 66 +++++++++++++ src/constants/repair-amount-limits.ts | 12 +++ .../dto/claim-price-drop-v2.dto.ts | 2 +- src/expert-claim/dto/expert-claim-v2.dto.ts | 24 +++-- src/expert-claim/dto/factor-validation.dto.ts | 10 +- src/expert-claim/expert-claim.service.ts | 14 +-- .../expert-claim.v2.controller.ts | 4 +- src/utils/unicode-digits.spec.ts | 34 +++++++ src/utils/unicode-digits.ts | 54 +++++++++++ 18 files changed, 467 insertions(+), 60 deletions(-) create mode 100644 src/common/interceptors/unicode-digits-normalize.interceptor.ts create mode 100644 src/common/validators/has-objection-entries.validator.ts create mode 100644 src/common/validators/money-amount-string.validator.ts create mode 100644 src/common/validators/objection-part-content.validator.ts create mode 100644 src/common/validators/repair-line-amount-toman.validator.ts create mode 100644 src/constants/repair-amount-limits.ts create mode 100644 src/utils/unicode-digits.spec.ts create mode 100644 src/utils/unicode-digits.ts diff --git a/src/app.module.ts b/src/app.module.ts index 1ff0d4c..6389d4f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,5 +1,7 @@ import { join } from "node:path"; -import { Module } from "@nestjs/common"; +import { Module, ValidationPipe } from "@nestjs/common"; +import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core"; +import { UnicodeDigitsNormalizeInterceptor } from "./common/interceptors/unicode-digits-normalize.interceptor"; import { MongooseModule } from "@nestjs/mongoose"; import { ScheduleModule } from "@nestjs/schedule"; import { ServeStaticModule } from "@nestjs/serve-static"; @@ -70,6 +72,19 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` }); WorkflowStepManagementModule, ], controllers: [], - providers: [], + providers: [ + { + provide: APP_INTERCEPTOR, + useClass: UnicodeDigitsNormalizeInterceptor, + }, + { + provide: APP_PIPE, + useValue: new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: false, + }), + }, + ], }) export class AppModule {} diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index d315de2..76ca242 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -116,9 +116,11 @@ import { type DamageSelectedPartV2, migrateExpertReplyCarPartDamageToUnified, normalizeDamageSelectedParts, - partIdentityKey, + parseCatalogPartIdInput, + partLookupKey, resolvePartCaptureIndex, } from "src/helpers/outer-damage-parts"; +import { normalizeMoneyAmountString } from "src/utils/unicode-digits"; import { CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS, @@ -5544,19 +5546,20 @@ export class ClaimRequestManagementService { ); } - const pricedPartIdSet = + const pricedCatalogIds = activeExpert?.parts?.length ? new Set( activeExpert.parts .filter((p) => p.factorNeeded !== true) - .map((p) => String((p as { partId?: string }).partId ?? "")) - .filter(Boolean), + .map((p) => parseCatalogPartIdInput(p.partId)) + .filter((id): id is number => id != null), ) - : new Set(); + : new Set(); if (partsIn.length && pricingObjectionEligible) { for (const op of partsIn) { - if (!pricedPartIdSet.has(String(op.partId))) { + const catalogId = parseCatalogPartIdInput(op.partId); + if (catalogId == null || !pricedCatalogIds.has(catalogId)) { throw new BadRequestException( `Only priced repair lines can be disputed (${String(op.partId)} is factor-only or unknown).`, ); @@ -5565,19 +5568,28 @@ export class ClaimRequestManagementService { } const objectionParts = partsIn.map((p) => ({ - partId: p.partId, - reason: p.reason, - partPrice: p.partPrice, - partSalary: p.partSalary, + partId: parseCatalogPartIdInput(p.partId)!, + reason: p.reason?.trim() || undefined, + partPrice: + p.partPrice != null && String(p.partPrice).trim() !== "" + ? normalizeMoneyAmountString(String(p.partPrice)) + : undefined, + partSalary: + p.partSalary != null && String(p.partSalary).trim() !== "" + ? normalizeMoneyAmountString(String(p.partSalary)) + : undefined, typeOfDamage: p.typeOfDamage != null ? String(p.typeOfDamage) : undefined, - carPartDamage: p.carPartDamage, - side: p.side, + carPartDamage: p.carPartDamage?.trim() || undefined, + side: p.side?.trim() || undefined, })); const newParts = newPartsIn.map((p) => ({ - partId: p.partId ? String(p.partId) : new Types.ObjectId().toString(), - partName: p.partName, - side: p.side, + partId: + p.partId != null && parseCatalogPartIdInput(p.partId) != null + ? parseCatalogPartIdInput(p.partId)! + : new Types.ObjectId().toString(), + partName: p.partName.trim(), + side: p.side?.trim() || undefined, })); const objectionCarType = claimCase.vehicle?.carType as @@ -5588,7 +5600,7 @@ export class ClaimRequestManagementService { objectionCarType, (claimCase.damage as any)?.selectedOuterParts, ); - const mergedKeys = new Set(mergedNorm.map((p) => partIdentityKey(p))); + const mergedKeys = new Set(mergedNorm.map((p) => partLookupKey(p))); for (const np of newParts) { const name = np.partName?.trim(); if (!name) continue; @@ -5599,7 +5611,7 @@ export class ClaimRequestManagementService { side, label_fa: name, }; - const k = partIdentityKey(row); + const k = partLookupKey(row); if (!mergedKeys.has(k)) { mergedNorm.push(row); mergedKeys.add(k); @@ -6112,7 +6124,7 @@ export class ClaimRequestManagementService { ); const displayParts = [...selectedNormDetails]; const seenDetailKeys = new Set( - selectedNormDetails.map((p) => partIdentityKey(p)), + selectedNormDetails.map((p) => partLookupKey(p)), ); for (const rk of resendPartKeys) { const extra = @@ -6124,7 +6136,7 @@ export class ClaimRequestManagementService { label_fa: rk, catalogKey: rk, }; - const k = partIdentityKey(extra); + const k = partLookupKey(extra); if (!seenDetailKeys.has(k)) { displayParts.push(extra); seenDetailKeys.add(k); diff --git a/src/claim-request-management/dto/user-objection-v2.dto.ts b/src/claim-request-management/dto/user-objection-v2.dto.ts index fae1d5d..bfcfe51 100644 --- a/src/claim-request-management/dto/user-objection-v2.dto.ts +++ b/src/claim-request-management/dto/user-objection-v2.dto.ts @@ -1,14 +1,21 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; import { Type } from "class-transformer"; -import { IsArray, IsOptional, ValidateNested } from "class-validator"; +import { + IsArray, + IsOptional, + Validate, + ValidateNested, +} from "class-validator"; +import { HasObjectionEntriesConstraint } from "src/common/validators/has-objection-entries.validator"; import { NewPartDto, UserObjectionPartDto } from "./user-objection.dto"; /** - * V2 user objection body — same shape as v1 {@link import("./user-objection.dto").UserObjectionDto} + * V2 user objection body — same shape as v1 {@link UserObjectionDto} * with nested validation enabled for the v2 controller pipeline. */ export class UserObjectionV2Dto { @ApiPropertyOptional({ type: [UserObjectionPartDto] }) + @Validate(HasObjectionEntriesConstraint) @IsOptional() @IsArray() @ValidateNested({ each: true }) @@ -16,6 +23,7 @@ export class UserObjectionV2Dto { objectionParts?: UserObjectionPartDto[]; @ApiPropertyOptional({ type: [NewPartDto] }) + @Validate(HasObjectionEntriesConstraint) @IsOptional() @IsArray() @ValidateNested({ each: true }) diff --git a/src/claim-request-management/dto/user-objection.dto.ts b/src/claim-request-management/dto/user-objection.dto.ts index fd36c2a..1aed64e 100644 --- a/src/claim-request-management/dto/user-objection.dto.ts +++ b/src/claim-request-management/dto/user-objection.dto.ts @@ -1,47 +1,113 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { Type } from "class-transformer"; +import { + IsArray, + IsEnum, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, + MinLength, + Validate, + ValidateNested, +} from "class-validator"; +import { HasObjectionEntriesConstraint } from "src/common/validators/has-objection-entries.validator"; +import { IsRepairLineAmountToman } from "src/common/validators/repair-line-amount-toman.validator"; +import { ObjectionPartHasContentConstraint } from "src/common/validators/objection-part-content.validator"; import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum"; export class UserObjectionPartDto { - @ApiProperty() - partId: string; + @ApiProperty({ + example: 201, + description: "Numeric catalog part id of the priced line being disputed.", + }) + @Validate(ObjectionPartHasContentConstraint) + @IsInt() + partId: number; - @ApiProperty({ required: false }) + @ApiPropertyOptional({ + description: + "Why the owner disagrees (min 3 chars when provided). Required unless partPrice or partSalary is sent.", + }) + @IsOptional() + @IsString() + @MaxLength(2000) reason?: string; - @ApiProperty({ required: false }) + @ApiPropertyOptional({ + description: "Owner-proposed part price (Toman, integer string).", + }) + @IsOptional() + @IsString() + @MaxLength(32) + @IsRepairLineAmountToman() partPrice?: string; - @ApiProperty({ required: false }) + @ApiPropertyOptional({ + description: "Owner-proposed salary / labor (Toman, integer string).", + }) + @IsOptional() + @IsString() + @MaxLength(32) + @IsRepairLineAmountToman() partSalary?: string; - @ApiProperty({ required: false }) + @ApiPropertyOptional({ enum: TypeOfDamage }) + @IsOptional() + @IsEnum(TypeOfDamage) typeOfDamage?: TypeOfDamage; - @ApiProperty({ required: false }) + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(500) carPartDamage?: string; - @ApiProperty({ required: false }) + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(64) side?: string; } export class NewPartDto { - @ApiProperty({ required: false, nullable: true }) - partId: string | null; + @ApiPropertyOptional({ + nullable: true, + description: "Optional catalog id when the new part exists in the outer catalog.", + }) + @IsOptional() + @IsInt() + partId?: number | null; - @ApiProperty() + @ApiProperty({ example: "سپر جلو" }) + @IsString() + @IsNotEmpty() + @MinLength(1) + @MaxLength(200) partName: string; - @ApiProperty({ required: false }) + @ApiPropertyOptional({ example: "front" }) + @IsOptional() + @IsString() + @MaxLength(64) side?: string; } export class UserObjectionDto { - @ApiProperty({ type: [UserObjectionPartDto], required: false }) + @ApiPropertyOptional({ type: [UserObjectionPartDto] }) + @Validate(HasObjectionEntriesConstraint) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) @Type(() => UserObjectionPartDto) objectionParts?: UserObjectionPartDto[]; - @ApiProperty({ type: [NewPartDto], required: false }) + @ApiPropertyOptional({ type: [NewPartDto] }) + @Validate(HasObjectionEntriesConstraint) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) @Type(() => NewPartDto) newParts?: NewPartDto[]; } diff --git a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts index 61406d3..ad1e4e5 100644 --- a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts +++ b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts @@ -141,8 +141,8 @@ export const ClaimExpertReplySchema = /** One line item the user disputes on an expert-priced part */ @Schema({ _id: false }) export class ClaimUserObjectionPart { - @Prop({ type: String, required: true }) - partId: string; + @Prop({ type: Number, required: true }) + partId: number; @Prop({ type: String }) reason?: string; @@ -167,8 +167,9 @@ export const ClaimUserObjectionPartSchema = @Schema({ _id: false }) export class ClaimUserObjectionNewPart { - @Prop({ type: String }) - partId?: string; + /** Catalog id when known; otherwise a generated string id for the new line. */ + @Prop({ type: MongooseSchema.Types.Mixed }) + partId?: number | string; @Prop({ type: String, required: true }) partName: string; diff --git a/src/common/interceptors/unicode-digits-normalize.interceptor.ts b/src/common/interceptors/unicode-digits-normalize.interceptor.ts new file mode 100644 index 0000000..7ff01d6 --- /dev/null +++ b/src/common/interceptors/unicode-digits-normalize.interceptor.ts @@ -0,0 +1,31 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from "@nestjs/common"; +import { Observable } from "rxjs"; +import { normalizeUnicodeDigitsDeep } from "src/utils/unicode-digits"; + +/** + * Normalizes Unicode decimal digits in JSON request bodies to ASCII before + * validation and handlers run (Persian/Arabic-Indic → 0-9). + */ +@Injectable() +export class UnicodeDigitsNormalizeInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const req = context.switchToHttp().getRequest(); + const contentType = String(req.headers?.["content-type"] ?? ""); + + if ( + req.body && + typeof req.body === "object" && + !Buffer.isBuffer(req.body) && + !contentType.includes("multipart/form-data") + ) { + req.body = normalizeUnicodeDigitsDeep(req.body); + } + + return next.handle(); + } +} diff --git a/src/common/validators/has-objection-entries.validator.ts b/src/common/validators/has-objection-entries.validator.ts new file mode 100644 index 0000000..69010ef --- /dev/null +++ b/src/common/validators/has-objection-entries.validator.ts @@ -0,0 +1,24 @@ +import { + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from "class-validator"; + +@ValidatorConstraint({ name: "hasObjectionEntries", async: false }) +export class HasObjectionEntriesConstraint + implements ValidatorConstraintInterface +{ + validate(_value: unknown, args: ValidationArguments): boolean { + const o = args.object as { + objectionParts?: unknown[]; + newParts?: unknown[]; + }; + const partsLen = Array.isArray(o.objectionParts) ? o.objectionParts.length : 0; + const newLen = Array.isArray(o.newParts) ? o.newParts.length : 0; + return partsLen > 0 || newLen > 0; + } + + defaultMessage(): string { + return "Provide at least one entry in objectionParts and/or newParts."; + } +} diff --git a/src/common/validators/money-amount-string.validator.ts b/src/common/validators/money-amount-string.validator.ts new file mode 100644 index 0000000..2f5f985 --- /dev/null +++ b/src/common/validators/money-amount-string.validator.ts @@ -0,0 +1,37 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from "class-validator"; +import { normalizeMoneyAmountString } from "src/utils/unicode-digits"; + +@ValidatorConstraint({ name: "isMoneyAmountString", async: false }) +export class IsMoneyAmountStringConstraint + implements ValidatorConstraintInterface +{ + validate(value: unknown): boolean { + if (value == null || value === "") return true; + if (typeof value !== "string") return false; + const n = normalizeMoneyAmountString(value); + if (!n) return false; + return /^\d+(\.\d+)?$/.test(n); + } + + defaultMessage(): string { + return "Must be a non-negative amount (digits only, optional decimal)."; + } +} + +export function IsMoneyAmountString(validationOptions?: ValidationOptions) { + return (object: object, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [], + validator: IsMoneyAmountStringConstraint, + }); + }; +} diff --git a/src/common/validators/objection-part-content.validator.ts b/src/common/validators/objection-part-content.validator.ts new file mode 100644 index 0000000..f3fa806 --- /dev/null +++ b/src/common/validators/objection-part-content.validator.ts @@ -0,0 +1,29 @@ +import { + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from "class-validator"; + +@ValidatorConstraint({ name: "objectionPartHasContent", async: false }) +export class ObjectionPartHasContentConstraint + implements ValidatorConstraintInterface +{ + validate(_value: unknown, args: ValidationArguments): boolean { + const o = args.object as { + reason?: string; + partPrice?: string; + partSalary?: string; + }; + const hasReason = + typeof o.reason === "string" && o.reason.trim().length >= 3; + const hasPrice = + o.partPrice != null && String(o.partPrice).trim() !== ""; + const hasSalary = + o.partSalary != null && String(o.partSalary).trim() !== ""; + return hasReason || hasPrice || hasSalary; + } + + defaultMessage(): string { + return "Each objection line needs a reason (min 3 characters) and/or proposed partPrice / partSalary."; + } +} diff --git a/src/common/validators/repair-line-amount-toman.validator.ts b/src/common/validators/repair-line-amount-toman.validator.ts new file mode 100644 index 0000000..f5a2db2 --- /dev/null +++ b/src/common/validators/repair-line-amount-toman.validator.ts @@ -0,0 +1,66 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from "class-validator"; +import { + REPAIR_LINE_AMOUNT_TOMAN, +} from "src/constants/repair-amount-limits"; +import { parseMoneyAmountToman } from "src/utils/unicode-digits"; + +export type RepairLineAmountTomanOptions = { + /** When true, `0` is valid (e.g. expert `price` / `salary` split where one side is zero). */ + allowZero?: boolean; +}; + +@ValidatorConstraint({ name: "isRepairLineAmountToman", async: false }) +export class IsRepairLineAmountTomanConstraint + implements ValidatorConstraintInterface +{ + validate(value: unknown, args: ValidationArguments): boolean { + const [minToman, maxToman, allowZero] = args.constraints as [ + number, + number, + boolean | undefined, + ]; + if (value == null || value === "") return true; + const amount = parseMoneyAmountToman(value); + if (amount == null) return false; + if (allowZero && amount === 0) return true; + return amount >= minToman && amount <= maxToman; + } + + defaultMessage(args: ValidationArguments): string { + const [minToman, maxToman, allowZero] = args.constraints as [ + number, + number, + boolean | undefined, + ]; + const zeroHint = allowZero ? " Use 0 where one side of price/salary is unused." : ""; + return ( + `Amount must be in Toman between ${minToman.toLocaleString("en-US")} and ${maxToman.toLocaleString("en-US")}.${zeroHint}` + ); + } +} + +/** Optional repair-line amount in Toman (digits only after unicode normalize). */ +export function IsRepairLineAmountToman( + options?: ValidationOptions & RepairLineAmountTomanOptions, +) { + const { allowZero, ...validationOptions } = options ?? {}; + return (object: object, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [ + REPAIR_LINE_AMOUNT_TOMAN.MIN, + REPAIR_LINE_AMOUNT_TOMAN.MAX, + allowZero === true, + ], + validator: IsRepairLineAmountTomanConstraint, + }); + }; +} diff --git a/src/constants/repair-amount-limits.ts b/src/constants/repair-amount-limits.ts new file mode 100644 index 0000000..9166cc8 --- /dev/null +++ b/src/constants/repair-amount-limits.ts @@ -0,0 +1,12 @@ +/** + * Per-line and total caps for repair money. All values are **Toman** (no unit conversion in the API). + */ +export const REPAIR_LINE_AMOUNT_TOMAN = { + /** Below this is not credible for a priced repair line (e.g. 1,000 Toman). */ + MIN: 10_000, + /** Aligns with the total assessment cap; rejects absurd values (e.g. 100bn). */ + MAX: 53_000_000, +} as const; + +/** Max sum of all priced + factor lines in one expert reply / validation (Toman). */ +export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX; diff --git a/src/expert-claim/dto/claim-price-drop-v2.dto.ts b/src/expert-claim/dto/claim-price-drop-v2.dto.ts index 7fd3055..45ccaff 100644 --- a/src/expert-claim/dto/claim-price-drop-v2.dto.ts +++ b/src/expert-claim/dto/claim-price-drop-v2.dto.ts @@ -44,7 +44,7 @@ export class UpsertClaimPriceDropV2Dto { @ApiProperty({ example: 450000000, - description: "Market price of the car (Rials)", + description: "Market price of the car (Toman)", }) carPrice: number | string; diff --git a/src/expert-claim/dto/expert-claim-v2.dto.ts b/src/expert-claim/dto/expert-claim-v2.dto.ts index 3da7f11..7d1c51b 100644 --- a/src/expert-claim/dto/expert-claim-v2.dto.ts +++ b/src/expert-claim/dto/expert-claim-v2.dto.ts @@ -10,11 +10,10 @@ import { IsInt, } from 'class-validator'; import { Type } from 'class-transformer'; +import { IsRepairLineAmountToman } from 'src/common/validators/repair-line-amount-toman.validator'; import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum'; import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto'; import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum'; - -/** Same shape as V1 {@link PartsList} `daghi` (damage expert reply). */ export class DaghiDetailsV2Dto { @ApiProperty({ enum: DaghiOption, @@ -25,10 +24,11 @@ export class DaghiDetailsV2Dto { option: DaghiOption; @ApiPropertyOptional({ - description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'`, + description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}' (Toman)`, }) @IsOptional() @IsString() + @IsRepairLineAmountToman() price?: string; @ApiPropertyOptional({ @@ -51,16 +51,28 @@ export class PartPricingV2Dto { @IsString() typeOfDamage: string; - @ApiProperty({ example: '5000000' }) + @ApiProperty({ + example: "5000000", + description: "Part price in Toman (integer string). Use 0 if the full amount is in salary.", + }) @IsString() + @IsRepairLineAmountToman({ allowZero: true }) price: string; - @ApiProperty({ example: '2000000' }) + @ApiProperty({ + example: "2000000", + description: "Labor in Toman (integer string). Use 0 if the full amount is in price.", + }) @IsString() + @IsRepairLineAmountToman({ allowZero: true }) salary: string; - @ApiProperty({ example: '7000000' }) + @ApiProperty({ + example: "7000000", + description: "Line total in Toman (integer string).", + }) @IsString() + @IsRepairLineAmountToman() totalPayment: string; @ApiProperty({ type: DaghiDetailsV2Dto }) diff --git a/src/expert-claim/dto/factor-validation.dto.ts b/src/expert-claim/dto/factor-validation.dto.ts index b87aa12..82627cb 100644 --- a/src/expert-claim/dto/factor-validation.dto.ts +++ b/src/expert-claim/dto/factor-validation.dto.ts @@ -8,6 +8,7 @@ import { IsString, ValidateNested, } from "class-validator"; +import { IsRepairLineAmountToman } from "src/common/validators/repair-line-amount-toman.validator"; import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum"; class FactorDecisionDto { @@ -50,26 +51,29 @@ class FactorDecisionV2Dto { @ApiPropertyOptional({ description: - "Part price (Persian/ASCII digits). For each factor line, send totalPayment **or** both price and salary. Required for **APPROVED** (accepted amount read from factor / expert) and **REJECTED** (expert’s replacement pricing).", + "Part price (Toman). With totalPayment or price+salary for APPROVED/REJECTED factor lines. Use 0 if unused.", }) @IsOptional() @IsString() + @IsRepairLineAmountToman({ allowZero: true }) price?: string; @ApiPropertyOptional({ description: - "Salary portion when not using a single totalPayment (must be sent together with price for APPROVED/REJECTED factor lines).", + "Salary in Toman. Send with price when not using only totalPayment. Use 0 if unused.", }) @IsOptional() @IsString() + @IsRepairLineAmountToman({ allowZero: true }) salary?: string; @ApiPropertyOptional({ description: - "Line total payment; preferred when the expert enters one number. Required together with APPROVED/REJECTED unless both price and salary are sent.", + "Line total in Toman; or send both price and salary.", }) @IsOptional() @IsString() + @IsRepairLineAmountToman() totalPayment?: string; } diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 31339a6..9ec4155 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -114,8 +114,10 @@ import { ExpertFileKind, } from "src/users/entities/schema/expert-file-activity.schema"; -/** Maximum sum of line `totalPayment` across the claim (priced parts + factor lines after validation). */ -const CLAIM_V2_TOTAL_PAYMENT_CAP = 53_000_000; +/** Maximum sum of line `totalPayment` across the claim (Toman; priced parts + factor lines after validation). */ +import { CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN } from "src/constants/repair-amount-limits"; + +const CLAIM_V2_TOTAL_PAYMENT_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN; @Injectable() export class ExpertClaimService { @@ -1523,7 +1525,7 @@ export class ExpertClaimService { if (totalPrice > PRICE_CAP) { throw new BadRequestException({ - message: `You have reached the maximum acceptable total price. The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`, + message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`, error: "PRICE_CAP_ERROR", code: "PRICE_CAP_ERROR", totalPrice: totalPrice, @@ -2089,7 +2091,7 @@ 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). - * Total of all repair lines must be ≤ `CLAIM_V2_TOTAL_PAYMENT_CAP` (53_000_000; same as initial expert reply). + * Total of all repair lines must be ≤ `CLAIM_V2_TOTAL_PAYMENT_CAP` Toman (53_000_000; same as initial expert reply). * Response: `claimStatus` = `ClaimStatus`; `caseStatus` = `ClaimCaseStatus`. */ async validateClaimFactorsV2( @@ -2238,7 +2240,7 @@ export class ExpertClaimService { } if (totalPrice > PRICE_CAP) { throw new BadRequestException({ - message: `You have reached the maximum acceptable total price. The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`, + message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`, error: "PRICE_CAP_ERROR", totalPrice, priceCap: PRICE_CAP, @@ -2700,7 +2702,7 @@ export class ExpertClaimService { } if (totalPrice > PRICE_CAP) { throw new BadRequestException({ - message: `You have reached the maximum acceptable total price. The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`, + message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`, error: 'PRICE_CAP_ERROR', totalPrice, priceCap: PRICE_CAP, diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index 737fe24..dd515a1 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -176,7 +176,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`. **Cap:** sum of line `totalPayment` values ≤ 53,000,000 (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`. **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" + "**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" + @@ -239,7 +239,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**; otherwise `PRICE_CAP_ERROR` with message that the maximum acceptable total was exceeded.\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" + "**Outcomes:**\n" + "- **All approved:** `caseStatus=COMPLETED`, `claimStatus=APPROVED`, workflow `CLAIM_COMPLETED` — no owner signature.\n" + "- **Any rejected (repriced):** same auto-complete for now (owner acceptance may be added later).\n" + diff --git a/src/utils/unicode-digits.spec.ts b/src/utils/unicode-digits.spec.ts new file mode 100644 index 0000000..dbbf982 --- /dev/null +++ b/src/utils/unicode-digits.spec.ts @@ -0,0 +1,34 @@ +import { + normalizeMoneyAmountString, + normalizeUnicodeDigitsDeep, + normalizeUnicodeDigitsToEnglish, + parseMoneyAmountToman, +} from "./unicode-digits"; + +describe("unicode-digits", () => { + it("converts Persian digits to ASCII", () => { + expect(normalizeUnicodeDigitsToEnglish("۱۲۳۴۵۶۷۸۹۰")).toBe("1234567890"); + }); + + it("normalizes nested request bodies", () => { + expect( + normalizeUnicodeDigitsDeep({ + partPrice: "۵۰۰۰۰۰۰", + nested: [{ total: "۱۰۰" }], + }), + ).toEqual({ + partPrice: "5000000", + nested: [{ total: "100" }], + }); + }); + + it("strips grouping separators for money strings", () => { + expect(normalizeMoneyAmountString("۵,۰۰۰,۰۰۰")).toBe("5000000"); + }); + + it("parses integer Toman amount strings", () => { + expect(parseMoneyAmountToman("۵۳۰۰۰۰۰")).toBe(5300000); + expect(parseMoneyAmountToman("1000")).toBe(1000); + expect(parseMoneyAmountToman("12.5")).toBeNull(); + }); +}); diff --git a/src/utils/unicode-digits.ts b/src/utils/unicode-digits.ts new file mode 100644 index 0000000..4967b6d --- /dev/null +++ b/src/utils/unicode-digits.ts @@ -0,0 +1,54 @@ +function unicodeCharToAsciiDigit(ch: string): string { + if (ch >= "0" && ch <= "9") return ch; + const cp = ch.codePointAt(0)!; + if (cp >= 0x06f0 && cp <= 0x06f9) return String(cp - 0x06f0); + if (cp >= 0x0660 && cp <= 0x0669) return String(cp - 0x0660); + if (cp >= 0x0966 && cp <= 0x096f) return String(cp - 0x0966); + if (cp >= 0xff10 && cp <= 0xff19) return String(cp - 0xff10); + const folded = ch.normalize("NFKC"); + if (folded.length === 1 && folded >= "0" && folded <= "9") return folded; + return ch; +} + +/** + * Convert decimal digits in any Unicode script (Persian, Arabic-Indic, etc.) + * to ASCII `0-9`. Other characters are left unchanged. + */ +export function normalizeUnicodeDigitsToEnglish(input: string): string { + return Array.from(input).map(unicodeCharToAsciiDigit).join(""); +} + +/** Strip grouping separators after digit normalization (for money fields). */ +export function normalizeMoneyAmountString(input: string): string { + return normalizeUnicodeDigitsToEnglish(input) + .trim() + .replace(/[,،\u066C\u060C]/g, ""); +} + +/** Parse a money string to a whole **Toman** amount, or `null` if invalid. */ +export function parseMoneyAmountToman(value: unknown): number | null { + if (value == null || value === "") return null; + const n = normalizeMoneyAmountString(String(value)); + if (!/^\d+$/.test(n)) return null; + const num = Number(n); + if (!Number.isFinite(num)) return null; + return num; +} + +export function normalizeUnicodeDigitsDeep(value: T): T { + if (value == null) return value; + if (typeof value === "string") { + return normalizeUnicodeDigitsToEnglish(value) as T; + } + if (Array.isArray(value)) { + return value.map((item) => normalizeUnicodeDigitsDeep(item)) as T; + } + if (typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = normalizeUnicodeDigitsDeep(v); + } + return out as T; + } + return value; +}