feat/working on update card control

This commit is contained in:
Abdalhamid Alhamad
2025-07-14 11:57:51 +03:00
parent 038b8ef6e3
commit 5a780eeb17
15 changed files with 263 additions and 28 deletions

View File

@ -34,4 +34,10 @@ export class CardRepository {
getCardByReferenceNumber(referenceNumber: string): Promise<Card | null> { getCardByReferenceNumber(referenceNumber: string): Promise<Card | null> {
return this.cardRepository.findOne({ where: { cardReference: referenceNumber }, relations: ['account'] }); return this.cardRepository.findOne({ where: { cardReference: referenceNumber }, relations: ['account'] });
} }
getActiveCardForCustomer(customerId: string): Promise<Card | null> {
return this.cardRepository.findOne({
where: { customerId, status: CardStatus.ACTIVE },
});
}
} }

View File

@ -34,4 +34,12 @@ export class CardService {
return card; return card;
} }
async getActiveCardForCustomer(customerId: string): Promise<Card> {
const card = await this.cardRepository.getActiveCardForCustomer(customerId);
if (!card) {
throw new BadRequestException('CARD.NOT_FOUND');
}
return card;
}
} }

View File

@ -28,11 +28,7 @@ export class TransactionService {
} }
const transaction = await this.transactionRepository.createCardTransaction(card, body); const transaction = await this.transactionRepository.createCardTransaction(card, body);
const total = new Decimal(body.transactionAmount) const total = new Decimal(body.transactionAmount).plus(body.billingAmount).plus(body.fees).plus(body.vatOnFees);
.plus(body.billingAmount)
.plus(body.settlementAmount)
.plus(body.fees)
.plus(body.vatOnFees);
await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()); await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber());

View File

