feat: kyc process

This commit is contained in:
Abdalhamid Alhamad
2025-08-07 14:23:33 +03:00
parent 275984954e
commit ee7b365527
73 changed files with 388 additions and 7318 deletions

View File

@ -21,6 +21,9 @@ export class UserResponseDto {
@ApiProperty()
lastName!: string;
@ApiProperty()
dateOfBirth!: Date;
@ApiPropertyOptional({ type: DocumentMetaResponseDto, nullable: true })
profilePicture!: DocumentMetaResponseDto | null;
@ -34,7 +37,7 @@ export class UserResponseDto {
this.id = user.id;
this.countryCode = user.countryCode;
this.phoneNumber = user.phoneNumber;
this.dateOfBirth = user.customer?.dateOfBirth;
this.email = user.email;
this.firstName = user.firstName;
this.lastName = user.lastName;

View File

@ -0,0 +1 @@
export * from './numeric-to-iso.mapper';

View File

@ -0,0 +1,11 @@
import { CountriesNumericISO } from '../constants';
import { CountryIso } from '../enums';
// At module top-level
export const NumericToCountryIso: Record<string, CountryIso> = Object.entries(CountriesNumericISO).reduce(
(acc, [isoKey, numeric]) => {
acc[numeric] = isoKey as CountryIso;
return acc;
},
{} as Record<string, CountryIso>,
);

View File

@ -1,2 +1,3 @@
export * from './create-application.mock';
export * from './initiate-kyc.mock';
export * from './inquire-application.mock';

View File

@ -0,0 +1,21 @@
export const INITIATE_KYC_MOCK = {
ResponseHeader: {
Version: '1.0.0',
MsgUid: 'f3a9d4b2-5c7a-4e2f-8121-9c4e5a6b7d8f',
Source: 'ZOD',
ServiceId: 'InitiateKyc',
ReqDateTime: '2025-08-07T14:20:00.000Z',
RspDateTime: '2025-08-07T14:20:00.123Z',
ResponseCode: '000',
ResponseType: 'Success',
ProcessingTime: 123,
ResponseDescription: 'KYC initiation successful',
},
InitiateKycResponseDetails: {
InstitutionCode: '1100',
TransId: '3136fd60-3f89-4d24-a92f-b9c63a53807f',
RandomNumber: '38',
Status: 'WAITING',
ExpiryDateTime: '2025-08-07T14:30:00.000Z',
},
};

View File

@ -0,0 +1,23 @@
export const getKycCallbackMock = (nationalId: string) => {
return {
InstId: '1100',
transId: '3136fd60-3f89-4d24-a92f-b9c63a53807f',
date: '20250807',
time: '150000',
status: 'SUCCESS',
firstName: 'John',
lastName: 'Doe',
dob: '19990107',
nationality: '682',
gender: 'M',
nationalIdExpiry: '20310917',
nationalId,
mobile: '+962798765432',
salaryMin: '500',
salaryMax: '1000',
incomeSource: 'Salary',
professionTitle: 'Software Engineer',
professionType: 'Full-Time',
isPep: 'N',
};
};

View File

@ -5,6 +5,7 @@ import {
AccountCardStatusChangedWebhookRequest,
AccountTransactionWebhookRequest,
CardTransactionWebhookRequest,
KycWebhookRequest,
} from '../dtos/requests';
import { NeoLeapWebhookService } from '../services';
@ -30,4 +31,10 @@ export class NeoLeapWebhooksController {
await this.neoleapWebhookService.handleAccountCardStatusChangedWebhook(body);
return ResponseFactory.data({ message: 'Card status updated successfully', status: 'success' });
}
@Post('kyc')
async handleKycWebhook(@Body() body: KycWebhookRequest) {
await this.neoleapWebhookService.handleKycWebhook(body);
return ResponseFactory.data({ message: 'KYC processed successfully', status: 'success' });
}
}

View File

@ -1,4 +1,5 @@
export * from './account-card-status-changed-webhook.request.dto';
export * from './account-transaction-webhook.request.dto';
export * from './card-transaction-webhook.request.dto';
export * from './kyc-webhook.request.dto';
export * from './update-card-controls.request.dto';

View File

@ -0,0 +1,99 @@
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;
@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;
}

View File

@ -1,3 +1,4 @@
export * from './create-application.response.dto';
export * from './initiate-kyc.response.dto';
export * from './inquire-application.response';
export * from './update-card-controls.response.dto';

View File

