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 241d984..373e1e2 100644 --- a/src/auth/dtos/request/verify-user.request.dto.ts +++ b/src/auth/dtos/request/verify-user.request.dto.ts @@ -1,7 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; import { - IsDateString, - IsEmail, IsEnum, IsNotEmpty, IsNumberString, @@ -15,7 +13,7 @@ import { i18nValidationMessage as i18n } from 'nestjs-i18n'; import { COUNTRY_CODE_REGEX, PASSWORD_REGEX } from '~/auth/constants'; import { CountryIso } from '~/common/enums'; import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; -import { IsAbove18, IsValidPhoneNumber } from '~/core/decorators/validations'; +import { IsValidPhoneNumber } from '~/core/decorators/validations'; export class VerifyUserRequestDto { @ApiProperty({ example: '+962' }) @@ -39,11 +37,6 @@ export class VerifyUserRequestDto { @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) }) lastName!: string; - @ApiProperty({ example: '2001-01-01' }) - @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) }) - @IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) }) - dateOfBirth!: Date; - @ApiProperty({ example: 'JO' }) @IsEnum(CountryIso, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }), @@ -51,10 +44,38 @@ export class VerifyUserRequestDto { @IsOptional() countryOfResidence: CountryIso = CountryIso.SAUDI_ARABIA; - @ApiProperty({ example: 'test@test.com' }) - @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) + // 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() - email!: string; + 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, { diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 59a22d9..53bb6bd 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -41,14 +41,6 @@ export class AuthService { ) {} async sendRegisterOtp(body: CreateUnverifiedUserRequestDto) { - if (body.email) { - const isEmailUsed = await this.userService.findUser({ email: body.email, isEmailVerified: true }); - if (isEmailUsed) { - this.logger.error(`Email ${body.email} is already used`); - throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN'); - } - } - if (body.password !== body.confirmPassword) { this.logger.error('Password and confirm password do not match'); throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts index 4cb0a51..5be8fbc 100644 --- a/src/card/services/card.service.ts +++ b/src/card/services/card.service.ts @@ -34,10 +34,26 @@ export class CardService { throw new BadRequestException('CUSTOMER.KYC_NOT_APPROVED'); } + if (!customer.neoleapExternalCustomerId) { + throw new BadRequestException('CUSTOMER.KYC_NOT_COMPLETED'); + } + if (customer.cards.length > 0) { throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD'); } + // Validate required fields for card creation + const missingFields = []; + if (!customer.nationalId) missingFields.push('nationalId'); + if (!customer.dateOfBirth) missingFields.push('dateOfBirth'); + if (!customer.nationalIdExpiry) missingFields.push('nationalIdExpiry'); + + 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/__mocks__/kyc-callback.mock.ts b/src/common/modules/neoleap/__mocks__/kyc-callback.mock.ts index 88d4e5b..3bd6b45 100644 --- a/src/common/modules/neoleap/__mocks__/kyc-callback.mock.ts +++ b/src/common/modules/neoleap/__mocks__/kyc-callback.mock.ts @@ -17,7 +17,6 @@ export const getKycCallbackMock = (nationalId: string) => { salaryMax: '1000', incomeSource: 'Salary', professionTitle: 'Software Engineer', - professionType: 'Full-Time', isPep: 'N', country: '682', region: 'Mecca', 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..443c604 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, + // Use default address values for Neoleap KYC + address: { + national: { + buildingNumber: '1', + additionalNumber: '', + street: 'King Fahd Road', + streetEn: 'King Fahd Road', + city: 'Riyadh', + cityEn: 'Riyadh', + zipcode: '', + unitNumber: '', + district: 'Al Olaya', + districtEn: 'Al Olaya', + }, + general: { + address: '1, King Fahd Road, Al Olaya, Riyadh, Riyadh', + website: '', + email: dto.email || '', + telephone1: dto.mobileNumber || '', + 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) { @@ -124,7 +186,9 @@ export class NeoLeapService { }, BillingCycle: 'C1', }, - ApplicationOtherInfo: {}, + ApplicationOtherInfo: { + ExternalCorporateId: customer.neoleapExternalCustomerId, + }, ApplicationCustomerDetails: { FirstName: customer.firstName, LastName: customer.lastName, @@ -137,14 +201,14 @@ export class NeoLeapService { Title: customer.gender === Gender.MALE ? 'Mr' : 'Ms', Gender: customer.gender === Gender.MALE ? 'M' : 'F', LocalizedDateOfBirth: moment(customer.dateOfBirth).format('YYYY-MM-DD'), - Nationality: CountriesNumericISO[customer.countryOfResidence], + Nationality: CountriesNumericISO[customer.countryOfResidence || 'SA'], }, ApplicationAddress: { - City: customer.city, - Country: CountriesNumericISO[customer.country], - Region: customer.region, - AddressLine1: `${customer.street} ${customer.building}`, - AddressLine2: customer.neighborhood, + City: 'Riyadh', + Country: CountriesNumericISO['SA'], + Region: 'Riyadh', + AddressLine1: 'King Fahd Road 1', + AddressLine2: 'Al Olaya', AddressRole: 0, Email: customer.user.email, Phone1: customer.user.phoneNumber, @@ -213,14 +277,14 @@ export class NeoLeapService { Title: parent.gender === Gender.MALE ? 'Mr' : 'Ms', Gender: parent.gender === Gender.MALE ? 'M' : 'F', LocalizedDateOfBirth: moment(parent.dateOfBirth).format('YYYY-MM-DD'), - Nationality: CountriesNumericISO[parent.countryOfResidence], + Nationality: CountriesNumericISO[parent.countryOfResidence || 'SA'], }, ApplicationAddress: { - City: parent.city, - Country: CountriesNumericISO[parent.country], - Region: parent.region, - AddressLine1: `${parent.street} ${parent.building}`, - AddressLine2: parent.neighborhood, + City: 'Riyadh', + Country: CountriesNumericISO['SA'], + Region: 'Riyadh', + AddressLine1: 'King Fahd Road 1', + AddressLine2: 'Al Olaya', AddressRole: 0, Email: child.user.email, Phone1: child.user.phoneNumber, 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..20ed1a7 100644 --- a/src/customer/dtos/request/initiate-kyc.request.dto.ts +++ b/src/customer/dtos/request/initiate-kyc.request.dto.ts @@ -1,8 +1,55 @@ 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 { Gender, 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({ example: '2030-12-31', format: 'date', description: 'National ID expiry date' }) + @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.nationalIdExpiry' }) }) + nationalIdExpiry!: string; + + @ApiProperty({ enum: Gender, example: Gender.MALE }) + @IsEnum(Gender, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.gender' }) }) + gender!: Gender; + + @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; } diff --git a/src/customer/dtos/response/customer.response.dto.ts b/src/customer/dtos/response/customer.response.dto.ts index 82bfe91..9e02c86 100644 --- a/src/customer/dtos/response/customer.response.dto.ts +++ b/src/customer/dtos/response/customer.response.dto.ts @@ -34,15 +34,6 @@ export class CustomerResponseDto { @ApiProperty({ example: 'JO' }) countryOfResidence!: string; - @ApiProperty({ example: 'Employee' }) - sourceOfIncome!: string; - - @ApiProperty({ example: 'Software Development' }) - profession!: string; - - @ApiProperty({ example: 'Full-time' }) - professionType!: string; - @ApiProperty({ example: false }) isPep!: boolean; @@ -58,24 +49,6 @@ export class CustomerResponseDto { @ApiProperty({ example: 12345 }) waitingNumber!: number; - @ApiProperty({ example: 'SA' }) - country!: string | null; - - @ApiProperty({ example: 'Riyadh' }) - region!: string | null; - - @ApiProperty({ example: 'Riyadh City' }) - city!: string | null; - - @ApiProperty({ example: 'Al-Masif' }) - neighborhood!: string | null; - - @ApiProperty({ example: 'King Fahd Road' }) - street!: string | null; - - @ApiProperty({ example: '123' }) - building!: string | null; - @ApiPropertyOptional({ type: DocumentMetaResponseDto }) profilePicture!: DocumentMetaResponseDto | null; @@ -90,19 +63,10 @@ export class CustomerResponseDto { this.nationalId = customer.nationalId; this.nationalIdExpiry = customer.nationalIdExpiry; this.countryOfResidence = customer.countryOfResidence; - this.sourceOfIncome = customer.sourceOfIncome; - this.profession = customer.profession; - this.professionType = customer.professionType; this.isPep = customer.isPep; this.gender = customer.gender; this.isJunior = customer.isJunior; this.isGuardian = customer.isGuardian; this.waitingNumber = customer.applicationNumber; - this.country = customer.country; - this.region = customer.region; - this.city = customer.city; - this.neighborhood = customer.neighborhood; - this.street = customer.street; - this.building = customer.building; } } 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..772dc11 100644 --- a/src/customer/entities/customer.entity.ts +++ b/src/customer/entities/customer.entity.ts @@ -49,15 +49,6 @@ export class Customer extends BaseEntity { @Column('varchar', { length: 255, nullable: true, name: 'country_of_residence' }) countryOfResidence!: CountryIso; - @Column('varchar', { length: 255, nullable: true, name: 'source_of_income' }) - sourceOfIncome!: string; - - @Column('varchar', { length: 255, nullable: true, name: 'profession' }) - profession!: string; - - @Column('varchar', { length: 255, nullable: true, name: 'profession_type' }) - professionType!: string; - @Column('boolean', { default: false, name: 'is_pep' }) isPep!: boolean; @@ -77,23 +68,27 @@ export class Customer extends BaseEntity { @Column('varchar', { name: 'user_id' }) userId!: string; - @Column('varchar', { name: 'country', length: 255, nullable: true }) - country!: CountryIso; + // KYC-specific fields + @Column('varchar', { length: 255, nullable: true, name: 'neoleap_external_customer_id' }) + neoleapExternalCustomerId!: string | null; - @Column('varchar', { name: 'region', length: 255, nullable: true }) - region!: string; + @Column('varchar', { length: 100, nullable: true, name: 'job_sector' }) + jobSector!: string | null; - @Column('varchar', { name: 'city', length: 255, nullable: true }) - city!: string; + @Column('varchar', { length: 255, nullable: true, name: 'employer' }) + employer!: string | null; - @Column('varchar', { name: 'neighborhood', length: 255, nullable: true }) - neighborhood!: string; + @Column('varchar', { length: 100, nullable: true, name: 'income_source' }) + incomeSource!: string | null; - @Column('varchar', { name: 'street', length: 255, nullable: true }) - street!: string; + @Column('varchar', { length: 100, nullable: true, name: 'job_category' }) + jobCategory!: string | null; - @Column('varchar', { name: 'building', length: 255, nullable: true }) - building!: string; + @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' }) 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/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..d47ab97 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,68 @@ 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 await this.customerRepository.updateCustomer(customerId, { - nationalId: body.nationalId, + nationalId: body.poiNumber, + dateOfBirth: new Date(body.dateOfBirth), + nationalIdExpiry: new Date(body.nationalIdExpiry), + gender: body.gender, + countryOfResidence: CountryIso.SAUDI_ARABIA, // Always default to Saudi Arabia + mobileNumber: body.mobileNumber, + jobSector: body.jobSector, + employer: body.employer, + incomeSource: body.incomeSource, + jobCategory: body.jobCategory, + incomeRange: body.incomeRange, kycStatus: KycStatus.PENDING, }); - 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 +140,34 @@ 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 and external customer ID + const kycStatus = body.status === 'ONBOARDING_SUCCESS' ? KycStatus.APPROVED : KycStatus.REJECTED; + + await this.customerRepository.updateCustomer(customer.id, { + kycStatus, + neoleapExternalCustomerId: body.entity.externalId, + }); + + this.logger.log(`KYC updated successfully for customer ${customer.id}, status: ${body.status}, externalId: ${body.entity.externalId}`); } // TO BE REMOVED: This function is for testing only and will be removed @@ -134,12 +180,6 @@ export class CustomerService { nationalId: '1089055972', nationalIdExpiry: moment('2031-09-17').toDate(), countryOfResidence: CountryIso.SAUDI_ARABIA, - country: CountryIso.SAUDI_ARABIA, - region: 'Mecca', - city: 'AT Taif', - neighborhood: 'Al Faisaliah', - street: 'Al Faisaliah Street', - building: '4', }); await User.update(userId, { 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/1765891028260-RemoveOldCustomerColumns.ts b/src/db/migrations/1765891028260-RemoveOldCustomerColumns.ts new file mode 100644 index 0000000..46f0948 --- /dev/null +++ b/src/db/migrations/1765891028260-RemoveOldCustomerColumns.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveOldCustomerColumns1765891028260 implements MigrationInterface { + name = 'RemoveOldCustomerColumns1765891028260'; + + public async up(queryRunner: QueryRunner): Promise { + // Remove duplicate/unused columns that were replaced by KYC-specific fields + // source_of_income -> replaced by income_source + // profession -> replaced by job_sector + // profession_type -> replaced by job_category + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN IF EXISTS "source_of_income"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN IF EXISTS "profession"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN IF EXISTS "profession_type"`); + } + + public async down(queryRunner: QueryRunner): Promise { + // Restore columns if migration is rolled back + await queryRunner.query(`ALTER TABLE "customers" ADD "source_of_income" character varying(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "profession" character varying(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "profession_type" character varying(255)`); + } +} + diff --git a/src/db/migrations/1765975126402-RemoveAddressColumns.ts b/src/db/migrations/1765975126402-RemoveAddressColumns.ts new file mode 100644 index 0000000..36d8b62 --- /dev/null +++ b/src/db/migrations/1765975126402-RemoveAddressColumns.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveAddressColumns1765975126402 implements MigrationInterface { + name = 'RemoveAddressColumns1765975126402'; + + public async up(queryRunner: QueryRunner): Promise { + // Drop address columns from customers table + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN IF EXISTS "country"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN IF EXISTS "region"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN IF EXISTS "city"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN IF EXISTS "neighborhood"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN IF EXISTS "street"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN IF EXISTS "building"`); + } + + public async down(queryRunner: QueryRunner): Promise { + // Re-add address columns in case of rollback + await queryRunner.query(`ALTER TABLE "customers" ADD "country" varchar(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "region" varchar(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "city" varchar(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "neighborhood" varchar(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "street" varchar(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "building" varchar(255)`); + } +} + diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index b4dd7ce..040255e 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -6,4 +6,11 @@ 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'; +<<<<<<< HEAD export * from './1767172707881-AddDataColumnToNotifications'; +======= +export * from './1765804942393-AddKycFieldsAndTransactions'; +export * from './1765877128065-AddNationalIdToKycTransactions'; +export * from './1765891028260-RemoveOldCustomerColumns'; +export * from './1765975126402-RemoveAddressColumns'; +>>>>>>> d77d59a79376e186354205e64efb2c793e5aae91 diff --git a/src/user/services/user.service.ts b/src/user/services/user.service.ts index 73e2e58..cc7aa12 100644 --- a/src/user/services/user.service.ts +++ b/src/user/services/user.service.ts @@ -64,14 +64,12 @@ export class UserService { this.customerService.createGuardianCustomer(userId, { firstName: body.firstName, lastName: body.lastName, - dateOfBirth: body.dateOfBirth, countryOfResidence: body.countryOfResidence, }), this.userRepository.update(userId, { isPhoneVerified: true, password: hashedPassword, salt, - ...(body.email && { email: body.email }), }), ]); } @@ -102,7 +100,6 @@ export class UserService { return this.userRepository.createUnverifiedUser({ phoneNumber: body.phoneNumber, countryCode: body.countryCode, - email: body.email, firstName: body.firstName, lastName: body.lastName, roles: [Roles.GUARDIAN], diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 1d251cc..26440de 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,5 +1,5 @@ -import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; import request from 'supertest'; import { AppModule } from './../src/app.module'; @@ -18,4 +18,6 @@ describe('AppController (e2e)', () => { it('/ (GET)', () => { return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); }); + + });