From ee50b4c07ceaa86f6e92cd81adcb1b891ed70ba3 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 9 Apr 2026 09:23:10 +0700 Subject: [PATCH] feat(api): add Vietnam validators and migrate payment services to ConfigService - Create custom class-validator decorators: IsVietnamPhone, IsVietnamDistrict, IsVND - Replace process.env/requireEnv() with NestJS ConfigService DI in VNPay, MoMo, ZaloPay services - Update all payment infrastructure tests with ConfigService mocks (42 tests passing) TEC-1569 Co-Authored-By: Claude Opus 4.6 --- .../__tests__/momo.service.spec.ts | 31 ++- .../__tests__/payment-gateway.factory.spec.ts | 14 +- .../__tests__/vnpay.service.spec.ts | 22 +- .../__tests__/zalopay.service.spec.ts | 27 ++- .../infrastructure/services/momo.service.ts | 31 +-- .../infrastructure/services/vnpay.service.ts | 31 +-- .../services/zalopay.service.ts | 31 +-- apps/api/src/modules/shared/utils/index.ts | 1 + .../__tests__/vietnam-validators.spec.ts | 189 ++++++++++++++++++ .../modules/shared/utils/validators/index.ts | 3 + .../is-vietnam-district.validator.ts | 38 ++++ .../validators/is-vietnam-phone.validator.ts | 31 +++ .../utils/validators/is-vnd.validator.ts | 51 +++++ 13 files changed, 418 insertions(+), 82 deletions(-) create mode 100644 apps/api/src/modules/shared/utils/validators/__tests__/vietnam-validators.spec.ts create mode 100644 apps/api/src/modules/shared/utils/validators/index.ts create mode 100644 apps/api/src/modules/shared/utils/validators/is-vietnam-district.validator.ts create mode 100644 apps/api/src/modules/shared/utils/validators/is-vietnam-phone.validator.ts create mode 100644 apps/api/src/modules/shared/utils/validators/is-vnd.validator.ts diff --git a/apps/api/src/modules/payments/infrastructure/__tests__/momo.service.spec.ts b/apps/api/src/modules/payments/infrastructure/__tests__/momo.service.spec.ts index 1e5e382..3e133d5 100644 --- a/apps/api/src/modules/payments/infrastructure/__tests__/momo.service.spec.ts +++ b/apps/api/src/modules/payments/infrastructure/__tests__/momo.service.spec.ts @@ -1,18 +1,35 @@ import * as crypto from 'crypto'; import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { type ConfigService } from '@nestjs/config'; import { MomoService } from '../services/momo.service'; describe('MomoService', () => { let service: MomoService; - const secretKey = 'test-momo-secret-key-32chars!!ab'; - const partnerCode = 'MOMO_TEST'; - const accessKey = 'test-access-key'; + const secretKey = 'TESTSECRETKEY123456789012345678'; + const partnerCode = 'TESTPARTNER'; + const accessKey = 'TESTACCESSKEY'; beforeEach(() => { - vi.stubEnv('MOMO_PARTNER_CODE', partnerCode); - vi.stubEnv('MOMO_ACCESS_KEY', accessKey); - vi.stubEnv('MOMO_SECRET_KEY', secretKey); - service = new MomoService(); + const mockConfig = { + get: vi.fn((key: string, defaultValue?: string) => { + const env: Record = { + 'MOMO_PARTNER_CODE': 'TESTPARTNER', + 'MOMO_ACCESS_KEY': 'TESTACCESSKEY', + 'MOMO_SECRET_KEY': 'TESTSECRETKEY123456789012345678', + }; + return env[key] ?? defaultValue; + }), + getOrThrow: vi.fn((key: string) => { + const env: Record = { + 'MOMO_PARTNER_CODE': 'TESTPARTNER', + 'MOMO_ACCESS_KEY': 'TESTACCESSKEY', + 'MOMO_SECRET_KEY': 'TESTSECRETKEY123456789012345678', + }; + if (!env[key]) throw new Error(`Missing ${key}`); + return env[key]; + }), + } as unknown as ConfigService; + service = new MomoService(mockConfig); }); function buildCallbackData(overrides: Record = {}): Record { diff --git a/apps/api/src/modules/payments/infrastructure/__tests__/payment-gateway.factory.spec.ts b/apps/api/src/modules/payments/infrastructure/__tests__/payment-gateway.factory.spec.ts index 4fa0f96..2267f77 100644 --- a/apps/api/src/modules/payments/infrastructure/__tests__/payment-gateway.factory.spec.ts +++ b/apps/api/src/modules/payments/infrastructure/__tests__/payment-gateway.factory.spec.ts @@ -1,13 +1,19 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; +import { type ConfigService } from '@nestjs/config'; import { MomoService } from '../services/momo.service'; import { PaymentGatewayFactory } from '../services/payment-gateway.factory'; import { VnpayService } from '../services/vnpay.service'; import { ZalopayService } from '../services/zalopay.service'; describe('PaymentGatewayFactory', () => { - const vnpay = new VnpayService(); - const momo = new MomoService(); - const zalopay = new ZalopayService(); + const mockConfig = { + get: vi.fn((key: string, defaultValue?: string) => defaultValue ?? 'test'), + getOrThrow: vi.fn(() => 'test-value'), + } as unknown as ConfigService; + + const vnpay = new VnpayService(mockConfig); + const momo = new MomoService(mockConfig); + const zalopay = new ZalopayService(mockConfig); const factory = new PaymentGatewayFactory(vnpay, momo, zalopay); diff --git a/apps/api/src/modules/payments/infrastructure/__tests__/vnpay.service.spec.ts b/apps/api/src/modules/payments/infrastructure/__tests__/vnpay.service.spec.ts index c42e36e..a13a836 100644 --- a/apps/api/src/modules/payments/infrastructure/__tests__/vnpay.service.spec.ts +++ b/apps/api/src/modules/payments/infrastructure/__tests__/vnpay.service.spec.ts @@ -1,14 +1,30 @@ import * as crypto from 'crypto'; import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { type ConfigService } from '@nestjs/config'; import { VnpayService } from '../services/vnpay.service'; describe('VnpayService', () => { let service: VnpayService; beforeEach(() => { - vi.stubEnv('VNPAY_TMN_CODE', 'TESTCODE'); - vi.stubEnv('VNPAY_HASH_SECRET', 'TESTSECRET123456TESTSECRET123456'); - service = new VnpayService(); + const mockConfig = { + get: vi.fn((key: string, defaultValue?: string) => { + const env: Record = { + VNPAY_TMN_CODE: 'TESTCODE', + VNPAY_HASH_SECRET: 'TESTSECRET123456TESTSECRET123456', + }; + return env[key] ?? defaultValue; + }), + getOrThrow: vi.fn((key: string) => { + const env: Record = { + VNPAY_TMN_CODE: 'TESTCODE', + VNPAY_HASH_SECRET: 'TESTSECRET123456TESTSECRET123456', + }; + if (!env[key]) throw new Error(`Missing ${key}`); + return env[key]; + }), + } as unknown as ConfigService; + service = new VnpayService(mockConfig); }); it('should create a payment URL', async () => { diff --git a/apps/api/src/modules/payments/infrastructure/__tests__/zalopay.service.spec.ts b/apps/api/src/modules/payments/infrastructure/__tests__/zalopay.service.spec.ts index b306b08..e1c68e3 100644 --- a/apps/api/src/modules/payments/infrastructure/__tests__/zalopay.service.spec.ts +++ b/apps/api/src/modules/payments/infrastructure/__tests__/zalopay.service.spec.ts @@ -1,16 +1,33 @@ import * as crypto from 'crypto'; import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { type ConfigService } from '@nestjs/config'; import { ZalopayService } from '../services/zalopay.service'; describe('ZalopayService', () => { let service: ZalopayService; - const key2 = 'test-zalopay-key2-for-callback!!'; + const key2 = 'TESTKEY2ABCDEF1234567890ABCDEF12'; beforeEach(() => { - vi.stubEnv('ZALOPAY_APP_ID', '2553'); - vi.stubEnv('ZALOPAY_KEY1', 'test-zalopay-key1-for-signing!!a'); - vi.stubEnv('ZALOPAY_KEY2', key2); - service = new ZalopayService(); + const mockConfig = { + get: vi.fn((key: string, defaultValue?: string) => { + const env: Record = { + 'ZALOPAY_APP_ID': '2553', + 'ZALOPAY_KEY1': 'TESTKEY1ABCDEF1234567890ABCDEF12', + 'ZALOPAY_KEY2': 'TESTKEY2ABCDEF1234567890ABCDEF12', + }; + return env[key] ?? defaultValue; + }), + getOrThrow: vi.fn((key: string) => { + const env: Record = { + 'ZALOPAY_APP_ID': '2553', + 'ZALOPAY_KEY1': 'TESTKEY1ABCDEF1234567890ABCDEF12', + 'ZALOPAY_KEY2': 'TESTKEY2ABCDEF1234567890ABCDEF12', + }; + if (!env[key]) throw new Error(`Missing ${key}`); + return env[key]; + }), + } as unknown as ConfigService; + service = new ZalopayService(mockConfig); }); function buildCallbackData( diff --git a/apps/api/src/modules/payments/infrastructure/services/momo.service.ts b/apps/api/src/modules/payments/infrastructure/services/momo.service.ts index acc5aa7..6ee3ced 100644 --- a/apps/api/src/modules/payments/infrastructure/services/momo.service.ts +++ b/apps/api/src/modules/payments/infrastructure/services/momo.service.ts @@ -1,5 +1,6 @@ import * as crypto from 'crypto'; import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { type PaymentProvider } from '@prisma/client'; import { type IPaymentGateway, @@ -10,33 +11,21 @@ import { type RefundResult, } from './payment-gateway.interface'; -function requireEnv(key: string): string { - const value = process.env[key]; - if (!value) { - throw new Error(`Missing required environment variable: ${key}`); - } - return value; -} - @Injectable() export class MomoService implements IPaymentGateway { private readonly logger = new Logger(MomoService.name); readonly provider: PaymentProvider = 'MOMO'; - private get partnerCode(): string { - return requireEnv('MOMO_PARTNER_CODE'); - } + private readonly partnerCode: string; + private readonly accessKey: string; + private readonly secretKey: string; + private readonly endpoint: string; - private get accessKey(): string { - return requireEnv('MOMO_ACCESS_KEY'); - } - - private get secretKey(): string { - return requireEnv('MOMO_SECRET_KEY'); - } - - private get endpoint(): string { - return process.env['MOMO_ENDPOINT'] ?? 'https://test-payment.momo.vn/v2/gateway/api'; + constructor(private readonly config: ConfigService) { + this.partnerCode = this.config.getOrThrow('MOMO_PARTNER_CODE'); + this.accessKey = this.config.getOrThrow('MOMO_ACCESS_KEY'); + this.secretKey = this.config.getOrThrow('MOMO_SECRET_KEY'); + this.endpoint = this.config.get('MOMO_ENDPOINT', 'https://test-payment.momo.vn/v2/gateway/api'); } async createPaymentUrl(params: CreatePaymentUrlParams): Promise { diff --git a/apps/api/src/modules/payments/infrastructure/services/vnpay.service.ts b/apps/api/src/modules/payments/infrastructure/services/vnpay.service.ts index 9cf3ebf..8b685aa 100644 --- a/apps/api/src/modules/payments/infrastructure/services/vnpay.service.ts +++ b/apps/api/src/modules/payments/infrastructure/services/vnpay.service.ts @@ -1,5 +1,6 @@ import * as crypto from 'crypto'; import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { type PaymentProvider } from '@prisma/client'; import { type IPaymentGateway, @@ -10,33 +11,21 @@ import { type RefundResult, } from './payment-gateway.interface'; -function requireEnv(key: string): string { - const value = process.env[key]; - if (!value) { - throw new Error(`Missing required environment variable: ${key}`); - } - return value; -} - @Injectable() export class VnpayService implements IPaymentGateway { private readonly logger = new Logger(VnpayService.name); readonly provider: PaymentProvider = 'VNPAY'; - private get tmnCode(): string { - return requireEnv('VNPAY_TMN_CODE'); - } + private readonly tmnCode: string; + private readonly hashSecret: string; + private readonly baseUrl: string; + private readonly apiUrl: string; - private get hashSecret(): string { - return requireEnv('VNPAY_HASH_SECRET'); - } - - private get baseUrl(): string { - return process.env['VNPAY_BASE_URL'] ?? 'https://sandbox.vnpayment.vn/paymentv2/vpcpay.html'; - } - - private get apiUrl(): string { - return process.env['VNPAY_API_URL'] ?? 'https://sandbox.vnpayment.vn/merchant_webapi/api/transaction'; + constructor(private readonly config: ConfigService) { + this.tmnCode = this.config.getOrThrow('VNPAY_TMN_CODE'); + this.hashSecret = this.config.getOrThrow('VNPAY_HASH_SECRET'); + this.baseUrl = this.config.get('VNPAY_BASE_URL', 'https://sandbox.vnpayment.vn/paymentv2/vpcpay.html'); + this.apiUrl = this.config.get('VNPAY_API_URL', 'https://sandbox.vnpayment.vn/merchant_webapi/api/transaction'); } async createPaymentUrl(params: CreatePaymentUrlParams): Promise { diff --git a/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts b/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts index 2118750..c7229de 100644 --- a/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts +++ b/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts @@ -1,5 +1,6 @@ import * as crypto from 'crypto'; import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { type PaymentProvider } from '@prisma/client'; import { type IPaymentGateway, @@ -10,33 +11,21 @@ import { type RefundResult, } from './payment-gateway.interface'; -function requireEnv(key: string): string { - const value = process.env[key]; - if (!value) { - throw new Error(`Missing required environment variable: ${key}`); - } - return value; -} - @Injectable() export class ZalopayService implements IPaymentGateway { private readonly logger = new Logger(ZalopayService.name); readonly provider: PaymentProvider = 'ZALOPAY'; - private get appId(): string { - return requireEnv('ZALOPAY_APP_ID'); - } + private readonly appId: string; + private readonly key1: string; + private readonly key2: string; + private readonly endpoint: string; - private get key1(): string { - return requireEnv('ZALOPAY_KEY1'); - } - - private get key2(): string { - return requireEnv('ZALOPAY_KEY2'); - } - - private get endpoint(): string { - return process.env['ZALOPAY_ENDPOINT'] ?? 'https://sb-openapi.zalopay.vn/v2'; + constructor(private readonly config: ConfigService) { + this.appId = this.config.getOrThrow('ZALOPAY_APP_ID'); + this.key1 = this.config.getOrThrow('ZALOPAY_KEY1'); + this.key2 = this.config.getOrThrow('ZALOPAY_KEY2'); + this.endpoint = this.config.get('ZALOPAY_ENDPOINT', 'https://sb-openapi.zalopay.vn/v2'); } async createPaymentUrl(params: CreatePaymentUrlParams): Promise { diff --git a/apps/api/src/modules/shared/utils/index.ts b/apps/api/src/modules/shared/utils/index.ts index 18e0c46..b9c4901 100644 --- a/apps/api/src/modules/shared/utils/index.ts +++ b/apps/api/src/modules/shared/utils/index.ts @@ -1,3 +1,4 @@ export { isValidVietnamPhone, normalizeVietnamPhone } from './vietnam-phone.validator'; export { formatVND, formatVNDCompact, parseVND } from './currency.formatter'; export { generateSlug } from './slug.generator'; +export { IsVietnamPhone, IsVietnamDistrict, IsVND, type IsVNDOptions } from './validators'; diff --git a/apps/api/src/modules/shared/utils/validators/__tests__/vietnam-validators.spec.ts b/apps/api/src/modules/shared/utils/validators/__tests__/vietnam-validators.spec.ts new file mode 100644 index 0000000..1eb63a4 --- /dev/null +++ b/apps/api/src/modules/shared/utils/validators/__tests__/vietnam-validators.spec.ts @@ -0,0 +1,189 @@ +import { validate } from 'class-validator'; +import { IsVietnamPhone } from '../is-vietnam-phone.validator'; +import { IsVietnamDistrict } from '../is-vietnam-district.validator'; +import { IsVND } from '../is-vnd.validator'; + +// Test DTOs +class PhoneDto { + @IsVietnamPhone() + phone!: string; +} + +class DistrictDto { + @IsVietnamDistrict() + district!: string; +} + +class AmountDto { + @IsVND() + amount!: number; +} + +class CustomAmountDto { + @IsVND({ min: 10_000, max: 50_000_000 }) + amount!: number; +} + +function makeDto(Cls: new () => T, overrides: Partial): T { + const dto = new Cls(); + Object.assign(dto, overrides); + return dto; +} + +describe('IsVietnamPhone', () => { + it('accepts valid phone with 0 prefix', async () => { + const dto = makeDto(PhoneDto, { phone: '0912345678' }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts valid phone with +84 prefix', async () => { + const dto = makeDto(PhoneDto, { phone: '+84912345678' }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts valid phone with 84 prefix', async () => { + const dto = makeDto(PhoneDto, { phone: '84912345678' }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts phone with spaces/dashes', async () => { + const dto = makeDto(PhoneDto, { phone: '091 234 5678' }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('rejects invalid phone number', async () => { + const dto = makeDto(PhoneDto, { phone: '12345' }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].constraints).toHaveProperty('isVietnamPhone'); + }); + + it('rejects non-string value', async () => { + const dto = makeDto(PhoneDto, { phone: 12345 as unknown as string }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + }); + + it('rejects empty string', async () => { + const dto = makeDto(PhoneDto, { phone: '' }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + }); +}); + +describe('IsVietnamDistrict', () => { + it('accepts "Quận 1"', async () => { + const dto = makeDto(DistrictDto, { district: 'Quận 1' }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts "Huyện Củ Chi"', async () => { + const dto = makeDto(DistrictDto, { district: 'Huyện Củ Chi' }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts "Bình Thạnh"', async () => { + const dto = makeDto(DistrictDto, { district: 'Bình Thạnh' }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts "Thành phố Thủ Đức"', async () => { + const dto = makeDto(DistrictDto, { district: 'Thành phố Thủ Đức' }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('rejects single character', async () => { + const dto = makeDto(DistrictDto, { district: 'Q' }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].constraints).toHaveProperty('isVietnamDistrict'); + }); + + it('rejects empty string', async () => { + const dto = makeDto(DistrictDto, { district: '' }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + }); + + it('rejects non-string value', async () => { + const dto = makeDto(DistrictDto, { district: 123 as unknown as string }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + }); +}); + +describe('IsVND', () => { + it('accepts valid VND amount (number)', async () => { + const dto = makeDto(AmountDto, { amount: 500_000 }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts minimum amount (1)', async () => { + const dto = makeDto(AmountDto, { amount: 1 }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts maximum amount (100 tỷ)', async () => { + const dto = makeDto(AmountDto, { amount: 100_000_000_000 }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('rejects zero', async () => { + const dto = makeDto(AmountDto, { amount: 0 }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].constraints).toHaveProperty('isVND'); + }); + + it('rejects negative amount', async () => { + const dto = makeDto(AmountDto, { amount: -1000 }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + }); + + it('rejects amount exceeding max', async () => { + const dto = makeDto(AmountDto, { amount: 100_000_000_001 }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + }); + + it('rejects non-integer', async () => { + const dto = makeDto(AmountDto, { amount: 1000.5 }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + }); + + it('rejects string value', async () => { + const dto = makeDto(AmountDto, { amount: '500000' as unknown as number }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + }); + + it('respects custom min/max options', async () => { + const dtoOk = makeDto(CustomAmountDto, { amount: 100_000 }); + expect(await validate(dtoOk)).toHaveLength(0); + + const dtoBelowMin = makeDto(CustomAmountDto, { amount: 5_000 }); + expect(await validate(dtoBelowMin)).toHaveLength(1); + + const dtoAboveMax = makeDto(CustomAmountDto, { amount: 60_000_000 }); + expect(await validate(dtoAboveMax)).toHaveLength(1); + }); + + it('accepts bigint values', async () => { + const dto = makeDto(AmountDto, { amount: 500_000n as unknown as number }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); +}); diff --git a/apps/api/src/modules/shared/utils/validators/index.ts b/apps/api/src/modules/shared/utils/validators/index.ts new file mode 100644 index 0000000..0444d0d --- /dev/null +++ b/apps/api/src/modules/shared/utils/validators/index.ts @@ -0,0 +1,3 @@ +export { IsVietnamPhone, IsVietnamPhoneConstraint } from './is-vietnam-phone.validator'; +export { IsVietnamDistrict, IsVietnamDistrictConstraint } from './is-vietnam-district.validator'; +export { IsVND, IsVNDConstraint, type IsVNDOptions } from './is-vnd.validator'; diff --git a/apps/api/src/modules/shared/utils/validators/is-vietnam-district.validator.ts b/apps/api/src/modules/shared/utils/validators/is-vietnam-district.validator.ts new file mode 100644 index 0000000..5b87630 --- /dev/null +++ b/apps/api/src/modules/shared/utils/validators/is-vietnam-district.validator.ts @@ -0,0 +1,38 @@ +import { + registerDecorator, + type ValidationArguments, + type ValidationOptions, + ValidatorConstraint, + type ValidatorConstraintInterface, +} from 'class-validator'; + +/** + * Vietnamese district name pattern — accepts: + * - "Quận 1", "Quận Bình Thạnh", "Huyện Củ Chi", "Thành phố Thủ Đức" + * - Plain names like "Bình Thạnh", "Củ Chi" (without prefix) + * Minimum 2 characters, maximum 100 characters, Vietnamese Unicode. + */ +const VN_DISTRICT_REGEX = /^[\p{L}\p{N}\s]{2,100}$/u; + +@ValidatorConstraint({ name: 'isVietnamDistrict', async: false }) +export class IsVietnamDistrictConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + return typeof value === 'string' && VN_DISTRICT_REGEX.test(value.trim()); + } + + defaultMessage(args: ValidationArguments): string { + return `${args.property} phải là tên quận/huyện hợp lệ (VD: Quận 1, Huyện Củ Chi)`; + } +} + +export function IsVietnamDistrict(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [], + validator: IsVietnamDistrictConstraint, + }); + }; +} diff --git a/apps/api/src/modules/shared/utils/validators/is-vietnam-phone.validator.ts b/apps/api/src/modules/shared/utils/validators/is-vietnam-phone.validator.ts new file mode 100644 index 0000000..5fcc2e6 --- /dev/null +++ b/apps/api/src/modules/shared/utils/validators/is-vietnam-phone.validator.ts @@ -0,0 +1,31 @@ +import { + registerDecorator, + type ValidationArguments, + type ValidationOptions, + ValidatorConstraint, + type ValidatorConstraintInterface, +} from 'class-validator'; +import { isValidVietnamPhone } from '../vietnam-phone.validator'; + +@ValidatorConstraint({ name: 'isVietnamPhone', async: false }) +export class IsVietnamPhoneConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + return typeof value === 'string' && isValidVietnamPhone(value); + } + + defaultMessage(args: ValidationArguments): string { + return `${args.property} phải là số điện thoại Việt Nam hợp lệ (VD: 0912345678, +84912345678)`; + } +} + +export function IsVietnamPhone(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [], + validator: IsVietnamPhoneConstraint, + }); + }; +} diff --git a/apps/api/src/modules/shared/utils/validators/is-vnd.validator.ts b/apps/api/src/modules/shared/utils/validators/is-vnd.validator.ts new file mode 100644 index 0000000..509541c --- /dev/null +++ b/apps/api/src/modules/shared/utils/validators/is-vnd.validator.ts @@ -0,0 +1,51 @@ +import { + registerDecorator, + type ValidationArguments, + type ValidationOptions, + ValidatorConstraint, + type ValidatorConstraintInterface, +} from 'class-validator'; + +const VND_MIN = 1; +const VND_MAX = 100_000_000_000; // 100 tỷ VND + +export interface IsVNDOptions { + min?: number; + max?: number; +} + +@ValidatorConstraint({ name: 'isVND', async: false }) +export class IsVNDConstraint implements ValidatorConstraintInterface { + validate(value: unknown, args: ValidationArguments): boolean { + const opts = (args.constraints[0] as IsVNDOptions) ?? {}; + const min = opts.min ?? VND_MIN; + const max = opts.max ?? VND_MAX; + + if (typeof value === 'bigint') { + return value >= BigInt(min) && value <= BigInt(max); + } + if (typeof value === 'number') { + return Number.isInteger(value) && value >= min && value <= max; + } + return false; + } + + defaultMessage(args: ValidationArguments): string { + const opts = (args.constraints[0] as IsVNDOptions) ?? {}; + const min = opts.min ?? VND_MIN; + const max = opts.max ?? VND_MAX; + return `${args.property} phải là số tiền VND hợp lệ (${min.toLocaleString('vi-VN')} - ${max.toLocaleString('vi-VN')} VND)`; + } +} + +export function IsVND(options?: IsVNDOptions, validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [options ?? {}], + validator: IsVNDConstraint, + }); + }; +}