From c007ac584f0a380b59f7192fb33389c39e481c6b Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Tue, 18 Nov 2025 15:03:42 +0300 Subject: [PATCH] feat: add KYC onboarding metadata endpoint with POI validation --- .../controllers/customer.controller.ts | 14 ++- src/customer/customer.module.ts | 4 +- src/customer/dtos/response/index.ts | 1 + .../response/kyc-metadata.response.dto.ts | 20 ++++ src/customer/enums/income-range.enum.ts | 8 ++ src/customer/enums/income-source.enum.ts | 9 ++ src/customer/enums/index.ts | 5 + src/customer/enums/job-category.enum.ts | 57 ++++++++++ src/customer/enums/job-sector.enum.ts | 12 ++ src/customer/enums/poi-type.enum.ts | 5 + src/customer/services/customer.service.ts | 7 ++ src/customer/services/index.ts | 1 + src/customer/services/metadata.service.ts | 105 ++++++++++++++++++ .../validators/poi-number.validator.ts | 64 +++++++++++ 14 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 src/customer/dtos/response/kyc-metadata.response.dto.ts create mode 100644 src/customer/enums/income-range.enum.ts create mode 100644 src/customer/enums/income-source.enum.ts create mode 100644 src/customer/enums/job-category.enum.ts create mode 100644 src/customer/enums/job-sector.enum.ts create mode 100644 src/customer/enums/poi-type.enum.ts create mode 100644 src/customer/services/metadata.service.ts create mode 100644 src/customer/validators/poi-number.validator.ts diff --git a/src/customer/controllers/customer.controller.ts b/src/customer/controllers/customer.controller.ts index 66aba7e..55d4126 100644 --- a/src/customer/controllers/customer.controller.ts +++ b/src/customer/controllers/customer.controller.ts @@ -1,12 +1,12 @@ import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { IJwtPayload } from '~/auth/interfaces'; import { AuthenticatedUser } from '~/common/decorators'; import { AccessTokenGuard } from '~/common/guards'; import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; import { InitiateKycRequestDto } from '../dtos/request'; -import { CustomerResponseDto, InitiateKycResponseDto } from '../dtos/response'; +import { CustomerResponseDto, InitiateKycResponseDto, KycMetadataResponseDto } from '../dtos/response'; import { CustomerService } from '../services'; @Controller('customers') @@ -32,4 +32,14 @@ export class CustomerController { return ResponseFactory.data(new InitiateKycResponseDto(res.randomNumber)); } + + @Get('/kyc/onboard-metadata') + @UseGuards(AccessTokenGuard) + @ApiOperation({ summary: 'Get KYC onboarding form metadata' }) + @ApiDataResponse(KycMetadataResponseDto) + async getKycMetadata() { + const metadata = await this.customerService.getKycOnboardMetadata(); + + return ResponseFactory.data(metadata); + } } diff --git a/src/customer/customer.module.ts b/src/customer/customer.module.ts index e2b6b88..a966521 100644 --- a/src/customer/customer.module.ts +++ b/src/customer/customer.module.ts @@ -6,12 +6,12 @@ import { UserModule } from '~/user/user.module'; import { CustomerController } from './controllers'; import { Customer } from './entities'; import { CustomerRepository } from './repositories/customer.repository'; -import { CustomerService } from './services'; +import { CustomerService, MetadataService } from './services'; @Module({ imports: [TypeOrmModule.forFeature([Customer]), GuardianModule, forwardRef(() => UserModule), NeoLeapModule], controllers: [CustomerController], - providers: [CustomerService, CustomerRepository], + providers: [CustomerService, CustomerRepository, MetadataService], exports: [CustomerService], }) export class CustomerModule {} diff --git a/src/customer/dtos/response/index.ts b/src/customer/dtos/response/index.ts index 9d2a535..6984250 100644 --- a/src/customer/dtos/response/index.ts +++ b/src/customer/dtos/response/index.ts @@ -1,2 +1,3 @@ export * from './customer.response.dto'; export * from './initiate-kyc.response.dto'; +export * from './kyc-metadata.response.dto'; diff --git a/src/customer/dtos/response/kyc-metadata.response.dto.ts b/src/customer/dtos/response/kyc-metadata.response.dto.ts new file mode 100644 index 0000000..9c56a2b --- /dev/null +++ b/src/customer/dtos/response/kyc-metadata.response.dto.ts @@ -0,0 +1,20 @@ +export class MetadataOptionDto { + value!: string; + label!: string; +} + +export class PoiValidationRule { + poiType!: string; + pattern!: string; + description!: string; + example!: string; +} + +export class KycMetadataResponseDto { + poiTypes!: MetadataOptionDto[]; + jobSectors!: MetadataOptionDto[]; + incomeSources!: MetadataOptionDto[]; + jobCategories!: MetadataOptionDto[]; + incomeRanges!: MetadataOptionDto[]; + poiValidation!: PoiValidationRule[]; +} \ No newline at end of file diff --git a/src/customer/enums/income-range.enum.ts b/src/customer/enums/income-range.enum.ts new file mode 100644 index 0000000..6a4dd2d --- /dev/null +++ b/src/customer/enums/income-range.enum.ts @@ -0,0 +1,8 @@ +export enum IncomeRange { + BELOW_2000 = 'SAR 2,000 and below', + RANGE_2000_5000 = 'SAR 2,000 to 5,000', + RANGE_5000_10000 = 'SAR 5,000 to 10,000', + RANGE_10000_20000 = 'SAR 10,000 to 20,000', + ABOVE_20000 = 'SAR 20,000 and above', +} + diff --git a/src/customer/enums/income-source.enum.ts b/src/customer/enums/income-source.enum.ts new file mode 100644 index 0000000..12ae331 --- /dev/null +++ b/src/customer/enums/income-source.enum.ts @@ -0,0 +1,9 @@ +export enum IncomeSource { + SALARY = 'SALARY', + ANCESTRAL = 'ANCESTRAL', + REAL_ESTATE = 'REAL_ESTATE', + INVESTMENT_RETURNS = 'INVESTMENT_RETURNS', + RENTAL_INCOME = 'RENTAL_INCOME', + OTHER = 'OTHER', +} + diff --git a/src/customer/enums/index.ts b/src/customer/enums/index.ts index ea026d8..3bf8810 100644 --- a/src/customer/enums/index.ts +++ b/src/customer/enums/index.ts @@ -1,3 +1,8 @@ export * from './customer-status.enum'; export * from './gender.enum'; export * from './kyc-status.enum'; +export * from './poi-type.enum'; +export * from './job-sector.enum'; +export * from './income-source.enum'; +export * from './job-category.enum'; +export * from './income-range.enum'; diff --git a/src/customer/enums/job-category.enum.ts b/src/customer/enums/job-category.enum.ts new file mode 100644 index 0000000..66e867d --- /dev/null +++ b/src/customer/enums/job-category.enum.ts @@ -0,0 +1,57 @@ +export enum JobCategory { + ASSISTANT_MINISTER = 'ASSISTANT_MINISTER', + DEPUTY_MINISTER = 'DEPUTY_MINISTER', + UNDER_SECRETARY = 'UNDER_SECRETARY', + GENERAL_MANAGER = 'GENERAL_MANAGER', + CHAIRMAN = 'CHAIRMAN', + MANAGER = 'MANAGER', + PROFESSOR = 'PROFESSOR', + HEAD_OF_COURT = 'HEAD_OF_COURT', + JUDGE = 'JUDGE', + LAWYER = 'LAWYER', + SCIENTIST = 'SCIENTIST', + NOTARY = 'NOTARY', + BUSINESSMAN = 'BUSINESSMAN', + MERCHANT = 'MERCHANT', + PHARMACIST = 'PHARMACIST', + DOCTOR = 'DOCTOR', + MEDICAL_TECHNICIAN = 'MEDICAL_TECHNICIAN', + NURSE = 'NURSE', + ENGINEER = 'ENGINEER', + CHEMIST = 'CHEMIST', + CONTRACTOR = 'CONTRACTOR', + AUDITOR_ACCOUNTANT = 'AUDITOR_ACCOUNTANT', + RESEARCHER = 'RESEARCHER', + ACCOUNTANT = 'ACCOUNTANT', + JOURNALIST = 'JOURNALIST', + DESIGNER = 'DESIGNER', + COMPUTER_SPECIALIST = 'COMPUTER_SPECIALIST', + TRANSLATOR = 'TRANSLATOR', + TEACHER = 'TEACHER', + PILOT = 'PILOT', + HOST = 'HOST', + OFFICER = 'OFFICER', + SOLDIER = 'SOLDIER', + RETIRED = 'RETIRED', + SALESMAN = 'SALESMAN', + AUTHOR = 'AUTHOR', + CRAFTSMAN = 'CRAFTSMAN', + SECURITY = 'SECURITY', + LABORER = 'LABORER', + DRIVER = 'DRIVER', + FARMER = 'FARMER', + HOUSEWIFE = 'HOUSEWIFE', + DIPLOMAT = 'DIPLOMAT', + STUDENT = 'STUDENT', + FREELANCER = 'FREELANCER', + SHEPHERD = 'SHEPHERD', + HOUSEMAID_OR_BABYSITTER = 'HOUSEMAID_OR_BABYSITTER', + CAPTAIN = 'CAPTAIN', + AMBASSADOR = 'AMBASSADOR', + MARKETING = 'MARKETING', + CONSULTING = 'CONSULTING', + SUPERVISOR = 'SUPERVISOR', + BANKER = 'BANKER', + BODYGUARD_OR_PERSONAL_ASSISTANT = 'BODYGUARD_OR_PERSONAL_ASSISTANT', +} + diff --git a/src/customer/enums/job-sector.enum.ts b/src/customer/enums/job-sector.enum.ts new file mode 100644 index 0000000..8db90ad --- /dev/null +++ b/src/customer/enums/job-sector.enum.ts @@ -0,0 +1,12 @@ +export enum JobSector { + GOVERNMENT_SECTOR = 'GOVERNMENT_SECTOR', + HOME_MAKER = 'HOME_MAKER', + MILITARY = 'MILITARY', + PRIVATE_SECTOR = 'PRIVATE_SECTOR', + RETIRED = 'RETIRED', + SELF_EMPLOYED = 'SELF_EMPLOYED', + STUDENT = 'STUDENT', + HOUSEHOLD_LABOR = 'HOUSEHOLD_LABOR', + UNEMPLOYED = 'UNEMPLOYED', +} + diff --git a/src/customer/enums/poi-type.enum.ts b/src/customer/enums/poi-type.enum.ts new file mode 100644 index 0000000..07eb522 --- /dev/null +++ b/src/customer/enums/poi-type.enum.ts @@ -0,0 +1,5 @@ +export enum PoiType { + IQA = 'IQA', // Iqama (Resident ID) + NAT = 'NAT', // National ID +} + diff --git a/src/customer/services/customer.service.ts b/src/customer/services/customer.service.ts index 0cfb6ca..783e221 100644 --- a/src/customer/services/customer.service.ts +++ b/src/customer/services/customer.service.ts @@ -12,6 +12,7 @@ import { InitiateKycRequestDto } from '../dtos/request'; import { Customer } from '../entities'; import { Gender, KycStatus } from '../enums'; import { CustomerRepository } from '../repositories/customer.repository'; +import { MetadataService } from './metadata.service'; @Injectable() export class CustomerService { @@ -20,6 +21,7 @@ export class CustomerService { private readonly customerRepository: CustomerRepository, private readonly guardianService: GuardianService, @Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService, + private readonly metadataService: MetadataService, ) {} async updateCustomer(userId: string, data: Partial): Promise { @@ -149,6 +151,11 @@ export class CustomerService { return this.findCustomerById(userId); } + getKycOnboardMetadata() { + this.logger.log('Getting KYC onboard metadata'); + return this.metadataService.getKycOnboardMetadata(); + } + // TO BE REMOVED: This function is for testing only and will be removed private generateSaudiPhoneNumber(): string { // Saudi mobile numbers are 9 digits, always starting with '5' diff --git a/src/customer/services/index.ts b/src/customer/services/index.ts index 0c04669..172237e 100644 --- a/src/customer/services/index.ts +++ b/src/customer/services/index.ts @@ -1 +1,2 @@ export * from './customer.service'; +export * from './metadata.service'; diff --git a/src/customer/services/metadata.service.ts b/src/customer/services/metadata.service.ts new file mode 100644 index 0000000..e4cc985 --- /dev/null +++ b/src/customer/services/metadata.service.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@nestjs/common'; +import { IncomeRange, IncomeSource, JobCategory, JobSector, PoiType } from '../enums'; +import { KycMetadataResponseDto, MetadataOptionDto } from '../dtos/response'; + +@Injectable() +export class MetadataService { + getKycOnboardMetadata(): KycMetadataResponseDto { + return { + poiTypes: this.enumToOptions(PoiType, { + [PoiType.IQA]: 'Iqama (Resident ID)', + [PoiType.NAT]: 'National ID', + }), + jobSectors: this.enumToOptions(JobSector, { + [JobSector.GOVERNMENT_SECTOR]: 'Government Sector', + [JobSector.HOME_MAKER]: 'Home Maker', + [JobSector.MILITARY]: 'Military', + [JobSector.PRIVATE_SECTOR]: 'Private Sector', + [JobSector.RETIRED]: 'Retired', + [JobSector.SELF_EMPLOYED]: 'Self Employed', + [JobSector.STUDENT]: 'Student', + [JobSector.HOUSEHOLD_LABOR]: 'Household Labor', + [JobSector.UNEMPLOYED]: 'Unemployed', + }), + incomeSources: this.enumToOptions(IncomeSource, { + [IncomeSource.SALARY]: 'Salary', + [IncomeSource.ANCESTRAL]: 'Ancestral/Inheritance', + [IncomeSource.REAL_ESTATE]: 'Real Estate', + [IncomeSource.INVESTMENT_RETURNS]: 'Investment Returns', + [IncomeSource.RENTAL_INCOME]: 'Rental Income', + [IncomeSource.OTHER]: 'Other', + }), + jobCategories: this.enumToOptions(JobCategory, { + [JobCategory.ASSISTANT_MINISTER]: 'Assistant Minister', + [JobCategory.DEPUTY_MINISTER]: 'Deputy Minister', + [JobCategory.UNDER_SECRETARY]: 'Under Secretary', + [JobCategory.GENERAL_MANAGER]: 'General Manager', + [JobCategory.CHAIRMAN]: 'Chairman', + [JobCategory.MANAGER]: 'Manager', + [JobCategory.PROFESSOR]: 'Professor', + [JobCategory.HEAD_OF_COURT]: 'Head of Court', + [JobCategory.JUDGE]: 'Judge', + [JobCategory.LAWYER]: 'Lawyer', + [JobCategory.SCIENTIST]: 'Scientist', + [JobCategory.NOTARY]: 'Notary', + [JobCategory.BUSINESSMAN]: 'Businessman', + [JobCategory.MERCHANT]: 'Merchant', + [JobCategory.PHARMACIST]: 'Pharmacist', + [JobCategory.DOCTOR]: 'Doctor', + [JobCategory.MEDICAL_TECHNICIAN]: 'Medical Technician', + [JobCategory.NURSE]: 'Nurse', + [JobCategory.ENGINEER]: 'Engineer', + [JobCategory.CHEMIST]: 'Chemist', + [JobCategory.CONTRACTOR]: 'Contractor', + [JobCategory.AUDITOR_ACCOUNTANT]: 'Auditor/Accountant', + [JobCategory.RESEARCHER]: 'Researcher', + [JobCategory.ACCOUNTANT]: 'Accountant', + [JobCategory.JOURNALIST]: 'Journalist', + [JobCategory.DESIGNER]: 'Designer', + [JobCategory.COMPUTER_SPECIALIST]: 'Computer Specialist', + [JobCategory.TRANSLATOR]: 'Translator', + [JobCategory.TEACHER]: 'Teacher', + [JobCategory.PILOT]: 'Pilot', + [JobCategory.HOST]: 'Host', + [JobCategory.OFFICER]: 'Officer', + [JobCategory.SOLDIER]: 'Soldier', + [JobCategory.RETIRED]: 'Retired', + [JobCategory.SALESMAN]: 'Salesman', + [JobCategory.AUTHOR]: 'Author', + [JobCategory.CRAFTSMAN]: 'Craftsman', + [JobCategory.SECURITY]: 'Security', + [JobCategory.LABORER]: 'Laborer', + [JobCategory.DRIVER]: 'Driver', + [JobCategory.FARMER]: 'Farmer', + [JobCategory.HOUSEWIFE]: 'Housewife', + [JobCategory.DIPLOMAT]: 'Diplomat', + [JobCategory.STUDENT]: 'Student', + [JobCategory.FREELANCER]: 'Freelancer', + [JobCategory.SHEPHERD]: 'Shepherd', + [JobCategory.HOUSEMAID_OR_BABYSITTER]: 'Housemaid/Babysitter', + [JobCategory.CAPTAIN]: 'Captain', + [JobCategory.AMBASSADOR]: 'Ambassador', + [JobCategory.MARKETING]: 'Marketing', + [JobCategory.CONSULTING]: 'Consulting', + [JobCategory.SUPERVISOR]: 'Supervisor', + [JobCategory.BANKER]: 'Banker', + [JobCategory.BODYGUARD_OR_PERSONAL_ASSISTANT]: 'Bodyguard/Personal Assistant', + }), + incomeRanges: this.enumToOptions(IncomeRange, { + [IncomeRange.BELOW_2000]: 'SAR 2,000 and below', + [IncomeRange.RANGE_2000_5000]: 'SAR 2,000 to 5,000', + [IncomeRange.RANGE_5000_10000]: 'SAR 5,000 to 10,000', + [IncomeRange.RANGE_10000_20000]: 'SAR 10,000 to 20,000', + [IncomeRange.ABOVE_20000]: 'SAR 20,000 and above', + }), + }; + } + + private enumToOptions(enumObj: any, labels: Record): MetadataOptionDto[] { + return Object.keys(enumObj).map((key) => ({ + value: enumObj[key], + label: labels[enumObj[key]] || enumObj[key], + })); + } +} + diff --git a/src/customer/validators/poi-number.validator.ts b/src/customer/validators/poi-number.validator.ts new file mode 100644 index 0000000..3bd76d4 --- /dev/null +++ b/src/customer/validators/poi-number.validator.ts @@ -0,0 +1,64 @@ +import { + registerDecorator, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, + ValidationArguments, +} from 'class-validator'; +import { PoiType } from '../enums'; + +@ValidatorConstraint({ name: 'IsValidPoiNumber', async: false }) +export class IsValidPoiNumberConstraint implements ValidatorConstraintInterface { + validate(poiNumber: string, args: ValidationArguments) { + const object = args.object as any; + const poiType = object.poiType; + + if (!poiNumber || !poiType) { + return false; + } + + // Saudi National ID: 10 digits, typically starts with 1 or 2 + const nationalIdPattern = /^[12]\d{9}$/; + + // Iqama (Resident ID): 10 digits, typically starts with other numbers (not 1 or 2) + const iqamaPattern = /^[3-9]\d{9}$/; + + if (poiType === PoiType.NAT) { + return nationalIdPattern.test(poiNumber); + } + + if (poiType === PoiType.IQA) { + return iqamaPattern.test(poiNumber); + } + + return false; + } + + defaultMessage(args: ValidationArguments) { + const object = args.object as any; + const poiType = object.poiType; + + if (poiType === PoiType.NAT) { + return 'National ID must be 10 digits and start with 1 or 2'; + } + + if (poiType === PoiType.IQA) { + return 'Iqama number must be 10 digits and start with 3-9'; + } + + return 'Invalid POI number format'; + } +} + +export function IsValidPoiNumber(validationOptions?: ValidationOptions) { + return function (object: Object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [], + validator: IsValidPoiNumberConstraint, + }); + }; +} +