From 3df34c00178991696d8766994d435a01a17cd332 Mon Sep 17 00:00:00 2001 From: Abdalhameed Ahmad Date: Tue, 26 Aug 2025 12:19:47 +0300 Subject: [PATCH 1/9] fix: fix duplicate iban --- src/common/modules/neoleap/__mocks__/create-application.mock.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/modules/neoleap/__mocks__/create-application.mock.ts b/src/common/modules/neoleap/__mocks__/create-application.mock.ts index 8a13120..3853390 100644 --- a/src/common/modules/neoleap/__mocks__/create-application.mock.ts +++ b/src/common/modules/neoleap/__mocks__/create-application.mock.ts @@ -705,7 +705,7 @@ export const CREATE_APPLICATION_MOCK = { UserData2: null, UserData3: 'D36407C9AE4C28D2185', UserData4: null, - UserData5: 'SA2380900000752991120011', + UserData5: `SA${getRandomWithDigits(22)}`, }, ], CardDetailsList: [ From 88730a2b2bb3e02a2b5ee3032d08e6b7c0d5be9e Mon Sep 17 00:00:00 2001 From: Abdalhameed Ahmad Date: Tue, 26 Aug 2025 19:17:00 +0300 Subject: [PATCH 2/9] fix: fix create application mock --- .../__mocks__/create-application.mock.ts | 934 ++++-------------- .../neoleap/services/neoleap.service.ts | 6 +- 2 files changed, 190 insertions(+), 750 deletions(-) diff --git a/src/common/modules/neoleap/__mocks__/create-application.mock.ts b/src/common/modules/neoleap/__mocks__/create-application.mock.ts index 3853390..96465c0 100644 --- a/src/common/modules/neoleap/__mocks__/create-application.mock.ts +++ b/src/common/modules/neoleap/__mocks__/create-application.mock.ts @@ -1,758 +1,198 @@ -function getRandomWithDigits(digits: number): string { - const min = Math.pow(10, digits - 1); // e.g. 1000 for 4 digits - const max = Math.pow(10, digits) - 1; // e.g. 9999 for 4 digits +import { randomInt, randomUUID } from 'crypto'; - const result = Math.floor(Math.random() * (max - min + 1)) + min; - return result.toString(); +/** Generate a string of `n` random digits (first digit never 0). */ +function randomDigits(n: number): string { + if (n <= 0) return '0'; + let s = String(randomInt(1, 10)); // first digit 1–9 + for (let i = 1; i < n; i++) s += String(randomInt(0, 10)); + return s; } -export const CREATE_APPLICATION_MOCK = { - ResponseHeader: { - Version: '1.0.0', - MsgUid: 'adaa1893-9f95-48a8-b7a1-0422bcf629b5', - Source: 'ZOD', - ServiceId: 'CreateNewApplication', - ReqDateTime: '2025-06-03T07:32:16.304Z', - RspDateTime: '2025-06-03T08:21:15.662', - ResponseCode: '000', - ResponseType: 'Success', - ProcessingTime: 1665, - EncryptionKey: null, - ResponseDescription: 'Operation Successful', - LocalizedResponseDescription: null, - CustomerSpecificResponseDescriptionList: null, - HeaderUserDataList: null, - }, +/** Build a fresh mock object every time it's called */ +export function buildCreateApplicationMock() { + const now = new Date().toISOString(); - CreateNewApplicationResponseDetails: { - InstitutionCode: '1100', - ApplicationTypeDetails: { - TypeCode: '01', - Description: 'Normal Primary', - Additional: false, - Corporate: false, - UserData: null, + return { + ResponseHeader: { + Version: '1.0.0', + MsgUid: randomUUID(), + Source: 'ZOD', + ServiceId: 'CreateNewApplication', + ReqDateTime: now, + RspDateTime: now, + ResponseCode: '000', + ResponseType: 'Success', + ProcessingTime: 1665, + EncryptionKey: null, + ResponseDescription: 'Operation Successful', + LocalizedResponseDescription: null, + CustomerSpecificResponseDescriptionList: null, + HeaderUserDataList: null, }, - ApplicationDetails: { - cif: null, - ApplicationNumber: '3300000000073', - ExternalApplicationNumber: '3', - ApplicationStatus: '04', - Organization: 0, - Product: '1101', - ApplicatonDate: '2025-05-29', - ApplicationSource: 'O', - SalesSource: null, - DeliveryMethod: 'V', - ProgramCode: null, - Campaign: null, - Plastic: null, - Design: null, - ProcessStage: '99', - ProcessStageStatus: 'S', - Score: null, - ExternalScore: null, - RequestedLimit: 0, - SuggestedLimit: null, - AssignedLimit: 0, - AllowedLimitList: null, - EligibilityCheckResult: '00', - EligibilityCheckDescription: null, - Title: 'Mr.', - FirstName: 'Abdalhamid', - SecondName: null, - ThirdName: null, - LastName: ' Ahmad', - FullName: 'Abdalhamid Ahmad', - EmbossName: 'ABDALHAMID AHMAD', - PlaceOfBirth: null, - DateOfBirth: '1999-01-07', - LocalizedDateOfBirth: '1999-01-07', - Age: 26, - Gender: 'M', - Married: 'U', - Nationality: '682', - IdType: '01', - IdNumber: '1089055972', - IdExpiryDate: '2031-09-17', - EducationLevel: null, - ProfessionCode: 0, - NumberOfDependents: 0, - EmployerName: 'N/A', - EmploymentYears: 0, - EmploymentMonths: 0, - EmployerPhoneArea: null, - EmployerPhoneNumber: null, - EmployerPhoneExtension: null, - EmployerMobile: null, - EmployerFaxArea: null, - EmployerFax: null, - EmployerCity: null, - EmployerAddress: null, - EmploymentActivity: null, - EmploymentStatus: null, - CIF: null, - BankAccountNumber: ' ', - Currency: { - CurrCode: '682', - AlphaCode: 'SAR', + + CreateNewApplicationResponseDetails: { + InstitutionCode: '1100', + ApplicationTypeDetails: { + TypeCode: '01', + Description: 'Normal Primary', + Additional: false, + Corporate: false, + UserData: null, }, - RequestedCurrencyList: null, - CreditAccountNumber: '6000000000000000', - AccountType: '30', - OpenDate: null, - Income: 0, - AdditionalIncome: 0, - TotalIncome: 0, - CurrentBalance: 0, - AverageBalance: 0, - AssetsBalance: 0, - InsuranceBalance: 0, - DepositAmount: 0, - GuarenteeAccountNumber: null, - GuarenteeAmount: 0, - InstalmentAmount: 0, - AutoDebit: 'N', - PaymentMethod: '2', - BillingCycle: 'C1', - OldIssueDate: null, - OtherPaymentsDate: null, - MaximumDelinquency: null, - CreditBureauDecision: null, - CreditBureauUserData: null, - ECommerce: 'N', - NumberOfCards: 0, - OtherBank: null, - OtherBankDescription: null, - InsuranceProduct: null, - SocialCode: '000', - JobGrade: 0, - Flags: [ - { - Position: 1, - Value: '0', - }, - { - Position: 2, - Value: '0', - }, - { - Position: 3, - Value: '0', - }, - { - Position: 4, - Value: '0', - }, - { - Position: 5, - Value: '0', - }, - { - Position: 6, - Value: '0', - }, - { - Position: 7, - Value: '0', - }, - { - Position: 8, - Value: '0', - }, - { - Position: 9, - Value: '0', - }, - { - Position: 10, - Value: '0', - }, - { - Position: 11, - Value: '0', - }, - { - Position: 12, - Value: '0', - }, - { - Position: 13, - Value: '0', - }, - { - Position: 14, - Value: '0', - }, - { - Position: 15, - Value: '0', - }, - { - Position: 16, - Value: '0', - }, - { - Position: 17, - Value: '0', - }, - { - Position: 18, - Value: '0', - }, - { - Position: 19, - Value: '0', - }, - { - Position: 20, - Value: '0', - }, - { - Position: 21, - Value: '0', - }, - { - Position: 22, - Value: '0', - }, - { - Position: 23, - Value: '0', - }, - { - Position: 24, - Value: '0', - }, - { - Position: 25, - Value: '0', - }, - { - Position: 26, - Value: '0', - }, - { - Position: 27, - Value: '0', - }, - { - Position: 28, - Value: '0', - }, - { - Position: 29, - Value: '0', - }, - { - Position: 30, - Value: '0', - }, - { - Position: 31, - Value: '0', - }, - { - Position: 32, - Value: '0', - }, - { - Position: 33, - Value: '0', - }, - { - Position: 34, - Value: '0', - }, - { - Position: 35, - Value: '0', - }, - { - Position: 36, - Value: '0', - }, - { - Position: 37, - Value: '0', - }, - { - Position: 38, - Value: '0', - }, - { - Position: 39, - Value: '0', - }, - { - Position: 40, - Value: '0', - }, - { - Position: 41, - Value: '0', - }, - { - Position: 42, - Value: '0', - }, - { - Position: 43, - Value: '0', - }, - { - Position: 44, - Value: '0', - }, - { - Position: 45, - Value: '0', - }, - { - Position: 46, - Value: '0', - }, - { - Position: 47, - Value: '0', - }, - { - Position: 48, - Value: '0', - }, - { - Position: 49, - Value: '0', - }, - { - Position: 50, - Value: '0', - }, - { - Position: 51, - Value: '0', - }, - { - Position: 52, - Value: '0', - }, - { - Position: 53, - Value: '0', - }, - { - Position: 54, - Value: '0', - }, - { - Position: 55, - Value: '0', - }, - { - Position: 56, - Value: '0', - }, - { - Position: 57, - Value: '0', - }, - { - Position: 58, - Value: '0', - }, - { - Position: 59, - Value: '0', - }, - { - Position: 60, - Value: '0', - }, - { - Position: 61, - Value: '0', - }, - { - Position: 62, - Value: '0', - }, - { - Position: 63, - Value: '0', - }, - { - Position: 64, - Value: '0', - }, - ], - CheckFlags: [ - { - Position: 1, - Value: '0', - }, - { - Position: 2, - Value: '0', - }, - { - Position: 3, - Value: '0', - }, - { - Position: 4, - Value: '0', - }, - { - Position: 5, - Value: '0', - }, - { - Position: 6, - Value: '0', - }, - { - Position: 7, - Value: '0', - }, - { - Position: 8, - Value: '0', - }, - { - Position: 9, - Value: '0', - }, - { - Position: 10, - Value: '0', - }, - { - Position: 11, - Value: '0', - }, - { - Position: 12, - Value: '0', - }, - { - Position: 13, - Value: '0', - }, - { - Position: 14, - Value: '0', - }, - { - Position: 15, - Value: '0', - }, - { - Position: 16, - Value: '0', - }, - { - Position: 17, - Value: '0', - }, - { - Position: 18, - Value: '0', - }, - { - Position: 19, - Value: '0', - }, - { - Position: 20, - Value: '0', - }, - { - Position: 21, - Value: '0', - }, - { - Position: 22, - Value: '0', - }, - { - Position: 23, - Value: '0', - }, - { - Position: 24, - Value: '0', - }, - { - Position: 25, - Value: '0', - }, - { - Position: 26, - Value: '0', - }, - { - Position: 27, - Value: '0', - }, - { - Position: 28, - Value: '0', - }, - { - Position: 29, - Value: '0', - }, - { - Position: 30, - Value: '0', - }, - { - Position: 31, - Value: '0', - }, - { - Position: 32, - Value: '0', - }, - { - Position: 33, - Value: '0', - }, - { - Position: 34, - Value: '0', - }, - { - Position: 35, - Value: '0', - }, - { - Position: 36, - Value: '0', - }, - { - Position: 37, - Value: '0', - }, - { - Position: 38, - Value: '0', - }, - { - Position: 39, - Value: '0', - }, - { - Position: 40, - Value: '0', - }, - { - Position: 41, - Value: '0', - }, - { - Position: 42, - Value: '0', - }, - { - Position: 43, - Value: '0', - }, - { - Position: 44, - Value: '0', - }, - { - Position: 45, - Value: '0', - }, - { - Position: 46, - Value: '0', - }, - { - Position: 47, - Value: '0', - }, - { - Position: 48, - Value: '0', - }, - { - Position: 49, - Value: '0', - }, - { - Position: 50, - Value: '0', - }, - { - Position: 51, - Value: '0', - }, - { - Position: 52, - Value: '0', - }, - { - Position: 53, - Value: '0', - }, - { - Position: 54, - Value: '0', - }, - { - Position: 55, - Value: '0', - }, - { - Position: 56, - Value: '0', - }, - { - Position: 57, - Value: '0', - }, - { - Position: 58, - Value: '0', - }, - { - Position: 59, - Value: '0', - }, - { - Position: 60, - Value: '0', - }, - { - Position: 61, - Value: '0', - }, - { - Position: 62, - Value: '0', - }, - { - Position: 63, - Value: '0', - }, - { - Position: 64, - Value: '0', - }, - ], - Maker: null, - Checker: null, - ReferredTo: null, - ReferralReason: null, - UserData1: null, - UserData2: null, - UserData3: null, - UserData4: null, - UserData5: null, - AdditionalFields: [], - }, - ApplicationStatusDetails: { - StatusCode: '04', - Description: 'Approved', - Canceled: false, - }, - CorporateDetails: null, - CustomerDetails: { - Id: 115158, - CustomerCode: '100000024619', - IdNumber: ' ', - TypeId: 0, - PreferredLanguage: 'EN', - ExternalCustomerCode: null, - Title: ' ', - FirstName: ' ', - LastName: ' ', - DateOfBirth: null, - UserData1: '2031-09-17', - UserData2: '01', - UserData3: null, - UserData4: '682', - CustomerSegment: null, - Gender: 'U', - Married: 'U', - }, - AccountDetailsList: [ - { - Id: getRandomWithDigits(5), - InstitutionCode: '1100', - AccountNumber: getRandomWithDigits(16), - Currency: { - CurrCode: '682', - AlphaCode: 'SAR', - }, - AccountTypeCode: '30', - ClassId: '2', - AccountStatus: '00', - VipFlag: '0', - BlockedAmount: 0, - EquivalentBlockedAmount: null, - UnclearCredit: 0, - EquivalentUnclearCredit: null, - AvailableBalance: 0, - EquivalentAvailableBalance: null, - AvailableBalanceToSpend: 0, - CreditLimit: 0, - RemainingCashLimit: null, - UserData1: 'D36407C9AE4C28D2185', - UserData2: null, - UserData3: 'D36407C9AE4C28D2185', - UserData4: null, - UserData5: `SA${getRandomWithDigits(22)}`, - }, - ], - CardDetailsList: [ - { - pvv: null, - ResponseCardIdentifier: { - Id: getRandomWithDigits(5), - Pan: 'DDDDDDDDDDDDDDDDDDD', - MaskedPan: '999999_9999', - VPan: getRandomWithDigits(16), - Seqno: 0, - }, - ExpiryDate: '2031-09-30', - EffectiveDate: '2025-06-02', - CardStatus: '30', - OldPlasticExpiryDate: null, - OldPlasticCardStatus: null, - EmbossingName: 'ABDALHAMID AHMAD', + ApplicationDetails: { + cif: null, + ApplicationNumber: '3300000000073', + ExternalApplicationNumber: '3', + ApplicationStatus: '04', + Organization: 0, + Product: '1101', + ApplicatonDate: '2025-05-29', + ApplicationSource: 'O', + SalesSource: null, + DeliveryMethod: 'V', + ProgramCode: null, + Campaign: null, + Plastic: null, + Design: null, + ProcessStage: '99', + ProcessStageStatus: 'S', + Score: null, + ExternalScore: null, + RequestedLimit: 0, + SuggestedLimit: null, + AssignedLimit: 0, + AllowedLimitList: null, + EligibilityCheckResult: '00', + EligibilityCheckDescription: null, Title: 'Mr.', FirstName: 'Abdalhamid', + SecondName: null, + ThirdName: null, LastName: ' Ahmad', - Additional: false, - BatchNumber: 8849, - ServiceCode: '226', - Kinship: null, + FullName: 'Abdalhamid Ahmad', + EmbossName: 'ABDALHAMID AHMAD', + PlaceOfBirth: null, DateOfBirth: '1999-01-07', - LastActivity: null, - LastStatusChangeDate: '2025-06-03', - ActivationDate: null, - DateLastIssued: null, - PVV: null, - UserData: '4', - UserData1: '3', - UserData2: null, - UserData3: null, - UserData4: null, - UserData5: null, - Memo: null, - CardAuthorizationParameters: null, - L10NTitle: null, - L10NFirstName: null, - L10NLastName: null, - PinStatus: '40', - OldPinStatus: '0', - CustomerIdNumber: '1089055972', - Language: 0, + LocalizedDateOfBirth: '1999-01-07', + Age: 26, + Gender: 'M', + Married: 'U', + Nationality: '682', + IdType: '01', + IdNumber: '1089055972', + IdExpiryDate: '2031-09-17', + EducationLevel: null, + ProfessionCode: 0, + NumberOfDependents: 0, + EmployerName: 'N/A', + EmploymentYears: 0, + EmploymentMonths: 0, + CIF: null, + BankAccountNumber: ' ', + Currency: { CurrCode: '682', AlphaCode: 'SAR' }, + CreditAccountNumber: '6000000000000000', + AccountType: '30', + Income: 0, + AdditionalIncome: 0, + TotalIncome: 0, + CurrentBalance: 0, + AverageBalance: 0, + AssetsBalance: 0, + InsuranceBalance: 0, + DepositAmount: 0, + GuarenteeAccountNumber: null, + GuarenteeAmount: 0, + InstalmentAmount: 0, + AutoDebit: 'N', + PaymentMethod: '2', + BillingCycle: 'C1', + MaximumDelinquency: null, + CreditBureauDecision: null, + ECommerce: 'N', + NumberOfCards: 0, + SocialCode: '000', + JobGrade: 0, + Flags: Array.from({ length: 64 }, (_, i) => ({ + Position: i + 1, + Value: '0', + })), + CheckFlags: Array.from({ length: 64 }, (_, i) => ({ + Position: i + 1, + Value: '0', + })), }, - ], - }, -}; + + ApplicationStatusDetails: { + StatusCode: '04', + Description: 'Approved', + Canceled: false, + }, + + CustomerDetails: { + Id: 115158, + CustomerCode: '100000024619', + IdNumber: ' ', + TypeId: 0, + PreferredLanguage: 'EN', + ExternalCustomerCode: null, + Title: ' ', + FirstName: ' ', + LastName: ' ', + DateOfBirth: null, + UserData1: '2031-09-17', + UserData2: '01', + UserData3: null, + UserData4: '682', + CustomerSegment: null, + Gender: 'U', + Married: 'U', + }, + + AccountDetailsList: [ + { + Id: randomDigits(5), + InstitutionCode: '1100', + AccountNumber: randomDigits(16), + Currency: { CurrCode: '682', AlphaCode: 'SAR' }, + AccountTypeCode: '30', + ClassId: '2', + AccountStatus: '00', + VipFlag: '0', + BlockedAmount: 0, + AvailableBalance: 0, + UserData1: 'D36407C9AE4C28D2185', + UserData3: 'D36407C9AE4C28D2185', + UserData5: `SA${randomDigits(22)}`, + }, + ], + + CardDetailsList: [ + { + pvv: null, + ResponseCardIdentifier: { + Id: randomDigits(5), + Pan: 'DDDDDDDDDDDDDDDDDDD', + MaskedPan: '999999_9999', + VPan: randomDigits(16), + Seqno: 0, + }, + ExpiryDate: '2031-09-30', + EffectiveDate: '2025-06-02', + CardStatus: '30', + EmbossingName: 'ABDALHAMID AHMAD', + Title: 'Mr.', + FirstName: 'Abdalhamid', + LastName: ' Ahmad', + BatchNumber: 8849, + ServiceCode: '226', + DateOfBirth: '1999-01-07', + LastStatusChangeDate: '2025-06-03', + PinStatus: '40', + OldPinStatus: '0', + CustomerIdNumber: '1089055972', + Language: 0, + }, + ], + }, + }; +} diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index cb5860f..b6ce0eb 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -10,8 +10,8 @@ import { InitiateKycRequestDto } from '~/customer/dtos/request'; import { Customer } from '~/customer/entities'; import { Gender } from '~/customer/enums'; import { + buildCreateApplicationMock, CARD_EMBOSSING_DETAILS_MOCK, - CREATE_APPLICATION_MOCK, INITIATE_KYC_MOCK, INQUIRE_APPLICATION_MOCK, } from '../__mocks__/'; @@ -95,7 +95,7 @@ export class NeoLeapService { const responseKey = 'CreateNewApplicationResponseDetails'; if (!this.useGateway) { - return plainToInstance(CreateApplicationResponse, CREATE_APPLICATION_MOCK[responseKey], { + return plainToInstance(CreateApplicationResponse, buildCreateApplicationMock()[responseKey], { excludeExtraneousValues: true, }); } @@ -169,7 +169,7 @@ export class NeoLeapService { const responseKey = 'CreateNewApplicationResponseDetails'; if (!this.useGateway) { - return plainToInstance(CreateApplicationResponse, CREATE_APPLICATION_MOCK[responseKey], { + return plainToInstance(CreateApplicationResponse, buildCreateApplicationMock()[responseKey], { excludeExtraneousValues: true, }); } From edddc2f45772923ec043292dc46171b19270034d Mon Sep 17 00:00:00 2001 From: Abdalhameed Ahmad Date: Sun, 7 Sep 2025 09:12:14 +0300 Subject: [PATCH 3/9] feat: update junior --- src/junior/controllers/junior.controller.ts | 18 ++++++++- src/junior/dtos/request/index.ts | 1 + .../dtos/request/update-junior.request.dto.ts | 6 +++ src/junior/services/junior.service.ts | 38 ++++++++++++++++++- 4 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 src/junior/dtos/request/update-junior.request.dto.ts diff --git a/src/junior/controllers/junior.controller.ts b/src/junior/controllers/junior.controller.ts index 07fb95b..ebd9ff0 100644 --- a/src/junior/controllers/junior.controller.ts +++ b/src/junior/controllers/junior.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Roles } from '~/auth/enums'; import { IJwtPayload } from '~/auth/interfaces'; @@ -8,7 +8,7 @@ import { ApiDataPageResponse, ApiDataResponse, ApiLangRequestHeader } from '~/co import { PageOptionsRequestDto } from '~/core/dtos'; import { CustomParseUUIDPipe } from '~/core/pipes'; import { ResponseFactory } from '~/core/utils'; -import { CreateJuniorRequestDto, SetThemeRequestDto } from '../dtos/request'; +import { CreateJuniorRequestDto, SetThemeRequestDto, UpdateJuniorRequestDto } from '../dtos/request'; import { JuniorResponseDto, QrCodeValidationResponseDto, ThemeResponseDto } from '../dtos/response'; import { JuniorService } from '../services'; @@ -59,6 +59,20 @@ export class JuniorController { return ResponseFactory.data(new JuniorResponseDto(junior)); } + @Patch(':juniorId') + @UseGuards(RolesGuard) + @AllowedRoles(Roles.GUARDIAN) + @ApiDataResponse(JuniorResponseDto) + async updateJunior( + @AuthenticatedUser() user: IJwtPayload, + @Param('juniorId', CustomParseUUIDPipe) juniorId: string, + @Body() body: UpdateJuniorRequestDto, + ) { + const junior = await this.juniorService.updateJunior(juniorId, body, user.sub); + + return ResponseFactory.data(new JuniorResponseDto(junior)); + } + @Post('set-theme') @UseGuards(RolesGuard) @AllowedRoles(Roles.JUNIOR) diff --git a/src/junior/dtos/request/index.ts b/src/junior/dtos/request/index.ts index da79731..08e7612 100644 --- a/src/junior/dtos/request/index.ts +++ b/src/junior/dtos/request/index.ts @@ -1,2 +1,3 @@ export * from './create-junior.request.dto'; export * from './set-theme.request.dto'; +export * from './update-junior.request.dto'; diff --git a/src/junior/dtos/request/update-junior.request.dto.ts b/src/junior/dtos/request/update-junior.request.dto.ts new file mode 100644 index 0000000..c9054b3 --- /dev/null +++ b/src/junior/dtos/request/update-junior.request.dto.ts @@ -0,0 +1,6 @@ +import { OmitType, PartialType } from '@nestjs/mapped-types'; +import { CreateJuniorRequestDto } from './create-junior.request.dto'; + +export class UpdateJuniorRequestDto extends PartialType( + OmitType(CreateJuniorRequestDto, ['cardColor', 'cardPin'] as const), +) {} diff --git a/src/junior/services/junior.service.ts b/src/junior/services/junior.service.ts index 50586c0..24432fd 100644 --- a/src/junior/services/junior.service.ts +++ b/src/junior/services/junior.service.ts @@ -9,7 +9,7 @@ import { DocumentService, OciService } from '~/document/services'; import { UserType } from '~/user/enums'; import { UserService } from '~/user/services'; import { UserTokenService } from '~/user/services/user-token.service'; -import { CreateJuniorRequestDto, SetThemeRequestDto } from '../dtos/request'; +import { CreateJuniorRequestDto, SetThemeRequestDto, UpdateJuniorRequestDto } from '../dtos/request'; import { Junior } from '../entities'; import { JuniorRepository } from '../repositories'; import { QrcodeService } from './qrcode.service'; @@ -85,6 +85,42 @@ export class JuniorService { return junior; } + async updateJunior(juniorId: string, body: UpdateJuniorRequestDto, guardianId: string) { + this.logger.log(`Updating junior ${juniorId}`); + const junior = await this.findJuniorById(juniorId, false, guardianId); + if (body.profilePictureId) { + junior.customer.user.profilePictureId = body.profilePictureId; + } + if (body.firstName) { + junior.customer.user.firstName = body.firstName; + junior.customer.firstName = body.firstName; + } + if (body.lastName) { + junior.customer.user.lastName = body.lastName; + junior.customer.lastName = body.lastName; + } + if (body.email) { + const existingUser = await this.userService.findUser({ email: body.email }); + if (existingUser && existingUser.id !== junior.customer.user.id) { + this.logger.error(`User with email ${body.email} already exists`); + throw new BadRequestException('USER.ALREADY_EXISTS'); + } + junior.customer.user.email = body.email; + } + + if (body.dateOfBirth) { + junior.customer.dateOfBirth = body.dateOfBirth; + } + if (body.relationship) { + junior.relationship = body.relationship; + } + console.log('++++++'); + + await Promise.all([junior.save(), junior.customer.user.save(), junior.customer.save()]); + this.logger.log(`Junior ${juniorId} updated successfully`); + return junior; + } + @Transactional() async setTheme(body: SetThemeRequestDto, juniorId: string) { this.logger.log(`Setting theme for junior ${juniorId}`); From 44b5937f7a465e0411143ac2a97ea9e74cc62d38 Mon Sep 17 00:00:00 2001 From: Abdalhameed Ahmad Date: Sun, 7 Sep 2025 18:13:18 +0300 Subject: [PATCH 4/9] feat: finalize update junior --- src/core/utils/index.ts | 5 +-- src/core/utils/patch.util.ts | 3 ++ src/customer/entities/customer.entity.ts | 4 +-- src/i18n/ar/app.json | 3 +- src/i18n/en/app.json | 3 +- .../dtos/request/update-junior.request.dto.ts | 7 ++-- .../dtos/response/junior.response.dto.ts | 15 +++++++- src/junior/services/junior.service.ts | 34 +++++++++---------- 8 files changed, 45 insertions(+), 29 deletions(-) create mode 100644 src/core/utils/patch.util.ts diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 6d80059..67d3af4 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -1,3 +1,4 @@ -export * from './response.factory.util'; -export * from './i18n-context-wrapper.util'; export * from './class-validator-formatter.util'; +export * from './i18n-context-wrapper.util'; +export * from './patch.util'; +export * from './response.factory.util'; diff --git a/src/core/utils/patch.util.ts b/src/core/utils/patch.util.ts new file mode 100644 index 0000000..2ba313f --- /dev/null +++ b/src/core/utils/patch.util.ts @@ -0,0 +1,3 @@ +export const setIf = (obj: T, key: K, val: T[K] | undefined) => { + if (typeof val !== 'undefined') obj[key] = val as T[K]; +}; diff --git a/src/customer/entities/customer.entity.ts b/src/customer/entities/customer.entity.ts index c1b2875..e675a4a 100644 --- a/src/customer/entities/customer.entity.ts +++ b/src/customer/entities/customer.entity.ts @@ -15,7 +15,7 @@ import { CountryIso } from '~/common/enums'; import { Guardian } from '~/guardian/entities/guradian.entity'; import { Junior } from '~/junior/entities'; import { User } from '~/user/entities'; -import { CustomerStatus, KycStatus } from '../enums'; +import { CustomerStatus, Gender, KycStatus } from '../enums'; @Entity('customers') export class Customer extends BaseEntity { @@ -62,7 +62,7 @@ export class Customer extends BaseEntity { isPep!: boolean; @Column('varchar', { length: 255, nullable: true, name: 'gender' }) - gender!: string; + gender!: Gender; @Column('boolean', { default: false, name: 'is_junior' }) isJunior!: boolean; diff --git a/src/i18n/ar/app.json b/src/i18n/ar/app.json index 7c5bcb4..411141d 100644 --- a/src/i18n/ar/app.json +++ b/src/i18n/ar/app.json @@ -67,7 +67,8 @@ "NOT_FOUND": "لم يتم العثور على الطفل.", "CIVIL_ID_REQUIRED": "مطلوب بطاقة الهوية المدنية.", "CIVIL_ID_NOT_CREATED_BY_GUARDIAN": "تم تحميل بطاقة الهوية المدنية من قبل شخص آخر غير ولي الأمر.", - "CIVIL_ID_ALREADY_EXISTS": "بطاقة الهوية المدنية مستخدمة بالفعل من قبل طفل آخر." + "CIVIL_ID_ALREADY_EXISTS": "بطاقة الهوية المدنية مستخدمة بالفعل من قبل طفل آخر.", + "CANNOT_UPDATE_REGISTERED_USER": "الطفل قد سجل بالفعل. لا يُسمح بتحديث البيانات." }, "MONEY_REQUEST": { diff --git a/src/i18n/en/app.json b/src/i18n/en/app.json index f6e5278..b6c4ccb 100644 --- a/src/i18n/en/app.json +++ b/src/i18n/en/app.json @@ -66,7 +66,8 @@ "NOT_FOUND": "The junior was not found.", "CIVIL_ID_REQUIRED": "Civil ID is required.", "CIVIL_ID_NOT_CREATED_BY_GUARDIAN": "The civil ID document was not uploaded by the guardian.", - "CIVIL_ID_ALREADY_EXISTS": "The civil ID is already used by another junior." + "CIVIL_ID_ALREADY_EXISTS": "The civil ID is already used by another junior.", + "CANNOT_UPDATE_REGISTERED_USER": "The junior has already registered. Updating details is not allowed." }, "MONEY_REQUEST": { diff --git a/src/junior/dtos/request/update-junior.request.dto.ts b/src/junior/dtos/request/update-junior.request.dto.ts index c9054b3..be9457b 100644 --- a/src/junior/dtos/request/update-junior.request.dto.ts +++ b/src/junior/dtos/request/update-junior.request.dto.ts @@ -1,6 +1,5 @@ -import { OmitType, PartialType } from '@nestjs/mapped-types'; +import { OmitType, PartialType } from '@nestjs/swagger'; import { CreateJuniorRequestDto } from './create-junior.request.dto'; -export class UpdateJuniorRequestDto extends PartialType( - OmitType(CreateJuniorRequestDto, ['cardColor', 'cardPin'] as const), -) {} +const omitted = OmitType(CreateJuniorRequestDto, ['cardColor', 'cardPin']); +export class UpdateJuniorRequestDto extends PartialType(omitted) {} diff --git a/src/junior/dtos/response/junior.response.dto.ts b/src/junior/dtos/response/junior.response.dto.ts index 52ea9a1..5a0436c 100644 --- a/src/junior/dtos/response/junior.response.dto.ts +++ b/src/junior/dtos/response/junior.response.dto.ts @@ -1,7 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; +import { Gender } from '~/customer/enums'; import { DocumentMetaResponseDto } from '~/document/dtos/response'; import { Junior } from '~/junior/entities'; -import { Relationship } from '~/junior/enums'; +import { GuardianRelationship, Relationship } from '~/junior/enums'; export class JuniorResponseDto { @ApiProperty({ example: 'id' }) @@ -16,9 +17,18 @@ export class JuniorResponseDto { @ApiProperty({ example: 'test@junior.com' }) email!: string; + @ApiProperty({ example: Gender.MALE }) + gender!: Gender; + + @ApiProperty({ example: '2000-01-01' }) + dateOfBirth!: Date; + @ApiProperty({ enum: Relationship }) relationship!: Relationship; + @ApiProperty({ enum: GuardianRelationship }) + guardianRelationship!: GuardianRelationship; + @ApiProperty({ type: DocumentMetaResponseDto }) profilePicture!: DocumentMetaResponseDto | null; @@ -27,7 +37,10 @@ export class JuniorResponseDto { this.firstName = junior.customer.firstName; this.lastName = junior.customer.lastName; this.email = junior.customer.user.email; + this.gender = junior.customer.gender; + this.dateOfBirth = junior.customer.dateOfBirth; this.relationship = junior.relationship; + this.guardianRelationship = GuardianRelationship[junior.relationship]; this.profilePicture = junior.customer.user.profilePicture ? new DocumentMetaResponseDto(junior.customer.user.profilePicture) : null; diff --git a/src/junior/services/junior.service.ts b/src/junior/services/junior.service.ts index 24432fd..63f5593 100644 --- a/src/junior/services/junior.service.ts +++ b/src/junior/services/junior.service.ts @@ -4,6 +4,7 @@ import { Roles } from '~/auth/enums'; import { CardService } from '~/card/services'; import { NeoLeapService } from '~/common/modules/neoleap/services'; import { PageOptionsRequestDto } from '~/core/dtos'; +import { setIf } from '~/core/utils'; import { CustomerService } from '~/customer/services'; import { DocumentService, OciService } from '~/document/services'; import { UserType } from '~/user/enums'; @@ -88,17 +89,14 @@ export class JuniorService { async updateJunior(juniorId: string, body: UpdateJuniorRequestDto, guardianId: string) { this.logger.log(`Updating junior ${juniorId}`); const junior = await this.findJuniorById(juniorId, false, guardianId); - if (body.profilePictureId) { - junior.customer.user.profilePictureId = body.profilePictureId; - } - if (body.firstName) { - junior.customer.user.firstName = body.firstName; - junior.customer.firstName = body.firstName; - } - if (body.lastName) { - junior.customer.user.lastName = body.lastName; - junior.customer.lastName = body.lastName; + const customer = junior.customer; + const user = customer.user; + + if (user.password) { + this.logger.error(`Cannot update junior ${juniorId} with registered user`); + throw new BadRequestException('JUNIOR.CANNOT_UPDATE_REGISTERED_USER'); } + if (body.email) { const existingUser = await this.userService.findUser({ email: body.email }); if (existingUser && existingUser.id !== junior.customer.user.id) { @@ -107,16 +105,16 @@ export class JuniorService { } junior.customer.user.email = body.email; } + setIf(user, 'profilePictureId', body.profilePictureId); + setIf(user, 'firstName', body.firstName); + setIf(user, 'lastName', body.lastName); - if (body.dateOfBirth) { - junior.customer.dateOfBirth = body.dateOfBirth; - } - if (body.relationship) { - junior.relationship = body.relationship; - } - console.log('++++++'); + setIf(customer, 'firstName', body.firstName); + setIf(customer, 'lastName', body.lastName); + setIf(customer, 'dateOfBirth', body.dateOfBirth as unknown as Date); - await Promise.all([junior.save(), junior.customer.user.save(), junior.customer.save()]); + setIf(junior, 'relationship', body.relationship); + await Promise.all([junior.save(), customer.save(), user.save()]); this.logger.log(`Junior ${juniorId} updated successfully`); return junior; } From 9b0e1791da2bdfb62bcedffddcf27145dbf86123 Mon Sep 17 00:00:00 2001 From: Abdalhameed Ahmad Date: Sun, 7 Sep 2025 20:14:28 +0300 Subject: [PATCH 5/9] feat: transfer money to child --- src/card/repositories/card.repository.ts | 6 ++++ .../repositories/transaction.repository.ts | 22 +++++++++++++++ src/card/services/card.service.ts | 25 +++++++++++++++++ src/card/services/transaction.service.ts | 10 +++++-- src/junior/controllers/junior.controller.ts | 28 +++++++++++++++++-- src/junior/dtos/request/index.ts | 1 + .../request/transfer-to-junior.request.dto.ts | 12 ++++++++ src/junior/dtos/response/index.ts | 1 + .../transfer-to-junior.response.dto.ts | 10 +++++++ src/junior/services/junior.service.ts | 18 +++++++++++- 10 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 src/junior/dtos/request/transfer-to-junior.request.dto.ts create mode 100644 src/junior/dtos/response/transfer-to-junior.response.dto.ts diff --git a/src/card/repositories/card.repository.ts b/src/card/repositories/card.repository.ts index 0fa9bd7..cecfdb0 100644 --- a/src/card/repositories/card.repository.ts +++ b/src/card/repositories/card.repository.ts @@ -60,4 +60,10 @@ export class CardRepository { statusDescription: statusDescription, }); } + + updateCardLimit(cardId: string, newLimit: number) { + return this.cardRepository.update(cardId, { + limit: newLimit, + }); + } } diff --git a/src/card/repositories/transaction.repository.ts b/src/card/repositories/transaction.repository.ts index 39d8665..b38a257 100644 --- a/src/card/repositories/transaction.repository.ts +++ b/src/card/repositories/transaction.repository.ts @@ -57,6 +57,28 @@ export class TransactionRepository { ); } + createInternalChildTransaction(card: Card, amount: number): Promise { + return this.transactionRepository.save( + this.transactionRepository.create({ + transactionId: `CHILD-${card.id}-${Date.now()}`, + transactionAmount: amount, + transactionCurrency: '682', + billingAmount: 0, + settlementAmount: 0, + transactionDate: new Date(), + fees: 0, + cardId: card.id, + cardReference: card.cardReference, + cardMaskedNumber: card.firstSixDigits + '******' + card.lastFourDigits, + accountId: card.account!.id, + transactionType: TransactionType.INTERNAL, + accountReference: card.account!.accountReference, + transactionScope: TransactionScope.CARD, + vatOnFees: 0, + }), + ); + } + findTransactionByReference(transactionId: string, accountReference: string): Promise { return this.transactionRepository.findOne({ where: { transactionId, accountReference }, diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts index 2231fcb..c251a5a 100644 --- a/src/card/services/card.service.ts +++ b/src/card/services/card.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common'; +import Decimal from 'decimal.js'; import { Transactional } from 'typeorm-transactional'; import { AccountCardStatusChangedWebhookRequest } from '~/common/modules/neoleap/dtos/requests'; import { NeoLeapService } from '~/common/modules/neoleap/services'; @@ -10,12 +11,14 @@ import { CardColors } from '../enums'; import { CardStatusMapper } from '../mappers/card-status.mapper'; import { CardRepository } from '../repositories'; import { AccountService } from './account.service'; +import { TransactionService } from './transaction.service'; @Injectable() export class CardService { constructor( private readonly cardRepository: CardRepository, private readonly accountService: AccountService, + @Inject(forwardRef(() => TransactionService)) private readonly transactionService: TransactionService, @Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService, @Inject(forwardRef(() => CustomerService)) private readonly customerService: CustomerService, ) {} @@ -99,4 +102,26 @@ export class CardService { return this.neoleapService.getEmbossingInformation(card); } + + async updateCardLimit(cardId: string, newLimit: number) { + const { affected } = await this.cardRepository.updateCardLimit(cardId, newLimit); + + if (affected === 0) { + throw new BadRequestException('CARD.NOT_FOUND'); + } + } + + @Transactional() + async transferToChild(juniorId: string, amount: number) { + const card = await this.getCardByCustomerId(juniorId); + + const finalAmount = Decimal(amount).plus(card.limit); + await Promise.all([ + this.neoleapService.updateCardControl(card.cardReference, finalAmount.toNumber()), + this.updateCardLimit(card.id, finalAmount.toNumber()), + this.transactionService.createInternalChildTransaction(card.id, amount), + ]); + + return finalAmount.toNumber(); + } } diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts index d195368..b48759c 100644 --- a/src/card/services/transaction.service.ts +++ b/src/card/services/transaction.service.ts @@ -1,4 +1,4 @@ -import { Injectable, UnprocessableEntityException } from '@nestjs/common'; +import { forwardRef, Inject, Injectable, UnprocessableEntityException } from '@nestjs/common'; import Decimal from 'decimal.js'; import { Transactional } from 'typeorm-transactional'; import { @@ -15,7 +15,7 @@ export class TransactionService { constructor( private readonly transactionRepository: TransactionRepository, private readonly accountService: AccountService, - private readonly cardService: CardService, + @Inject(forwardRef(() => CardService)) private readonly cardService: CardService, ) {} @Transactional() @@ -51,6 +51,12 @@ export class TransactionService { return transaction; } + async createInternalChildTransaction(cardId: string, amount: number) { + const card = await this.cardService.getCardById(cardId); + const transaction = await this.transactionRepository.createInternalChildTransaction(card, amount); + return transaction; + } + private async findExistingTransaction(transactionId: string, accountReference: string): Promise { const existingTransaction = await this.transactionRepository.findTransactionByReference( transactionId, diff --git a/src/junior/controllers/junior.controller.ts b/src/junior/controllers/junior.controller.ts index ebd9ff0..35d9480 100644 --- a/src/junior/controllers/junior.controller.ts +++ b/src/junior/controllers/junior.controller.ts @@ -8,8 +8,18 @@ import { ApiDataPageResponse, ApiDataResponse, ApiLangRequestHeader } from '~/co import { PageOptionsRequestDto } from '~/core/dtos'; import { CustomParseUUIDPipe } from '~/core/pipes'; import { ResponseFactory } from '~/core/utils'; -import { CreateJuniorRequestDto, SetThemeRequestDto, UpdateJuniorRequestDto } from '../dtos/request'; -import { JuniorResponseDto, QrCodeValidationResponseDto, ThemeResponseDto } from '../dtos/response'; +import { + CreateJuniorRequestDto, + SetThemeRequestDto, + TransferToJuniorRequestDto, + UpdateJuniorRequestDto, +} from '../dtos/request'; +import { + JuniorResponseDto, + QrCodeValidationResponseDto, + ThemeResponseDto, + TransferToJuniorResponseDto, +} from '../dtos/response'; import { JuniorService } from '../services'; @Controller('juniors') @@ -100,4 +110,18 @@ export class JuniorController { return ResponseFactory.data(new QrCodeValidationResponseDto(junior)); } + + @Post(':juniorId/transfer') + @UseGuards(RolesGuard) + @AllowedRoles(Roles.GUARDIAN) + @ApiDataResponse(TransferToJuniorResponseDto) + async doesJuniorBelongToGuardian( + @AuthenticatedUser() user: IJwtPayload, + @Param('juniorId', CustomParseUUIDPipe) juniorId: string, + @Body() body: TransferToJuniorRequestDto, + ) { + const newAmount = await this.juniorService.transferToJunior(juniorId, body, user.sub); + + return ResponseFactory.data(new TransferToJuniorResponseDto(newAmount)); + } } diff --git a/src/junior/dtos/request/index.ts b/src/junior/dtos/request/index.ts index 08e7612..1f3590b 100644 --- a/src/junior/dtos/request/index.ts +++ b/src/junior/dtos/request/index.ts @@ -1,3 +1,4 @@ export * from './create-junior.request.dto'; export * from './set-theme.request.dto'; +export * from './transfer-to-junior.request.dto'; export * from './update-junior.request.dto'; diff --git a/src/junior/dtos/request/transfer-to-junior.request.dto.ts b/src/junior/dtos/request/transfer-to-junior.request.dto.ts new file mode 100644 index 0000000..a62ca44 --- /dev/null +++ b/src/junior/dtos/request/transfer-to-junior.request.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; + +export class TransferToJuniorRequestDto { + @ApiProperty({ example: 300.42 }) + @IsNumber( + { maxDecimalPlaces: 3 }, + { message: i18n('validation.IsNumber', { path: 'general', property: 'transferToJunior.amount' }) }, + ) + amount!: number; +} diff --git a/src/junior/dtos/response/index.ts b/src/junior/dtos/response/index.ts index 12ce574..2564df2 100644 --- a/src/junior/dtos/response/index.ts +++ b/src/junior/dtos/response/index.ts @@ -2,3 +2,4 @@ export * from './junior.response.dto'; export * from './qr-code-validation-details.response.dto'; export * from './qr-code-validation.response.dto'; export * from './theme.response.dto'; +export * from './transfer-to-junior.response.dto'; diff --git a/src/junior/dtos/response/transfer-to-junior.response.dto.ts b/src/junior/dtos/response/transfer-to-junior.response.dto.ts new file mode 100644 index 0000000..73f06bc --- /dev/null +++ b/src/junior/dtos/response/transfer-to-junior.response.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class TransferToJuniorResponseDto { + @ApiProperty({ example: 300.42 }) + newAmount!: number; + + constructor(newAmount: number) { + this.newAmount = newAmount; + } +} diff --git a/src/junior/services/junior.service.ts b/src/junior/services/junior.service.ts index 63f5593..acb611e 100644 --- a/src/junior/services/junior.service.ts +++ b/src/junior/services/junior.service.ts @@ -10,7 +10,12 @@ import { DocumentService, OciService } from '~/document/services'; import { UserType } from '~/user/enums'; import { UserService } from '~/user/services'; import { UserTokenService } from '~/user/services/user-token.service'; -import { CreateJuniorRequestDto, SetThemeRequestDto, UpdateJuniorRequestDto } from '../dtos/request'; +import { + CreateJuniorRequestDto, + SetThemeRequestDto, + TransferToJuniorRequestDto, + UpdateJuniorRequestDto, +} from '../dtos/request'; import { Junior } from '../entities'; import { JuniorRepository } from '../repositories'; import { QrcodeService } from './qrcode.service'; @@ -167,6 +172,17 @@ export class JuniorService { return !!junior; } + async transferToJunior(juniorId: string, body: TransferToJuniorRequestDto, guardianId: string) { + const doesBelong = await this.doesJuniorBelongToGuardian(guardianId, juniorId); + + if (!doesBelong) { + this.logger.error(`Junior ${juniorId} does not belong to guardian ${guardianId}`); + throw new BadRequestException('JUNIOR.NOT_BELONG_TO_GUARDIAN'); + } + + return this.cardService.transferToChild(juniorId, body.amount); + } + private async prepareJuniorImages(juniors: Junior[]) { this.logger.log(`Preparing junior images`); await Promise.all( From d768da70f227dd0b55bd242c0e5a194c25729f16 Mon Sep 17 00:00:00 2001 From: Abdalhameed Ahmad Date: Sun, 7 Sep 2025 20:23:11 +0300 Subject: [PATCH 6/9] feat: transfer to parent --- src/card/controllers/cards.controller.ts | 16 +++++++++++++--- .../dtos/responses/account-iban.response.dto.ts | 10 ++++++++++ src/card/dtos/responses/index.ts | 1 + src/card/repositories/account.repository.ts | 7 +++++++ src/card/services/account.service.ts | 10 +++++++++- src/card/services/card.service.ts | 5 +++++ 6 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 src/card/dtos/responses/account-iban.response.dto.ts diff --git a/src/card/controllers/cards.controller.ts b/src/card/controllers/cards.controller.ts index 385958a..b81fb0e 100644 --- a/src/card/controllers/cards.controller.ts +++ b/src/card/controllers/cards.controller.ts @@ -1,12 +1,13 @@ import { Controller, Get, Post, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Roles } from '~/auth/enums'; import { IJwtPayload } from '~/auth/interfaces'; -import { AuthenticatedUser } from '~/common/decorators'; -import { AccessTokenGuard } from '~/common/guards'; +import { AllowedRoles, AuthenticatedUser } from '~/common/decorators'; +import { AccessTokenGuard, RolesGuard } from '~/common/guards'; import { CardEmbossingDetailsResponseDto } from '~/common/modules/neoleap/dtos/response'; import { ApiDataResponse } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; -import { CardResponseDto } from '../dtos/responses'; +import { AccountIbanResponseDto, CardResponseDto } from '../dtos/responses'; import { CardService } from '../services'; @Controller('cards') @@ -36,4 +37,13 @@ export class CardsController { const res = await this.cardService.getEmbossingInformation(sub); return ResponseFactory.data(res); } + + @Get('iban') + @UseGuards(RolesGuard) + @AllowedRoles(Roles.GUARDIAN) + @ApiDataResponse(AccountIbanResponseDto) + async getCardIban(@AuthenticatedUser() { sub }: IJwtPayload) { + const iban = await this.cardService.getIbanInformation(sub); + return ResponseFactory.data(new AccountIbanResponseDto(iban)); + } } diff --git a/src/card/dtos/responses/account-iban.response.dto.ts b/src/card/dtos/responses/account-iban.response.dto.ts new file mode 100644 index 0000000..a4e1537 --- /dev/null +++ b/src/card/dtos/responses/account-iban.response.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AccountIbanResponseDto { + @ApiProperty({ example: 'DE89370400440532013000' }) + iban!: string; + + constructor(iban: string) { + this.iban = iban; + } +} diff --git a/src/card/dtos/responses/index.ts b/src/card/dtos/responses/index.ts index 5f2d4bb..e32607e 100644 --- a/src/card/dtos/responses/index.ts +++ b/src/card/dtos/responses/index.ts @@ -1 +1,2 @@ +export * from './account-iban.response.dto'; export * from './card.response.dto'; diff --git a/src/card/repositories/account.repository.ts b/src/card/repositories/account.repository.ts index 428b109..1e4af12 100644 --- a/src/card/repositories/account.repository.ts +++ b/src/card/repositories/account.repository.ts @@ -34,6 +34,13 @@ export class AccountRepository { }); } + getAccountByCustomerId(customerId: string): Promise { + return this.accountRepository.findOne({ + where: { cards: { customerId } }, + relations: ['cards'], + }); + } + topUpAccountBalance(accountReference: string, amount: number) { return this.accountRepository.increment({ accountReference }, 'balance', amount); } diff --git a/src/card/services/account.service.ts b/src/card/services/account.service.ts index ac9e0dd..fb6e2f3 100644 --- a/src/card/services/account.service.ts +++ b/src/card/services/account.service.ts @@ -27,10 +27,18 @@ export class AccountService { return account; } - async creditAccountBalance(accountReference: string, amount: number) { + creditAccountBalance(accountReference: string, amount: number) { return this.accountRepository.topUpAccountBalance(accountReference, amount); } + async getAccountByCustomerId(customerId: string): Promise { + const account = await this.accountRepository.getAccountByCustomerId(customerId); + if (!account) { + throw new UnprocessableEntityException('ACCOUNT.NOT_FOUND'); + } + return account; + } + async decreaseAccountBalance(accountReference: string, amount: number) { const account = await this.getAccountByReferenceNumber(accountReference); /** diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts index c251a5a..854160e 100644 --- a/src/card/services/card.service.ts +++ b/src/card/services/card.service.ts @@ -111,6 +111,11 @@ export class CardService { } } + async getIbanInformation(customerId: string) { + const account = await this.accountService.getAccountByCustomerId(customerId); + return account.iban; + } + @Transactional() async transferToChild(juniorId: string, amount: number) { const card = await this.getCardByCustomerId(juniorId); From 15a48e48840e6b2339fb12c1fa1a90dc9d747bd6 Mon Sep 17 00:00:00 2001 From: Abdalhameed Ahmad Date: Sun, 7 Sep 2025 21:18:49 +0300 Subject: [PATCH 7/9] fix: validate card spending limit before transfering to child --- src/card/controllers/cards.controller.ts | 14 ++++++++++++-- src/card/dtos/requests/fund-iban.request.dto.ts | 7 +++++++ src/card/dtos/requests/index.ts | 1 + src/card/repositories/account.repository.ts | 7 +++++++ src/card/repositories/transaction.repository.ts | 13 +++++++++++++ src/card/services/account.service.ts | 14 ++++++++++++++ src/card/services/card.service.ts | 9 +++++++++ src/card/services/transaction.service.ts | 6 ++++++ src/i18n/ar/app.json | 3 +++ src/i18n/en/app.json | 3 +++ 10 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 src/card/dtos/requests/fund-iban.request.dto.ts create mode 100644 src/card/dtos/requests/index.ts diff --git a/src/card/controllers/cards.controller.ts b/src/card/controllers/cards.controller.ts index b81fb0e..4b1a12a 100644 --- a/src/card/controllers/cards.controller.ts +++ b/src/card/controllers/cards.controller.ts @@ -1,5 +1,5 @@ -import { Controller, Get, Post, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Get, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { Roles } from '~/auth/enums'; import { IJwtPayload } from '~/auth/interfaces'; import { AllowedRoles, AuthenticatedUser } from '~/common/decorators'; @@ -7,6 +7,7 @@ import { AccessTokenGuard, RolesGuard } from '~/common/guards'; import { CardEmbossingDetailsResponseDto } from '~/common/modules/neoleap/dtos/response'; import { ApiDataResponse } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; +import { FundIbanRequestDto } from '../dtos/requests'; import { AccountIbanResponseDto, CardResponseDto } from '../dtos/responses'; import { CardService } from '../services'; @@ -46,4 +47,13 @@ export class CardsController { const iban = await this.cardService.getIbanInformation(sub); return ResponseFactory.data(new AccountIbanResponseDto(iban)); } + + @Post('mock/fund-iban') + @ApiOperation({ summary: 'Mock endpoint to fund the IBAN - For testing purposes only' }) + @UseGuards(RolesGuard) + @AllowedRoles(Roles.GUARDIAN) + @HttpCode(HttpStatus.NO_CONTENT) + fundIban(@Body() { amount, iban }: FundIbanRequestDto) { + return this.cardService.fundIban(iban, amount); + } } diff --git a/src/card/dtos/requests/fund-iban.request.dto.ts b/src/card/dtos/requests/fund-iban.request.dto.ts new file mode 100644 index 0000000..d22a544 --- /dev/null +++ b/src/card/dtos/requests/fund-iban.request.dto.ts @@ -0,0 +1,7 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { TransferToJuniorRequestDto } from '~/junior/dtos/request'; + +export class FundIbanRequestDto extends TransferToJuniorRequestDto { + @ApiProperty({ example: 'DE89370400440532013000' }) + iban!: string; +} diff --git a/src/card/dtos/requests/index.ts b/src/card/dtos/requests/index.ts new file mode 100644 index 0000000..36ead34 --- /dev/null +++ b/src/card/dtos/requests/index.ts @@ -0,0 +1 @@ +export * from './fund-iban.request.dto'; diff --git a/src/card/repositories/account.repository.ts b/src/card/repositories/account.repository.ts index 1e4af12..ae1349f 100644 --- a/src/card/repositories/account.repository.ts +++ b/src/card/repositories/account.repository.ts @@ -27,6 +27,13 @@ export class AccountRepository { }); } + getAccountByIban(iban: string): Promise { + return this.accountRepository.findOne({ + where: { iban }, + relations: ['cards'], + }); + } + getAccountByAccountNumber(accountNumber: string): Promise { return this.accountRepository.findOne({ where: { accountNumber }, diff --git a/src/card/repositories/transaction.repository.ts b/src/card/repositories/transaction.repository.ts index b38a257..a61f55d 100644 --- a/src/card/repositories/transaction.repository.ts +++ b/src/card/repositories/transaction.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { Decimal } from 'decimal.js'; import moment from 'moment'; import { Repository } from 'typeorm'; import { @@ -84,4 +85,16 @@ export class TransactionRepository { where: { transactionId, accountReference }, }); } + + findInternalTransactionTotal(accountId: string): Promise { + return this.transactionRepository + .find({ + where: { accountId, transactionType: TransactionType.INTERNAL }, + }) + .then((transactions) => { + return transactions + .reduce((total, tx) => new Decimal(total).plus(tx.transactionAmount), new Decimal(0)) + .toNumber(); + }); + } } diff --git a/src/card/services/account.service.ts b/src/card/services/account.service.ts index fb6e2f3..4c6af29 100644 --- a/src/card/services/account.service.ts +++ b/src/card/services/account.service.ts @@ -27,6 +27,14 @@ export class AccountService { return account; } + async getAccountByIban(iban: string): Promise { + const account = await this.accountRepository.getAccountByIban(iban); + if (!account) { + throw new UnprocessableEntityException('ACCOUNT.NOT_FOUND'); + } + return account; + } + creditAccountBalance(accountReference: string, amount: number) { return this.accountRepository.topUpAccountBalance(accountReference, amount); } @@ -52,4 +60,10 @@ export class AccountService { return this.accountRepository.decreaseAccountBalance(accountReference, amount); } + + //THIS IS A MOCK FUNCTION FOR TESTING PURPOSES ONLY + async fundIban(iban: string, amount: number) { + const account = await this.getAccountByIban(iban); + return this.accountRepository.topUpAccountBalance(account.accountReference, amount); + } } diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts index 854160e..3aae9a2 100644 --- a/src/card/services/card.service.ts +++ b/src/card/services/card.service.ts @@ -119,6 +119,11 @@ export class CardService { @Transactional() async transferToChild(juniorId: string, amount: number) { const card = await this.getCardByCustomerId(juniorId); + const availableSpendingLimit = await this.transactionService.calculateAvailableSpendingLimitForParent(card.account); + + if (amount > availableSpendingLimit) { + throw new BadRequestException('CARD.INSUFFICIENT_BALANCE'); + } const finalAmount = Decimal(amount).plus(card.limit); await Promise.all([ @@ -129,4 +134,8 @@ export class CardService { return finalAmount.toNumber(); } + + fundIban(iban: string, amount: number) { + return this.accountService.fundIban(iban, amount); + } } diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts index b48759c..839f370 100644 --- a/src/card/services/transaction.service.ts +++ b/src/card/services/transaction.service.ts @@ -5,6 +5,7 @@ import { AccountTransactionWebhookRequest, CardTransactionWebhookRequest, } from '~/common/modules/neoleap/dtos/requests'; +import { Account } from '../entities/account.entity'; import { Transaction } from '../entities/transaction.entity'; import { TransactionRepository } from '../repositories/transaction.repository'; import { AccountService } from './account.service'; @@ -57,6 +58,11 @@ export class TransactionService { return transaction; } + async calculateAvailableSpendingLimitForParent(account: Account): Promise { + const internalTransactionSum = await this.transactionRepository.findInternalTransactionTotal(account.id); + return new Decimal(account.balance).minus(internalTransactionSum).toNumber(); + } + private async findExistingTransaction(transactionId: string, accountReference: string): Promise { const existingTransaction = await this.transactionRepository.findTransactionByReference( transactionId, diff --git a/src/i18n/ar/app.json b/src/i18n/ar/app.json index 411141d..ea7f2f4 100644 --- a/src/i18n/ar/app.json +++ b/src/i18n/ar/app.json @@ -103,5 +103,8 @@ }, "OTP": { "INVALID_OTP": "رمز التحقق الذي أدخلته غير صالح. يرجى المحاولة مرة أخرى." + }, + "CARD": { + "INSUFFICIENT_BALANCE": "البطاقة لا تحتوي على رصيد كافٍ لإكمال هذا التحويل." } } diff --git a/src/i18n/en/app.json b/src/i18n/en/app.json index b6c4ccb..3373254 100644 --- a/src/i18n/en/app.json +++ b/src/i18n/en/app.json @@ -102,5 +102,8 @@ }, "OTP": { "INVALID_OTP": "The OTP you entered is invalid. Please try again." + }, + "CARD": { + "INSUFFICIENT_BALANCE": "The card does not have sufficient balance to complete this transfer." } } From 954aa422a2f99435508af2bdea162dd8b6353692 Mon Sep 17 00:00:00 2001 From: Abdalhameed Ahmad Date: Sun, 7 Sep 2025 21:40:55 +0300 Subject: [PATCH 8/9] fix: fix mock request for funding decorators --- src/card/dtos/requests/fund-iban.request.dto.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/card/dtos/requests/fund-iban.request.dto.ts b/src/card/dtos/requests/fund-iban.request.dto.ts index d22a544..66c2597 100644 --- a/src/card/dtos/requests/fund-iban.request.dto.ts +++ b/src/card/dtos/requests/fund-iban.request.dto.ts @@ -1,7 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; import { TransferToJuniorRequestDto } from '~/junior/dtos/request'; export class FundIbanRequestDto extends TransferToJuniorRequestDto { @ApiProperty({ example: 'DE89370400440532013000' }) + @IsString() iban!: string; } From e6642b5a154b640cd2a6fb6b6591a72f417bc187 Mon Sep 17 00:00:00 2001 From: Abdalhameed Ahmad Date: Sun, 7 Sep 2025 21:47:17 +0300 Subject: [PATCH 9/9] fix: fix controller name --- src/junior/controllers/junior.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/junior/controllers/junior.controller.ts b/src/junior/controllers/junior.controller.ts index 35d9480..230c4e3 100644 --- a/src/junior/controllers/junior.controller.ts +++ b/src/junior/controllers/junior.controller.ts @@ -115,7 +115,7 @@ export class JuniorController { @UseGuards(RolesGuard) @AllowedRoles(Roles.GUARDIAN) @ApiDataResponse(TransferToJuniorResponseDto) - async doesJuniorBelongToGuardian( + async transferToJunior( @AuthenticatedUser() user: IJwtPayload, @Param('juniorId', CustomParseUUIDPipe) juniorId: string, @Body() body: TransferToJuniorRequestDto,