@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose, Transform } from 'class-transformer';
export class InitiateKycResponseDto {
@Transform(({ obj }) => obj?.InstitutionCode)
@Expose()
@ApiProperty()
institutionCode!: string;
@Transform(({ obj }) => obj?.TransId)
@Expose()
@ApiProperty()
transId!: string;
@Transform(({ obj }) => obj?.RandomNumber)
@Expose()
@ApiProperty()
randomNumber!: string;
@Transform(({ obj }) => obj?.Status)
@Expose()
@ApiProperty()
status!: string;
@Transform(({ obj }) => obj?.ExpiryDateTime)
@Expose()
@ApiProperty()
expiryDateTime!: string;
}

View File

@ -1,5 +1,5 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { CardModule } from '~/card/card.module';
import { CustomerModule } from '~/customer/customer.module';
import { NeoLeapWebhooksController } from './controllers/neoleap-webhooks.controller';
@ -8,8 +8,9 @@ import { NeoLeapWebhookService } from './services';
import { NeoLeapService } from './services/neoleap.service';
@Module({
imports: [HttpModule, CustomerModule, CardModule],
imports: [HttpModule, CardModule, forwardRef(() => CustomerModule)],
controllers: [NeoTestController, NeoLeapWebhooksController],
providers: [NeoLeapService, NeoLeapWebhookService],
exports: [NeoLeapService],
})
export class NeoLeapModule {}

View File