@ -1,16 +1,22 @@
import { Controller, Get } from '@nestjs/common'; import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; 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 { CardService } from '~/card/services';
import { AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards';
import { ApiDataResponse } from '~/core/decorators'; import { ApiDataResponse } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils'; import { ResponseFactory } from '~/core/utils';
import { Customer } from '~/customer/entities'; import { CustomerResponseDto } from '~/customer/dtos/response';
import { CustomerService } from '~/customer/services'; import { CustomerService } from '~/customer/services';
import { UpdateCardControlsRequestDto } from '../dtos/requests';
import { CreateApplicationResponse, InquireApplicationResponse } from '../dtos/response'; import { CreateApplicationResponse, InquireApplicationResponse } from '../dtos/response';
import { NeoLeapService } from '../services/neoleap.service'; import { NeoLeapService } from '../services/neoleap.service';
@Controller('neotest') @Controller('neotest')
@ApiTags('Neoleap Test API , for testing purposes only, will be removed in production') @ApiTags('Neoleap Test API , for testing purposes only, will be removed in production')
@UseGuards(AccessTokenGuard)
@ApiBearerAuth()
export class NeoTestController { export class NeoTestController {
constructor( constructor(
private readonly neoleapService: NeoLeapService, private readonly neoleapService: NeoLeapService,
@ -19,21 +25,38 @@ export class NeoTestController {
private readonly configService: ConfigService, 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) @ApiDataResponse(InquireApplicationResponse)
async inquireApplication() { async inquireApplication(@AuthenticatedUser() user: IJwtPayload) {
const data = await this.neoleapService.inquireApplication('15'); const customer = await this.customerService.findCustomerById(user.sub);
const data = await this.neoleapService.inquireApplication(customer.waitingNumber.toString());
return ResponseFactory.data(data); return ResponseFactory.data(data);
} }
@Get('create-application') @Post('create-application')
@ApiDataResponse(CreateApplicationResponse) @ApiDataResponse(CreateApplicationResponse)
async createApplication() { async createApplication(@AuthenticatedUser() user: IJwtPayload) {
const customer = await this.customerService.findCustomerById( const customer = await this.customerService.findCustomerById(user.sub);
this.configService.get<string>('MOCK_CUSTOMER_ID', 'ff462af1-1e0c-4216-8865-738e5b525ac1'), const data = await this.neoleapService.createApplication(customer);
);
const data = await this.neoleapService.createApplication(customer as Customer);
await this.cardService.createCard(customer.id, data); await this.cardService.createCard(customer.id, data);
return ResponseFactory.data(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' });
}
} }

View File

@ -1,2 +1,3 @@
export * from './account-transaction-webhook.request.dto'; export * from './account-transaction-webhook.request.dto';
export * from './card-transaction-webhook.request.dto'; export * from './card-transaction-webhook.request.dto';
export * from './update-card-controls.request.dto';

View File

@ -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;
}

View File

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

View File

@ -0,0 +1 @@
export class UpdateCardControlsResponseDto {}

View File

@ -1,3 +1,4 @@
export * from './create-application.request.interface'; export * from './create-application.request.interface';
export * from './inquire-application.request.interface'; export * from './inquire-application.request.interface';
export * from './neoleap-header.request.interface'; export * from './neoleap-header.request.interface';
export * from './update-card-control.request.interface';

View File

@ -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;
};
};
}

View File

@ -6,10 +6,15 @@ import moment from 'moment';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { CountriesNumericISO } from '~/common/constants'; import { CountriesNumericISO } from '~/common/constants';
import { Customer } from '~/customer/entities'; 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 { CREATE_APPLICATION_MOCK, INQUIRE_APPLICATION_MOCK } from '../__mocks__/';
import { CreateApplicationResponse, InquireApplicationResponse } from '../dtos/response'; import { CreateApplicationResponse, InquireApplicationResponse, UpdateCardControlsResponseDto } from '../dtos/response';
import { ICreateApplicationRequest, IInquireApplicationRequest, INeoleapHeaderRequest } from '../interfaces'; import {
ICreateApplicationRequest,
IInquireApplicationRequest,
INeoleapHeaderRequest,
IUpdateCardControlRequest,
} from '../interfaces';
@Injectable() @Injectable()
export class NeoLeapService { export class NeoLeapService {
private readonly baseUrl: string; private readonly baseUrl: string;
@ -26,9 +31,15 @@ export class NeoLeapService {
async createApplication(customer: Customer) { async createApplication(customer: Customer) {
const responseKey = 'CreateNewApplicationResponseDetails'; 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) { if (!this.useGateway) {
return plainToInstance(CreateApplicationResponse, CREATE_APPLICATION_MOCK[responseKey], { return plainToInstance(CreateApplicationResponse, CREATE_APPLICATION_MOCK[responseKey], {
excludeExtraneousValues: true, 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<IUpdateCardControlRequest, UpdateCardControlsResponseDto>(
'cardcontrol/UpdateCardControl',
payload,
responseKey,
UpdateCardControlsResponseDto,
);
}
private prepareHeaders(serviceName: string): INeoleapHeaderRequest['RequestHeader'] { private prepareHeaders(serviceName: string): INeoleapHeaderRequest['RequestHeader'] {
return { return {
Version: '1.0.0', 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( throw new BadRequestException(
data.data.ResponseHeader.ResponseDescription || 'Unexpected Error from Service Provider', data.data.ResponseHeader.ResponseDescription || 'Unexpected Error from Service Provider',
); );

View File

@ -58,6 +58,24 @@ export class CustomerResponseDto {
@ApiProperty() @ApiProperty()
waitingNumber!: number; 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 }) @ApiPropertyOptional({ type: DocumentMetaResponseDto })
profilePicture!: DocumentMetaResponseDto | null; profilePicture!: DocumentMetaResponseDto | null;
@ -80,7 +98,12 @@ export class CustomerResponseDto {
this.isJunior = customer.isJunior; this.isJunior = customer.isJunior;
this.isGuardian = customer.isGuardian; this.isGuardian = customer.isGuardian;
this.waitingNumber = customer.waitingNumber; 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; this.profilePicture = customer.profilePicture ? new DocumentMetaResponseDto(customer.profilePicture) : null;
} }
} }

View File

@ -1,8 +1,11 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import moment from 'moment';
import { Transactional } from 'typeorm-transactional'; import { Transactional } from 'typeorm-transactional';
import { CountryIso } from '~/common/enums';
import { DocumentService, OciService } from '~/document/services'; import { DocumentService, OciService } from '~/document/services';
import { GuardianService } from '~/guardian/services'; import { GuardianService } from '~/guardian/services';
import { CreateJuniorRequestDto } from '~/junior/dtos/request'; import { CreateJuniorRequestDto } from '~/junior/dtos/request';
import { User } from '~/user/entities';
import { import {
CreateCustomerRequestDto, CreateCustomerRequestDto,
CustomerFiltersRequestDto, CustomerFiltersRequestDto,
@ -10,7 +13,7 @@ import {
UpdateCustomerRequestDto, UpdateCustomerRequestDto,
} from '../dtos/request'; } from '../dtos/request';
import { Customer } from '../entities'; import { Customer } from '../entities';
import { KycStatus } from '../enums'; import { Gender, KycStatus } from '../enums';
import { CustomerRepository } from '../repositories/customer.repository'; import { CustomerRepository } from '../repositories/customer.repository';
@Injectable() @Injectable()
@ -125,6 +128,33 @@ export class CustomerService {
this.logger.log(`KYC rejected for customer ${customerId}`); 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) { private async validateProfilePictureForCustomer(userId: string, profilePictureId?: string) {
if (!profilePictureId) return; if (!profilePictureId) return;
@ -202,4 +232,15 @@ export class CustomerService {
async findAnyCustomer() { async findAnyCustomer() {
return this.customerRepository.findOne({ isGuardian: true }); 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}`;
}
} }

View File

@ -43,7 +43,8 @@
}, },
"CUSTOMER": { "CUSTOMER": {
"NOT_FOUND": "لم يتم العثور على العميل.", "NOT_FOUND": "لم يتم العثور على العميل.",
"ALREADY_EXISTS": "العميل موجود بالفعل." "ALREADY_EXISTS": "العميل موجود بالفعل.",
"KYC_NOT_APPROVED": "لم يتم الموافقة على هوية العميل بعد."
}, },
"GIFT": { "GIFT": {

View File

@ -42,7 +42,8 @@
}, },
"CUSTOMER": { "CUSTOMER": {
"NOT_FOUND": "The customer was not found.", "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": { "GIFT": {