From 3200f6082136e74ed4249d8669e9c4b3a6fdc307 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Tue, 16 Dec 2025 14:44:07 +0300 Subject: [PATCH 1/3] feat: Complete KYC implementation with address fields - Added address fields to registration (verify-user DTO) - Added address fields to KYC initiation (initiate-kyc DTO) - Added national_id column to kyc_transactions table - Changed duplicate KYC check from customerId to nationalId - Added KYC webhook endpoint (/api/neoleap-webhooks/kyc) - Added webhook processing logic - Updated customer service to save address during registration and KYC - Added validation to require address before card creation - Removed duplicate src/migrations/ directory --- queries/Query.sql | 0 .../dtos/request/verify-user.request.dto.ts | 33 ++++ src/card/services/card.service.ts | 14 ++ .../dtos/requests/kyc-webhook.request.dto.ts | 173 +++++------------- .../neoleap/services/neoleap.service.ts | 110 ++++++++--- .../controllers/customer.controller.ts | 2 +- src/customer/customer.module.ts | 8 +- .../dtos/request/initiate-kyc.request.dto.ts | 81 +++++++- .../response/initiate-kyc.response.dto.ts | 26 ++- src/customer/entities/customer.entity.ts | 22 +++ src/customer/entities/index.ts | 1 + .../entities/kyc-transaction.entity.ts | 76 ++++++++ .../repositories/customer.repository.ts | 7 + src/customer/repositories/index.ts | 3 + .../kyc-transaction.repository.ts | 46 +++++ src/customer/services/customer.service.ts | 109 ++++++++--- ...65804942393-AddKycFieldsAndTransactions.ts | 32 ++++ ...77128065-AddNationalIdToKycTransactions.ts | 26 +++ src/db/migrations/index.ts | 4 +- src/user/services/user.service.ts | 7 + 20 files changed, 586 insertions(+), 194 deletions(-) create mode 100644 queries/Query.sql create mode 100644 src/customer/entities/kyc-transaction.entity.ts create mode 100644 src/customer/repositories/kyc-transaction.repository.ts create mode 100644 src/db/migrations/1765804942393-AddKycFieldsAndTransactions.ts create mode 100644 src/db/migrations/1765877128065-AddNationalIdToKycTransactions.ts diff --git a/queries/Query.sql b/queries/Query.sql new file mode 100644 index 0000000..e69de29 diff --git a/src/auth/dtos/request/verify-user.request.dto.ts b/src/auth/dtos/request/verify-user.request.dto.ts index 71a62ba..ad589eb 100644 --- a/src/auth/dtos/request/verify-user.request.dto.ts +++ b/src/auth/dtos/request/verify-user.request.dto.ts @@ -44,6 +44,39 @@ export class VerifyUserRequestDto { @IsOptional() countryOfResidence: CountryIso = CountryIso.SAUDI_ARABIA; + // Address fields (optional during registration, required for card creation) + @ApiProperty({ example: 'SA', description: 'Country code', required: false }) + @IsEnum(CountryIso, { + message: i18n('validation.IsEnum', { path: 'general', property: 'customer.country' }), + }) + @IsOptional() + country?: CountryIso; + + @ApiProperty({ example: 'Riyadh', description: 'Region/Province', required: false }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.region' }) }) + @IsOptional() + region?: string; + + @ApiProperty({ example: 'Riyadh', description: 'City', required: false }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.city' }) }) + @IsOptional() + city?: string; + + @ApiProperty({ example: 'Al Olaya', description: 'Neighborhood/District', required: false }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.neighborhood' }) }) + @IsOptional() + neighborhood?: string; + + @ApiProperty({ example: 'King Fahd Road', description: 'Street name', required: false }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.street' }) }) + @IsOptional() + street?: string; + + @ApiProperty({ example: '123', description: 'Building number', required: false }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.building' }) }) + @IsOptional() + building?: string; + @ApiProperty({ example: 'Abcd1234@' }) @Matches(PASSWORD_REGEX, { message: i18n('validation.Matches', { path: 'general', property: 'auth.password' }), diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts index 4cb0a51..92df9ab 100644 --- a/src/card/services/card.service.ts +++ b/src/card/services/card.service.ts @@ -38,6 +38,20 @@ export class CardService { throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD'); } + // Validate required address fields for card creation + const missingFields = []; + if (!customer.country) missingFields.push('country'); + if (!customer.city) missingFields.push('city'); + if (!customer.region) missingFields.push('region'); + if (!customer.nationalId) missingFields.push('nationalId'); + if (!customer.dateOfBirth) missingFields.push('dateOfBirth'); + + if (missingFields.length > 0) { + throw new BadRequestException( + `CUSTOMER.MISSING_REQUIRED_FIELDS: ${missingFields.join(', ')}. Please complete your profile.` + ); + } + const data = await this.neoleapService.createApplication(customer); const account = await this.accountService.createAccount(data); const createdCard = await this.cardRepository.createCard(customerId, account.id, data); diff --git a/src/common/modules/neoleap/dtos/requests/kyc-webhook.request.dto.ts b/src/common/modules/neoleap/dtos/requests/kyc-webhook.request.dto.ts index 92d8504..af60872 100644 --- a/src/common/modules/neoleap/dtos/requests/kyc-webhook.request.dto.ts +++ b/src/common/modules/neoleap/dtos/requests/kyc-webhook.request.dto.ts @@ -1,129 +1,50 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Expose } from 'class-transformer'; -import { IsString } from 'class-validator'; -export class KycWebhookRequest { - @Expose({ name: 'InstId' }) - @IsString() - @ApiProperty({ name: 'InstId', example: '1100' }) - instId!: string; +import { IsEnum, IsObject, IsString } from 'class-validator'; - @Expose() - @IsString() - @ApiProperty({ example: '3136fd60-3f89-4d24-a92f-b9c63a53807f' }) - transId!: string; - - @Expose() - @IsString() - @ApiProperty({ example: '20250807' }) - date!: string; - - @Expose() - @IsString() - @ApiProperty({ example: '150000' }) - time!: string; - - @Expose() - @IsString() - @ApiProperty({ example: 'SUCCESS' }) - status!: string; - - @Expose() - @IsString() - @ApiProperty({ example: 'John' }) - firstName!: string; - - @Expose() - @IsString() - @ApiProperty({ example: 'Doe' }) - lastName!: string; - - @Expose() - @IsString() - @ApiProperty({ example: '19990107' }) - dob!: string; - - @Expose() - @IsString() - @ApiProperty({ example: '682' }) - nationality!: string; - - @Expose() - @IsString() - @ApiProperty({ example: 'M' }) - gender!: string; - - @Expose() - @IsString() - @ApiProperty({ example: '20310917' }) - nationalIdExpiry!: string; - - @Expose() - @IsString() - @ApiProperty({ example: '1250820840' }) - nationalId!: string; - - @Expose() - @IsString() - @ApiProperty({ example: '+962798765432' }) - mobile!: string; - - @Expose() - @IsString() - @ApiProperty({ example: '500' }) - salaryMin!: string; - - @Expose() - @IsString() - @ApiProperty({ example: '1000' }) - salaryMax!: string; - - @Expose() - @IsString() - @ApiProperty({ example: 'Salary' }) - incomeSource!: string; - - @Expose() - @IsString() - @ApiProperty({ example: 'Software Engineer' }) - professionTitle!: string; - - @Expose() - @IsString() - @ApiProperty({ example: 'Full-Time' }) - professionType!: string; - - @Expose() - @IsString() - @ApiProperty({ example: 'N' }) - isPep!: string; - - @Expose() - @IsString() - @ApiProperty({ example: '682' }) - country!: string; - - @Expose() - @IsString() - @ApiProperty({ example: 'Mecca' }) - region!: string; - - @Expose() - @IsString() - @ApiProperty({ example: 'At-Taif' }) - city!: string; - - @Expose() - @IsString() - @ApiProperty({ example: 'Al-Hamra' }) - neighborhood!: string; - - @Expose() - @IsString() - @ApiProperty({ example: 'Al-Masjid Al-Haram' }) - street!: string; - - @Expose() - @IsString() - @ApiProperty({ example: '123' }) - building!: string; +export enum NeoleapKycWebhookStatus { + ONBOARDING_SUCCESS = 'ONBOARDING_SUCCESS', + ONBOARDING_FAILURE = 'ONBOARDING_FAILURE', + IN_PROGRESS = 'IN_PROGRESS', +} + +class KycEntityDto { + @ApiProperty({ example: 'INDIVIDUAL', description: 'Entity type - INDIVIDUAL for KYC' }) + @IsString() + type!: string; + + @ApiProperty({ example: 'FIN-TECK-CUSTOMER-20393', description: 'Customer external ID from Neoleap' }) + @IsString() + externalId!: string; +} + +export class KycWebhookRequest { + @ApiProperty({ + example: '8a745b1b-1252-4921-a569-b3d4406c25fd', + description: 'Transaction ID, the same as returned from onboard API response' + }) + @IsString() + stateId!: string; + + @ApiProperty({ + example: '8a745b1b-1252-4921-a569-b3d4406c25fd', + description: 'Unique callback ID used as reference and for tracking' + }) + @IsString() + callbackId!: string; + + @ApiProperty({ example: '1100', description: 'Fintech ID (1100 for ZOD)' }) + @IsString() + externalFintechId!: string; + + @ApiProperty({ type: KycEntityDto }) + @IsObject() + entity!: KycEntityDto; + + @ApiProperty({ + enum: NeoleapKycWebhookStatus, + example: NeoleapKycWebhookStatus.ONBOARDING_SUCCESS, + description: 'Status of onboarding: ONBOARDING_SUCCESS or ONBOARDING_FAILURE' + }) + @IsEnum(NeoleapKycWebhookStatus) + status!: NeoleapKycWebhookStatus; } diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index b6ce0eb..1818b50 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -48,47 +48,109 @@ export class NeoLeapService { this.useKycMock = [true, 'true'].includes(this.configService.get('USE_KYC_MOCK', true)); } - initiateKyc(customerId: string, body: InitiateKycRequestDto) { - const responseKey = 'InitiateKycResponseDetails'; - + async initiateKycOnboarding(dto: InitiateKycRequestDto) { + // Mock mode for development if (this.useKycMock) { - const responseDto = plainToInstance(InitiateKycResponseDto, INITIATE_KYC_MOCK[responseKey], { - excludeExtraneousValues: true, - }); + const mockResponse = { + externalCustomerId: `FIN-TECK-CUSTOMER-${Date.now()}`, + externalFintechId: '1100', + nafathRandomCode: '38', + stateId: uuid(), + status: 'IN_PROGRESS', + }; + // Trigger mock webhook after 7 seconds setTimeout(() => { this.httpService - .post(`${this.zodApiUrl}/neoleap-webhooks/kyc`, getKycCallbackMock(body.nationalId), { - headers: { - 'Content-Type': 'application/json', + .post(`${this.zodApiUrl}/neoleap-webhooks/kyc`, { + stateId: mockResponse.stateId, + callbackId: uuid(), + externalFintechId: '1100', + entity: { + type: 'INDIVIDUAL', + externalId: mockResponse.externalCustomerId, }, + status: 'ONBOARDING_SUCCESS', }) .subscribe({ - next: () => this.logger.log('Mock KYC webhook sent'), - error: (err) => console.error(err), + next: () => this.logger.log('Mock KYC webhook sent successfully'), + error: (err) => this.logger.error('Mock KYC webhook failed:', err.message), }); }, 7000); - return responseDto; + return mockResponse; } + // Real API call to Neoleap const payload = { - InitiateKycRequestDetails: { - CustomerIdentifier: { - InstitutionCode: this.institutionCode, - Id: customerId, - NationalId: body.nationalId, + poiNumber: dto.poiNumber, + poiType: dto.poiType, + mobileNumber: dto.mobileNumber, + email: dto.email, + dateOfBirth: dto.dateOfBirth, + jobSector: dto.jobSector, + employer: dto.employer, + incomeSource: dto.incomeSource, + jobCategory: dto.jobCategory, + incomeRange: dto.incomeRange, + // Send empty address strings (Neoleap doesn't validate content) + address: { + national: { + buildingNumber: '', + additionalNumber: '', + street: '', + streetEn: '', + city: '', + cityEn: '', + zipcode: '', + unitNumber: '', + district: '', + districtEn: '', + }, + general: { + address: '', + website: '', + email: '', + telephone1: '', + telephone2: '', + fax1: '', + fax2: '', + postalBox1: '', + postalBox2: '', + zipcode: '', }, }, - RequestHeader: this.prepareHeaders('InitiateKyc'), }; - return this.sendRequestToNeoLeap( - 'kyc/InitiateKyc', - payload, - responseKey, - InitiateKycResponseDto, - ); + try { + const { data } = await this.httpService.axiosRef.post( + `${this.gatewayBaseUrl}/kyc/onboardCustomer`, + payload, + { + headers: { + 'Content-Type': 'application/json', + Authorization: this.apiKey, + 'X-Request-id': uuid(), + 'X-Session-Language': 'ar', + }, + }, + ); + + return data.data; + } catch (error: any) { + this.logger.error('Error initiating KYC:', error.response?.data || error.message); + + // Handle specific Neoleap errors + if (error.response?.data?.errorCode === 'E810109') { + throw new BadRequestException('National ID is already registered with Neoleap'); + } + + if (error.response?.data?.error === 'schema validation failed') { + throw new BadRequestException('Invalid data format for KYC verification'); + } + + throw new InternalServerErrorException('Failed to initiate KYC verification'); + } } createApplication(customer: Customer) { diff --git a/src/customer/controllers/customer.controller.ts b/src/customer/controllers/customer.controller.ts index 55d4126..adb0ae3 100644 --- a/src/customer/controllers/customer.controller.ts +++ b/src/customer/controllers/customer.controller.ts @@ -30,7 +30,7 @@ export class CustomerController { async initiateKyc(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: InitiateKycRequestDto) { const res = await this.customerService.initiateKycRequest(sub, body); - return ResponseFactory.data(new InitiateKycResponseDto(res.randomNumber)); + return ResponseFactory.data(new InitiateKycResponseDto(res)); } @Get('/kyc/onboard-metadata') diff --git a/src/customer/customer.module.ts b/src/customer/customer.module.ts index a966521..afd4574 100644 --- a/src/customer/customer.module.ts +++ b/src/customer/customer.module.ts @@ -4,14 +4,14 @@ import { NeoLeapModule } from '~/common/modules/neoleap/neoleap.module'; import { GuardianModule } from '~/guardian/guardian.module'; import { UserModule } from '~/user/user.module'; import { CustomerController } from './controllers'; -import { Customer } from './entities'; -import { CustomerRepository } from './repositories/customer.repository'; +import { Customer, KycTransaction } from './entities'; +import { CustomerRepository, KycTransactionRepository } from './repositories'; import { CustomerService, MetadataService } from './services'; @Module({ - imports: [TypeOrmModule.forFeature([Customer]), GuardianModule, forwardRef(() => UserModule), NeoLeapModule], + imports: [TypeOrmModule.forFeature([Customer, KycTransaction]), GuardianModule, forwardRef(() => UserModule), NeoLeapModule], controllers: [CustomerController], - providers: [CustomerService, CustomerRepository, MetadataService], + providers: [CustomerService, CustomerRepository, KycTransactionRepository, MetadataService], exports: [CustomerService], }) export class CustomerModule {} diff --git a/src/customer/dtos/request/initiate-kyc.request.dto.ts b/src/customer/dtos/request/initiate-kyc.request.dto.ts index 9ea2357..65a2171 100644 --- a/src/customer/dtos/request/initiate-kyc.request.dto.ts +++ b/src/customer/dtos/request/initiate-kyc.request.dto.ts @@ -1,8 +1,81 @@ import { ApiProperty } from '@nestjs/swagger'; +import { IsDateString, IsEmail, IsEnum, IsOptional, IsString, Matches } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -import { isValidSaudiId } from '~/core/decorators/validations'; +import { CountryIso } from '~/common/enums'; +import { IncomeRange, IncomeSource, JobCategory, JobSector, PoiType } from '~/customer/enums'; + export class InitiateKycRequestDto { - @ApiProperty({ example: '999300024' }) - @isValidSaudiId({ message: i18n('validation.isValidSaudiId', { path: 'general', property: 'customer.nationalId' }) }) - nationalId!: string; + @ApiProperty({ example: '2586234623', description: 'Saudi National ID or Iqama number' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.poiNumber' }) }) + poiNumber!: string; + + @ApiProperty({ enum: PoiType, example: PoiType.NAT, default: PoiType.NAT }) + @IsEnum(PoiType, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.poiType' }) }) + poiType!: PoiType; + + @ApiProperty({ example: '0512345678', pattern: '^05\\d{8}$' }) + @Matches(/^05\d{8}$/, { message: i18n('validation.Matches', { path: 'general', property: 'customer.mobileNumber' }) }) + mobileNumber!: string; + + @ApiProperty({ example: 'user@zodwallet.com', required: false }) + @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'customer.email' }) }) + @IsOptional() + email?: string; + + @ApiProperty({ example: '1990-01-01', format: 'date' }) + @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) }) + dateOfBirth!: string; + + @ApiProperty({ enum: JobSector, example: JobSector.PRIVATE_SECTOR }) + @IsEnum(JobSector, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.jobSector' }) }) + jobSector!: JobSector; + + @ApiProperty({ example: 'Test Company Ltd' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.employer' }) }) + employer!: string; + + @ApiProperty({ enum: IncomeSource, example: IncomeSource.SALARY }) + @IsEnum(IncomeSource, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.incomeSource' }) }) + incomeSource!: IncomeSource; + + @ApiProperty({ enum: JobCategory, example: JobCategory.ENGINEER }) + @IsEnum(JobCategory, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.jobCategory' }) }) + jobCategory!: JobCategory; + + @ApiProperty({ enum: IncomeRange, example: IncomeRange.RANGE_10000_20000 }) + @IsEnum(IncomeRange, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.incomeRange' }) }) + incomeRange!: IncomeRange; + + // Address fields (optional - can be provided here or during registration) + @ApiProperty({ example: 'SA', description: 'Country code', required: false }) + @IsEnum(CountryIso, { + message: i18n('validation.IsEnum', { path: 'general', property: 'customer.country' }), + }) + @IsOptional() + country?: CountryIso; + + @ApiProperty({ example: 'Riyadh', description: 'Region/Province', required: false }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.region' }) }) + @IsOptional() + region?: string; + + @ApiProperty({ example: 'Riyadh', description: 'City', required: false }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.city' }) }) + @IsOptional() + city?: string; + + @ApiProperty({ example: 'Al Olaya', description: 'Neighborhood/District', required: false }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.neighborhood' }) }) + @IsOptional() + neighborhood?: string; + + @ApiProperty({ example: 'King Fahd Road', description: 'Street name', required: false }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.street' }) }) + @IsOptional() + street?: string; + + @ApiProperty({ example: '123', description: 'Building number', required: false }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.building' }) }) + @IsOptional() + building?: string; } diff --git a/src/customer/dtos/response/initiate-kyc.response.dto.ts b/src/customer/dtos/response/initiate-kyc.response.dto.ts index 54dd40a..5922c99 100644 --- a/src/customer/dtos/response/initiate-kyc.response.dto.ts +++ b/src/customer/dtos/response/initiate-kyc.response.dto.ts @@ -1,10 +1,28 @@ import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; export class InitiateKycResponseDto { - @ApiProperty() - randomNumber!: string; + @ApiProperty({ description: 'Internal transaction ID to track this KYC attempt' }) + @Expose() + transactionId!: string; - constructor(randomNumber: string) { - this.randomNumber = randomNumber; + @ApiProperty({ description: 'Neoleap state ID for tracking' }) + @Expose() + stateId!: string; + + @ApiProperty({ description: 'Nafath random code to show to the user', example: '38' }) + @Expose() + nafathRandomCode!: string; + + @ApiProperty({ description: 'Current status', example: 'IN_PROGRESS' }) + @Expose() + status!: string; + + @ApiProperty({ description: 'External customer ID from Neoleap' }) + @Expose() + externalCustomerId!: string; + + constructor(data: Partial) { + Object.assign(this, data); } } diff --git a/src/customer/entities/customer.entity.ts b/src/customer/entities/customer.entity.ts index e675a4a..cd263ae 100644 --- a/src/customer/entities/customer.entity.ts +++ b/src/customer/entities/customer.entity.ts @@ -95,6 +95,28 @@ export class Customer extends BaseEntity { @Column('varchar', { name: 'building', length: 255, nullable: true }) building!: string; + // KYC-specific fields + @Column('varchar', { length: 255, nullable: true, name: 'neoleap_external_customer_id' }) + neoleapExternalCustomerId!: string | null; + + @Column('varchar', { length: 100, nullable: true, name: 'job_sector' }) + jobSector!: string | null; + + @Column('varchar', { length: 255, nullable: true, name: 'employer' }) + employer!: string | null; + + @Column('varchar', { length: 100, nullable: true, name: 'income_source' }) + incomeSource!: string | null; + + @Column('varchar', { length: 100, nullable: true, name: 'job_category' }) + jobCategory!: string | null; + + @Column('varchar', { length: 100, nullable: true, name: 'income_range' }) + incomeRange!: string | null; + + @Column('varchar', { length: 20, nullable: true, name: 'mobile_number' }) + mobileNumber!: string | null; + @OneToOne(() => User, (user) => user.customer, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id' }) user!: User; diff --git a/src/customer/entities/index.ts b/src/customer/entities/index.ts index 342b1d3..65eb1e6 100644 --- a/src/customer/entities/index.ts +++ b/src/customer/entities/index.ts @@ -1 +1,2 @@ export * from './customer.entity'; +export * from './kyc-transaction.entity'; diff --git a/src/customer/entities/kyc-transaction.entity.ts b/src/customer/entities/kyc-transaction.entity.ts new file mode 100644 index 0000000..6d2b013 --- /dev/null +++ b/src/customer/entities/kyc-transaction.entity.ts @@ -0,0 +1,76 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Customer } from './customer.entity'; +import { User } from '~/user/entities'; + +@Entity('kyc_transactions') +export class KycTransaction extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column('uuid', { name: 'customer_id' }) + customerId!: string; + + @Column('uuid', { name: 'user_id' }) + userId!: string; + + // National ID from form + @Column('varchar', { length: 50, name: 'national_id', nullable: false }) + nationalId!: string; + + // Neoleap IDs + @Column('varchar', { length: 255, unique: true, name: 'state_id' }) + stateId!: string; + + @Column('varchar', { length: 255, nullable: true, name: 'external_customer_id' }) + externalCustomerId!: string | null; + + // Nafath details + @Column('varchar', { length: 10, nullable: true, name: 'nafath_random_code' }) + nafathRandomCode!: string | null; + + // Status tracking + @Column('varchar', { length: 50, default: 'INITIATED', name: 'status' }) + status!: string; + + // Audit trail + @Column('jsonb', { name: 'form_data' }) + formData!: any; + + @Column('varchar', { length: 255, nullable: true, name: 'callback_id' }) + callbackId!: string | null; + + // Timestamps + @Column('timestamp', { default: () => 'CURRENT_TIMESTAMP', name: 'initiated_at' }) + initiatedAt!: Date; + + @Column('timestamp', { nullable: true, name: 'completed_at' }) + completedAt!: Date | null; + + @Column('timestamp', { nullable: true, name: 'expires_at' }) + expiresAt!: Date | null; + + @CreateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', name: 'updated_at' }) + updatedAt!: Date; + + // Relationships + @ManyToOne(() => Customer, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'customer_id' }) + customer!: Customer; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user!: User; +} + diff --git a/src/customer/repositories/customer.repository.ts b/src/customer/repositories/customer.repository.ts index 276a6d4..6bb35e3 100644 --- a/src/customer/repositories/customer.repository.ts +++ b/src/customer/repositories/customer.repository.ts @@ -30,6 +30,13 @@ export class CustomerRepository { dateOfBirth: body.dateOfBirth, countryOfResidence: body.countryOfResidence, gender: body.gender, + // Address fields + country: body.country, + region: body.region, + city: body.city, + neighborhood: body.neighborhood, + street: body.street, + building: body.building, }), ); } diff --git a/src/customer/repositories/index.ts b/src/customer/repositories/index.ts index e69de29..6968aa4 100644 --- a/src/customer/repositories/index.ts +++ b/src/customer/repositories/index.ts @@ -0,0 +1,3 @@ +export * from './customer.repository'; +export * from './kyc-transaction.repository'; + diff --git a/src/customer/repositories/kyc-transaction.repository.ts b/src/customer/repositories/kyc-transaction.repository.ts new file mode 100644 index 0000000..eb1ab90 --- /dev/null +++ b/src/customer/repositories/kyc-transaction.repository.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { KycTransaction } from '../entities'; + +@Injectable() +export class KycTransactionRepository { + constructor( + @InjectRepository(KycTransaction) + private readonly kycTransactionRepository: Repository, + ) {} + + async create(data: Partial): Promise { + const transaction = this.kycTransactionRepository.create(data); + return this.kycTransactionRepository.save(transaction); + } + + async findByStateId(stateId: string): Promise { + return this.kycTransactionRepository.findOne({ + where: { stateId }, + relations: ['customer', 'user'], + }); + } + + async findActiveByNationalId(nationalId: string): Promise { + return this.kycTransactionRepository.findOne({ + where: { + nationalId, + status: 'IN_PROGRESS', + }, + order: { initiatedAt: 'DESC' }, + }); + } + + async updateByStateId(stateId: string, data: Partial): Promise { + await this.kycTransactionRepository.update({ stateId }, data); + } + + async findAllByCustomerId(customerId: string): Promise { + return this.kycTransactionRepository.find({ + where: { customerId }, + order: { initiatedAt: 'DESC' }, + }); + } +} + diff --git a/src/customer/services/customer.service.ts b/src/customer/services/customer.service.ts index 783e221..a7b8694 100644 --- a/src/customer/services/customer.service.ts +++ b/src/customer/services/customer.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, ConflictException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; import moment from 'moment'; import { Transactional } from 'typeorm-transactional'; import { CountryIso } from '~/common/enums'; @@ -11,7 +11,7 @@ import { User } from '~/user/entities'; import { InitiateKycRequestDto } from '../dtos/request'; import { Customer } from '../entities'; import { Gender, KycStatus } from '../enums'; -import { CustomerRepository } from '../repositories/customer.repository'; +import { CustomerRepository, KycTransactionRepository } from '../repositories'; import { MetadataService } from './metadata.service'; @Injectable() @@ -19,6 +19,7 @@ export class CustomerService { private readonly logger = new Logger(CustomerService.name); constructor( private readonly customerRepository: CustomerRepository, + private readonly kycTransactionRepo: KycTransactionRepository, private readonly guardianService: GuardianService, @Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService, private readonly metadataService: MetadataService, @@ -55,23 +56,72 @@ export class CustomerService { } async initiateKycRequest(customerId: string, body: InitiateKycRequestDto) { - this.logger.log(`Initiating KYC request for user ${customerId}`); + this.logger.log(`Initiating KYC request for customer ${customerId}`); const customer = await this.findCustomerById(customerId); + // Validate customer is not already verified if (customer.kycStatus === KycStatus.APPROVED) { this.logger.error(`KYC for customer ${customerId} is already approved`); throw new BadRequestException('CUSTOMER.KYC_ALREADY_APPROVED'); } - // I will assume the api for initiating KYC is not allowing me to send customerId as correlationId so I will store the nationalId in the customer entity + // Check for active KYC transaction by National ID + const activeTransaction = await this.kycTransactionRepo.findActiveByNationalId(body.poiNumber); + if (activeTransaction) { + this.logger.error(`KYC verification already in progress for National ID ${body.poiNumber}`); + throw new ConflictException('KYC verification already in progress for this National ID'); + } + // Update customer with KYC data (including address if provided) await this.customerRepository.updateCustomer(customerId, { - nationalId: body.nationalId, + nationalId: body.poiNumber, + dateOfBirth: new Date(body.dateOfBirth), + mobileNumber: body.mobileNumber, + jobSector: body.jobSector, + employer: body.employer, + incomeSource: body.incomeSource, + jobCategory: body.jobCategory, + incomeRange: body.incomeRange, kycStatus: KycStatus.PENDING, + // Address fields (only update if provided) + ...(body.country && { country: body.country }), + ...(body.region && { region: body.region }), + ...(body.city && { city: body.city }), + ...(body.neighborhood && { neighborhood: body.neighborhood }), + ...(body.street && { street: body.street }), + ...(body.building && { building: body.building }), }); - return this.neoleapService.initiateKyc(customerId, body); + // Call Neoleap KYC API + const neoleapResponse = await this.neoleapService.initiateKycOnboarding(body); + + // Create transaction record + const transaction = await this.kycTransactionRepo.create({ + customerId, + userId: customer.userId, + nationalId: body.poiNumber, + stateId: neoleapResponse.stateId, + externalCustomerId: neoleapResponse.externalCustomerId, + nafathRandomCode: neoleapResponse.nafathRandomCode, + status: neoleapResponse.status, + formData: body, + initiatedAt: new Date(), + }); + + // Update customer with external ID + await this.customerRepository.updateCustomer(customerId, { + neoleapExternalCustomerId: neoleapResponse.externalCustomerId, + }); + + // Return formatted response + return { + transactionId: transaction.id, + stateId: neoleapResponse.stateId, + nafathRandomCode: neoleapResponse.nafathRandomCode, + status: neoleapResponse.status, + externalCustomerId: neoleapResponse.externalCustomerId, + }; } @Transactional() @@ -94,34 +144,33 @@ export class CustomerService { } async updateCustomerKyc(body: KycWebhookRequest) { - this.logger.log(`Updating KYC for customer with national ID ${body.nationalId}`); + this.logger.log(`Updating KYC for stateId ${body.stateId}`); - const customer = await this.customerRepository.findOne({ nationalId: body.nationalId }); - - if (!customer) { - throw new BadRequestException('CUSTOMER.NOT_FOUND'); + // Find transaction by stateId + const transaction = await this.kycTransactionRepo.findByStateId(body.stateId); + + if (!transaction) { + this.logger.error(`KYC transaction not found for stateId ${body.stateId}`); + throw new BadRequestException('KYC transaction not found'); } - await this.customerRepository.updateCustomer(customer.id, { - kycStatus: body.status === 'SUCCESS' ? KycStatus.APPROVED : KycStatus.REJECTED, - firstName: body.firstName, - lastName: body.lastName, - dateOfBirth: moment(body.dob, 'YYYYMMDD').toDate(), - nationalId: body.nationalId, - nationalIdExpiry: moment(body.nationalIdExpiry, 'YYYYMMDD').toDate(), - countryOfResidence: NumericToCountryIso[body.country], - country: NumericToCountryIso[body.country], - gender: body.gender === 'M' ? Gender.MALE : Gender.FEMALE, - sourceOfIncome: body.incomeSource, - profession: body.professionTitle, - professionType: body.professionType, - isPep: body.isPep === 'Y', - city: body.city, - region: body.region, - neighborhood: body.neighborhood, - street: body.street, - building: body.building, + const customer = await this.findCustomerById(transaction.customerId); + + // Update transaction record + await this.kycTransactionRepo.updateByStateId(body.stateId, { + status: body.status, + callbackId: body.callbackId, + completedAt: new Date(), }); + + // Update customer KYC status + const kycStatus = body.status === 'ONBOARDING_SUCCESS' ? KycStatus.APPROVED : KycStatus.REJECTED; + + await this.customerRepository.updateCustomer(customer.id, { + kycStatus, + }); + + this.logger.log(`KYC updated successfully for customer ${customer.id}, status: ${body.status}`); } // TO BE REMOVED: This function is for testing only and will be removed diff --git a/src/db/migrations/1765804942393-AddKycFieldsAndTransactions.ts b/src/db/migrations/1765804942393-AddKycFieldsAndTransactions.ts new file mode 100644 index 0000000..045fff2 --- /dev/null +++ b/src/db/migrations/1765804942393-AddKycFieldsAndTransactions.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddKycFieldsAndTransactions1765804942393 implements MigrationInterface { + name = 'AddKycFieldsAndTransactions1765804942393' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "kyc_transactions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "customer_id" uuid NOT NULL, "user_id" uuid NOT NULL, "state_id" character varying(255) NOT NULL, "external_customer_id" character varying(255), "nafath_random_code" character varying(10), "status" character varying(50) NOT NULL DEFAULT 'INITIATED', "form_data" jsonb NOT NULL, "callback_id" character varying(255), "initiated_at" TIMESTAMP NOT NULL DEFAULT now(), "completed_at" TIMESTAMP, "expires_at" TIMESTAMP, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_231ce1d974b00919a8202e9ca3f" UNIQUE ("state_id"), CONSTRAINT "PK_aa56e3feebd4323c684ca146418" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "customers" ADD "neoleap_external_customer_id" character varying(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "job_sector" character varying(100)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "employer" character varying(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "income_source" character varying(100)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "job_category" character varying(100)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "income_range" character varying(100)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "mobile_number" character varying(20)`); + await queryRunner.query(`ALTER TABLE "kyc_transactions" ADD CONSTRAINT "FK_7651cf2e3ae6381377d8b9ed963" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "kyc_transactions" ADD CONSTRAINT "FK_336a3791fd94d386e5c428850db" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "kyc_transactions" DROP CONSTRAINT "FK_336a3791fd94d386e5c428850db"`); + await queryRunner.query(`ALTER TABLE "kyc_transactions" DROP CONSTRAINT "FK_7651cf2e3ae6381377d8b9ed963"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "mobile_number"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "income_range"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "job_category"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "income_source"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "employer"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "job_sector"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "neoleap_external_customer_id"`); + await queryRunner.query(`DROP TABLE "kyc_transactions"`); + } + +} diff --git a/src/db/migrations/1765877128065-AddNationalIdToKycTransactions.ts b/src/db/migrations/1765877128065-AddNationalIdToKycTransactions.ts new file mode 100644 index 0000000..f6a313b --- /dev/null +++ b/src/db/migrations/1765877128065-AddNationalIdToKycTransactions.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddNationalIdToKycTransactions1765877128065 implements MigrationInterface { + name = 'AddNationalIdToKycTransactions1765877128065' + + public async up(queryRunner: QueryRunner): Promise { + // Add column as nullable first (to handle existing records) + await queryRunner.query(`ALTER TABLE "kyc_transactions" ADD "national_id" character varying(50)`); + + // Backfill existing records from form_data->poiNumber + await queryRunner.query(` + UPDATE "kyc_transactions" + SET "national_id" = form_data->>'poiNumber' + WHERE "national_id" IS NULL AND form_data->>'poiNumber' IS NOT NULL + `); + + // Now make it NOT NULL with a default empty string for safety + await queryRunner.query(`ALTER TABLE "kyc_transactions" ALTER COLUMN "national_id" SET DEFAULT ''`); + await queryRunner.query(`ALTER TABLE "kyc_transactions" ALTER COLUMN "national_id" SET NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "kyc_transactions" DROP COLUMN "national_id"`); + } + +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 1d5acc9..304fdd0 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -5,4 +5,6 @@ export * from './1757349525708-create-money-requests-table'; export * from './1757433339849-add-reservation-amount-to-account-entity'; export * from './1757915357218-add-deleted-at-column-to-junior'; export * from './1760869651296-AddMerchantInfoToTransactions'; -export * from './1761032305682-AddUniqueConstraintToUserEmail'; \ No newline at end of file +export * from './1761032305682-AddUniqueConstraintToUserEmail'; +export * from './1765804942393-AddKycFieldsAndTransactions'; +export * from './1765877128065-AddNationalIdToKycTransactions'; \ No newline at end of file diff --git a/src/user/services/user.service.ts b/src/user/services/user.service.ts index cc7aa12..075af6c 100644 --- a/src/user/services/user.service.ts +++ b/src/user/services/user.service.ts @@ -65,6 +65,13 @@ export class UserService { firstName: body.firstName, lastName: body.lastName, countryOfResidence: body.countryOfResidence, + // Address fields (optional during registration) + country: body.country, + region: body.region, + city: body.city, + neighborhood: body.neighborhood, + street: body.street, + building: body.building, }), this.userRepository.update(userId, { isPhoneVerified: true, From fe11f35b329f13536531829bc53c543c5532b48a Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Tue, 16 Dec 2025 14:51:21 +0300 Subject: [PATCH 2/3] feat: send the adress data to noleap --- .../neoleap/services/neoleap.service.ts | 24 +++++++++-------- ...77128065-AddNationalIdToKycTransactions.ts | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+), 11 deletions(-) create mode 100644 src/migrations/1765877128065-AddNationalIdToKycTransactions.ts diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index 1818b50..bd881cc 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -93,25 +93,27 @@ export class NeoLeapService { incomeSource: dto.incomeSource, jobCategory: dto.jobCategory, incomeRange: dto.incomeRange, - // Send empty address strings (Neoleap doesn't validate content) + // Map collected address fields to Neoleap's format address: { national: { - buildingNumber: '', + buildingNumber: dto.building || '', additionalNumber: '', - street: '', - streetEn: '', - city: '', - cityEn: '', + street: dto.street || '', + streetEn: dto.street || '', + city: dto.city || '', + cityEn: dto.city || '', zipcode: '', unitNumber: '', - district: '', - districtEn: '', + district: dto.neighborhood || '', + districtEn: dto.neighborhood || '', }, general: { - address: '', + address: [dto.building, dto.street, dto.neighborhood, dto.city, dto.region] + .filter(Boolean) + .join(', '), website: '', - email: '', - telephone1: '', + email: dto.email || '', + telephone1: dto.mobileNumber || '', telephone2: '', fax1: '', fax2: '', diff --git a/src/migrations/1765877128065-AddNationalIdToKycTransactions.ts b/src/migrations/1765877128065-AddNationalIdToKycTransactions.ts new file mode 100644 index 0000000..f6a313b --- /dev/null +++ b/src/migrations/1765877128065-AddNationalIdToKycTransactions.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddNationalIdToKycTransactions1765877128065 implements MigrationInterface { + name = 'AddNationalIdToKycTransactions1765877128065' + + public async up(queryRunner: QueryRunner): Promise { + // Add column as nullable first (to handle existing records) + await queryRunner.query(`ALTER TABLE "kyc_transactions" ADD "national_id" character varying(50)`); + + // Backfill existing records from form_data->poiNumber + await queryRunner.query(` + UPDATE "kyc_transactions" + SET "national_id" = form_data->>'poiNumber' + WHERE "national_id" IS NULL AND form_data->>'poiNumber' IS NOT NULL + `); + + // Now make it NOT NULL with a default empty string for safety + await queryRunner.query(`ALTER TABLE "kyc_transactions" ALTER COLUMN "national_id" SET DEFAULT ''`); + await queryRunner.query(`ALTER TABLE "kyc_transactions" ALTER COLUMN "national_id" SET NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "kyc_transactions" DROP COLUMN "national_id"`); + } + +} From 5e708c16fe600f2d4474551d2a82034ff1300ac9 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Tue, 16 Dec 2025 14:57:03 +0300 Subject: [PATCH 3/3] chore: remove migration from wrong directory Migration already exists in correct location: src/db/migrations/ --- ...77128065-AddNationalIdToKycTransactions.ts | 26 ------------------- 1 file changed, 26 deletions(-) delete mode 100644 src/migrations/1765877128065-AddNationalIdToKycTransactions.ts diff --git a/src/migrations/1765877128065-AddNationalIdToKycTransactions.ts b/src/migrations/1765877128065-AddNationalIdToKycTransactions.ts deleted file mode 100644 index f6a313b..0000000 --- a/src/migrations/1765877128065-AddNationalIdToKycTransactions.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class AddNationalIdToKycTransactions1765877128065 implements MigrationInterface { - name = 'AddNationalIdToKycTransactions1765877128065' - - public async up(queryRunner: QueryRunner): Promise { - // Add column as nullable first (to handle existing records) - await queryRunner.query(`ALTER TABLE "kyc_transactions" ADD "national_id" character varying(50)`); - - // Backfill existing records from form_data->poiNumber - await queryRunner.query(` - UPDATE "kyc_transactions" - SET "national_id" = form_data->>'poiNumber' - WHERE "national_id" IS NULL AND form_data->>'poiNumber' IS NOT NULL - `); - - // Now make it NOT NULL with a default empty string for safety - await queryRunner.query(`ALTER TABLE "kyc_transactions" ALTER COLUMN "national_id" SET DEFAULT ''`); - await queryRunner.query(`ALTER TABLE "kyc_transactions" ALTER COLUMN "national_id" SET NOT NULL`); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "kyc_transactions" DROP COLUMN "national_id"`); - } - -}