@ -1,15 +1,21 @@
import { Injectable } from '@nestjs/common';
import { CardService } from '~/card/services';
import { TransactionService } from '~/card/services/transaction.service';
import { CustomerService } from '~/customer/services';
import {
AccountCardStatusChangedWebhookRequest,
AccountTransactionWebhookRequest,
CardTransactionWebhookRequest,
KycWebhookRequest,
} from '../dtos/requests';
@Injectable()
export class NeoLeapWebhookService {
constructor(private readonly transactionService: TransactionService, private readonly cardService: CardService) {}
constructor(
private readonly transactionService: TransactionService,
private readonly cardService: CardService,
private customerService: CustomerService,
) {}
handleCardTransactionWebhook(body: CardTransactionWebhookRequest) {
return this.transactionService.createCardTransaction(body);
@ -22,4 +28,8 @@ export class NeoLeapWebhookService {
handleAccountCardStatusChangedWebhook(body: AccountCardStatusChangedWebhookRequest) {
return this.cardService.updateCardStatus(body);
}
handleKycWebhook(body: KycWebhookRequest) {
return this.customerService.updateCustomerKyc(body);
}
}

View File

@ -1,14 +1,21 @@
import { HttpService } from '@nestjs/axios';
import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ClassConstructor, plainToInstance } from 'class-transformer';
import moment from 'moment';
import { v4 as uuid } from 'uuid';
import { CountriesNumericISO } from '~/common/constants';
import { InitiateKycRequestDto } from '~/customer/dtos/request';
import { Customer } from '~/customer/entities';
import { Gender, KycStatus } from '~/customer/enums';
import { CREATE_APPLICATION_MOCK, INQUIRE_APPLICATION_MOCK } from '../__mocks__/';
import { CreateApplicationResponse, InquireApplicationResponse, UpdateCardControlsResponseDto } from '../dtos/response';
import { CREATE_APPLICATION_MOCK, INITIATE_KYC_MOCK, INQUIRE_APPLICATION_MOCK } from '../__mocks__/';
import { getKycCallbackMock } from '../__mocks__/kyc-callback.mock';
import {
CreateApplicationResponse,
InitiateKycResponseDto,
InquireApplicationResponse,
UpdateCardControlsResponseDto,
} from '../dtos/response';
import {
ICreateApplicationRequest,
IInquireApplicationRequest,
@ -17,16 +24,62 @@ import {
} from '../interfaces';
@Injectable()
export class NeoLeapService {
private readonly baseUrl: string;
private readonly logger = new Logger(NeoLeapService.name);
private readonly gatewayBaseUrl: string;
private readonly zodApiUrl: string;
private readonly apiKey: string;
private readonly useGateway: boolean;
private readonly institutionCode = '1100';
useLocalCert: boolean;
constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) {
this.baseUrl = this.configService.getOrThrow<string>('GATEWAY_URL');
this.gatewayBaseUrl = this.configService.getOrThrow<string>('GATEWAY_URL');
this.apiKey = this.configService.getOrThrow<string>('GATEWAY_API_KEY');
this.useGateway = [true, 'true'].includes(this.configService.get<boolean>('USE_GATEWAY', false));
this.useLocalCert = this.configService.get<boolean>('USE_LOCAL_CERT', false);
this.zodApiUrl = this.configService.getOrThrow<string>('ZOD_API_URL');
}
async initiateKyc(customerId: string, body: InitiateKycRequestDto) {
const responseKey = 'InitiateKycResponseDetails';
if (!this.useGateway) {
const responseDto = plainToInstance(InitiateKycResponseDto, INITIATE_KYC_MOCK[responseKey], {
excludeExtraneousValues: true,
});
setTimeout(() => {
this.httpService
.post(`${this.zodApiUrl}/neoleap-webhooks/kyc`, getKycCallbackMock(body.nationalId), {
headers: {
'Content-Type': 'application/json',
},
})
.subscribe({
next: () => this.logger.log('Mock KYC webhook sent'),
error: (err) => console.error(err),
});
}, 7000);
return responseDto;
}
const payload = {
InitiateKycRequestDetails: {
CustomerIdentifier: {
InstitutionCode: this.institutionCode,
Id: customerId,
NationalId: body.nationalId,
},
},
RequestHeader: this.prepareHeaders('InitiateKyc'),
};
return this.sendRequestToNeoLeap<typeof payload, InitiateKycResponseDto>(
'kyc/InitiateKyc',
payload,
responseKey,
InitiateKycResponseDto,
);
}
async createApplication(customer: Customer) {
@ -189,7 +242,7 @@ export class NeoLeapService {
responseClass: ClassConstructor<R>,
): Promise<R> {
try {
const { data } = await this.httpService.axiosRef.post(`${this.baseUrl}/${endpoint}`, payload, {
const { data } = await this.httpService.axiosRef.post(`${this.gatewayBaseUrl}/${endpoint}`, payload, {
headers: {
'Content-Type': 'application/json',
Authorization: `${this.apiKey}`,

View File

@ -1,3 +1,4 @@
// placeholder
export * from './is-above-18';
export * from './is-valid-phone-number';
export * from './is-valid-saudi-id';

View File

@ -0,0 +1,35 @@
import { registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator';
export function isValidSaudiId(validationOptions?: ValidationOptions) {
return function (object: any, propertyName: string) {
registerDecorator({
name: 'isValidSaudiId',
target: object.constructor,
propertyName,
options: {
message: `${propertyName} must be a valid Saudi ID`,
...validationOptions,
},
constraints: [],
validator: {
validate(value: any, args: ValidationArguments) {
if (typeof value !== 'string') return false;
if (!/^[12]\d{9}$/.test(value)) return false;
// Luhn algorithm
let sum = 0;
for (let i = 0; i < 10; i++) {
let digit = parseInt(value[i]);
if (i % 2 === 0) {
digit *= 2;
if (digit > 9) digit -= 9;
}
sum += digit;
}
return sum % 10 === 0;
},
},
});
};
}

View File

@ -1,12 +1,12 @@
import { Body, Controller, Get, Patch, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { IJwtPayload } from '~/auth/interfaces';
import { AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards';
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import { CreateCustomerRequestDto, UpdateCustomerRequestDto } from '../dtos/request';
import { CustomerResponseDto } from '../dtos/response';
import { InitiateKycRequestDto } from '../dtos/request';
import { CustomerResponseDto, InitiateKycResponseDto } from '../dtos/response';
import { CustomerService } from '../services';
@Controller('customers')
@ -15,8 +15,7 @@ import { CustomerService } from '../services';
@ApiLangRequestHeader()
export class CustomerController {
constructor(private readonly customerService: CustomerService) {}
@Get('/profile')
@Get('/kyc')
@UseGuards(AccessTokenGuard)
@ApiDataResponse(CustomerResponseDto)
async getCustomerProfile(@AuthenticatedUser() { sub }: IJwtPayload) {
@ -25,21 +24,12 @@ export class CustomerController {
return ResponseFactory.data(new CustomerResponseDto(customer));
}
@Patch()
@Post('/kyc/initiate')
@UseGuards(AccessTokenGuard)
@ApiDataResponse(CustomerResponseDto)
async updateCustomer(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: UpdateCustomerRequestDto) {
const customer = await this.customerService.updateCustomer(sub, body);
@ApiDataResponse(InitiateKycResponseDto)
async initiateKyc(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: InitiateKycRequestDto) {
const res = await this.customerService.initiateKycRequest(sub, body);
return ResponseFactory.data(new CustomerResponseDto(customer));
}
@Post('')
@UseGuards(AccessTokenGuard)
@ApiDataResponse(CustomerResponseDto)
async createCustomer(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateCustomerRequestDto) {
const customer = await this.customerService.createGuardianCustomer(sub, body);
return ResponseFactory.data(new CustomerResponseDto(customer));
return ResponseFactory.data(new InitiateKycResponseDto(res.randomNumber));
}
}

View File

@ -1,2 +1 @@
export * from './customer.controller';
export * from './internal.customer.controller';

View File

@ -1,49 +0,0 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Patch, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CustomParseUUIDPipe } from '~/core/pipes';
import { ResponseFactory } from '~/core/utils';
import { CustomerFiltersRequestDto, RejectCustomerKycRequestDto } from '../dtos/request';
import { InternalCustomerDetailsResponseDto } from '../dtos/response';
import { InternalCustomerListResponse } from '../dtos/response/internal.customer-list.response.dto';
import { CustomerService } from '../services';
@ApiTags('Customers')
@Controller('internal/customers')
export class InternalCustomerController {
constructor(private readonly customerService: CustomerService) {}
@Get()
async findCustomers(@Query() filters: CustomerFiltersRequestDto) {
const [customers, count] = await this.customerService.findCustomers(filters);
return ResponseFactory.dataPage(
customers.map((customer) => new InternalCustomerListResponse(customer)),
{
page: filters.page,
size: filters.size,
itemCount: count,
},
);
}
@Get(':customerId')
async findCustomerById(@Param('customerId', CustomParseUUIDPipe) customerId: string) {
const customer = await this.customerService.findInternalCustomerById(customerId);
return ResponseFactory.data(new InternalCustomerDetailsResponseDto(customer));
}
@Patch(':customerId/approve')
@HttpCode(HttpStatus.NO_CONTENT)
async approveKycForCustomer(@Param('customerId', CustomParseUUIDPipe) customerId: string) {
await this.customerService.approveKycForCustomer(customerId);
}
@Patch(':customerId/reject')
@HttpCode(HttpStatus.NO_CONTENT)
async rejectKycForCustomer(
@Param('customerId', CustomParseUUIDPipe) customerId: string,
@Body() body: RejectCustomerKycRequestDto,
) {
await this.customerService.rejectKycForCustomer(customerId, body);
}
}

View File

@ -1,15 +1,21 @@
import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { NeoLeapModule } from '~/common/modules/neoleap/neoleap.module';
import { GuardianModule } from '~/guardian/guardian.module';
import { UserModule } from '~/user/user.module';
import { CustomerController, InternalCustomerController } from './controllers';
import { CustomerController } from './controllers';
import { Customer } from './entities';
import { CustomerRepository } from './repositories/customer.repository';
import { CustomerService } from './services';
@Module({
imports: [TypeOrmModule.forFeature([Customer]), forwardRef(() => UserModule), GuardianModule],
controllers: [CustomerController, InternalCustomerController],
imports: [
TypeOrmModule.forFeature([Customer]),
forwardRef(() => UserModule),
GuardianModule,
forwardRef(() => NeoLeapModule),
],
controllers: [CustomerController],
providers: [CustomerService, CustomerRepository],
exports: [CustomerService],
})

View File

@ -1,80 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsDateString, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { CountryIso } from '~/common/enums';
import { IsAbove18 } from '~/core/decorators/validations';
import { Gender } from '~/customer/enums';
export class CreateCustomerRequestDto {
@ApiProperty({ example: 'John' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) })
firstName!: string;
@ApiProperty({ example: 'Doe' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) })
lastName!: string;
@ApiProperty({ example: 'MALE' })
@IsEnum(Gender, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.gender' }) })
@IsOptional()
gender?: Gender;
@ApiProperty({ enum: CountryIso, example: CountryIso.SAUDI_ARABIA })
@IsEnum(CountryIso, {
message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }),
})
countryOfResidence!: CountryIso;
@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: '999300024' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.nationalId' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.nationalId' }) })
@IsOptional()
nationalId?: string;
@ApiProperty({ example: '2021-01-01' })
@IsDateString(
{},
{ message: i18n('validation.IsDateString', { path: 'general', property: 'junior.nationalIdExpiry' }) },
)
@IsOptional()
nationalIdExpiry?: Date;
@ApiProperty({ example: 'Employee' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.sourceOfIncome' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.sourceOfIncome' }) })
@IsOptional()
sourceOfIncome?: string;
@ApiProperty({ example: 'Accountant' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.profession' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.profession' }) })
@IsOptional()
profession?: string;
@ApiProperty({ example: 'Finance' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.professionType' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.professionType' }) })
@IsOptional()
professionType?: string;
@ApiProperty({ example: false })
@IsBoolean({ message: i18n('validation.IsBoolean', { path: 'general', property: 'junior.isPep' }) })
@IsOptional()
isPep?: boolean;
@ApiProperty({ example: 'bf342-3f3f-3f3f-3f3f' })
@IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'junior.civilIdFrontId' }) })
@IsOptional()
civilIdFrontId?: string;
@ApiProperty({ example: 'bf342-3f3f-3f3f-3f3f' })
@IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'junior.civilIdBackId' }) })
@IsOptional()
civilIdBackId?: string;
}

