fix(payments): harden payment flow with idempotency keys, amount validation, and magic byte file validation

- Add dedicated idempotencyKey column with unique constraint (userId, provider, idempotencyKey) to prevent duplicate payments at DB level
- Add @Min(1) @Max(100B) validators on amountVND in CreatePaymentDto to reject invalid amounts at API boundary
- Replace read-check-write callback handler with atomic updateIfStatus to eliminate race condition on concurrent callbacks
- Add magic byte verification in FileValidationPipe to validate file content matches declared MIME type server-side

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 06:18:26 +07:00
parent be0deddeed
commit 9583d1cb66
9 changed files with 148 additions and 43 deletions

View File

@@ -62,7 +62,7 @@ export class PaymentsController {
user.sub,
dto.provider,
dto.type,
dto.amountVND,
BigInt(dto.amountVND),
dto.description,
dto.returnUrl,
ip || '127.0.0.1',

View File

@@ -1,9 +1,12 @@
import {
IsEnum,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsUrl,
Max,
Min,
MinLength,
} from 'class-validator';
import { Transform } from 'class-transformer';
@@ -19,10 +22,17 @@ export class CreatePaymentDto {
@IsEnum(PaymentType)
type!: PaymentType;
@ApiProperty({ type: Number, description: 'Amount in VND', example: 500000 })
@ApiProperty({ type: Number, description: 'Amount in VND (1 100,000,000,000)', example: 500000 })
@IsNotEmpty()
@Transform(({ value }) => BigInt(value))
amountVND!: bigint;
@IsNumber()
@Min(1, { message: 'Số tiền phải lớn hơn 0' })
@Max(100_000_000_000, { message: 'Số tiền vượt quá giới hạn cho phép (100 tỷ VND)' })
@Transform(({ value }) => {
const num = Number(value);
if (!Number.isFinite(num) || !Number.isInteger(num)) return value;
return num;
}, { toClassOnly: true })
amountVND!: number;
@ApiProperty({ description: 'Payment description', example: 'Listing fee' })
@IsString()