From 5a780eeb1760020afa89472a52417ea2df07bd99 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 14 Jul 2025 11:57:51 +0300 Subject: [PATCH] feat/working on update card control --- src/card/repositories/card.repository.ts | 6 ++ src/card/services/card.service.ts | 8 ++ src/card/services/transaction.service.ts | 6 +- .../neoleap/controllers/neotest.controller.ts | 47 ++++++++--- .../modules/neoleap/dtos/requests/index.ts | 1 + .../update-card-controls.request.dto.ts | 15 ++++ .../modules/neoleap/dtos/response/index.ts | 1 + .../update-card-controls.response.dto.ts | 1 + .../modules/neoleap/interfaces/index.ts | 1 + .../update-card-control.request.interface.ts | 77 +++++++++++++++++++ .../neoleap/services/neoleap.service.ts | 54 +++++++++++-- .../dtos/response/customer.response.dto.ts | 25 +++++- src/customer/services/customer.service.ts | 43 ++++++++++- src/i18n/ar/app.json | 3 +- src/i18n/en/app.json | 3 +- 15 files changed, 263 insertions(+), 28 deletions(-) create mode 100644 src/common/modules/neoleap/dtos/requests/update-card-controls.request.dto.ts create mode 100644 src/common/modules/neoleap/dtos/response/update-card-controls.response.dto.ts create mode 100644 src/common/modules/neoleap/interfaces/update-card-control.request.interface.ts diff --git a/src/card/repositories/card.repository.ts b/src/card/repositories/card.repository.ts index a83d71c..ebab1f7 100644 --- a/src/card/repositories/card.repository.ts +++ b/src/card/repositories/card.repository.ts @@ -34,4 +34,10 @@ export class CardRepository { getCardByReferenceNumber(referenceNumber: string): Promise { return this.cardRepository.findOne({ where: { cardReference: referenceNumber }, relations: ['account'] }); } + + getActiveCardForCustomer(customerId: string): Promise { + return this.cardRepository.findOne({ + where: { customerId, status: CardStatus.ACTIVE }, + }); + } } diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts index 25573da..bc71cfd 100644 --- a/src/card/services/card.service.ts +++ b/src/card/services/card.service.ts @@ -34,4 +34,12 @@ export class CardService { return card; } + + async getActiveCardForCustomer(customerId: string): Promise { + const card = await this.cardRepository.getActiveCardForCustomer(customerId); + if (!card) { + throw new BadRequestException('CARD.NOT_FOUND'); + } + return card; + } } diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts index 6c98b9c..d512299 100644 --- a/src/card/services/transaction.service.ts +++ b/src/card/services/transaction.service.ts @@ -28,11 +28,7 @@ export class TransactionService { } const transaction = await this.transactionRepository.createCardTransaction(card, body); - const total = new Decimal(body.transactionAmount) - .plus(body.billingAmount) - .plus(body.settlementAmount) - .plus(body.fees) - .plus(body.vatOnFees); + const total = new Decimal(body.transactionAmount).plus(body.billingAmount).plus(body.fees).plus(body.vatOnFees); await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()); diff --git a/src/common/modules/neoleap/controllers/neotest.controller.ts b/src/common/modules/neoleap/controllers/neotest.controller.ts index f7ba156..47ed777 100644 --- a/src/common/modules/neoleap/controllers/neotest.controller.ts +++ b/src/common/modules/neoleap/controllers/neotest.controller.ts @@ -1,16 +1,22 @@ -import { Controller, Get } from '@nestjs/common'; +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { IJwtPayload } from '~/auth/interfaces'; import { CardService } from '~/card/services'; +import { AuthenticatedUser } from '~/common/decorators'; +import { AccessTokenGuard } from '~/common/guards'; import { ApiDataResponse } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; -import { Customer } from '~/customer/entities'; +import { CustomerResponseDto } from '~/customer/dtos/response'; import { CustomerService } from '~/customer/services'; +import { UpdateCardControlsRequestDto } from '../dtos/requests'; import { CreateApplicationResponse, InquireApplicationResponse } from '../dtos/response'; import { NeoLeapService } from '../services/neoleap.service'; @Controller('neotest') @ApiTags('Neoleap Test API , for testing purposes only, will be removed in production') +@UseGuards(AccessTokenGuard) +@ApiBearerAuth() export class NeoTestController { constructor( private readonly neoleapService: NeoLeapService, @@ -19,21 +25,38 @@ export class NeoTestController { private readonly configService: ConfigService, ) {} - @Get('inquire-application') + @Post('update-kys') + @ApiDataResponse(CustomerResponseDto) + async updateKys(@AuthenticatedUser() user: IJwtPayload) { + const customer = await this.customerService.updateKyc(user.sub); + + return ResponseFactory.data(new CustomerResponseDto(customer)); + } + + @Post('inquire-application') @ApiDataResponse(InquireApplicationResponse) - async inquireApplication() { - const data = await this.neoleapService.inquireApplication('15'); + async inquireApplication(@AuthenticatedUser() user: IJwtPayload) { + const customer = await this.customerService.findCustomerById(user.sub); + const data = await this.neoleapService.inquireApplication(customer.waitingNumber.toString()); return ResponseFactory.data(data); } - @Get('create-application') + @Post('create-application') @ApiDataResponse(CreateApplicationResponse) - async createApplication() { - const customer = await this.customerService.findCustomerById( - this.configService.get('MOCK_CUSTOMER_ID', 'ff462af1-1e0c-4216-8865-738e5b525ac1'), - ); - const data = await this.neoleapService.createApplication(customer as Customer); + async createApplication(@AuthenticatedUser() user: IJwtPayload) { + const customer = await this.customerService.findCustomerById(user.sub); + const data = await this.neoleapService.createApplication(customer); await this.cardService.createCard(customer.id, data); return ResponseFactory.data(data); } + + @Post('update-card-controls') + async updateCardControls( + @AuthenticatedUser() user: IJwtPayload, + @Body() { amount, count }: UpdateCardControlsRequestDto, + ) { + const card = await this.cardService.getActiveCardForCustomer(user.sub); + await this.neoleapService.updateCardControl(card.cardReference, amount, count); + return ResponseFactory.data({ message: 'Card controls updated successfully' }); + } } diff --git a/src/common/modules/neoleap/dtos/requests/index.ts b/src/common/modules/neoleap/dtos/requests/index.ts index bf49fc9..d4cad6f 100644 --- a/src/common/modules/neoleap/dtos/requests/index.ts +++ b/src/common/modules/neoleap/dtos/requests/index.ts @@ -1,2 +1,3 @@ export * from './account-transaction-webhook.request.dto'; export * from './card-transaction-webhook.request.dto'; +export * from './update-card-controls.request.dto'; diff --git a/src/common/modules/neoleap/dtos/requests/update-card-controls.request.dto.ts b/src/common/modules/neoleap/dtos/requests/update-card-controls.request.dto.ts new file mode 100644 index 0000000..280c05e --- /dev/null +++ b/src/common/modules/neoleap/dtos/requests/update-card-controls.request.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNumber, IsOptional, IsPositive } from 'class-validator'; + +export class UpdateCardControlsRequestDto { + @ApiProperty() + @IsNumber() + @IsPositive() + amount!: number; + + @IsNumber() + @IsPositive() + @IsOptional() + @ApiPropertyOptional() + count?: number; +} diff --git a/src/common/modules/neoleap/dtos/response/index.ts b/src/common/modules/neoleap/dtos/response/index.ts index 50e4920..457e4bc 100644 --- a/src/common/modules/neoleap/dtos/response/index.ts +++ b/src/common/modules/neoleap/dtos/response/index.ts @@ -1,2 +1,3 @@ export * from './create-application.response.dto'; export * from './inquire-application.response'; +export * from './update-card-controls.response.dto'; diff --git a/src/common/modules/neoleap/dtos/response/update-card-controls.response.dto.ts b/src/common/modules/neoleap/dtos/response/update-card-controls.response.dto.ts new file mode 100644 index 0000000..f39eaa3 --- /dev/null +++ b/src/common/modules/neoleap/dtos/response/update-card-controls.response.dto.ts @@ -0,0 +1 @@ +export class UpdateCardControlsResponseDto {} diff --git a/src/common/modules/neoleap/interfaces/index.ts b/src/common/modules/neoleap/interfaces/index.ts index e6757c9..911935c 100644 --- a/src/common/modules/neoleap/interfaces/index.ts +++ b/src/common/modules/neoleap/interfaces/index.ts @@ -1,3 +1,4 @@ export * from './create-application.request.interface'; export * from './inquire-application.request.interface'; export * from './neoleap-header.request.interface'; +export * from './update-card-control.request.interface'; diff --git a/src/common/modules/neoleap/interfaces/update-card-control.request.interface.ts b/src/common/modules/neoleap/interfaces/update-card-control.request.interface.ts new file mode 100644 index 0000000..3764887 --- /dev/null +++ b/src/common/modules/neoleap/interfaces/update-card-control.request.interface.ts @@ -0,0 +1,77 @@ +import { INeoleapHeaderRequest } from './neoleap-header.request.interface'; + +export interface IUpdateCardControlRequest extends INeoleapHeaderRequest { + UpdateCardControlsRequestDetails: { + InstitutionCode: string; + CardIdentifier: { + Id: string; + InstitutionCode: string; + }; + + UsageTransactionLimit?: { + AmountLimit: number; + CountLimit: number; + }; + DomesticCashDailyLimit?: { + AmountLimit: number; + CountLimit: number; + }; + InternationalCashDailyLimit?: { + AmountLimit: number; + CountLimit: number; + }; + DomesticPosDailyLimit?: { + AmountLimit: number; + CountLimit: number; + lenient?: boolean; + }; + InternationalPosDailyLimit?: { + AmountLimit: number; + CountLimit: number; + }; + EcommerceDailyLimit?: { + AmountLimit: number; + CountLimit: number; + }; + DomesticCashMonthlyLimit?: { + AmountLimit: number; + CountLimit: number; + }; + InternationalCashMonthlyLimit?: { + AmountLimit: number; + CountLimit: number; + }; + DomesticPosMonthlyLimit?: { + AmountLimit: number; + CountLimit: number; + }; + InternationalPosMonthlyLimit?: { + AmountLimit: number; + CountLimit: number; + }; + EcommerceMonthlyLimit?: { + AmountLimit: number; + CountLimit: number; + }; + DomesticCashTransactionLimit?: { + AmountLimit: number; + CountLimit: number; + }; + InternationalCashTransactionLimit?: { + AmountLimit: number; + CountLimit: number; + }; + DomesticPosTransactionLimit?: { + AmountLimit: number; + CountLimit: number; + }; + InternationalPosTransactionLimit?: { + AmountLimit: number; + CountLimit: number; + }; + EcommerceTransactionLimit?: { + AmountLimit: number; + CountLimit: number; + }; + }; +} diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index 8e6403b..6f31ac4 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -6,10 +6,15 @@ import moment from 'moment'; import { v4 as uuid } from 'uuid'; import { CountriesNumericISO } from '~/common/constants'; import { Customer } from '~/customer/entities'; -import { Gender } from '~/customer/enums'; +import { Gender, KycStatus } from '~/customer/enums'; import { CREATE_APPLICATION_MOCK, INQUIRE_APPLICATION_MOCK } from '../__mocks__/'; -import { CreateApplicationResponse, InquireApplicationResponse } from '../dtos/response'; -import { ICreateApplicationRequest, IInquireApplicationRequest, INeoleapHeaderRequest } from '../interfaces'; +import { CreateApplicationResponse, InquireApplicationResponse, UpdateCardControlsResponseDto } from '../dtos/response'; +import { + ICreateApplicationRequest, + IInquireApplicationRequest, + INeoleapHeaderRequest, + IUpdateCardControlRequest, +} from '../interfaces'; @Injectable() export class NeoLeapService { private readonly baseUrl: string; @@ -26,9 +31,15 @@ export class NeoLeapService { async createApplication(customer: Customer) { const responseKey = 'CreateNewApplicationResponseDetails'; - // if (customer.cards.length > 0) { - // throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD'); - // } + + if (customer.kycStatus !== KycStatus.APPROVED) { + throw new BadRequestException('CUSTOMER.KYC_NOT_APPROVED'); + } + + if (customer.cards.length > 0) { + throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD'); + } + if (!this.useGateway) { return plainToInstance(CreateApplicationResponse, CREATE_APPLICATION_MOCK[responseKey], { excludeExtraneousValues: true, @@ -132,6 +143,35 @@ export class NeoLeapService { ); } + async updateCardControl(cardId: string, amount: number, count?: number) { + const responseKey = 'UpdateCardControlResponseDetails'; + if (!this.useGateway) { + return; + } + + const payload: IUpdateCardControlRequest = { + UpdateCardControlsRequestDetails: { + InstitutionCode: this.institutionCode, + CardIdentifier: { + InstitutionCode: this.institutionCode, + Id: cardId, + }, + UsageTransactionLimit: { + AmountLimit: amount, + CountLimit: count || 10, + }, + }, + RequestHeader: this.prepareHeaders('UpdateCardControl'), + }; + + return this.sendRequestToNeoLeap( + 'cardcontrol/UpdateCardControl', + payload, + responseKey, + UpdateCardControlsResponseDto, + ); + } + private prepareHeaders(serviceName: string): INeoleapHeaderRequest['RequestHeader'] { return { Version: '1.0.0', @@ -156,7 +196,7 @@ export class NeoLeapService { }, }); - if (data.data?.ResponseHeader?.ResponseCode !== '000' || !data.data[responseKey]) { + if (data.data?.ResponseHeader?.ResponseCode !== '000') { throw new BadRequestException( data.data.ResponseHeader.ResponseDescription || 'Unexpected Error from Service Provider', ); diff --git a/src/customer/dtos/response/customer.response.dto.ts b/src/customer/dtos/response/customer.response.dto.ts index 3d40cf7..f49d12c 100644 --- a/src/customer/dtos/response/customer.response.dto.ts +++ b/src/customer/dtos/response/customer.response.dto.ts @@ -58,6 +58,24 @@ export class CustomerResponseDto { @ApiProperty() waitingNumber!: number; + @ApiProperty() + country!: string | null; + + @ApiProperty() + region!: string | null; + + @ApiProperty() + city!: string | null; + + @ApiProperty() + neighborhood!: string | null; + + @ApiProperty() + street!: string | null; + + @ApiProperty() + building!: string | null; + @ApiPropertyOptional({ type: DocumentMetaResponseDto }) profilePicture!: DocumentMetaResponseDto | null; @@ -80,7 +98,12 @@ export class CustomerResponseDto { this.isJunior = customer.isJunior; this.isGuardian = customer.isGuardian; this.waitingNumber = customer.waitingNumber; - + this.country = customer.country; + this.region = customer.region; + this.city = customer.city; + this.neighborhood = customer.neighborhood; + this.street = customer.street; + this.building = customer.building; this.profilePicture = customer.profilePicture ? new DocumentMetaResponseDto(customer.profilePicture) : null; } } diff --git a/src/customer/services/customer.service.ts b/src/customer/services/customer.service.ts index dff93ff..5690858 100644 --- a/src/customer/services/customer.service.ts +++ b/src/customer/services/customer.service.ts @@ -1,8 +1,11 @@ import { BadRequestException, 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 { GuardianService } from '~/guardian/services'; import { CreateJuniorRequestDto } from '~/junior/dtos/request'; +import { User } from '~/user/entities'; import { CreateCustomerRequestDto, CustomerFiltersRequestDto, @@ -10,7 +13,7 @@ import { UpdateCustomerRequestDto, } from '../dtos/request'; import { Customer } from '../entities'; -import { KycStatus } from '../enums'; +import { Gender, KycStatus } from '../enums'; import { CustomerRepository } from '../repositories/customer.repository'; @Injectable() @@ -125,6 +128,33 @@ export class CustomerService { this.logger.log(`KYC rejected for customer ${customerId}`); } + // this function is for testing only and will be removed + @Transactional() + async updateKyc(userId: string) { + this.logger.log(`Updating KYC for customer ${userId}`); + await this.customerRepository.updateCustomer(userId, { + kycStatus: KycStatus.APPROVED, + gender: Gender.MALE, + 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, { + phoneNumber: this.generateSaudiPhoneNumber(), + countryCode: '+966', + }); + + this.logger.log(`KYC updated for customer ${userId}`); + return this.findCustomerById(userId); + } + private async validateProfilePictureForCustomer(userId: string, profilePictureId?: string) { if (!profilePictureId) return; @@ -202,4 +232,15 @@ export class CustomerService { 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' + const firstDigit = '5'; + let rest = ''; + for (let i = 0; i < 8; i++) { + rest += Math.floor(Math.random() * 10); + } + return `${firstDigit}${rest}`; + } } diff --git a/src/i18n/ar/app.json b/src/i18n/ar/app.json index 1ce9b9e..754a4b5 100644 --- a/src/i18n/ar/app.json +++ b/src/i18n/ar/app.json @@ -43,7 +43,8 @@ }, "CUSTOMER": { "NOT_FOUND": "لم يتم العثور على العميل.", - "ALREADY_EXISTS": "العميل موجود بالفعل." + "ALREADY_EXISTS": "العميل موجود بالفعل.", + "KYC_NOT_APPROVED": "لم يتم الموافقة على هوية العميل بعد." }, "GIFT": { diff --git a/src/i18n/en/app.json b/src/i18n/en/app.json index 64b6c26..677a7df 100644 --- a/src/i18n/en/app.json +++ b/src/i18n/en/app.json @@ -42,7 +42,8 @@ }, "CUSTOMER": { "NOT_FOUND": "The customer was not found.", - "ALREADY_EXISTS": "The customer already exists." + "ALREADY_EXISTS": "The customer already exists.", + "KYC_NOT_APPROVED": "The customer's KYC has not been approved yet." }, "GIFT": {