View File

@ -1,23 +0,0 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { PageOptionsRequestDto } from '~/core/dtos';
import { KycStatus } from '~/customer/enums';
export class CustomerFiltersRequestDto extends PageOptionsRequestDto {
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.name' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.name' }) })
@IsOptional()
@ApiPropertyOptional({ description: 'search by name' })
name?: string;
@IsEnum(KycStatus, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.kycStatus' }) })
@IsOptional()
@ApiPropertyOptional({
enum: KycStatus,
enumName: 'KycStatus',
example: KycStatus.PENDING,
description: 'kyc status of the customer',
})
kycStatus?: string;
}

View File

@ -1,4 +1 @@
export * from './create-customer.request.dto';
export * from './customer-filters.request.dto';
export * from './reject-customer-kyc.request.dto';
export * from './update-customer.request.dto';
export * from './initiate-kyc.request.dto';

View File

@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { isValidSaudiId } from '~/core/decorators/validations';
export class InitiateKycRequestDto {
@ApiProperty({ example: '999300024' })
@isValidSaudiId({ message: i18n('validation.isValidSaudiId', { path: 'general', property: 'customer.nationalId' }) })
nationalId!: string;
}

View File

@ -1,11 +0,0 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class RejectCustomerKycRequestDto {
@ApiPropertyOptional({ description: 'reason for rejecting the customer kyc' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.rejectionReason' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.rejectionReason' }) })
@IsOptional({ message: i18n('validation.IsOptional', { path: 'general', property: 'customer.rejectionReason' }) })
reason?: string;
}

