mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2025-11-26 00:24:54 +00:00
feat: kyc process
This commit is contained in:
@ -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;
|
||||
|
||||
1
src/common/mappers/index.ts
Normal file
1
src/common/mappers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './numeric-to-iso.mapper';
|
||||
11
src/common/mappers/numeric-to-iso.mapper.ts
Normal file
11
src/common/mappers/numeric-to-iso.mapper.ts
Normal 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>,
|
||||
);
|
||||
@ -1,2 +1,3 @@
|
||||
export * from './create-application.mock';
|
||||
export * from './initiate-kyc.mock';
|
||||
export * from './inquire-application.mock';
|
||||
|
||||
21
src/common/modules/neoleap/__mocks__/initiate-kyc.mock.ts
Normal file
21
src/common/modules/neoleap/__mocks__/initiate-kyc.mock.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
23
src/common/modules/neoleap/__mocks__/kyc-callback.mock.ts
Normal file
23
src/common/modules/neoleap/__mocks__/kyc-callback.mock.ts
Normal 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',
|
||||
};
|
||||
};
|
||||
@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}`,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// placeholder
|
||||
export * from './is-above-18';
|
||||
export * from './is-valid-phone-number';
|
||||
export * from './is-valid-saudi-id';
|
||||
|
||||
35
src/core/decorators/validations/is-valid-saudi-id.ts
Normal file
35
src/core/decorators/validations/is-valid-saudi-id.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,2 +1 @@
|
||||
export * from './customer.controller';
|
||||
export * from './internal.customer.controller';
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
8
src/customer/dtos/request/initiate-kyc.request.dto.ts
Normal file
8
src/customer/dtos/request/initiate-kyc.request.dto.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
10
src/customer/dtos/response/initiate-kyc.response.dto.ts
Normal file
10
src/customer/dtos/response/initiate-kyc.response.dto.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class InitiateKycResponseDto {
|
||||
@ApiProperty()
|
||||
randomNumber!: string;
|
||||
|
||||
constructor(randomNumber: string) {
|
||||
this.randomNumber = randomNumber;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 }],
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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}) يجب أن يكون رقم هوية سعودية صحيح"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user