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 <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-09 09:23:10 +07:00
parent 628150b7d8
commit ee50b4c07c
13 changed files with 418 additions and 82 deletions

View File

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

View File

@@ -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<T extends object>(Cls: new () => T, overrides: Partial<T>): 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);
});
});

View File

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

View File

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

View File

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

View File

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