View File

@ -1,10 +0,0 @@
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { IsOptional, IsUUID } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { CreateCustomerRequestDto } from './create-customer.request.dto';
export class UpdateCustomerRequestDto extends PartialType(CreateCustomerRequestDto) {
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' })
@IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'customer.profilePictureId' }) })
@IsOptional()
profilePictureId!: string;
}

View File

@ -1,3 +1,2 @@
export * from './customer.response.dto';
export * from './internal.customer-details.response.dto';
export * from './internal.customer-list.response.dto';
export * from './initiate-kyc.response.dto';

View File

@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
export class InitiateKycResponseDto {
@ApiProperty()
randomNumber!: string;
constructor(randomNumber: string) {
this.randomNumber = randomNumber;
}
}

View File

@ -1,88 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Customer } from '~/customer/entities';
import { CustomerStatus, KycStatus } from '~/customer/enums';
import { DocumentMetaResponseDto } from '~/document/dtos/response';
export class InternalCustomerDetailsResponseDto {
@ApiProperty()
id!: string;
@ApiProperty()
customerStatus!: CustomerStatus;
@ApiProperty()
kycStatus!: KycStatus;
@ApiProperty()
rejectionReason!: string | null;
@ApiProperty()
fullName!: string;
@ApiProperty()
phoneNumber!: string;
@ApiProperty()
dateOfBirth!: Date;
@ApiProperty()
nationalId!: string;
@ApiProperty()
nationalIdExpiry!: Date;
@ApiProperty()
countryOfResidence!: string;
@ApiProperty()
sourceOfIncome!: string;
@ApiProperty()
profession!: string;
@ApiProperty()
professionType!: string;
@ApiProperty()
isPep!: boolean;
@ApiProperty()
gender!: string;
@ApiProperty()
isJunior!: boolean;
@ApiProperty()
isGuardian!: boolean;
@ApiProperty({ type: DocumentMetaResponseDto })
civilIdFront!: DocumentMetaResponseDto;
@ApiProperty({ type: DocumentMetaResponseDto })
civilIdBack!: DocumentMetaResponseDto;
@ApiPropertyOptional({ type: DocumentMetaResponseDto })
profilePicture!: DocumentMetaResponseDto | null;
constructor(customer: Customer) {
this.id = customer.id;
this.customerStatus = customer.customerStatus;
this.kycStatus = customer.kycStatus;
this.rejectionReason = customer.rejectionReason;
this.fullName = `${customer.firstName} ${customer.lastName}`;
this.phoneNumber = customer.user.fullPhoneNumber;
this.dateOfBirth = customer.dateOfBirth;
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.civilIdFront = new DocumentMetaResponseDto(customer.civilIdFront);
this.civilIdBack = new DocumentMetaResponseDto(customer.civilIdBack);
}
}

View File

@ -1,44 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { Customer } from '~/customer/entities';
import { CustomerStatus, KycStatus } from '~/customer/enums';
export class InternalCustomerListResponse {
@ApiProperty()
id!: string;
@ApiProperty()
fullName!: string;
@ApiProperty()
phoneNumber!: string;
@ApiProperty()
customerStatus!: CustomerStatus;
@ApiProperty()
kycStatus!: KycStatus;
@ApiProperty()
dateOfBirth!: Date;
@ApiProperty()
gender!: string;
@ApiProperty()
isJunior!: boolean;
@ApiProperty()
isGuardian!: boolean;
constructor(customer: Customer) {
this.id = customer.id;
this.fullName = `${customer.firstName} ${customer.lastName}`;
this.phoneNumber = customer.user?.fullPhoneNumber;
this.customerStatus = customer.customerStatus;
this.kycStatus = customer.kycStatus;
this.dateOfBirth = customer.dateOfBirth;
this.gender = customer.gender;
this.isGuardian = customer.isGuardian;
this.isJunior = customer.isJunior;
}
}

View File

@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, Repository } from 'typeorm';
import { CustomerFiltersRequestDto } from '../dtos/request';
import { Customer } from '../entities';
@Injectable()
@ -34,33 +33,6 @@ export class CustomerRepository {
);
}
findCustomers(filters: CustomerFiltersRequestDto) {
const query = this.customerRepository.createQueryBuilder('customer');
query.leftJoinAndSelect('customer.user', 'user');
if (filters.name) {
const nameParts = filters.name.trim().split(/\s+/);
nameParts.length > 1
? query.andWhere('customer.firstName LIKE :firstName AND customer.lastName LIKE :lastName', {
firstName: `%${nameParts[0]}%`,
lastName: `%${nameParts[1]}%`,
})
: query.andWhere('customer.firstName LIKE :name OR customer.lastName LIKE :name', {
name: `%${filters.name.trim()}%`,
});
}
if (filters.kycStatus) {
query.andWhere('customer.kycStatus = :kycStatus', { kycStatus: filters.kycStatus });
}
query.orderBy('customer.createdAt', 'DESC');
query.take(filters.size);
query.skip((filters.page - 1) * filters.size);
return query.getManyAndCount();
}
findCustomerByCivilId(civilIdFrontId: string, civilIdBackId: string) {
return this.customerRepository.findOne({
where: [{ civilIdFrontId, civilIdBackId }, { civilIdFrontId }, { civilIdBackId }],

View File

@ -1,17 +1,14 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { BadRequestException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import moment from 'moment';
import { Transactional } from 'typeorm-transactional';
import { CountryIso } from '~/common/enums';
import { DocumentService, OciService } from '~/document/services';
import { NumericToCountryIso } from '~/common/mappers';
import { KycWebhookRequest } from '~/common/modules/neoleap/dtos/requests';
import { NeoLeapService } from '~/common/modules/neoleap/services';
import { GuardianService } from '~/guardian/services';
import { CreateJuniorRequestDto } from '~/junior/dtos/request';
import { User } from '~/user/entities';
import {
CreateCustomerRequestDto,
CustomerFiltersRequestDto,
RejectCustomerKycRequestDto,
UpdateCustomerRequestDto,
} from '../dtos/request';
import { InitiateKycRequestDto } from '../dtos/request';
import { Customer } from '../entities';
import { Gender, KycStatus } from '../enums';
import { CustomerRepository } from '../repositories/customer.repository';
@ -21,18 +18,13 @@ export class CustomerService {
private readonly logger = new Logger(CustomerService.name);
constructor(
private readonly customerRepository: CustomerRepository,
private readonly ociService: OciService,
private readonly documentService: DocumentService,
private readonly guardianService: GuardianService,
@Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService,
) {}
async updateCustomer(userId: string, data: UpdateCustomerRequestDto): Promise<Customer> {
async updateCustomer(userId: string, data: Partial<Customer>): Promise<Customer> {
this.logger.log(`Updating customer ${userId}`);
await this.validateProfilePictureForCustomer(userId, data.profilePictureId);
if (data.civilIdBackId || data.civilIdFrontId) {
await this.validateCivilIdForCustomer(userId, data.civilIdFrontId!, data.civilIdBackId!);
}
await this.customerRepository.updateCustomer(userId, data);
this.logger.log(`Customer ${userId} updated successfully`);
return this.findCustomerById(userId);
@ -41,8 +33,6 @@ export class CustomerService {
async createJuniorCustomer(guardianId: string, juniorId: string, body: CreateJuniorRequestDto) {
this.logger.log(`Creating junior customer for user ${juniorId}`);
await this.validateCivilIdForCustomer(guardianId, body.civilIdFrontId, body.civilIdBackId);
return this.customerRepository.createCustomer(juniorId, body, false);
}
@ -59,40 +49,28 @@ export class CustomerService {
return customer;
}
async findInternalCustomerById(id: string) {
this.logger.log(`Finding internal customer ${id}`);
const customer = await this.customerRepository.findOne({ id });
async initiateKycRequest(customerId: string, body: InitiateKycRequestDto) {
this.logger.log(`Initiating KYC request for user ${customerId}`);
if (!customer) {
this.logger.error(`Internal customer ${id} not found`);
throw new BadRequestException('CUSTOMER.NOT_FOUND');
}
await this.prepareCustomerDocuments(customer);
this.logger.log(`Internal customer ${id} found successfully`);
return customer;
}
async approveKycForCustomer(customerId: string) {
const customer = await this.findCustomerById(customerId);
if (customer.kycStatus === KycStatus.APPROVED) {
this.logger.error(`Customer ${customerId} is already approved`);
throw new BadRequestException('CUSTOMER.ALREADY_APPROVED');
this.logger.error(`KYC for customer ${customerId} is already approved`);
throw new BadRequestException('CUSTOMER.KYC_ALREADY_APPROVED');
}
this.logger.debug(`Approving KYC for customer ${customerId}`);
await this.customerRepository.updateCustomer(customerId, { kycStatus: KycStatus.APPROVED, rejectionReason: null });
this.logger.log(`KYC approved for customer ${customerId}`);
}
// 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
findCustomers(filters: CustomerFiltersRequestDto) {
this.logger.log(`Finding customers with filters ${JSON.stringify(filters)}`);
return this.customerRepository.findCustomers(filters);
await this.customerRepository.updateCustomer(customerId, {
nationalId: body.nationalId,
kycStatus: KycStatus.PENDING,
});
return this.neoleapService.initiateKyc(customerId, body);
}
@Transactional()
async createGuardianCustomer(userId: string, body: Partial<CreateCustomerRequestDto>) {
async createGuardianCustomer(userId: string, body: Partial<Customer>) {
this.logger.log(`Creating guardian customer for user ${userId}`);
const existingCustomer = await this.customerRepository.findOne({ id: userId });
@ -110,23 +88,33 @@ export class CustomerService {
return customer;
}
async rejectKycForCustomer(customerId: string, { reason }: RejectCustomerKycRequestDto) {
const customer = await this.findCustomerById(customerId);
async updateCustomerKyc(body: KycWebhookRequest) {
this.logger.log(`Updating KYC for customer with national ID ${body.nationalId}`);
if (customer.kycStatus === KycStatus.REJECTED) {
this.logger.error(`Customer ${customerId} is already rejected`);
throw new BadRequestException('CUSTOMER.ALREADY_REJECTED');
const customer = await this.customerRepository.findOne({ nationalId: body.nationalId });
if (!customer) {
throw new BadRequestException('CUSTOMER.NOT_FOUND');
}
this.logger.debug(`Rejecting KYC for customer ${customerId}`);
await this.customerRepository.updateCustomer(customerId, {
kycStatus: KycStatus.REJECTED,
rejectionReason: reason,
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.nationality],
country: NumericToCountryIso[body.nationality],
gender: body.gender === 'M' ? Gender.MALE : Gender.FEMALE,
sourceOfIncome: body.incomeSource,
profession: body.professionTitle,
professionType: body.professionType,
isPep: body.isPep === 'Y',
});
this.logger.log(`KYC rejected for customer ${customerId}`);
}
// this function is for testing only and will be removed
// TO BE REMOVED: This function is for testing only and will be removed
@Transactional()
async updateKyc(userId: string) {
this.logger.log(`Updating KYC for customer ${userId}`);
@ -153,77 +141,6 @@ export class CustomerService {
return this.findCustomerById(userId);
}
private async validateProfilePictureForCustomer(userId: string, profilePictureId?: string) {
if (!profilePictureId) return;
this.logger.log(`Validating profile picture ${profilePictureId}`);
const profilePicture = await this.documentService.findDocumentById(profilePictureId);
if (!profilePicture) {
this.logger.error(`Profile picture ${profilePictureId} not found`);
throw new BadRequestException('DOCUMENT.NOT_FOUND');
}
if (profilePicture.createdById && profilePicture.createdById !== userId) {
this.logger.error(`Profile picture ${profilePictureId} does not belong to user ${userId}`);
throw new BadRequestException('DOCUMENT.NOT_CREATED_BY_USER');
}
}
private async validateCivilIdForCustomer(userId: string, civilIdFrontId: string, civilIdBackId: string) {
this.logger.log(`Validating customer documents`);
if (!civilIdFrontId || !civilIdBackId) {
this.logger.error('Civil id front and back are required');
throw new BadRequestException('CUSTOMER.CIVIL_ID_REQUIRED');
}
const [civilIdFront, civilIdBack] = await Promise.all([
this.documentService.findDocumentById(civilIdFrontId),
this.documentService.findDocumentById(civilIdBackId),
]);
if (!civilIdFront || !civilIdBack) {
this.logger.error('Civil id front or back not found');
throw new BadRequestException('CUSTOMER.CIVIL_ID_REQUIRED');
}
if (civilIdFront.createdById !== userId || civilIdBack.createdById !== userId) {
this.logger.error(`Civil id front or back not created by user with id ${userId}`);
throw new BadRequestException('CUSTOMER.CIVIL_ID_NOT_CREATED_BY_USER');
}
const customerWithTheSameCivilId = await this.customerRepository.findCustomerByCivilId(
civilIdFrontId,
civilIdBackId,
);
if (customerWithTheSameCivilId) {
this.logger.error(
`Customer with civil id front ${civilIdFrontId} and civil id back ${civilIdBackId} already exists`,
);
throw new BadRequestException('CUSTOMER.CIVIL_ID_ALREADY_EXISTS');
}
}
private async prepareCustomerDocuments(customer: Customer) {
const promises = [];
promises.push(this.ociService.generatePreSignedUrl(customer.civilIdFront));
promises.push(this.ociService.generatePreSignedUrl(customer.civilIdBack));
const [civilIdFrontUrl, civilIdBackUrl, profilePictureUrl] = await Promise.all(promises);
customer.civilIdFront.url = civilIdFrontUrl;
customer.civilIdBack.url = civilIdBackUrl;
return customer;
}
async findAnyCustomer() {
return this.customerRepository.findOne({ isGuardian: true });
}
// TO BE REMOVED: This function is for testing only and will be removed
private generateSaudiPhoneNumber(): string {
// Saudi mobile numbers are 9 digits, always starting with '5'

View File

@ -24,5 +24,6 @@
"IsValidExpiryDate": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان يكون تاريخ انتهاء بطاقه صحيح",
"IsEnglishOnly": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب أن يكون باللغة الانجليزية",
"IsAbove18": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان يكون فوق 18 سنة",
"IsValidPhoneNumber": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان يكون رقم هاتف صحيح"
"IsValidPhoneNumber": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان يكون رقم هاتف صحيح",
"isValidSaudiId": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب أن يكون رقم هوية سعودية صحيح"
}

View File

@ -25,5 +25,6 @@
"IsEnglishOnly": "$t({path}.PROPERTY_MAPPINGS.{property}) must be in English",
"IsAbove18": "$t({path}.PROPERTY_MAPPINGS.{property}) must be above 18 years",
"IsValidPhoneNumber": "$t({path}.PROPERTY_MAPPINGS.{property}) must be a valid phone number",
"IsPositive": "$t({path}.PROPERTY_MAPPINGS.{property}) must be a positive number"
"IsPositive": "$t({path}.PROPERTY_MAPPINGS.{property}) must be a positive number",
"isValidSaudiId": "$t({path}.PROPERTY_MAPPINGS.{property}) must be a valid Saudi ID"
}

View File

@ -19,7 +19,6 @@ const SALT_ROUNDS = 10;
@Injectable()
export class UserService {
private readonly logger = new Logger(UserService.name);
private adminPortalUrl = this.configService.getOrThrow('ADMIN_PORTAL_URL');
constructor(
private readonly userRepository: UserRepository,
@Inject(forwardRef(() => NotificationsService)) private readonly notificationsService: NotificationsService,