Compare commits

...
Author SHA1 Message Date
Abdalhamid Alhamad 63b0a42eca feat: enhance Redis module exports for pub/sub functionality
- Added 'REDIS_PUBLISHER' and 'REDIS_SUBSCRIBER' to the exports of RedisModule to improve pub/sub capabilities.
2026-01-11 14:38:59 +03:00
Abdalhamid Alhamad b1cda5e7dc feat: Complete Phase 2 notification system implementation
- Implement messaging system factory pattern
- Fix all transaction notification blockers
- Complete listener logic for all notification types
2026-01-11 11:17:08 +03:00
Abdalhamid Alhamad 2c8de913f8 refactor: update notification titles and enhance notification creation process
- Simplified notification titles by removing emojis for better clarity.
- Modified createNotification method to include automatic publishing to Redis, improving notification delivery.
- Updated email and OTP notification methods to leverage the new createNotification functionality.
2026-01-06 16:22:21 +03:00
Abdalhamid Alhamad 170aa903c7 add eveint lestiner to the parent 2026-01-06 14:51:44 +03:00
Abdalhamid Alhamad 2f74aa36a9 merge conflect 2026-01-06 12:57:13 +03:00
Abdalhamid Alhamad 2562515574 Merge branch 'dev' of github.com:HamzaSha1/zod-backend into feature/notification-system-fcm-registration 2026-01-06 12:53:44 +03:00
Abdalhamid Alhamad 93b509b256 feat: add notification event handling and notification factory service
- Introduce constants for notification event names
- Implement interfaces for transaction created events
- Create a transaction notification listener to handle transaction notifications
- Develop a notification factory service for sending notifications based on user preferences
- Add a migration to include a data column in the notifications table
2026-01-06 12:38:19 +03:00
Abdalhamid Alhamad 9c93a35093 feat: implement notification system with FCM token registration
- Add FCM token registration during login/signup
- Implement transaction notification listeners
- Add notification data column to database
- Update Firebase service with data payload support
- Add transaction notification scopes
- Update card repository to load relations for notifications
2026-01-06 12:29:01 +03:00
Majdalkilany0 d77d59a793 Merge pull request #70 from Zod-Alkhair/feature/kyc-onboarding
Feature/kyc onboarding
2025-12-18 14:26:27 +03:00
Abdalhamid Alhamad 110a6fb0ee refactor: remove address fields from customer entity and related services
- Removed address-related fields from Customer entity, DTOs, and services to streamline KYC process.
- Updated KYC initiation and customer update logic to default to Saudi Arabia for country and use fixed address values.
- Added migration to drop address columns from the database.
2025-12-18 12:35:32 +03:00
Majdalkilany0 83787c7c67 Merge pull request #69 from Zod-Alkhair/feature/kyc-onboarding
feat: enhance KYC process with external customer ID validation
2025-12-17 12:56:32 +03:00
Abdalhamid Alhamad 24bcb10d76 feat: enhance KYC process with external customer ID validation
- Added validation to ensure customer has a neoleapExternalCustomerId before card creation.
- Updated KYC status update to include neoleapExternalCustomerId in the customer record.
- Enhanced application info to include ExternalCorporateId for better integration with Neoleap.
2025-12-17 12:51:20 +03:00
Majdalkilany0 a3cdf50cb7 Merge pull request #68 from Zod-Alkhair/feature/kyc-onboarding
refactor: remove obsolete customer fields and update migration
2025-12-16 16:42:30 +03:00
Abdalhamid Alhamad cfd02e8c30 refactor: remove obsolete customer fields and update migration
- Removed unused fields: sourceOfIncome, profession, and professionType from Customer entity and DTOs.
- Updated KYC callback mock to reflect the removal of professionType.
- Added migration to drop the corresponding columns from the database.
2025-12-16 16:40:13 +03:00
Majdalkilany0 0fb76d712d Merge pull request #67 from Zod-Alkhair/feature/kyc-onboarding
Feature/kyc onboarding
2025-12-16 14:59:28 +03:00
Abdalhamid Alhamad 5e708c16fe chore: remove migration from wrong directory
Migration already exists in correct location: src/db/migrations/
2025-12-16 14:57:03 +03:00
Abdalhamid Alhamad fe11f35b32 feat: send the adress data to noleap 2025-12-16 14:51:21 +03:00
Abdalhamid Alhamad 3200f60821 feat: Complete KYC implementation with address fields
- Added address fields to registration (verify-user DTO)
- Added address fields to KYC initiation (initiate-kyc DTO)
- Added national_id column to kyc_transactions table
- Changed duplicate KYC check from customerId to nationalId
- Added KYC webhook endpoint (/api/neoleap-webhooks/kyc)
- Added webhook processing logic
- Updated customer service to save address during registration and KYC
- Added validation to require address before card creation
- Removed duplicate src/migrations/ directory
2025-12-16 14:44:07 +03:00
Majdalkilany0 24521c4223 Merge pull request #66 from Zod-Alkhair/chore/remove-email-dob-from-signup
chore: remove email and dob from guardian signup flow
2025-12-11 12:15:54 +03:00
Abdalhamid Alhamad e8127970f6 chore: remove email and dob from guardian signup flow 2025-12-11 12:13:51 +03:00
Abdalhamid Alhamad 07d4a83cf9 test ssh 2025-12-07 20:18:59 +03:00
abdalhamid99 ce1f6341b7 Merge pull request #65 from HamzaSha1/dev
Dev to main
2025-11-26 09:58:33 +03:00
abdalhamid99 2a62787c3b Merge pull request #63 from HamzaSha1/feature/kyc-onboarding-metadata
refactor: remove unused PoiValidationRule class from KycMetadataRespo
2025-11-18 15:16:33 +03:00
Abdalhamid Alhamad 91dea22f45 refactor: remove unused PoiValidationRule class from KycMetadataResponseDto 2025-11-18 15:14:47 +03:00
abdalhamid99 ef28c75f9b Merge pull request #62 from HamzaSha1/feature/kyc-onboarding-metadata
feat: add KYC onboarding metadata endpoint with POI validation
2025-11-18 15:06:50 +03:00
Abdalhamid Alhamad c007ac584f feat: add KYC onboarding metadata endpoint with POI validation 2025-11-18 15:03:42 +03:00
abdalhamid99 d2d83549b2 Merge pull request #61 from HamzaSha1/fix/junior-profile-picture-refresh-on-update
Enhance profile picture handling in JuniorService to ensure foreign
2025-11-09 12:43:54 +03:00
Abdalhamid Alhamad 506974afc8 Enhance profile picture handling in JuniorService to ensure foreign key consistency and validate document ownership before assignment. 2025-11-09 12:42:48 +03:00
abdalhamid99 95f8cfbfdf Merge pull request #60 from HamzaSha1/fix/junior-profile-picture-refresh-on-update
Update return value in updateJunior method to fetch updated junior dtails by ID instead of returning the junior object directly.
2025-11-09 12:26:44 +03:00
Abdalhamid Alhamad 8b00cda23d Update return value in updateJunior method to fetch updated junior details by ID instead of returning the junior object directly. 2025-11-09 12:25:37 +03:00
abdalhamid99 12cc88a50e Merge pull request #59 from HamzaSha1/money-request-to-use-the-parint-account
Refactor balance check in increaseReservedBalance method to delegate …
2025-11-02 12:41:51 +03:00
Abdalhamid Alhamad 2172051093 Refactor balance check in increaseReservedBalance method to delegate validation to the caller, improving clarity and responsibility separation. 2025-11-02 12:41:16 +03:00
abdalhamid99 a6a573957c Merge pull request #58 from HamzaSha1/money-request-to-use-the-parint-account
add more loggs
2025-11-02 12:35:31 +03:00
Abdalhamid Alhamad d6fb5f48d9 add more loggs 2025-11-02 12:34:41 +03:00
abdalhamid99 b0011eb7cc Merge pull request #57 from HamzaSha1/money-request-to-use-the-parint-account
Money request to use the parint account
2025-11-02 12:07:13 +03:00
Abdalhamid Alhamad 99af65a300 money-request to use the parent card 2025-11-02 11:57:41 +03:00
abdalhamid99 0c9b40132a Merge pull request #56 from HamzaSha1/ZOD-344-after-a-child-completes-registration-using-the-qr-code-the-same-qr-code-remains-valid-and-allows-the-child-to-register-again-instead-of-expiring
ZOD-344-Add QR code validation error handling and localization support
2025-11-02 11:02:25 +03:00
Abdalhamid Alhamad 3b295ea79f ZOD-344-Add QR code validation error handling and localization support
- Introduced new error handling for already used or expired QR codes in JuniorService.
- Added corresponding localization entries in Arabic and English app.json files for QR code validation messages.
2025-11-02 10:52:43 +03:00
abdalhamid99 5ffe18ede3 Merge pull request #54 from HamzaSha1/fix/verfy-email
Implement OTP generation and email verification logic in UserService
2025-10-28 16:17:51 +03:00
Abdalhamid Alhamad a3a61b4923 Implement OTP generation and email verification logic in UserService 2025-10-28 15:52:24 +03:00
abdalhamid99 39d5fc1869 Merge pull request #52 from HamzaSha1/ZOD-349-weekly-spending-data-not-displaying-in-the-child-profile-parent-view
Enhance weekly summary functionality to accept optional date range pa…
2025-10-28 11:22:52 +03:00
Abdalhamid Alhamad 05a6ad2d84 Enhance weekly summary functionality to accept optional date range parameters in CardService, TransactionService, JuniorService, and JuniorController. Update API documentation to reflect new query parameters for start and end dates. 2025-10-28 11:20:49 +03:00
abdalhamid99 5649d24724 Merge pull request #50 from HamzaSha1/ZOD-349-weekly-spending-data-not-displaying-in-the-child-profile-parent-view
git checkout -b ZOD-349-weekly-spending-data-not-displaying-in-the-child-profile-parent-view
2025-10-26 16:05:00 +03:00
Abdalhamid Alhamad bbeece9e03 git checkout -b ZOD-349-weekly-spending-data-not-displaying-in-the-child-profile-parent-view 2025-10-26 13:14:35 +03:00
abdalhamid99 596562f6dc Merge pull request #48 from HamzaSha1/feat/neoleap-integration
Feat/neoleap integration
2025-10-21 14:56:38 +03:00
abdalhamid99 10de8f69c9 Merge pull request #47 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
Remove duplicate email cleanup logic and add unique constraint to use…
2025-10-21 14:15:03 +03:00
Abdalhamid Alhamad 8a6b1cc900 Remove duplicate email cleanup logic and add unique constraint to user email 2025-10-21 14:10:14 +03:00
abdalhamid99 d16ae66252 Merge pull request #46 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
ZOD-341-Add unique constraint to user email and clean up duplicates
2025-10-21 10:51:12 +03:00
Abdalhamid Alhamad e966f95463 ZOD-341-Add unique constraint to user email and clean up duplicates 2025-10-21 10:49:43 +03:00
abdalhamid99 2714255dd1 Merge pull request #45 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
ZOD-341 Add email uniqueness validation to prevent duplicate emails
2025-10-20 14:31:11 +03:00
abdalhamid99 39a0b131b8 Merge pull request #44 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
Zod 341 junior a child can edit their email to an existing email causing multiple child accounts to share the same login
2025-10-20 14:27:40 +03:00
Abdalhamid Alhamad 4f778f7904 * ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login 2025-10-20 14:25:53 +03:00
abdalhamid99 7e9bc397a9 Merge pull request #43 from HamzaSha1/ZOD-204-view-spending-from-child-login
git checkout -b ZOD-204-view-spending-from-child-login
2025-10-20 10:30:27 +03:00
abdalhamid99 7bfc14f0d9 Merge pull request #42 from HamzaSha1/ZOD-204-view-spending-from-child-login
ZOD-204-view-spending-from-child-login
2025-10-19 15:44:16 +03:00
Abdalhamid Alhamad d2e084d3e4 git checkout -b ZOD-204-view-spending-from-child-login 2025-10-19 15:26:47 +03:00
abdalhamid99 f81714a525 Merge pull request #41 from HamzaSha1/ZOD-339-child-profile-gender-update-is-not-reflected-after-editing
ZOD-339-child-profile-gender-update-is-not-reflected-after-editing
2025-10-19 11:07:39 +03:00
abdalhamid99 f3282a680b Merge pull request #40 from HamzaSha1/ZOD-339-child-profile-gender-update-is-not-reflected-after-editing
Zod 339 child profile gender update is not reflected after editing
2025-10-19 11:02:40 +03:00
Abdalhamid Alhamad 7b57277a7f ZOD-339-child-profile-gender-update-is-not-reflected-after-editing 2025-10-19 11:01:52 +03:00
abdalhamid99 fdd2e23669 Merge pull request #39 from HamzaSha1/ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instead-of-daughter-or-son-in-child-confirmation-details-after-the-scan-the-qr-code
ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instea
2025-10-19 10:47:51 +03:00
abdalhamid99 d70ab09960 Merge pull request #38 from HamzaSha1/ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instead-of-daughter-or-son-in-child-confirmation-details-after-the-scan-the-qr-code
Zod 333 junior incorrect relationship label displayed as child instead of daughter or son in child confirmation details after the scan the qr code
2025-10-19 09:58:57 +03:00
Abdalhamid Alhamad 297a2fe5ad ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instead-of-daughter-or-son-in-child-confirmation-details-after-the-scan-the-qr-code 2025-10-19 09:57:35 +03:00
abdalhamid99 33b4f13ec8 Merge pull request #37 from HamzaSha1/feat/neoleap-integration
Feat/neoleap integration
2025-10-16 14:50:23 +03:00
abdalhamid99 310233c519 Merge pull request #36 from HamzaSha1/ZOD-309-child-transaction-history-parent-→-child-transfers
ZOD-309-child-transaction-history-parent-→-child-transfers
2025-10-16 12:26:50 +03:00
Abdalhamid Alhamad 15621124ad ZOD-309-child-transaction-history-parent-→-child-transfers 2025-10-16 12:25:16 +03:00
abdalhamid99 7fc1918de0 Merge pull request #35 from HamzaSha1/feat/parent-topups-and-child-transfers
feat: add guardian transactions feature with response DTOs and service integration
2025-10-15 14:17:08 +03:00
Abdalhamid Alhamad f6fa74897a feat: add guardian transactions feature with response DTOs and service integration 2025-10-15 14:14:59 +03:00
abdalhamid99 dd6886ff2b Merge pull request #34 from HamzaSha1/feat/neoleap-integration
match the neoleap-integration branch with dev
2025-10-14 12:20:19 +03:00
abdalhamid99 649191f3f4 Merge pull request #33 from HamzaSha1/fix/customer-gender-missing-in-get-profile
fix: add gender property to UserResponseDto
2025-10-14 12:14:01 +03:00
abdalhamid99 183f6b4475 fix: add gender property to UserResponseDto
fix: add gender property to UserResponseDto
2025-10-12 16:06:39 +03:00
Abdalhameed Ahmad 918b15c315 fix: add swagger 2025-09-23 09:00:41 +03:00
Abdalhameed Ahmad 1830d92cbd feat: weekly stats for junior 2025-09-23 08:56:57 +03:00
Abdalhameed Ahmad 44124b9964 Merge branch 'dev' of github.com:HamzaSha0/zod-backend into dev 2025-09-18 10:03:47 +03:00
Abdalhameed Ahmad 454ded627f fix: fix transfer to child bug 2025-09-16 21:01:32 +03:00
Abdalhameed Ahmad f1484e125b feat: soft delete junior 2025-09-15 09:02:56 +03:00
Abdalhameed Ahmad df4d2e3c1f feat: get card by child id 2025-09-15 08:47:56 +03:00
Abdalhameed Ahmad 872d231f72 feat: show embossing information for child cards 2025-09-09 21:45:37 +03:00
Abdalhameed Ahmad cc4c8254f6 feat: view child active cards 2025-09-09 21:37:55 +03:00
Abdalhameed Ahmad 039c95aa56 fix: calculating child and parent balance 2025-09-09 20:31:48 +03:00
Abdalhameed Ahmad e1f50decfa feat: money requests 2025-09-08 21:38:11 +03:00
abdalhamid99 11712bedf3 Merge pull request #31 from HamzaSha1/feat/neoleap-integration
Feat/neoleap integration
2025-09-08 21:34:46 +03:00
Abdalhameed Ahmad e6642b5a15 fix: fix controller name 2025-09-07 21:47:17 +03:00
Abdalhameed Ahmad 954aa422a2 fix: fix mock request for funding decorators 2025-09-07 21:40:55 +03:00
Abdalhameed Ahmad 15a48e4884 fix: validate card spending limit before transfering to child 2025-09-07 21:18:49 +03:00
Abdalhameed Ahmad d768da70f2 feat: transfer to parent 2025-09-07 20:23:11 +03:00
Abdalhameed Ahmad 9b0e1791da feat: transfer money to child 2025-09-07 20:14:28 +03:00
Abdalhameed Ahmad 44b5937f7a feat: finalize update junior 2025-09-07 18:13:28 +03:00
Abdalhameed Ahmad edddc2f457 feat: update junior 2025-09-07 09:12:14 +03:00
Abdalhameed Ahmad 88730a2b2b fix: fix create application mock 2025-08-26 19:17:00 +03:00
Abdalhameed Ahmad 3df34c0017 fix: fix duplicate iban 2025-08-26 12:19:47 +03:00
Abdalhameed Ahmad 7dd309e0e3 fix: fix null assertion in creating junior 2025-08-24 20:14:59 +03:00
Abdalhameed Ahmad 4552a7fc93 fix: fix issue with customer relationship in creating junior 2025-08-24 20:12:57 +03:00
Abdalhameed Ahmad 740135051d feat: create card for children 2025-08-24 20:01:07 +03:00
Abdalhameed Ahmad 3222aa4a66 fix: fix card embossing info endpoint 2025-08-24 18:40:49 +03:00
Abdalhameed Ahmad 6602414779 feat: finialize creating juniors 2025-08-23 21:52:59 +03:00
Abdalhameed Ahmad 7291447c5a Merge branch 'dev' into feat/neoleap-integration 2025-08-23 20:12:48 +03:00
Abdalhameed Ahmad d437b21dc3 fix: make kyc run on mocks 2025-08-23 19:30:29 +03:00
149 changed files with 11126 additions and 3632 deletions
+6575 -2428
View File
File diff suppressed because it is too large Load Diff
View File
+2
View File
@@ -28,6 +28,7 @@ import { HealthModule } from './health/health.module';
import { JuniorModule } from './junior/junior.module';
import { UserModule } from './user/user.module';
import { WebhookModule } from './webhook/webhook.module';
import { MoneyRequestModule } from './money-request/money-request.module';
@Module({
controllers: [],
@@ -74,6 +75,7 @@ import { WebhookModule } from './webhook/webhook.module';
CronModule,
NeoLeapModule,
WebhookModule,
MoneyRequestModule,
],
providers: [
// Global Pipes
+19 -1
View File
@@ -9,9 +9,11 @@ import {
ChangePasswordRequestDto,
CreateUnverifiedUserRequestDto,
ForgetPasswordRequestDto,
JuniorLoginRequestDto,
LoginRequestDto,
RefreshTokenRequestDto,
SendForgetPasswordOtpRequestDto,
setJuniorPasswordRequestDto,
VerifyForgetPasswordOtpRequestDto,
VerifyUserRequestDto,
} from '../dtos/request';
@@ -69,10 +71,26 @@ export class AuthController {
@Post('change-password')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard)
async changePassword(@AuthenticatedUser() { sub }: IJwtPayload, @Body() forgetPasswordDto: ChangePasswordRequestDto) {
changePassword(@AuthenticatedUser() { sub }: IJwtPayload, @Body() forgetPasswordDto: ChangePasswordRequestDto) {
return this.authService.changePassword(sub, forgetPasswordDto);
}
@Post('junior/set-password')
@HttpCode(HttpStatus.NO_CONTENT)
@Public()
setJuniorPasscode(@Body() setPassworddto: setJuniorPasswordRequestDto) {
return this.authService.setJuniorPassword(setPassworddto);
}
@Post('junior/login')
@HttpCode(HttpStatus.OK)
@ApiDataResponse(LoginResponseDto)
async juniorLogin(@Body() juniorLoginDto: JuniorLoginRequestDto) {
const [res, user] = await this.authService.juniorLogin(juniorLoginDto);
return ResponseFactory.data(new LoginResponseDto(res, user));
}
@Post('refresh-token')
@Public()
async refreshToken(@Body() { refreshToken }: RefreshTokenRequestDto) {
+1 -1
View File
@@ -1,11 +1,11 @@
export * from './change-password.request.dto';
export * from './create-unverified-user.request.dto';
export * from './forget-password.request.dto';
export * from './junior-login.request.dto';
export * from './login.request.dto';
export * from './refresh-token.request.dto';
export * from './send-forget-password-otp.request.dto';
export * from './set-junior-password.request.dto';
export * from './set-passcode.request.dto';
export * from './verify-forget-password-otp.request.dto';
export * from './verify-otp.request.dto';
export * from './verify-user.request.dto';
@@ -0,0 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsOptional, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class JuniorLoginRequestDto {
@ApiProperty({ example: '[email protected]' })
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) })
email!: string;
@ApiProperty({ example: 'Abcd1234@' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) })
password!: string;
@ApiProperty({ example: 'device-123', description: 'Unique device identifier', required: false })
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) })
deviceId?: string;
@ApiProperty({
example: 'cXYzABC:APA91bHunvwY7rKpn8N7y6vDxS0qmQ5RZx2C8K...',
description: 'Firebase Cloud Messaging token for push notifications',
required: false,
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) })
fcmToken?: string;
}
@@ -21,4 +21,18 @@ export class LoginRequestDto {
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) })
@ValidateIf((o) => o.grantType === GrantType.PASSWORD)
password!: string;
@ApiProperty({ example: 'device-123', description: 'Unique device identifier', required: false })
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) })
deviceId?: string;
@ApiProperty({
example: 'cXYzABC:APA91bHunvwY7rKpn8N7y6vDxS0qmQ5RZx2C8K...',
description: 'Firebase Cloud Messaging token for push notifications',
required: false,
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) })
fcmToken?: string;
}
@@ -1,8 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, PickType } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { SetPasscodeRequestDto } from './set-passcode.request.dto';
export class setJuniorPasswordRequestDto extends SetPasscodeRequestDto {
import { ChangePasswordRequestDto } from './change-password.request.dto';
export class setJuniorPasswordRequestDto extends PickType(ChangePasswordRequestDto, [
'newPassword',
'confirmNewPassword',
]) {
@ApiProperty()
@IsString({ message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.qrToken' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.qrToken' }) })
@@ -1,15 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumberString, MaxLength, MinLength } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
const PASSCODE_LENGTH = 6;
export class SetPasscodeRequestDto {
@ApiProperty({ example: '123456' })
@IsNumberString(
{ no_symbols: true },
{ message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.passcode' }) },
)
@MinLength(PASSCODE_LENGTH, { message: i18n('validation.MinLength', { path: 'general', property: 'auth.passcode' }) })
@MaxLength(PASSCODE_LENGTH, { message: i18n('validation.MaxLength', { path: 'general', property: 'auth.passcode' }) })
passcode!: string;
}
@@ -1,7 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsDateString,
IsEmail,
IsEnum,
IsNotEmpty,
IsNumberString,
@@ -15,7 +13,7 @@ import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { COUNTRY_CODE_REGEX, PASSWORD_REGEX } from '~/auth/constants';
import { CountryIso } from '~/common/enums';
import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants';
import { IsAbove18, IsValidPhoneNumber } from '~/core/decorators/validations';
import { IsValidPhoneNumber } from '~/core/decorators/validations';
export class VerifyUserRequestDto {
@ApiProperty({ example: '+962' })
@@ -39,11 +37,6 @@ export class VerifyUserRequestDto {
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) })
lastName!: string;
@ApiProperty({ example: '2001-01-01' })
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) })
@IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) })
dateOfBirth!: Date;
@ApiProperty({ example: 'JO' })
@IsEnum(CountryIso, {
message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }),
@@ -51,10 +44,38 @@ export class VerifyUserRequestDto {
@IsOptional()
countryOfResidence: CountryIso = CountryIso.SAUDI_ARABIA;
@ApiProperty({ example: '[email protected]' })
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) })
// Address fields (optional during registration, required for card creation)
@ApiProperty({ example: 'SA', description: 'Country code', required: false })
@IsEnum(CountryIso, {
message: i18n('validation.IsEnum', { path: 'general', property: 'customer.country' }),
})
@IsOptional()
email!: string;
country?: CountryIso;
@ApiProperty({ example: 'Riyadh', description: 'Region/Province', required: false })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.region' }) })
@IsOptional()
region?: string;
@ApiProperty({ example: 'Riyadh', description: 'City', required: false })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.city' }) })
@IsOptional()
city?: string;
@ApiProperty({ example: 'Al Olaya', description: 'Neighborhood/District', required: false })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.neighborhood' }) })
@IsOptional()
neighborhood?: string;
@ApiProperty({ example: 'King Fahd Road', description: 'Street name', required: false })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.street' }) })
@IsOptional()
street?: string;
@ApiProperty({ example: '123', description: 'Building number', required: false })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.building' }) })
@IsOptional()
building?: string;
@ApiProperty({ example: 'Abcd1234@' })
@Matches(PASSWORD_REGEX, {
@@ -80,4 +101,18 @@ export class VerifyUserRequestDto {
message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }),
})
otp!: string;
@ApiProperty({ example: 'device-123', description: 'Unique device identifier', required: false })
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) })
deviceId?: string;
@ApiProperty({
example: 'cXYzABC:APA91bHunvwY7rKpn8N7y6vDxS0qmQ5RZx2C8K...',
description: 'Firebase Cloud Messaging token for push notifications',
required: false,
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) })
fcmToken?: string;
}
+81 -10
View File
@@ -14,6 +14,7 @@ import {
ChangePasswordRequestDto,
CreateUnverifiedUserRequestDto,
ForgetPasswordRequestDto,
JuniorLoginRequestDto,
LoginRequestDto,
SendForgetPasswordOtpRequestDto,
setJuniorPasswordRequestDto,
@@ -40,14 +41,6 @@ export class AuthService {
) {}
async sendRegisterOtp(body: CreateUnverifiedUserRequestDto) {
if (body.email) {
const isEmailUsed = await this.userService.findUser({ email: body.email, isEmailVerified: true });
if (isEmailUsed) {
this.logger.error(`Email ${body.email} is already used`);
throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN');
}
}
if (body.password !== body.confirmPassword) {
this.logger.error('Password and confirm password do not match');
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
@@ -93,6 +86,12 @@ export class AuthService {
const tokens = await this.generateAuthToken(user);
this.logger.log(`User with phone number ${user.fullPhoneNumber} verified successfully`);
// Register/update device with FCM token if provided
if (verifyUserDto.fcmToken && verifyUserDto.deviceId) {
await this.registerDeviceToken(user.id, verifyUserDto.deviceId, verifyUserDto.fcmToken);
}
return [tokens, user];
}
@@ -196,11 +195,14 @@ export class AuthService {
this.logger.log(`Password changed successfully for user with id ${userId}`);
}
async setJuniorPasscode(body: setJuniorPasswordRequestDto) {
async setJuniorPassword(body: setJuniorPasswordRequestDto) {
this.logger.log(`Setting passcode for junior with qrToken ${body.qrToken}`);
if (body.newPassword != body.confirmNewPassword) {
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
}
const juniorId = await this.userTokenService.validateToken(body.qrToken, UserType.JUNIOR);
const salt = bcrypt.genSaltSync(SALT_ROUNDS);
const hashedPasscode = bcrypt.hashSync(body.passcode, salt);
const hashedPasscode = bcrypt.hashSync(body.newPassword, salt);
await this.userService.setPassword(juniorId!, hashedPasscode, salt);
await this.userTokenService.invalidateToken(body.qrToken);
this.logger.log(`Passcode set successfully for junior with id ${juniorId}`);
@@ -275,9 +277,78 @@ export class AuthService {
const tokens = await this.generateAuthToken(user);
this.logger.log(`Password validated successfully for user`);
// Register/update device with FCM token if provided
if (loginDto.fcmToken && loginDto.deviceId) {
await this.registerDeviceToken(user.id, loginDto.deviceId, loginDto.fcmToken);
}
return [tokens, user];
}
async juniorLogin(juniorLoginDto: JuniorLoginRequestDto): Promise<[ILoginResponse, User]> {
const user = await this.userService.findUser({ email: juniorLoginDto.email });
if (!user || !user.roles.includes(Roles.JUNIOR)) {
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
}
this.logger.log(`validating password for user with email ${juniorLoginDto.email}`);
const isPasswordValid = bcrypt.compareSync(juniorLoginDto.password, user.password);
if (!isPasswordValid) {
this.logger.error(`Invalid password for user with email ${juniorLoginDto.email}`);
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
}
const tokens = await this.generateAuthToken(user);
this.logger.log(`Password validated successfully for user`);
// Register/update device with FCM token if provided
if (juniorLoginDto.fcmToken && juniorLoginDto.deviceId) {
await this.registerDeviceToken(user.id, juniorLoginDto.deviceId, juniorLoginDto.fcmToken);
}
return [tokens, user];
}
/**
* Register or update device with FCM token
* This method handles both new device registration and existing device updates
*/
private async registerDeviceToken(userId: string, deviceId: string, fcmToken: string): Promise<void> {
try {
this.logger.log(`Registering/updating device ${deviceId} with FCM token for user ${userId}`);
// Check if device already exists for this user
const existingDevice = await this.deviceService.findUserDeviceById(deviceId, userId);
if (existingDevice) {
// Update existing device with new FCM token and last access time
await this.deviceService.updateDevice(deviceId, {
fcmToken,
userId,
lastAccessOn: new Date(),
});
this.logger.log(`Device ${deviceId} updated with new FCM token for user ${userId}`);
} else {
// Create new device
await this.deviceService.createDevice({
deviceId,
userId,
fcmToken,
lastAccessOn: new Date(),
});
this.logger.log(`New device ${deviceId} registered with FCM token for user ${userId}`);
}
} catch (error) {
// Log error but don't fail the login/signup process
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
this.logger.error(`Failed to register device token for user ${userId}: ${errorMessage}`, errorStack);
}
}
private async generateAuthToken(user: User) {
this.logger.log(`Generating auth token for user with id ${user.id}`);
const [accessToken, refreshToken] = await Promise.all([
+52 -5
View File
@@ -1,12 +1,14 @@
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, 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 { FundIbanRequestDto } from '../dtos/requests';
import { AccountIbanResponseDto, CardResponseDto, ChildCardResponseDto } from '../dtos/responses';
import { CardService } from '../services';
@Controller('cards')
@@ -23,6 +25,33 @@ export class CardsController {
return ResponseFactory.data(new CardResponseDto(card));
}
@Get('child-cards')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(ChildCardResponseDto)
async getChildCards(@AuthenticatedUser() { sub }: IJwtPayload) {
const cards = await this.cardService.getChildCards(sub);
return ResponseFactory.data(cards.map((card) => new ChildCardResponseDto(card)));
}
@Get('child-cards/:childid')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(ChildCardResponseDto)
async getChildCardById(@Param('childid') childId: string, @AuthenticatedUser() { sub }: IJwtPayload) {
const card = await this.cardService.getCardByChildId(sub, childId);
return ResponseFactory.data(new ChildCardResponseDto(card));
}
@Get('child-cards/:cardid/embossing-details')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(CardEmbossingDetailsResponseDto)
async getChildCardEmbossingDetails(@Param('cardid') cardId: string, @AuthenticatedUser() { sub }: IJwtPayload) {
const res = await this.cardService.getChildCardEmbossingInformation(cardId, sub);
return ResponseFactory.data(res);
}
@Get('current')
@ApiDataResponse(CardResponseDto)
async getCurrentCard(@AuthenticatedUser() { sub }: IJwtPayload) {
@@ -36,4 +65,22 @@ 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));
}
@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);
}
}
@@ -0,0 +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;
}
+1
View File
@@ -0,0 +1 @@
export * from './fund-iban.request.dto';
@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
export class AccountIbanResponseDto {
@ApiProperty({ example: 'DE89370400440532013000' })
iban!: string;
constructor(iban: string) {
this.iban = iban;
}
}
+11 -1
View File
@@ -43,6 +43,13 @@ export class CardResponseDto {
})
balance!: number;
@ApiProperty({
example: 100.0,
nullable: true,
description: 'The reserved balance of the card (applicable for child accounts).',
})
reservedBalance!: number | null;
constructor(card: Card) {
this.id = card.id;
this.firstSixDigits = card.firstSixDigits;
@@ -51,6 +58,9 @@ export class CardResponseDto {
this.status = card.status;
this.statusDescription = CardStatusDescriptionMapper[card.statusDescription][UserLocale.ENGLISH].description;
this.balance =
card.customerType === CustomerType.CHILD ? Math.min(card.limit, card.account.balance) : card.account.balance;
card.customerType === CustomerType.CHILD
? Math.min(card.limit, card.account.balance)
: card.account.balance - card.account.reservedBalance;
this.reservedBalance = card.customerType === CustomerType.PARENT ? card.account.reservedBalance : null;
}
}
@@ -0,0 +1,48 @@
import { ApiProperty } from '@nestjs/swagger';
import { Card } from '~/card/entities';
import { Gender } from '~/customer/enums';
import { DocumentMetaResponseDto } from '~/document/dtos/response';
import { CardResponseDto } from './card.response.dto';
class JuniorInfo {
@ApiProperty({ example: 'id' })
id!: string;
@ApiProperty({ example: 'FirstName' })
firstName!: string;
@ApiProperty({ example: 'LastName' })
lastName!: string;
@ApiProperty({ example: '[email protected]' })
email!: string;
@ApiProperty({ enum: Gender, example: Gender.MALE })
gender!: Gender;
@ApiProperty({ example: '2000-01-01' })
dateOfBirth!: Date;
@ApiProperty({ example: DocumentMetaResponseDto, nullable: true })
profilePicture!: DocumentMetaResponseDto | null;
constructor(card: Card) {
this.id = card.customer?.junior?.id;
this.firstName = card.customer?.firstName;
this.lastName = card.customer?.lastName;
this.email = card.customer?.user?.email;
this.gender = card.customer.gender;
this.profilePicture = card.customer?.user?.profilePicture
? new DocumentMetaResponseDto(card.customer.user.profilePicture)
: null;
}
}
export class ChildCardResponseDto extends CardResponseDto {
@ApiProperty({ type: JuniorInfo })
junior!: JuniorInfo | null;
constructor(card: Card) {
super(card);
this.junior = card.customer?.junior ? new JuniorInfo(card) : null;
}
}
@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
export class ChildTransferItemDto {
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
date!: Date;
@ApiProperty({ example: 50.0 })
amount!: number;
@ApiProperty({ example: 'SAR' })
currency!: string;
@ApiProperty({ example: 'You received {{amount}} {{currency}} from your parent.' })
message!: string;
}
@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { TransactionItemResponseDto } from './transaction-item.response.dto';
export class GuardianHomeResponseDto {
@ApiProperty({ example: 2000.0 })
availableBalance!: number;
@ApiProperty({ type: [TransactionItemResponseDto] })
recentTransactions!: TransactionItemResponseDto[];
constructor(availableBalance: number, recentTransactions: TransactionItemResponseDto[]) {
this.availableBalance = availableBalance;
this.recentTransactions = recentTransactions;
}
}
+14
View File
@@ -1 +1,15 @@
export * from './account-iban.response.dto';
export * from './card.response.dto';
export * from './child-card.response.dto';
export * from './transaction-item.response.dto';
export * from './guardian-home.response.dto';
export * from './paged-transactions.response.dto';
export * from './parent-transfer-item.response.dto';
export * from './parent-home.response.dto';
export * from './paged-parent-transfers.response.dto';
export * from './child-transfer-item.response.dto';
export * from './junior-home.response.dto';
export * from './paged-child-transfers.response.dto';
export * from './spending-history-item.response.dto';
export * from './spending-history.response.dto';
export * from './transaction-detail.response.dto';
@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { ChildTransferItemDto } from './child-transfer-item.response.dto';
export class JuniorHomeResponseDto {
@ApiProperty({ example: 500.0 })
availableBalance!: number;
@ApiProperty({ type: [ChildTransferItemDto] })
recentTransfers!: ChildTransferItemDto[];
constructor(availableBalance: number, recentTransfers: ChildTransferItemDto[]) {
this.availableBalance = availableBalance;
this.recentTransfers = recentTransfers;
}
}
@@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import { ChildTransferItemDto } from './child-transfer-item.response.dto';
export class PagedChildTransfersResponseDto {
@ApiProperty({ type: [ChildTransferItemDto] })
items!: ChildTransferItemDto[];
@ApiProperty({ example: 1 })
page!: number;
@ApiProperty({ example: 10 })
size!: number;
@ApiProperty({ example: 20 })
total!: number;
@ApiProperty({ example: true })
hasMore!: boolean;
constructor(
items: ChildTransferItemDto[],
page: number,
size: number,
total: number,
) {
this.items = items;
this.page = page;
this.size = size;
this.total = total;
this.hasMore = page * size < total;
}
}
@@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import { ParentTransferItemDto } from './parent-transfer-item.response.dto';
export class PagedParentTransfersResponseDto {
@ApiProperty({ type: [ParentTransferItemDto] })
items!: ParentTransferItemDto[];
@ApiProperty({ example: 1 })
page!: number;
@ApiProperty({ example: 10 })
size!: number;
@ApiProperty({ example: 45 })
total!: number;
@ApiProperty({ example: true })
hasMore!: boolean;
constructor(
items: ParentTransferItemDto[],
page: number,
size: number,
total: number,
) {
this.items = items;
this.page = page;
this.size = size;
this.total = total;
this.hasMore = page * size < total;
}
}
@@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import { TransactionItemResponseDto } from './transaction-item.response.dto';
export class PagedTransactionsResponseDto {
@ApiProperty({ type: [TransactionItemResponseDto] })
items!: TransactionItemResponseDto[];
@ApiProperty({ example: 1 })
page!: number;
@ApiProperty({ example: 10 })
size!: number;
@ApiProperty({ example: 45 })
total!: number;
@ApiProperty({ example: true })
hasMore!: boolean;
constructor(
items: TransactionItemResponseDto[],
page: number,
size: number,
total: number,
) {
this.items = items;
this.page = page;
this.size = size;
this.total = total;
this.hasMore = page * size < total;
}
}
@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { ParentTransferItemDto } from './parent-transfer-item.response.dto';
export class ParentHomeResponseDto {
@ApiProperty({ example: 2000.0 })
availableBalance!: number;
@ApiProperty({ type: [ParentTransferItemDto] })
recentTransfers!: ParentTransferItemDto[];
constructor(availableBalance: number, recentTransfers: ParentTransferItemDto[]) {
this.availableBalance = availableBalance;
this.recentTransfers = recentTransfers;
}
}
@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
export class ParentTransferItemDto {
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
date!: Date;
@ApiProperty({ example: 50.0 })
amount!: number;
@ApiProperty({ example: 'SAR' })
currency!: string;
@ApiProperty({ example: 'Ahmed Ali' })
childName!: string;
}
@@ -0,0 +1,58 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transaction } from '~/card/entities/transaction.entity';
export class SpendingHistoryItemDto {
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
date!: Date;
@ApiProperty({ example: 50.5 })
amount!: number;
@ApiProperty({ example: 'SAR' })
currency!: string;
@ApiPropertyOptional({ example: 'Shopping' })
category!: string | null;
@ApiPropertyOptional({ example: 'Target Store' })
merchantName!: string | null;
@ApiPropertyOptional({ example: 'Riyadh' })
merchantCity!: string | null;
@ApiProperty({ example: '277012*****3456' })
cardMasked!: string;
@ApiProperty()
transactionId!: string;
constructor(transaction: Transaction) {
this.date = transaction.transactionDate;
this.amount = transaction.transactionAmount;
this.currency = transaction.transactionCurrency === '682' ? 'SAR' : transaction.transactionCurrency;
this.category = this.mapMccToCategory(transaction.merchantCategoryCode);
this.merchantName = transaction.merchantName;
this.merchantCity = transaction.merchantCity;
this.cardMasked = transaction.cardMaskedNumber;
this.transactionId = transaction.id;
}
private mapMccToCategory(mcc: string | null): string {
if (!mcc) return 'Other';
const mccCode = mcc;
// Map MCC codes to categories
if (mccCode >= '5200' && mccCode <= '5599') return 'Shopping';
if (mccCode >= '5800' && mccCode <= '5899') return 'Food & Dining';
if (mccCode >= '3000' && mccCode <= '3999') return 'Travel';
if (mccCode >= '4000' && mccCode <= '4799') return 'Transportation';
if (mccCode >= '7200' && mccCode <= '7999') return 'Entertainment';
if (mccCode >= '5900' && mccCode <= '5999') return 'Services';
if (mccCode >= '4800' && mccCode <= '4899') return 'Utilities';
if (mccCode >= '8000' && mccCode <= '8999') return 'Health & Wellness';
return 'Other';
}
}
@@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { SpendingHistoryItemDto } from './spending-history-item.response.dto';
export class SpendingHistoryResponseDto {
@ApiProperty({ type: [SpendingHistoryItemDto] })
transactions!: SpendingHistoryItemDto[];
@ApiProperty({ example: 150.75 })
totalSpent!: number;
@ApiProperty({ example: 'SAR' })
currency!: string;
@ApiProperty({ example: 10 })
count!: number;
constructor(transactions: SpendingHistoryItemDto[], currency: string = 'SAR') {
this.transactions = transactions;
this.totalSpent = transactions.reduce((sum, tx) => sum + tx.amount, 0);
this.currency = currency;
this.count = transactions.length;
}
}
@@ -0,0 +1,74 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transaction } from '~/card/entities/transaction.entity';
export class TransactionDetailResponseDto {
@ApiProperty()
id!: string;
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
date!: Date;
@ApiProperty({ example: 50.5 })
amount!: number;
@ApiProperty({ example: 'SAR' })
currency!: string;
@ApiProperty({ example: 2.5 })
fees!: number;
@ApiProperty({ example: 0.5 })
vatOnFees!: number;
@ApiPropertyOptional({ example: 'Target Store' })
merchantName!: string | null;
@ApiPropertyOptional({ example: 'Shopping' })
category!: string | null;
@ApiPropertyOptional({ example: 'Riyadh' })
merchantCity!: string | null;
@ApiProperty({ example: '277012*****3456' })
cardMasked!: string;
@ApiProperty()
rrn!: string;
@ApiProperty()
transactionId!: string;
constructor(transaction: Transaction) {
this.id = transaction.id;
this.date = transaction.transactionDate;
this.amount = transaction.transactionAmount;
this.currency = transaction.transactionCurrency === '682' ? 'SAR' : transaction.transactionCurrency;
this.fees = transaction.fees;
this.vatOnFees = transaction.vatOnFees;
this.merchantName = transaction.merchantName;
this.category = this.mapMccToCategory(transaction.merchantCategoryCode);
this.merchantCity = transaction.merchantCity;
this.cardMasked = transaction.cardMaskedNumber;
this.rrn = transaction.rrn;
this.transactionId = transaction.transactionId;
}
private mapMccToCategory(mcc: string | null): string {
if (!mcc) return 'Other';
const mccCode = mcc;
// Map MCC codes to categories
if (mccCode >= '5200' && mccCode <= '5599') return 'Shopping';
if (mccCode >= '5800' && mccCode <= '5899') return 'Food & Dining';
if (mccCode >= '3000' && mccCode <= '3999') return 'Travel';
if (mccCode >= '4000' && mccCode <= '4799') return 'Transportation';
if (mccCode >= '7200' && mccCode <= '7999') return 'Entertainment';
if (mccCode >= '5900' && mccCode <= '5999') return 'Services';
if (mccCode >= '4800' && mccCode <= '4899') return 'Utilities';
if (mccCode >= '8000' && mccCode <= '8999') return 'Health & Wellness';
return 'Other';
}
}
@@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { ParentTransactionType } from '~/card/enums';
export class TransactionItemResponseDto {
@ApiProperty()
date!: Date;
@ApiProperty({ example: -50.0 })
amountSigned!: number;
@ApiProperty({ enum: ParentTransactionType })
type!: ParentTransactionType;
@ApiProperty({ description: 'Counterparty display name (child for transfer, source label for top-up)' })
counterpartyName!: string;
@ApiProperty({ nullable: true })
counterpartyAccountMasked!: string | null;
@ApiProperty({ required: false })
childName?: string;
}
+22 -1
View File
@@ -22,9 +22,30 @@ export class Account {
@Column('varchar', { length: 255, nullable: false, name: 'currency' })
currency!: string;
@Column('decimal', { precision: 10, scale: 2, default: 0.0, name: 'balance' })
@Column('decimal', {
precision: 10,
scale: 2,
default: 0.0,
name: 'balance',
transformer: {
to: (value: number) => value,
from: (value: string) => parseFloat(value),
},
})
balance!: number;
@Column('decimal', {
precision: 10,
scale: 2,
default: 0.0,
name: 'reserved_balance',
transformer: {
to: (value: number) => value,
from: (value: string) => parseFloat(value),
},
})
reservedBalance!: number;
@OneToMany(() => Card, (card) => card.account, { cascade: true })
cards!: Card[];
+1 -1
View File
@@ -39,7 +39,7 @@ export class Card {
@Column({ type: 'varchar', nullable: false, name: 'customer_type' })
customerType!: CustomerType;
@Column({ type: 'varchar', nullable: false, default: CardColors.BLUE })
@Column({ type: 'varchar', nullable: false, default: CardColors.DEEP_MAGENTA })
color!: CardColors;
@Column({ type: 'varchar', nullable: false, default: CardStatus.PENDING })
+19 -1
View File
@@ -32,7 +32,16 @@ export class Transaction {
@Column({ name: 'rrn', nullable: true, type: 'varchar' })
rrn!: string;
@Column({ type: 'decimal', precision: 12, scale: 2, name: 'transaction_amount' })
@Column({
type: 'decimal',
precision: 12,
scale: 2,
name: 'transaction_amount',
transformer: {
to: (value: number) => value,
from: (value: string) => parseFloat(value),
},
})
transactionAmount!: number;
@Column({ type: 'varchar', name: 'transaction_currency' })
@@ -50,6 +59,15 @@ export class Transaction {
@Column({ type: 'decimal', name: 'vat_on_fees', precision: 12, scale: 2, default: 0.0 })
vatOnFees!: number;
@Column({ name: 'merchant_name', type: 'varchar', nullable: true })
merchantName!: string | null;
@Column({ name: 'merchant_category_code', type: 'varchar', nullable: true })
merchantCategoryCode!: string | null;
@Column({ name: 'merchant_city', type: 'varchar', nullable: true })
merchantCity!: string | null;
@Column({ name: 'card_id', type: 'uuid', nullable: true })
cardId!: string;
+11 -2
View File
@@ -1,4 +1,13 @@
export enum CardColors {
RED = 'RED',
BLUE = 'BLUE',
RAINBOW_PASTEL = 'RAINBOW_PASTEL',
DEEP_MAGENTA = 'DEEP_MAGENTA',
GREEN_TEAL = 'GREEN_TEAL',
BLUE_GREEN = 'BLUE_GREEN',
TEAL_NAVY = 'TEAL_NAVY',
PURPLE_PINK = 'PURPLE_PINK',
GOLD_BLUE = 'GOLD_BLUE',
OCEAN_BLUE = 'OCEAN_BLUE',
BROWN_RUST = 'BROWN_RUST',
}
+1
View File
@@ -6,3 +6,4 @@ export * from './card-status.enum';
export * from './customer-type.enum';
export * from './transaction-scope.enum';
export * from './transaction-type.enum';
export * from './parent-transaction-type.enum';
@@ -0,0 +1,6 @@
export enum ParentTransactionType {
PARENT_TRANSFER = 'PARENT_TRANSFER',
PARENT_TOPUP = 'PARENT_TOPUP',
}
@@ -27,6 +27,13 @@ export class AccountRepository {
});
}
getAccountByIban(iban: string): Promise<Account | null> {
return this.accountRepository.findOne({
where: { iban },
relations: ['cards'],
});
}
getAccountByAccountNumber(accountNumber: string): Promise<Account | null> {
return this.accountRepository.findOne({
where: { accountNumber },
@@ -34,6 +41,13 @@ export class AccountRepository {
});
}
getAccountByCustomerId(customerId: string): Promise<Account | null> {
return this.accountRepository.findOne({
where: { cards: { customerId } },
relations: ['cards'],
});
}
topUpAccountBalance(accountReference: string, amount: number) {
return this.accountRepository.increment({ accountReference }, 'balance', amount);
}
@@ -41,4 +55,12 @@ export class AccountRepository {
decreaseAccountBalance(accountReference: string, amount: number) {
return this.accountRepository.decrement({ accountReference }, 'balance', amount);
}
increaseReservedBalance(accountId: string, amount: number) {
return this.accountRepository.increment({ id: accountId }, 'reservedBalance', amount);
}
decreaseReservedBalance(accountId: string, amount: number) {
return this.accountRepository.decrement({ id: accountId }, 'reservedBalance', amount);
}
}
+60 -6
View File
@@ -9,26 +9,58 @@ import { CardColors, CardIssuers, CardScheme, CardStatus, CardStatusDescription,
export class CardRepository {
constructor(@InjectRepository(Card) private readonly cardRepository: Repository<Card>) {}
createCard(customerId: string, accountId: string, card: CreateApplicationResponse): Promise<Card> {
createCard(
customerId: string,
accountId: string,
card: CreateApplicationResponse,
cardColor?: CardColors,
parentId?: string,
): Promise<Card> {
return this.cardRepository.save(
this.cardRepository.create({
customerId: customerId,
expiry: card.expiryDate,
cardReference: card.cardId,
customerType: CustomerType.PARENT,
customerType: parentId ? CustomerType.CHILD : CustomerType.PARENT,
firstSixDigits: card.firstSixDigits,
lastFourDigits: card.lastFourDigits,
color: CardColors.BLUE,
color: cardColor ? cardColor : CardColors.DEEP_MAGENTA,
scheme: CardScheme.VISA,
issuer: CardIssuers.NEOLEAP,
accountId: accountId,
vpan: card.vpan,
parentId,
}),
);
}
findChildCardsForGuardian(guardianId: string): Promise<Card[]> {
return this.cardRepository.find({
where: { parentId: guardianId, customerType: CustomerType.CHILD },
relations: ['account', 'customer', 'customer.user', 'customer.user.profilePicture', 'customer.junior'],
});
}
getCardById(id: string): Promise<Card | null> {
return this.cardRepository.findOne({ where: { id }, relations: ['account'] });
return this.cardRepository.findOne({
where: { id },
relations: [
'account',
'customer',
'customer.user',
'customer.junior',
'customer.junior.guardian',
'customer.junior.guardian.customer',
'customer.junior.guardian.customer.user',
],
});
}
findCardByChildId(guardianId: string, childId: string): Promise<Card | null> {
return this.cardRepository.findOne({
where: { parentId: guardianId, customerId: childId, customerType: CustomerType.CHILD },
relations: ['account', 'customer', 'customer.user', 'customer.user.profilePicture', 'customer.junior'],
});
}
getCardByReferenceNumber(referenceNumber: string): Promise<Card | null> {
@@ -38,14 +70,30 @@ export class CardRepository {
getCardByVpan(vpan: string): Promise<Card | null> {
return this.cardRepository.findOne({
where: { vpan },
relations: ['account'],
relations: [
'account',
'customer',
'customer.user',
'customer.junior',
'customer.junior.guardian',
'customer.junior.guardian.customer',
'customer.junior.guardian.customer.user',
],
});
}
getCardByCustomerId(customerId: string): Promise<Card | null> {
return this.cardRepository.findOne({
where: { customerId },
relations: ['account'],
relations: [
'account',
'customer',
'customer.user',
'customer.junior',
'customer.junior.guardian',
'customer.junior.guardian.customer',
'customer.junior.guardian.customer.user',
],
});
}
@@ -55,4 +103,10 @@ export class CardRepository {
statusDescription: statusDescription,
});
}
updateCardLimit(cardId: string, newLimit: number) {
return this.cardRepository.update(cardId, {
limit: newLimit,
});
}
}
+2
View File
@@ -1 +1,3 @@
export * from './card.repository';
export * from './transaction.repository';
export * from './account.repository';
@@ -34,6 +34,9 @@ export class TransactionRepository {
accountReference: card.account!.accountReference,
transactionScope: TransactionScope.CARD,
vatOnFees: transactionData.vatOnFees,
merchantName: transactionData.cardAcceptorLocation?.merchantName || null,
merchantCategoryCode: transactionData.cardAcceptorLocation?.mcc || null,
merchantCity: transactionData.cardAcceptorLocation?.merchantCity || null,
}),
);
}
@@ -57,9 +60,124 @@ export class TransactionRepository {
);
}
createInternalChildTransaction(card: Card, amount: number): Promise<Transaction> {
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<Transaction | null> {
return this.transactionRepository.findOne({
where: { transactionId, accountReference },
});
}
getTransactionsForCardWithinDateRange(juniorId: string, startDate: Date, endDate: Date): Promise<Transaction[]> {
return this.transactionRepository
.createQueryBuilder('transaction')
.innerJoinAndSelect('transaction.card', 'card')
.where('card.customerId = :juniorId', { juniorId })
.andWhere('transaction.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('transaction.transactionType = :type', { type: TransactionType.EXTERNAL })
.andWhere('transaction.transactionDate BETWEEN :startDate AND :endDate', { startDate, endDate })
.orderBy('transaction.transactionDate', 'DESC')
.getMany();
}
findParentTransfers(guardianCustomerId: string, skip: number, take: number): Promise<Transaction[]> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoinAndSelect('tx.card', 'card')
.innerJoinAndSelect('card.customer', 'childCustomer')
.innerJoinAndSelect('card.account', 'account')
.where('card.parentId = :guardianCustomerId', { guardianCustomerId })
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
.orderBy('tx.transactionDate', 'DESC')
.skip(skip)
.take(take)
.getMany();
}
findParentTopups(guardianCustomerId: string, skip: number, take: number): Promise<Transaction[]> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoinAndSelect('tx.account', 'account')
.leftJoinAndSelect('account.cards', 'parentCards')
.where('tx.transactionScope = :scope', { scope: TransactionScope.ACCOUNT })
.andWhere('parentCards.customerId = :guardianCustomerId', { guardianCustomerId })
.orderBy('tx.transactionDate', 'DESC')
.skip(skip)
.take(take)
.getMany();
}
countParentTransfers(guardianCustomerId: string): Promise<number> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoin('tx.card', 'card')
.where('card.parentId = :guardianCustomerId', { guardianCustomerId })
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
.getCount();
}
countParentTopups(guardianCustomerId: string): Promise<number> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoin('tx.account', 'account')
.leftJoin('account.cards', 'parentCards')
.where('tx.transactionScope = :scope', { scope: TransactionScope.ACCOUNT })
.andWhere('parentCards.customerId = :guardianCustomerId', { guardianCustomerId })
.getCount();
}
findTransfersToJunior(juniorId: string, skip: number, take: number): Promise<Transaction[]> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoinAndSelect('tx.card', 'card')
.innerJoinAndSelect('card.account', 'account')
.where('card.customerId = :juniorId', { juniorId })
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
.orderBy('tx.transactionDate', 'DESC')
.skip(skip)
.take(take)
.getMany();
}
countTransfersToJunior(juniorId: string): Promise<number> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoin('tx.card', 'card')
.where('card.customerId = :juniorId', { juniorId })
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
.getCount();
}
findTransactionById(transactionId: string, juniorId: string): Promise<Transaction | null> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoinAndSelect('tx.card', 'card')
.where('tx.id = :transactionId', { transactionId })
.andWhere('card.customerId = :juniorId', { juniorId })
.getOne();
}
}
+36 -2
View File
@@ -27,15 +27,33 @@ export class AccountService {
return account;
}
async creditAccountBalance(accountReference: string, amount: number) {
async getAccountByIban(iban: string): Promise<Account> {
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);
}
async getAccountByCustomerId(customerId: string): Promise<Account> {
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);
/**
*
* While there is no need to check for insufficient balance because this is a webhook handler,
* I just added this check to ensure we don't have corruption in our data especially if this service is used elsewhere.
* I just added this check to ensure we don't have corruption in our data.
*/
if (account.balance < amount) {
@@ -44,4 +62,20 @@ export class AccountService {
return this.accountRepository.decreaseAccountBalance(accountReference, amount);
}
increaseReservedBalance(account: Account, amount: number) {
// Balance check is performed by the caller (e.g., transferToChild)
// to ensure correct account (guardian vs child) is validated
return this.accountRepository.increaseReservedBalance(account.id, amount);
}
decrementReservedBalance(account: Account, amount: number) {
return this.accountRepository.decreaseReservedBalance(account.id, 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);
}
}
+125 -1
View File
@@ -1,19 +1,27 @@
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
import { BadRequestException, forwardRef, Inject, Injectable, Logger } 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';
import { Customer } from '~/customer/entities';
import { KycStatus } from '~/customer/enums';
import { CustomerService } from '~/customer/services';
import { OciService } from '~/document/services';
import { Card } from '../entities';
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 {
private readonly logger = new Logger(CardService.name);
constructor(
private readonly cardRepository: CardRepository,
private readonly accountService: AccountService,
private readonly ociService: OciService,
@Inject(forwardRef(() => TransactionService)) private readonly transactionService: TransactionService,
@Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService,
@Inject(forwardRef(() => CustomerService)) private readonly customerService: CustomerService,
) {}
@@ -26,10 +34,26 @@ export class CardService {
throw new BadRequestException('CUSTOMER.KYC_NOT_APPROVED');
}
if (!customer.neoleapExternalCustomerId) {
throw new BadRequestException('CUSTOMER.KYC_NOT_COMPLETED');
}
if (customer.cards.length > 0) {
throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD');
}
// Validate required fields for card creation
const missingFields = [];
if (!customer.nationalId) missingFields.push('nationalId');
if (!customer.dateOfBirth) missingFields.push('dateOfBirth');
if (!customer.nationalIdExpiry) missingFields.push('nationalIdExpiry');
if (missingFields.length > 0) {
throw new BadRequestException(
`CUSTOMER.MISSING_REQUIRED_FIELDS: ${missingFields.join(', ')}. Please complete your profile.`
);
}
const data = await this.neoleapService.createApplication(customer);
const account = await this.accountService.createAccount(data);
const createdCard = await this.cardRepository.createCard(customerId, account.id, data);
@@ -37,6 +61,33 @@ export class CardService {
return this.getCardById(createdCard.id);
}
async getChildCards(guardianId: string): Promise<Card[]> {
const cards = await this.cardRepository.findChildCardsForGuardian(guardianId);
await this.prepareJuniorImages(cards);
return cards;
}
async createCardForChild(parentCustomer: Customer, childCustomer: Customer, cardColor: CardColors, cardPin: string) {
const data = await this.neoleapService.createChildCard(parentCustomer, childCustomer, cardPin);
const createdCard = await this.cardRepository.createCard(
childCustomer.id,
parentCustomer.cards[0].account.id,
data,
cardColor,
parentCustomer.id,
);
return this.getCardById(createdCard.id);
}
async getCardByChildId(guardianId: string, childId: string): Promise<Card> {
const card = await this.cardRepository.findCardByChildId(guardianId, childId);
if (!card) {
throw new BadRequestException('CARD.NOT_FOUND');
}
await this.prepareJuniorImages([card]);
return card;
}
async getCardById(id: string): Promise<Card> {
const card = await this.cardRepository.getCardById(id);
@@ -71,6 +122,7 @@ export class CardService {
if (!card) {
throw new BadRequestException('CARD.NOT_FOUND');
}
return card;
}
@@ -86,4 +138,76 @@ export class CardService {
return this.neoleapService.getEmbossingInformation(card);
}
async getChildCardEmbossingInformation(cardId: string, guardianId: string) {
const card = await this.getCardById(cardId);
if (card.parentId !== guardianId) {
throw new BadRequestException('CARD.DOES_NOT_BELONG_TO_GUARDIAN');
}
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');
}
}
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);
this.logger.debug(`Transfer to child - juniorId: ${juniorId}, parentId: ${card.parentId}, cardId: ${card.id}`);
this.logger.debug(`Card account - balance: ${card.account.balance}, reserved: ${card.account.reservedBalance}`);
const fundingAccount = card.parentId
? await this.accountService.getAccountByCustomerId(card.parentId)
: card.account;
this.logger.debug(`Funding account - balance: ${fundingAccount.balance}, reserved: ${fundingAccount.reservedBalance}, available: ${fundingAccount.balance - fundingAccount.reservedBalance}`);
this.logger.debug(`Amount requested: ${amount}`);
if (amount > fundingAccount.balance - fundingAccount.reservedBalance) {
this.logger.error(`Insufficient balance - requested: ${amount}, available: ${fundingAccount.balance - fundingAccount.reservedBalance}`);
throw new BadRequestException('CARD.INSUFFICIENT_BALANCE');
}
const finalAmount = Decimal(amount).plus(card.limit);
await Promise.all([
this.neoleapService.updateCardControl(card.cardReference, finalAmount.toNumber()),
this.updateCardLimit(card.id, finalAmount.toNumber()),
this.accountService.increaseReservedBalance(fundingAccount, amount),
this.transactionService.createInternalChildTransaction(card.id, amount),
]);
return finalAmount.toNumber();
}
getWeeklySummary(juniorId: string, startDate?: Date, endDate?: Date) {
return this.transactionService.getWeeklySummary(juniorId, startDate, endDate);
}
fundIban(iban: string, amount: number) {
return this.accountService.fundIban(iban, amount);
}
private async prepareJuniorImages(cards: Card[]) {
this.logger.log(`Preparing junior images`);
await Promise.all(
cards.map(async (card) => {
const profilePicture = card.customer?.user?.profilePicture;
if (profilePicture) {
profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture);
}
}),
);
}
}
+2
View File
@@ -1 +1,3 @@
export * from './card.service';
export * from './transaction.service';
export * from './account.service';
+303 -3
View File
@@ -1,21 +1,36 @@
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import { forwardRef, Inject, Injectable, UnprocessableEntityException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import Decimal from 'decimal.js';
import moment from 'moment';
import { Transactional } from 'typeorm-transactional';
import {
AccountTransactionWebhookRequest,
CardTransactionWebhookRequest,
} from '~/common/modules/neoleap/dtos/requests';
import { NOTIFICATION_EVENTS } from '~/common/modules/notification/constants/event-names.constant';
import { ITransactionCreatedEvent } from '~/common/modules/notification/interfaces/notification-events.interface';
import { Transaction } from '../entities/transaction.entity';
import { CustomerType, TransactionType } from '../enums';
import { TransactionRepository } from '../repositories/transaction.repository';
import { AccountService } from './account.service';
import { CardService } from './card.service';
import {
TransactionItemResponseDto,
PagedTransactionsResponseDto,
ParentTransferItemDto,
PagedParentTransfersResponseDto,
ChildTransferItemDto,
PagedChildTransfersResponseDto,
} from '../dtos/responses';
import { ParentTransactionType } from '../enums';
@Injectable()
export class TransactionService {
constructor(
private readonly transactionRepository: TransactionRepository,
private readonly accountService: AccountService,
private readonly cardService: CardService,
@Inject(forwardRef(() => CardService)) private readonly cardService: CardService,
private readonly eventEmitter: EventEmitter2,
) {}
@Transactional()
@@ -30,7 +45,31 @@ export class TransactionService {
const transaction = await this.transactionRepository.createCardTransaction(card, body);
const total = new Decimal(body.transactionAmount).plus(body.billingAmount).plus(body.fees).plus(body.vatOnFees);
await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber());
if (card.customerType === CustomerType.CHILD) {
if (card.parentId) {
const parentAccount = await this.accountService.getAccountByCustomerId(card.parentId);
await Promise.all([
this.accountService.decreaseAccountBalance(parentAccount.accountReference, total.toNumber()),
this.accountService.decrementReservedBalance(parentAccount, total.toNumber()),
]);
} else {
await Promise.all([
this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()),
this.accountService.decrementReservedBalance(card.account, total.toNumber()),
]);
}
} else {
await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber());
}
const event: ITransactionCreatedEvent = {
transaction,
card,
isTopUp: false,
isChildSpending: card.customerType === CustomerType.CHILD,
timestamp: new Date(),
};
this.eventEmitter.emit(NOTIFICATION_EVENTS.TRANSACTION_CREATED, event);
return transaction;
}
@@ -48,6 +87,38 @@ export class TransactionService {
const transaction = await this.transactionRepository.createAccountTransaction(account, body);
await this.accountService.creditAccountBalance(account.accountReference, body.amount);
const accountWithCards = await this.accountService.getAccountByAccountNumber(body.accountId);
const card = accountWithCards.cards?.[0]
? await this.cardService.getCardById(accountWithCards.cards[0].id)
: null;
if (card) {
const event: ITransactionCreatedEvent = {
transaction,
card,
isTopUp: true,
isChildSpending: false,
timestamp: new Date(),
};
this.eventEmitter.emit(NOTIFICATION_EVENTS.TRANSACTION_CREATED, event);
}
return transaction;
}
async createInternalChildTransaction(cardId: string, amount: number) {
const card = await this.cardService.getCardById(cardId);
const transaction = await this.transactionRepository.createInternalChildTransaction(card, amount);
const event: ITransactionCreatedEvent = {
transaction,
card,
isTopUp: true,
isChildSpending: true,
timestamp: new Date(),
};
this.eventEmitter.emit(NOTIFICATION_EVENTS.TRANSACTION_CREATED, event);
return transaction;
}
@@ -59,4 +130,233 @@ export class TransactionService {
return existingTransaction;
}
async getWeeklySummary(juniorId: string, startDate?: Date, endDate?: Date) {
let startOfWeek: Date;
let endOfWeek: Date;
if (startDate && endDate) {
startOfWeek = startDate;
endOfWeek = endDate;
} else {
const now = moment();
const dayOfWeek = now.day();
startOfWeek = moment().subtract(dayOfWeek, 'days').startOf('day').toDate();
endOfWeek = moment().add(6 - dayOfWeek, 'days').endOf('day').toDate();
}
const transactions = await this.transactionRepository.getTransactionsForCardWithinDateRange(
juniorId,
startOfWeek,
endOfWeek,
);
const summary = {
startOfWeek: startOfWeek,
endOfWeek: endOfWeek,
total: 0,
monday: 0,
tuesday: 0,
wednesday: 0,
thursday: 0,
friday: 0,
saturday: 0,
sunday: 0,
};
transactions.forEach((transaction) => {
const day = moment(transaction.transactionDate).format('dddd').toLowerCase() as
| 'monday'
| 'tuesday'
| 'wednesday'
| 'thursday'
| 'friday'
| 'saturday'
| 'sunday';
summary[day] += transaction.transactionAmount;
});
summary.total = transactions.reduce((acc, curr) => acc + curr.transactionAmount, 0);
return summary;
}
async getParentConsolidated(
guardianCustomerId: string,
page: number,
size: number,
): Promise<TransactionItemResponseDto[]> {
const skip = (page - 1) * size;
const [transfers, topups] = await Promise.all([
this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size),
this.transactionRepository.findParentTopups(guardianCustomerId, skip, size),
]);
const merged = [...transfers, ...topups].sort(
(a, b) => new Date(b.transactionDate).getTime() - new Date(a.transactionDate).getTime(),
);
const trimmed = merged.slice(0, size);
return trimmed.map((t) => this.mapParentItem(t));
}
async getParentTransactionsPaginated(
guardianCustomerId: string,
page: number,
size: number,
type?: ParentTransactionType,
): Promise<PagedTransactionsResponseDto> {
const skip = (page - 1) * size;
let transfers: Transaction[] = [];
let topups: Transaction[] = [];
let transferCount = 0;
let topupCount = 0;
if (!type || type === ParentTransactionType.PARENT_TRANSFER) {
[transfers, transferCount] = await Promise.all([
this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size),
this.transactionRepository.countParentTransfers(guardianCustomerId),
]);
}
if (!type || type === ParentTransactionType.PARENT_TOPUP) {
[topups, topupCount] = await Promise.all([
this.transactionRepository.findParentTopups(guardianCustomerId, skip, size),
this.transactionRepository.countParentTopups(guardianCustomerId),
]);
}
const total = transferCount + topupCount;
if (type) {
const items = type === ParentTransactionType.PARENT_TRANSFER ? transfers : topups;
const mapped = items.map((t) => this.mapParentItem(t));
return new PagedTransactionsResponseDto(mapped, page, size, total);
}
const merged = [...transfers, ...topups].sort(
(a, b) => new Date(b.transactionDate).getTime() - new Date(a.transactionDate).getTime(),
);
const paginated = merged.slice(0, size);
const mapped = paginated.map((t) => this.mapParentItem(t));
return new PagedTransactionsResponseDto(mapped, page, size, total);
}
async getParentTransfersOnly(guardianCustomerId: string, page: number, size: number): Promise<ParentTransferItemDto[]> {
const skip = (page - 1) * size;
const transfers = await this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size);
return transfers.map((t) => this.mapToParentTransferItem(t));
}
async getParentTransfersPaginated(
guardianCustomerId: string,
page: number,
size: number,
): Promise<PagedParentTransfersResponseDto> {
const skip = (page - 1) * size;
const [transfers, total] = await Promise.all([
this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size),
this.transactionRepository.countParentTransfers(guardianCustomerId),
]);
const items = transfers.map((t) => this.mapToParentTransferItem(t));
return new PagedParentTransfersResponseDto(items, page, size, total);
}
async getChildTransfers(juniorId: string, page: number, size: number): Promise<ChildTransferItemDto[]> {
const skip = (page - 1) * size;
const transfers = await this.transactionRepository.findTransfersToJunior(juniorId, skip, size);
return transfers.map((t) => this.mapToChildTransferItem(t));
}
async getChildTransfersPaginated(
juniorId: string,
page: number,
size: number,
): Promise<PagedChildTransfersResponseDto> {
const skip = (page - 1) * size;
const [transfers, total] = await Promise.all([
this.transactionRepository.findTransfersToJunior(juniorId, skip, size),
this.transactionRepository.countTransfersToJunior(juniorId),
]);
const items = transfers.map((t) => this.mapToChildTransferItem(t));
return new PagedChildTransfersResponseDto(items, page, size, total);
}
private mapToParentTransferItem(t: Transaction): ParentTransferItemDto {
const child = t.card?.customer;
const currency = t.transactionCurrency === '682' ? 'SAR' : t.transactionCurrency;
return {
date: t.transactionDate,
amount: Math.abs(t.transactionAmount),
currency,
childName: child ? `${child.firstName} ${child.lastName}` : 'Child',
};
}
private mapToChildTransferItem(t: Transaction): ChildTransferItemDto {
const amount = Math.abs(t.transactionAmount);
const currency = t.transactionCurrency === '682' ? 'SAR' : t.transactionCurrency;
return {
date: t.transactionDate,
amount,
currency,
message: `You received {{amount}} {{currency}} from your parent.`,
};
}
async getChildSpendingHistory(juniorId: string, startUtc: Date, endUtc: Date) {
const transactions = await this.transactionRepository.getTransactionsForCardWithinDateRange(
juniorId,
startUtc,
endUtc,
);
const { SpendingHistoryItemDto, SpendingHistoryResponseDto } = await import('../dtos/responses');
const items = transactions.map((t) => new SpendingHistoryItemDto(t));
return new SpendingHistoryResponseDto(items);
}
async getTransactionDetail(transactionId: string, juniorId: string) {
const transaction = await this.transactionRepository.findTransactionById(transactionId, juniorId);
if (!transaction) {
throw new UnprocessableEntityException('TRANSACTION.NOT_FOUND');
}
const { TransactionDetailResponseDto } = await import('../dtos/responses');
return new TransactionDetailResponseDto(transaction);
}
private mapParentItem(t: Transaction): TransactionItemResponseDto {
const dto = new TransactionItemResponseDto();
dto.date = t.transactionDate;
if (t.transactionType === TransactionType.INTERNAL) {
dto.type = ParentTransactionType.PARENT_TRANSFER;
dto.amountSigned = -Math.abs(t.transactionAmount);
const child = t.card?.customer;
dto.counterpartyName = child ? `${child.firstName} ${child.lastName}` : 'Child';
dto.childName = dto.counterpartyName;
dto.counterpartyAccountMasked = t.card?.account?.accountReference
? `****${t.card.account.accountReference.slice(-4)}`
: null;
return dto;
}
dto.type = ParentTransactionType.PARENT_TOPUP;
const settlement = Number(t.settlementAmount ?? 0);
const txn = Number(t.transactionAmount ?? 0);
const creditAmount = settlement > 0 ? settlement : txn;
dto.amountSigned = Math.abs(Number.isFinite(creditAmount) ? creditAmount : 0);
dto.counterpartyName = 'Top-up';
dto.counterpartyAccountMasked = t.accountReference ? `****${t.accountReference.slice(-4)}` : null;
return dto;
}
}
@@ -1,750 +1,198 @@
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,
},
import { randomInt, randomUUID } from 'crypto';
CreateNewApplicationResponseDetails: {
InstitutionCode: '1100',
ApplicationTypeDetails: {
TypeCode: '01',
Description: 'Normal Primary',
Additional: false,
Corporate: false,
UserData: null,
/** 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;
}
/** Build a fresh mock object every time it's called */
export function buildCreateApplicationMock() {
const now = new Date().toISOString();
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: 21017,
InstitutionCode: '1100',
AccountNumber: '6899999999999999',
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: 'SA2380900000752991120011',
},
],
CardDetailsList: [
{
pvv: null,
ResponseCardIdentifier: {
Id: 28595,
Pan: 'DDDDDDDDDDDDDDDDDDD',
MaskedPan: '999999_9999',
VPan: '1100000000000000',
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,
},
],
},
};
}
@@ -17,7 +17,6 @@ export const getKycCallbackMock = (nationalId: string) => {
salaryMax: '1000',
incomeSource: 'Salary',
professionTitle: 'Software Engineer',
professionType: 'Full-Time',
isPep: 'N',
country: '682',
region: 'Mecca',
@@ -56,12 +56,12 @@ export class AccountTransactionWebhookRequest {
@ApiProperty({ example: '682' })
currency!: string;
@Expose()
@Expose({ name: 'Date' })
@IsString()
@ApiProperty({ name: 'Date', example: '20241112' })
date!: string;
@Expose()
@Expose({ name: 'Time' })
@IsString()
@ApiProperty({ name: 'Time', example: '125340' })
time!: string;
@@ -1,129 +1,50 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose } from 'class-transformer';
import { IsString } from 'class-validator';
export class KycWebhookRequest {
@Expose({ name: 'InstId' })
@IsString()
@ApiProperty({ name: 'InstId', example: '1100' })
instId!: string;
import { IsEnum, IsObject, IsString } from 'class-validator';
@Expose()
@IsString()
@ApiProperty({ example: '3136fd60-3f89-4d24-a92f-b9c63a53807f' })
transId!: string;
@Expose()
@IsString()
@ApiProperty({ example: '20250807' })
date!: string;
@Expose()
@IsString()
@ApiProperty({ example: '150000' })
time!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'SUCCESS' })
status!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'John' })
firstName!: string;
export enum NeoleapKycWebhookStatus {
ONBOARDING_SUCCESS = 'ONBOARDING_SUCCESS',
ONBOARDING_FAILURE = 'ONBOARDING_FAILURE',
IN_PROGRESS = 'IN_PROGRESS',
}
@Expose()
class KycEntityDto {
@ApiProperty({ example: 'INDIVIDUAL', description: 'Entity type - INDIVIDUAL for KYC' })
@IsString()
@ApiProperty({ example: 'Doe' })
lastName!: string;
type!: string;
@Expose()
@ApiProperty({ example: 'FIN-TECK-CUSTOMER-20393', description: 'Customer external ID from Neoleap' })
@IsString()
@ApiProperty({ example: '19990107' })
dob!: string;
externalId!: string;
}
@Expose()
export class KycWebhookRequest {
@ApiProperty({
example: '8a745b1b-1252-4921-a569-b3d4406c25fd',
description: 'Transaction ID, the same as returned from onboard API response'
})
@IsString()
@ApiProperty({ example: '682' })
nationality!: string;
stateId!: string;
@Expose()
@ApiProperty({
example: '8a745b1b-1252-4921-a569-b3d4406c25fd',
description: 'Unique callback ID used as reference and for tracking'
})
@IsString()
@ApiProperty({ example: 'M' })
gender!: string;
callbackId!: string;
@Expose()
@ApiProperty({ example: '1100', description: 'Fintech ID (1100 for ZOD)' })
@IsString()
@ApiProperty({ example: '20310917' })
nationalIdExpiry!: string;
externalFintechId!: string;
@Expose()
@IsString()
@ApiProperty({ example: '1250820840' })
nationalId!: string;
@Expose()
@IsString()
@ApiProperty({ example: '+962798765432' })
mobile!: string;
@Expose()
@IsString()
@ApiProperty({ example: '500' })
salaryMin!: string;
@Expose()
@IsString()
@ApiProperty({ example: '1000' })
salaryMax!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'Salary' })
incomeSource!: string;
@ApiProperty({ type: KycEntityDto })
@IsObject()
entity!: KycEntityDto;
@Expose()
@IsString()
@ApiProperty({ example: 'Software Engineer' })
professionTitle!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'Full-Time' })
professionType!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'N' })
isPep!: string;
@Expose()
@IsString()
@ApiProperty({ example: '682' })
country!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'Mecca' })
region!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'At-Taif' })
city!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'Al-Hamra' })
neighborhood!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'Al-Masjid Al-Haram' })
street!: string;
@Expose()
@IsString()
@ApiProperty({ example: '123' })
building!: string;
@ApiProperty({
enum: NeoleapKycWebhookStatus,
example: NeoleapKycWebhookStatus.ONBOARDING_SUCCESS,
description: 'Status of onboarding: ONBOARDING_SUCCESS or ONBOARDING_FAILURE'
})
@IsEnum(NeoleapKycWebhookStatus)
status!: NeoleapKycWebhookStatus;
}
@@ -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__/';
@@ -36,6 +36,7 @@ export class NeoLeapService {
private readonly zodApiUrl: string;
private readonly apiKey: string;
private readonly useGateway: boolean;
private readonly useKycMock: boolean;
private readonly institutionCode = '1100';
useLocalCert: boolean;
constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) {
@@ -44,56 +45,119 @@ export class NeoLeapService {
this.useGateway = [true, 'true'].includes(this.configService.get<boolean>('USE_GATEWAY', false));
this.useLocalCert = this.configService.get<boolean>('USE_LOCAL_CERT', false);
this.zodApiUrl = this.configService.getOrThrow<string>('ZOD_API_URL');
this.useKycMock = [true, 'true'].includes(this.configService.get<boolean>('USE_KYC_MOCK', true));
}
async initiateKyc(customerId: string, body: InitiateKycRequestDto) {
const responseKey = 'InitiateKycResponseDetails';
if (!this.useGateway) {
const responseDto = plainToInstance(InitiateKycResponseDto, INITIATE_KYC_MOCK[responseKey], {
excludeExtraneousValues: true,
});
async initiateKycOnboarding(dto: InitiateKycRequestDto) {
// Mock mode for development
if (this.useKycMock) {
const mockResponse = {
externalCustomerId: `FIN-TECK-CUSTOMER-${Date.now()}`,
externalFintechId: '1100',
nafathRandomCode: '38',
stateId: uuid(),
status: 'IN_PROGRESS',
};
// Trigger mock webhook after 7 seconds
setTimeout(() => {
this.httpService
.post(`${this.zodApiUrl}/neoleap-webhooks/kyc`, getKycCallbackMock(body.nationalId), {
headers: {
'Content-Type': 'application/json',
.post(`${this.zodApiUrl}/neoleap-webhooks/kyc`, {
stateId: mockResponse.stateId,
callbackId: uuid(),
externalFintechId: '1100',
entity: {
type: 'INDIVIDUAL',
externalId: mockResponse.externalCustomerId,
},
status: 'ONBOARDING_SUCCESS',
})
.subscribe({
next: () => this.logger.log('Mock KYC webhook sent'),
error: (err) => console.error(err),
next: () => this.logger.log('Mock KYC webhook sent successfully'),
error: (err) => this.logger.error('Mock KYC webhook failed:', err.message),
});
}, 7000);
return responseDto;
return mockResponse;
}
// Real API call to Neoleap
const payload = {
InitiateKycRequestDetails: {
CustomerIdentifier: {
InstitutionCode: this.institutionCode,
Id: customerId,
NationalId: body.nationalId,
poiNumber: dto.poiNumber,
poiType: dto.poiType,
mobileNumber: dto.mobileNumber,
email: dto.email,
dateOfBirth: dto.dateOfBirth,
jobSector: dto.jobSector,
employer: dto.employer,
incomeSource: dto.incomeSource,
jobCategory: dto.jobCategory,
incomeRange: dto.incomeRange,
// Use default address values for Neoleap KYC
address: {
national: {
buildingNumber: '1',
additionalNumber: '',
street: 'King Fahd Road',
streetEn: 'King Fahd Road',
city: 'Riyadh',
cityEn: 'Riyadh',
zipcode: '',
unitNumber: '',
district: 'Al Olaya',
districtEn: 'Al Olaya',
},
general: {
address: '1, King Fahd Road, Al Olaya, Riyadh, Riyadh',
website: '',
email: dto.email || '',
telephone1: dto.mobileNumber || '',
telephone2: '',
fax1: '',
fax2: '',
postalBox1: '',
postalBox2: '',
zipcode: '',
},
},
RequestHeader: this.prepareHeaders('InitiateKyc'),
};
return this.sendRequestToNeoLeap<typeof payload, InitiateKycResponseDto>(
'kyc/InitiateKyc',
payload,
responseKey,
InitiateKycResponseDto,
);
try {
const { data } = await this.httpService.axiosRef.post(
`${this.gatewayBaseUrl}/kyc/onboardCustomer`,
payload,
{
headers: {
'Content-Type': 'application/json',
Authorization: this.apiKey,
'X-Request-id': uuid(),
'X-Session-Language': 'ar',
},
},
);
return data.data;
} catch (error: any) {
this.logger.error('Error initiating KYC:', error.response?.data || error.message);
// Handle specific Neoleap errors
if (error.response?.data?.errorCode === 'E810109') {
throw new BadRequestException('National ID is already registered with Neoleap');
}
if (error.response?.data?.error === 'schema validation failed') {
throw new BadRequestException('Invalid data format for KYC verification');
}
throw new InternalServerErrorException('Failed to initiate KYC verification');
}
}
async createApplication(customer: Customer) {
createApplication(customer: Customer) {
const responseKey = 'CreateNewApplicationResponseDetails';
if (!this.useGateway) {
return plainToInstance(CreateApplicationResponse, CREATE_APPLICATION_MOCK[responseKey], {
return plainToInstance(CreateApplicationResponse, buildCreateApplicationMock()[responseKey], {
excludeExtraneousValues: true,
});
}
@@ -122,7 +186,9 @@ export class NeoLeapService {
},
BillingCycle: 'C1',
},
ApplicationOtherInfo: {},
ApplicationOtherInfo: {
ExternalCorporateId: customer.neoleapExternalCustomerId,
},
ApplicationCustomerDetails: {
FirstName: customer.firstName,
LastName: customer.lastName,
@@ -135,14 +201,14 @@ export class NeoLeapService {
Title: customer.gender === Gender.MALE ? 'Mr' : 'Ms',
Gender: customer.gender === Gender.MALE ? 'M' : 'F',
LocalizedDateOfBirth: moment(customer.dateOfBirth).format('YYYY-MM-DD'),
Nationality: CountriesNumericISO[customer.countryOfResidence],
Nationality: CountriesNumericISO[customer.countryOfResidence || 'SA'],
},
ApplicationAddress: {
City: customer.city,
Country: CountriesNumericISO[customer.country],
Region: customer.region,
AddressLine1: `${customer.street} ${customer.building}`,
AddressLine2: customer.neighborhood,
City: 'Riyadh',
Country: CountriesNumericISO['SA'],
Region: 'Riyadh',
AddressLine1: 'King Fahd Road 1',
AddressLine2: 'Al Olaya',
AddressRole: 0,
Email: customer.user.email,
Phone1: customer.user.phoneNumber,
@@ -163,7 +229,82 @@ export class NeoLeapService {
);
}
async inquireApplication(externalApplicationNumber: string) {
createChildCard(parent: Customer, child: Customer, cardPin: string) {
const responseKey = 'CreateNewApplicationResponseDetails';
if (!this.useGateway) {
return plainToInstance(CreateApplicationResponse, buildCreateApplicationMock()[responseKey], {
excludeExtraneousValues: true,
});
}
const payload: ICreateApplicationRequest = {
CreateNewApplicationRequestDetails: {
ApplicationRequestDetails: {
InstitutionCode: this.institutionCode,
ExternalApplicationNumber: child.applicationNumber.toString(),
ApplicationType: '01',
Product: '1101',
ApplicationDate: moment().format('YYYY-MM-DD'),
BranchCode: '000',
ApplicationSource: 'O',
DeliveryMethod: 'V',
},
ApplicationProcessingDetails: {
SuggestedLimit: 0,
RequestedLimit: 0,
AssignedLimit: 0,
ProcessControl: 'STND',
},
ApplicationFinancialInformation: {
Currency: {
AlphaCode: 'SAR',
},
BillingCycle: 'C1',
},
ApplicationOtherInfo: {
ParentAccountNumber: parent.cards[0].account.accountNumber,
},
ApplicationCustomerDetails: {
FirstName: parent.firstName,
LastName: parent.lastName,
FullName: parent.fullName,
DateOfBirth: moment(parent.dateOfBirth).format('YYYY-MM-DD'),
EmbossName: child.fullName.toUpperCase(), // TODO Enter Emboss Name
IdType: '01',
IdNumber: parent.nationalId,
IdExpiryDate: moment(parent.nationalIdExpiry).format('YYYY-MM-DD'),
Title: parent.gender === Gender.MALE ? 'Mr' : 'Ms',
Gender: parent.gender === Gender.MALE ? 'M' : 'F',
LocalizedDateOfBirth: moment(parent.dateOfBirth).format('YYYY-MM-DD'),
Nationality: CountriesNumericISO[parent.countryOfResidence || 'SA'],
},
ApplicationAddress: {
City: 'Riyadh',
Country: CountriesNumericISO['SA'],
Region: 'Riyadh',
AddressLine1: 'King Fahd Road 1',
AddressLine2: 'Al Olaya',
AddressRole: 0,
Email: child.user.email,
Phone1: child.user.phoneNumber,
CountryDetails: {
DefaultCurrency: {},
Description: [],
},
},
},
RequestHeader: this.prepareHeaders('CreateNewApplication'),
};
return this.sendRequestToNeoLeap<ICreateApplicationRequest, CreateApplicationResponse>(
'application/CreateNewApplication',
payload,
responseKey,
CreateApplicationResponse,
);
}
inquireApplication(externalApplicationNumber: string) {
const responseKey = 'InquireApplicationResponseDetails';
if (!this.useGateway) {
return plainToInstance(InquireApplicationResponse, INQUIRE_APPLICATION_MOCK[responseKey], {
@@ -195,7 +336,7 @@ export class NeoLeapService {
);
}
async updateCardControl(cardId: string, amount: number, count?: number) {
updateCardControl(cardId: string, amount: number, count?: number) {
const responseKey = 'UpdateCardControlResponseDetails';
if (!this.useGateway) {
return;
@@ -224,7 +365,7 @@ export class NeoLeapService {
);
}
async getEmbossingInformation(card: Card) {
getEmbossingInformation(card: Card) {
const responseKey = 'GetEmbossingInformationResponseDetails';
if (!this.useGateway) {
return plainToInstance(CardEmbossingDetailsResponseDto, CARD_EMBOSSING_DETAILS_MOCK[responseKey], {
@@ -244,7 +385,7 @@ export class NeoLeapService {
};
return this.sendRequestToNeoLeap<typeof payload, CardEmbossingDetailsResponseDto>(
'cardembossing/CardEmbossingDetails',
'issuance/GetEmbossingInformation',
payload,
responseKey,
CardEmbossingDetailsResponseDto,
@@ -0,0 +1,15 @@
/**
* Notification event names
* These are the event identifiers used throughout the notification system
*/
export const NOTIFICATION_EVENTS = {
// Transaction events
TRANSACTION_CREATED: 'notification.transaction.created',
} as const;
export type NotificationEventName =
typeof NOTIFICATION_EVENTS[keyof typeof NOTIFICATION_EVENTS];
@@ -0,0 +1,3 @@
// Export all constants from this folder
export * from './event-names.constant';
@@ -36,6 +36,9 @@ export class Notification {
@Column('uuid', { name: 'user_id', nullable: true })
userId!: string;
@Column('jsonb', { name: 'data', nullable: true })
data?: Record<string, any>;
@ManyToOne(() => User, (user) => user.notifications, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'user_id' })
user!: User;
@@ -1,7 +1,41 @@
export enum NotificationScope {
// Existing scopes
USER_REGISTERED = 'USER_REGISTERED',
TASK_COMPLETED = 'TASK_COMPLETED',
GIFT_RECEIVED = 'GIFT_RECEIVED',
OTP = 'OTP',
USER_INVITED = 'USER_INVITED',
// Transaction notifications - Top-up
CHILD_TOP_UP = 'CHILD_TOP_UP',
PARENT_TOP_UP_CONFIRMATION = 'PARENT_TOP_UP_CONFIRMATION',
// Transaction notifications - Spending
CHILD_SPENDING = 'CHILD_SPENDING',
PARENT_SPENDING_ALERT = 'PARENT_SPENDING_ALERT',
}
/**
* Critical notification scopes that require guaranteed delivery
* These will use RabbitMQ/Kafka instead of Redis PubSub when configured
*
* Add scopes here when you need guaranteed delivery for specific notification types
* Examples:
* - ACCOUNT_LOCKED
* - SUSPICIOUS_ACTIVITY
* - LARGE_TRANSACTION_ALERT
* - PAYMENT_FAILED
*/
export const CRITICAL_NOTIFICATION_SCOPES = new Set<NotificationScope>([
// Add critical scopes here as needed
// Example: NotificationScope.ACCOUNT_LOCKED,
]);
/**
* Check if a notification scope requires guaranteed delivery
* @param scope - Notification scope to check
* @returns true if the scope requires guaranteed delivery
*/
export function requiresGuaranteedDelivery(scope: NotificationScope): boolean {
return CRITICAL_NOTIFICATION_SCOPES.has(scope);
}
@@ -1 +1,3 @@
export * from './notification-page-meta.interface';
export * from './notification-events.interface';
export * from './messaging-system.interface';
@@ -0,0 +1,25 @@
/**
* Interface for messaging systems (Redis PubSub, RabbitMQ, Kafka, etc.)
* Allows switching between different messaging systems based on notification requirements
*/
export interface IMessagingSystem {
/**
* Publish a notification event
* @param channel - Channel/topic name
* @param payload - Notification payload
*/
publish(channel: string, payload: any): Promise<void>;
/**
* Subscribe to a channel
* @param channel - Channel/topic name
* @param handler - Message handler function
*/
subscribe(channel: string, handler: (message: any) => Promise<void>): Promise<void>;
/**
* Get system name (for logging)
*/
getName(): string;
}
@@ -0,0 +1,24 @@
import { Transaction } from '~/card/entities/transaction.entity';
import { Card } from '~/card/entities/card.entity';
/**
* Event payload for when a transaction is created
* Used to notify users about transactions (spending or top-ups)
*/
export interface ITransactionCreatedEvent {
/** The transaction that was created */
transaction: Transaction;
/** The card used in the transaction (with all relations loaded) */
card: Card;
/** True if this is a top-up/load transaction, false if spending */
isTopUp: boolean;
/** True if this transaction was made by a child (requires parent notification) */
isChildSpending: boolean;
/** When the event occurred */
timestamp: Date;
}
@@ -1 +1,2 @@
export * from './notification-created.listener';
export * from './transaction-notification.listener';
@@ -21,7 +21,7 @@ export class NotificationCreatedListener {
/**
* Handles the NOTIFICATION_CREATED event by calling the appropriate channel logic.
*/
async handle(event: IEventInterface) {
handle(event: IEventInterface) {
this.logger.log(
`Handling ${EventType.NOTIFICATION_CREATED} event for notification ${event.id} (channel: ${event.channel})`,
);
@@ -31,7 +31,7 @@ export class NotificationCreatedListener {
return this.sendSMS(event.recipient!, event.message);
case NotificationChannel.PUSH:
return this.sendPushNotification(event.userId, event.title, event.message);
return this.sendPushNotification(event.userId, event.title, event.message, event.data);
case NotificationChannel.EMAIL:
return this.sendEmail({
@@ -54,7 +54,12 @@ export class NotificationCreatedListener {
}
}
private async sendPushNotification(userId: string, title: string, body: string) {
private async sendPushNotification(
userId: string,
title: string,
body: string,
data?: Record<string, any>,
) {
this.logger.log(`Sending push notification to user ${userId}`);
const tokens = await this.deviceService.getTokens(userId);
@@ -62,7 +67,19 @@ export class NotificationCreatedListener {
this.logger.log(`No device tokens found for user ${userId}, but notification was created in the DB.`);
return;
}
return this.firebaseService.sendNotification(tokens, title, body);
// Convert data to string values (Firebase requires string values in data payload)
const stringData: Record<string, string> | undefined = data
? Object.entries(data).reduce(
(acc, [key, value]) => {
acc[key] = String(value);
return acc;
},
{} as Record<string, string>,
)
: undefined;
return this.firebaseService.sendNotification(tokens, title, body, stringData);
}
private async sendSMS(to: string, body: string) {
@@ -0,0 +1,249 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { NotificationFactory, NotificationPreferences } from '../services/notification-factory.service';
import { UserService } from '~/user/services/user.service';
import { NOTIFICATION_EVENTS } from '../constants/event-names.constant';
import { ITransactionCreatedEvent } from '../interfaces/notification-events.interface';
import { NotificationScope } from '../enums/notification-scope.enum';
import { Transaction } from '~/card/entities/transaction.entity';
import { Card } from '~/card/entities/card.entity';
import { User } from '~/user/entities';
/**
* TransactionNotificationListener
*
* Handles notifications for transaction events.
* Determines who should be notified and what message to send.
*
* Responsibilities:
* - Listen for transaction events
* - Determine notification recipients (child, parent, or both)
* - Construct appropriate messages
* - Fetch user preferences
* - Call NotificationFactory to send
*/
@Injectable()
export class TransactionNotificationListener {
private readonly logger = new Logger(TransactionNotificationListener.name);
constructor(
private readonly notificationFactory: NotificationFactory,
private readonly userService: UserService,
) {}
/**
* Main event handler for transaction created events
* Routes to appropriate notification logic based on transaction type
*/
@OnEvent(NOTIFICATION_EVENTS.TRANSACTION_CREATED)
async handleTransactionCreated(event: ITransactionCreatedEvent): Promise<void> {
try {
const { transaction, card, isTopUp, isChildSpending } = event;
this.logger.log(
`Processing transaction notification for transaction ${transaction.id} - ` +
`isTopUp: ${isTopUp}, isChildSpending: ${isChildSpending}`
);
await this.notifyTransactionOwner(transaction, card, isTopUp, isChildSpending);
if (isChildSpending) {
if (isTopUp) {
await this.notifyParentOfTopUp(transaction, card);
} else {
await this.notifyParentOfChildSpending(transaction, card);
}
}
this.logger.log(
`Transaction notification processed successfully for transaction ${transaction.id}`
);
} catch (error: any) {
this.logger.error(
`Failed to process transaction notification: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Notify the transaction owner (the cardholder)
* Could be a child or a parent depending on whose card was used
*/
private async notifyTransactionOwner(
transaction: Transaction,
card: Card,
isTopUp: boolean,
isChildSpending: boolean
): Promise<void> {
try {
const user = card?.customer?.user;
if (!user) {
this.logger.warn(`No user found for transaction ${transaction.id}, skipping notification`);
return;
}
const scope = isTopUp
? NotificationScope.CHILD_TOP_UP
: NotificationScope.CHILD_SPENDING;
const title = isTopUp ? 'Card Topped Up' : 'Purchase Successful';
const amount = transaction.transactionAmount;
const merchant = transaction.merchantName || 'merchant';
const balance = card.account?.balance || 0;
const message = isTopUp
? `Your card has been topped up with $${amount.toFixed(2)}`
: `You spent $${amount.toFixed(2)} at ${merchant}. Balance: $${balance.toFixed(2)}`;
this.logger.debug(
`Notifying transaction owner (user ${user.id}) - Amount: $${amount}, Merchant: ${merchant}`
);
await this.notificationFactory.send({
userId: user.id,
title,
message,
scope,
preferences: this.getUserPreferences(user),
data: {
transactionId: transaction.id,
amount: amount.toString(),
merchant: merchant,
merchantCategory: transaction.merchantCategoryCode || 'OTHER',
balance: balance.toString(),
timestamp: transaction.transactionDate.toISOString(),
type: isTopUp ? 'TOP_UP' : 'SPENDING',
action: 'OPEN_TRANSACTION',
},
});
this.logger.log(`✅ Notified user ${user.id} for transaction ${transaction.id}`);
} catch (error: any) {
this.logger.error(
`Failed to notify transaction owner: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Notify parent when their child makes a purchase
* This is a spending alert for parents to monitor their children's expenses
*/
private async notifyParentOfChildSpending(transaction: Transaction, card: Card): Promise<void> {
try {
this.logger.debug(`Checking for parent to notify about child spending`);
const customer = card?.customer;
const parentUser = customer?.junior?.guardian?.customer?.user;
if (!parentUser) {
this.logger.debug(`No parent found for transaction ${transaction.id}, skipping parent notification`);
return;
}
const childUser = customer.user;
const childName = childUser?.firstName || 'Your child';
const amount = transaction.transactionAmount;
const merchant = transaction.merchantName || 'a merchant';
this.logger.debug(
`Notifying parent (user ${parentUser.id}): ${childName} spent $${amount} at ${merchant}`
);
await this.notificationFactory.send({
userId: parentUser.id,
title: 'Child Spending Alert',
message: `${childName} spent $${amount.toFixed(2)} at ${merchant}`,
scope: NotificationScope.PARENT_SPENDING_ALERT,
preferences: this.getUserPreferences(parentUser),
data: {
transactionId: transaction.id,
childId: childUser.id,
childName: childName,
amount: amount.toString(),
merchant: merchant,
merchantCategory: transaction.merchantCategoryCode || 'OTHER',
timestamp: transaction.transactionDate.toISOString(),
type: 'CHILD_SPENDING',
action: 'OPEN_TRANSACTION',
},
});
this.logger.log(`✅ Notified parent ${parentUser.id} about child spending`);
} catch (error: any) {
this.logger.error(
`Failed to notify parent of child spending: ${ error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Notify parent when they top up their child's card
* This is a confirmation notification for the parent
*/
private async notifyParentOfTopUp(transaction: Transaction, card: Card): Promise<void> {
try {
this.logger.debug(`Checking for parent to notify about top-up`);
const customer = card?.customer;
const parentUser = customer?.junior?.guardian?.customer?.user;
if (!parentUser) {
this.logger.debug(`No parent found for transaction ${transaction.id}, skipping parent notification`);
return;
}
const childUser = customer.user;
const childName = childUser?.firstName || 'Your child';
const amount = transaction.transactionAmount;
const balance = card.account?.balance || 0;
this.logger.debug(
`Notifying parent (user ${parentUser.id}): Topped up ${childName}'s card with $${amount}`
);
await this.notificationFactory.send({
userId: parentUser.id,
title: 'Top-Up Confirmation',
message: `You topped up ${childName}'s card with $${amount.toFixed(2)}. New balance: $${balance.toFixed(2)}`,
scope: NotificationScope.PARENT_TOP_UP_CONFIRMATION,
preferences: this.getUserPreferences(parentUser),
data: {
transactionId: transaction.id,
childId: childUser.id,
childName: childName,
amount: amount.toString(),
balance: balance.toString(),
timestamp: transaction.transactionDate.toISOString(),
type: 'TOP_UP',
action: 'OPEN_TRANSACTION',
},
});
this.logger.log(`✅ Notified parent ${parentUser.id} about top-up`);
} catch (error: any) {
this.logger.error(
`Failed to notify parent of top-up: ${ error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Extract user preferences from User entity
* Converts User properties to NotificationPreferences interface
*/
private getUserPreferences(user: User): NotificationPreferences {
return {
isPushEnabled: user.isPushEnabled,
isEmailEnabled: user.isEmailEnabled,
isSmsEnabled: user.isSmsEnabled,
};
}
}
@@ -8,9 +8,10 @@ import { buildMailerOptions, buildTwilioOptions } from '~/core/module-options';
import { UserModule } from '~/user/user.module';
import { NotificationsController } from './controllers';
import { Notification } from './entities';
import { NotificationCreatedListener } from './listeners';
import { NotificationCreatedListener, TransactionNotificationListener } from './listeners';
import { NotificationsRepository } from './repositories';
import { FirebaseService, NotificationsService, TwilioService } from './services';
import { FirebaseService, NotificationFactory, NotificationsService, TwilioService } from './services';
import { MessagingSystemFactory, RedisPubSubMessagingService } from './services/messaging';
@Module({
imports: [
@@ -28,12 +29,16 @@ import { FirebaseService, NotificationsService, TwilioService } from './services
],
providers: [
NotificationsService,
NotificationFactory,
FirebaseService,
NotificationsRepository,
TwilioService,
NotificationCreatedListener,
TransactionNotificationListener,
RedisPubSubMessagingService,
MessagingSystemFactory,
],
exports: [NotificationsService, NotificationCreatedListener],
exports: [NotificationsService, NotificationFactory, NotificationCreatedListener],
controllers: [NotificationsController],
})
export class NotificationModule {}
@@ -1,29 +1,77 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as admin from 'firebase-admin';
@Injectable()
export class FirebaseService {
private readonly logger = new Logger(FirebaseService.name);
constructor(private readonly configService: ConfigService) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: this.configService.get('FIREBASE_PROJECT_ID'),
clientEmail: this.configService.get('FIREBASE_CLIENT_EMAIL'),
privateKey: this.configService.get('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'),
}),
});
try {
this.logger.log('🔥 Initializing Firebase Admin SDK...');
const projectId = this.configService.get('FIREBASE_PROJECT_ID');
const clientEmail = this.configService.get('FIREBASE_CLIENT_EMAIL');
const privateKey = this.configService.get('FIREBASE_PRIVATE_KEY');
// Log configuration (without exposing sensitive data)
this.logger.log(`📋 Project ID: ${projectId}`);
this.logger.log(`📋 Client Email: ${clientEmail}`);
this.logger.log(`📋 Private Key: ${privateKey ? 'SET ✅' : 'MISSING ❌'}`);
admin.initializeApp({
credential: admin.credential.cert({
projectId,
clientEmail,
privateKey: privateKey.replace(/\\n/g, '\n'),
}),
});
this.logger.log('✅ Firebase Admin SDK initialized successfully!');
this.logger.log(`📱 Connected to project: ${projectId}`);
} catch (error: any) {
this.logger.error('❌ Failed to initialize Firebase Admin SDK');
this.logger.error(`Error: ${error.message}`);
throw error;
}
}
sendNotification(tokens: string | string[], title: string, body: string) {
this.logger.log(`Sending push notification to ${tokens}`);
const message = {
notification: {
title,
body,
},
tokens: Array.isArray(tokens) ? tokens : [tokens],
};
async sendNotification(tokens: string | string[], title: string, body: string, data?: Record<string, string>) {
this.logger.log(
`Sending push notification to ${Array.isArray(tokens) ? tokens.length : 1} device(s)`,
);
admin.messaging().sendEachForMulticast(message);
try {
const message = {
notification: {
title,
body,
},
data: data || {},
tokens: Array.isArray(tokens) ? tokens : [tokens],
};
const response = await admin.messaging().sendEachForMulticast(message);
this.logger.log(
`✅ Push sent! Success: ${response.successCount}, Failed: ${response.failureCount}`,
);
// Log failed tokens for debugging
if (response.failureCount > 0) {
response.responses.forEach((resp, idx) => {
if (!resp.success) {
this.logger.warn(
`Failed to send to token ${idx}: ${resp.error?.code} - ${resp.error?.message}`,
);
}
});
}
return response;
} catch (error: any) {
this.logger.error(`❌ Failed to send push notification: ${error.message}`);
throw error;
}
}
}
@@ -1,3 +1,4 @@
export * from './firebase.service';
export * from './notification-factory.service';
export * from './notifications.service';
export * from './twilio.service';
@@ -0,0 +1,3 @@
export * from './redis-pubsub-messaging.service';
export * from './messaging-system-factory.service';
@@ -0,0 +1,58 @@
import { Injectable, Logger, Optional } from '@nestjs/common';
import { NotificationScope, requiresGuaranteedDelivery } from '../../enums/notification-scope.enum';
import { IMessagingSystem } from '../../interfaces/messaging-system.interface';
import { RedisPubSubMessagingService } from './redis-pubsub-messaging.service';
/**
* Messaging System Factory
*
* Determines which messaging system to use based on notification requirements.
*
* - Regular notifications → Redis PubSub (fast, 2-5ms)
* - Critical notifications → RabbitMQ/Kafka (guaranteed delivery, 20-50ms)
*
* Usage:
* ```typescript
* const system = factory.getMessagingSystem(NotificationScope.CHILD_SPENDING);
* await system.publish('NOTIFICATION_CREATED', payload);
* ```
*/
@Injectable()
export class MessagingSystemFactory {
private readonly logger = new Logger(MessagingSystemFactory.name);
constructor(
private readonly redisPubSubService: RedisPubSubMessagingService,
) {}
/**
* Get the appropriate messaging system based on notification scope
*
* @param scope - Notification scope
* @returns Messaging system to use
*/
getMessagingSystem(scope: NotificationScope): IMessagingSystem {
const needsGuaranteedDelivery = requiresGuaranteedDelivery(scope);
if (needsGuaranteedDelivery) {
this.logger.warn(
`[Factory] Critical notification ${scope} requires guaranteed delivery, ` +
`but RabbitMQ not configured. Falling back to Redis PubSub.`
);
return this.redisPubSubService;
} else {
this.logger.debug(`[Factory] Using Redis PubSub for notification: ${scope}`);
return this.redisPubSubService;
}
}
/**
* Get default messaging system (Redis PubSub)
*
* @returns Default messaging system
*/
getDefaultMessagingSystem(): IMessagingSystem {
return this.redisPubSubService;
}
}
@@ -0,0 +1,55 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { RedisClientType } from '@keyv/redis';
import { IMessagingSystem } from '../../interfaces/messaging-system.interface';
/**
* Redis PubSub Messaging System Implementation
*
* Fast, real-time messaging for regular notifications.
* Uses Redis PubSub for 2-5ms latency.
*
* Note: Messages are not persisted (fire-and-forget).
* Suitable for notifications that are already saved in PostgreSQL.
*/
@Injectable()
export class RedisPubSubMessagingService implements IMessagingSystem {
private readonly logger = new Logger(RedisPubSubMessagingService.name);
constructor(
@Inject('REDIS_PUBLISHER') private readonly publisher: RedisClientType,
@Inject('REDIS_SUBSCRIBER') private readonly subscriber: RedisClientType,
) {}
getName(): string {
return 'Redis PubSub';
}
async publish(channel: string, payload: any): Promise<void> {
try {
const message = JSON.stringify(payload);
const subscriberCount = await this.publisher.publish(channel, message);
this.logger.debug(
`[Redis PubSub] Published to ${channel}, ${subscriberCount} subscriber(s) received`
);
} catch (error: any) {
this.logger.error(`[Redis PubSub] Failed to publish to ${channel}: ${error?.message}`);
throw error;
}
}
async subscribe(channel: string, handler: (message: any) => Promise<void>): Promise<void> {
await this.subscriber.subscribe(channel, async (message) => {
try {
const data = JSON.parse(message);
await handler(data);
} catch (error: any) {
this.logger.error(
`[Redis PubSub] Failed to process message from ${channel}: ${error?.message}`
);
}
});
this.logger.log(`[Redis PubSub] Subscribed to channel: ${channel}`);
}
}
@@ -0,0 +1,140 @@
import { Injectable, Logger } from '@nestjs/common';
import { NotificationsService } from './notifications.service';
import { NotificationChannel } from '../enums/notification-channel.enum';
import { NotificationScope } from '../enums/notification-scope.enum';
/**
* User notification preferences
* Determines which channels are enabled for a user
*/
export interface NotificationPreferences {
/** Whether push notifications are enabled */
isPushEnabled: boolean;
/** Whether email notifications are enabled */
isEmailEnabled: boolean;
/** Whether SMS notifications are enabled */
isSmsEnabled: boolean;
}
/**
* Payload for sending a notification
*/
export interface NotificationPayload {
/** ID of the user to notify */
userId: string;
/** Notification title */
title: string;
/** Notification message body */
message: string;
/** Category/type of notification */
scope: NotificationScope;
/**
* User's notification preferences
* If not provided, defaults to push-only
*/
preferences?: NotificationPreferences;
/** Additional data to attach to the notification */
data?: Record<string, any>;
}
/**
* NotificationFactory
*
* Central service for sending notifications.
* Independent service with no external dependencies (microservice-ready).
*
* Handles:
* - Channel routing based on provided preferences
* - Parallel notification delivery
* - Error handling
*
* Note: Caller is responsible for providing user preferences.
* This keeps the factory independent and testable.
*
* Usage:
* await notificationFactory.send({
* userId: 'user-123',
* title: 'Transaction Alert',
* message: 'You spent $50.00',
* scope: NotificationScope.CHILD_SPENDING,
* preferences: {
* isPushEnabled: true,
* isEmailEnabled: false,
* isSmsEnabled: false,
* },
* });
*/
@Injectable()
export class NotificationFactory {
private readonly logger = new Logger(NotificationFactory.name);
constructor(
private readonly notificationsService: NotificationsService,
) {}
/**
* Send a notification to a user
* Routes to enabled channels based on provided preferences
*
* @param payload - Notification payload including preferences
*/
async send(payload: NotificationPayload): Promise<void> {
try {
this.logger.log(`Sending notification to user ${payload.userId} - ${payload.title}`);
const preferences = payload.preferences || {
isPushEnabled: true,
isEmailEnabled: false,
isSmsEnabled: false,
};
const promises: Promise<any>[] = [];
if (preferences.isPushEnabled) {
this.logger.debug(`Routing to PUSH channel for user ${payload.userId}`);
promises.push(
this.sendToChannel(payload, NotificationChannel.PUSH)
);
}
await Promise.all(promises);
this.logger.log(
`Notification sent to user ${payload.userId} via ${promises.length} channel(s)`
);
} catch (error: any) {
this.logger.error(
`Failed to send notification to user ${payload.userId}: ${error?.message || 'Unknown error'}`,
error?.stack
);
// Don't throw - prevents breaking the main business flow
// Notification failures should not break transactions, etc.
}
}
/**
* Send notification via a specific channel
* Creates the notification record and publishes it for delivery
*/
private async sendToChannel(
payload: NotificationPayload,
channel: NotificationChannel
): Promise<void> {
await this.notificationsService.createNotification({
userId: payload.userId,
title: payload.title,
message: payload.message,
scope: payload.scope,
channel,
data: payload.data,
});
}
}
@@ -8,6 +8,7 @@ import { SendEmailRequestDto } from '../dtos/request';
import { Notification } from '../entities';
import { EventType, NotificationChannel, NotificationScope } from '../enums';
import { NotificationsRepository } from '../repositories';
import { MessagingSystemFactory } from './messaging/messaging-system-factory.service';
@Injectable()
export class NotificationsService {
@@ -17,6 +18,7 @@ export class NotificationsService {
@Inject(forwardRef(() => RedisPubSubService))
private readonly redisPubSubService: RedisPubSubService,
private readonly messagingSystemFactory: MessagingSystemFactory,
) {}
async getNotifications(userId: string, pageOptionsDto: PageOptionsRequestDto) {
@@ -31,9 +33,29 @@ export class NotificationsService {
return { notifications, count, unreadCount };
}
createNotification(notification: Partial<Notification>) {
async createNotification(notification: Partial<Notification>) {
this.logger.log(`Creating notification for user ${notification.userId}`);
return this.notificationRepository.createNotification(notification);
const savedNotification = await this.notificationRepository.createNotification(notification);
const scope = notification.scope || NotificationScope.USER_REGISTERED;
const messagingSystem = this.messagingSystemFactory.getMessagingSystem(scope);
this.logger.log(
`Publishing ${EventType.NOTIFICATION_CREATED} event to ${messagingSystem.getName()}`
);
messagingSystem.publish(EventType.NOTIFICATION_CREATED, {
...savedNotification,
data: notification.data || savedNotification.data,
}).catch((error) => {
this.logger.error(
`Failed to publish notification ${savedNotification.id} to ${messagingSystem.getName()}: ` +
`${error?.message || 'Unknown error'}`,
error?.stack
);
});
return savedNotification;
}
markAsRead(userId: string) {
@@ -42,35 +64,25 @@ export class NotificationsService {
}
async sendEmailAsync(data: SendEmailRequestDto) {
this.logger.log(`emitting ${EventType.NOTIFICATION_CREATED} event`);
const notification = await this.createNotification({
this.logger.log(`Creating email notification for ${data.to}`);
await this.createNotification({
recipient: data.to,
title: data.subject,
message: '',
scope: NotificationScope.USER_INVITED,
channel: NotificationChannel.EMAIL,
});
// return this.redisPubSubService.emit(EventType.NOTIFICATION_CREATED, notification, data.data);
this.redisPubSubService.publishEvent(EventType.NOTIFICATION_CREATED, {
...notification,
data,
data: data.data,
});
}
async sendOtpNotification(sendOtpRequest: ISendOtp, otp: string) {
this.logger.log(`Sending OTP to ${sendOtpRequest.recipient}`);
const notification = await this.createNotification({
return this.createNotification({
recipient: sendOtpRequest.recipient,
title: OTP_TITLE,
message: OTP_BODY.replace('{otp}', otp),
scope: NotificationScope.OTP,
channel: sendOtpRequest.otpType === OtpType.EMAIL ? NotificationChannel.EMAIL : NotificationChannel.SMS,
});
this.logger.log(`emitting ${EventType.NOTIFICATION_CREATED} event`);
return this.redisPubSubService.publishEvent(EventType.NOTIFICATION_CREATED, {
...notification,
data: { otp },
});
}
@@ -19,7 +19,6 @@ export class OtpService {
async generateAndSendOtp(sendOtpRequest: ISendOtp): Promise<string> {
this.logger.log(`invalidate OTP for ${sendOtpRequest.recipient} and ${sendOtpRequest.otpType}`);
await this.otpRepository.invalidateOtp(sendOtpRequest);
this.logger.log(`Generating OTP for ${sendOtpRequest.recipient}`);
const otp = this.useMock ? DEFAULT_OTP_DIGIT.repeat(DEFAULT_OTP_LENGTH) : generateRandomOtp(DEFAULT_OTP_LENGTH);
+5 -1
View File
@@ -32,7 +32,11 @@ export class RedisModule {
},
RedisPubSubService,
],
exports: [RedisPubSubService],
exports: [
RedisPubSubService,
'REDIS_PUBLISHER',
'REDIS_SUBSCRIBER',
],
imports: [NotificationModule],
};
}
+3 -2
View File
@@ -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';
+3
View File
@@ -0,0 +1,3 @@
export const setIf = <T, K extends keyof T>(obj: T, key: K, val: T[K] | undefined) => {
if (typeof val !== 'undefined') obj[key] = val as T[K];
};
@@ -1,12 +1,12 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { IJwtPayload } from '~/auth/interfaces';
import { AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards';
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import { InitiateKycRequestDto } from '../dtos/request';
import { CustomerResponseDto, InitiateKycResponseDto } from '../dtos/response';
import { CustomerResponseDto, InitiateKycResponseDto, KycMetadataResponseDto } from '../dtos/response';
import { CustomerService } from '../services';
@Controller('customers')
@@ -30,6 +30,16 @@ export class CustomerController {
async initiateKyc(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: InitiateKycRequestDto) {
const res = await this.customerService.initiateKycRequest(sub, body);
return ResponseFactory.data(new InitiateKycResponseDto(res.randomNumber));
return ResponseFactory.data(new InitiateKycResponseDto(res));
}
@Get('/kyc/onboard-metadata')
@UseGuards(AccessTokenGuard)
@ApiOperation({ summary: 'Get KYC onboarding form metadata' })
@ApiDataResponse(KycMetadataResponseDto)
async getKycMetadata() {
const metadata = await this.customerService.getKycOnboardMetadata();
return ResponseFactory.data(metadata);
}
}
+5 -5
View File
@@ -4,14 +4,14 @@ import { NeoLeapModule } from '~/common/modules/neoleap/neoleap.module';
import { GuardianModule } from '~/guardian/guardian.module';
import { UserModule } from '~/user/user.module';
import { CustomerController } from './controllers';
import { Customer } from './entities';
import { CustomerRepository } from './repositories/customer.repository';
import { CustomerService } from './services';
import { Customer, KycTransaction } from './entities';
import { CustomerRepository, KycTransactionRepository } from './repositories';
import { CustomerService, MetadataService } from './services';
@Module({
imports: [TypeOrmModule.forFeature([Customer]), GuardianModule, forwardRef(() => UserModule), NeoLeapModule],
imports: [TypeOrmModule.forFeature([Customer, KycTransaction]), GuardianModule, forwardRef(() => UserModule), NeoLeapModule],
controllers: [CustomerController],
providers: [CustomerService, CustomerRepository],
providers: [CustomerService, CustomerRepository, KycTransactionRepository, MetadataService],
exports: [CustomerService],
})
export class CustomerModule {}
@@ -1,8 +1,55 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDateString, IsEmail, IsEnum, IsOptional, IsString, Matches } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { isValidSaudiId } from '~/core/decorators/validations';
import { Gender, IncomeRange, IncomeSource, JobCategory, JobSector, PoiType } from '~/customer/enums';
export class InitiateKycRequestDto {
@ApiProperty({ example: '999300024' })
@isValidSaudiId({ message: i18n('validation.isValidSaudiId', { path: 'general', property: 'customer.nationalId' }) })
nationalId!: string;
@ApiProperty({ example: '2586234623', description: 'Saudi National ID or Iqama number' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.poiNumber' }) })
poiNumber!: string;
@ApiProperty({ enum: PoiType, example: PoiType.NAT, default: PoiType.NAT })
@IsEnum(PoiType, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.poiType' }) })
poiType!: PoiType;
@ApiProperty({ example: '0512345678', pattern: '^05\\d{8}$' })
@Matches(/^05\d{8}$/, { message: i18n('validation.Matches', { path: 'general', property: 'customer.mobileNumber' }) })
mobileNumber!: string;
@ApiProperty({ example: '[email protected]', required: false })
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'customer.email' }) })
@IsOptional()
email?: string;
@ApiProperty({ example: '1990-01-01', format: 'date' })
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) })
dateOfBirth!: string;
@ApiProperty({ example: '2030-12-31', format: 'date', description: 'National ID expiry date' })
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.nationalIdExpiry' }) })
nationalIdExpiry!: string;
@ApiProperty({ enum: Gender, example: Gender.MALE })
@IsEnum(Gender, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.gender' }) })
gender!: Gender;
@ApiProperty({ enum: JobSector, example: JobSector.PRIVATE_SECTOR })
@IsEnum(JobSector, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.jobSector' }) })
jobSector!: JobSector;
@ApiProperty({ example: 'Test Company Ltd' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.employer' }) })
employer!: string;
@ApiProperty({ enum: IncomeSource, example: IncomeSource.SALARY })
@IsEnum(IncomeSource, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.incomeSource' }) })
incomeSource!: IncomeSource;
@ApiProperty({ enum: JobCategory, example: JobCategory.ENGINEER })
@IsEnum(JobCategory, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.jobCategory' }) })
jobCategory!: JobCategory;
@ApiProperty({ enum: IncomeRange, example: IncomeRange.RANGE_10000_20000 })
@IsEnum(IncomeRange, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.incomeRange' }) })
incomeRange!: IncomeRange;
}
@@ -34,15 +34,6 @@ export class CustomerResponseDto {
@ApiProperty({ example: 'JO' })
countryOfResidence!: string;
@ApiProperty({ example: 'Employee' })
sourceOfIncome!: string;
@ApiProperty({ example: 'Software Development' })
profession!: string;
@ApiProperty({ example: 'Full-time' })
professionType!: string;
@ApiProperty({ example: false })
isPep!: boolean;
@@ -58,24 +49,6 @@ export class CustomerResponseDto {
@ApiProperty({ example: 12345 })
waitingNumber!: number;
@ApiProperty({ example: 'SA' })
country!: string | null;
@ApiProperty({ example: 'Riyadh' })
region!: string | null;
@ApiProperty({ example: 'Riyadh City' })
city!: string | null;
@ApiProperty({ example: 'Al-Masif' })
neighborhood!: string | null;
@ApiProperty({ example: 'King Fahd Road' })
street!: string | null;
@ApiProperty({ example: '123' })
building!: string | null;
@ApiPropertyOptional({ type: DocumentMetaResponseDto })
profilePicture!: DocumentMetaResponseDto | null;
@@ -90,19 +63,10 @@ export class CustomerResponseDto {
this.nationalId = customer.nationalId;
this.nationalIdExpiry = customer.nationalIdExpiry;
this.countryOfResidence = customer.countryOfResidence;
this.sourceOfIncome = customer.sourceOfIncome;
this.profession = customer.profession;
this.professionType = customer.professionType;
this.isPep = customer.isPep;
this.gender = customer.gender;
this.isJunior = customer.isJunior;
this.isGuardian = customer.isGuardian;
this.waitingNumber = customer.applicationNumber;
this.country = customer.country;
this.region = customer.region;
this.city = customer.city;
this.neighborhood = customer.neighborhood;
this.street = customer.street;
this.building = customer.building;
}
}
+1
View File
@@ -1,2 +1,3 @@
export * from './customer.response.dto';
export * from './initiate-kyc.response.dto';
export * from './kyc-metadata.response.dto';
@@ -1,10 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose } from 'class-transformer';
export class InitiateKycResponseDto {
@ApiProperty()
randomNumber!: string;
@ApiProperty({ description: 'Internal transaction ID to track this KYC attempt' })
@Expose()
transactionId!: string;
constructor(randomNumber: string) {
this.randomNumber = randomNumber;
@ApiProperty({ description: 'Neoleap state ID for tracking' })
@Expose()
stateId!: string;
@ApiProperty({ description: 'Nafath random code to show to the user', example: '38' })
@Expose()
nafathRandomCode!: string;
@ApiProperty({ description: 'Current status', example: 'IN_PROGRESS' })
@Expose()
status!: string;
@ApiProperty({ description: 'External customer ID from Neoleap' })
@Expose()
externalCustomerId!: string;
constructor(data: Partial<InitiateKycResponseDto>) {
Object.assign(this, data);
}
}
@@ -0,0 +1,12 @@
export class MetadataOptionDto {
value!: string;
label!: string;
}
export class KycMetadataResponseDto {
poiTypes!: MetadataOptionDto[];
jobSectors!: MetadataOptionDto[];
incomeSources!: MetadataOptionDto[];
jobCategories!: MetadataOptionDto[];
incomeRanges!: MetadataOptionDto[];
}
+18 -23
View File
@@ -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 {
@@ -49,20 +49,11 @@ export class Customer extends BaseEntity {
@Column('varchar', { length: 255, nullable: true, name: 'country_of_residence' })
countryOfResidence!: CountryIso;
@Column('varchar', { length: 255, nullable: true, name: 'source_of_income' })
sourceOfIncome!: string;
@Column('varchar', { length: 255, nullable: true, name: 'profession' })
profession!: string;
@Column('varchar', { length: 255, nullable: true, name: 'profession_type' })
professionType!: string;
@Column('boolean', { default: false, name: 'is_pep' })
isPep!: boolean;
@Column('varchar', { length: 255, nullable: true, name: 'gender' })
gender!: string;
gender!: Gender;
@Column('boolean', { default: false, name: 'is_junior' })
isJunior!: boolean;
@@ -77,23 +68,27 @@ export class Customer extends BaseEntity {
@Column('varchar', { name: 'user_id' })
userId!: string;
@Column('varchar', { name: 'country', length: 255, nullable: true })
country!: CountryIso;
// KYC-specific fields
@Column('varchar', { length: 255, nullable: true, name: 'neoleap_external_customer_id' })
neoleapExternalCustomerId!: string | null;
@Column('varchar', { name: 'region', length: 255, nullable: true })
region!: string;
@Column('varchar', { length: 100, nullable: true, name: 'job_sector' })
jobSector!: string | null;
@Column('varchar', { name: 'city', length: 255, nullable: true })
city!: string;
@Column('varchar', { length: 255, nullable: true, name: 'employer' })
employer!: string | null;
@Column('varchar', { name: 'neighborhood', length: 255, nullable: true })
neighborhood!: string;
@Column('varchar', { length: 100, nullable: true, name: 'income_source' })
incomeSource!: string | null;
@Column('varchar', { name: 'street', length: 255, nullable: true })
street!: string;
@Column('varchar', { length: 100, nullable: true, name: 'job_category' })
jobCategory!: string | null;
@Column('varchar', { name: 'building', length: 255, nullable: true })
building!: string;
@Column('varchar', { length: 100, nullable: true, name: 'income_range' })
incomeRange!: string | null;
@Column('varchar', { length: 20, nullable: true, name: 'mobile_number' })
mobileNumber!: string | null;
@OneToOne(() => User, (user) => user.customer, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
+1
View File
@@ -1 +1,2 @@
export * from './customer.entity';
export * from './kyc-transaction.entity';
@@ -0,0 +1,76 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Customer } from './customer.entity';
import { User } from '~/user/entities';
@Entity('kyc_transactions')
export class KycTransaction extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column('uuid', { name: 'customer_id' })
customerId!: string;
@Column('uuid', { name: 'user_id' })
userId!: string;
// National ID from form
@Column('varchar', { length: 50, name: 'national_id', nullable: false })
nationalId!: string;
// Neoleap IDs
@Column('varchar', { length: 255, unique: true, name: 'state_id' })
stateId!: string;
@Column('varchar', { length: 255, nullable: true, name: 'external_customer_id' })
externalCustomerId!: string | null;
// Nafath details
@Column('varchar', { length: 10, nullable: true, name: 'nafath_random_code' })
nafathRandomCode!: string | null;
// Status tracking
@Column('varchar', { length: 50, default: 'INITIATED', name: 'status' })
status!: string;
// Audit trail
@Column('jsonb', { name: 'form_data' })
formData!: any;
@Column('varchar', { length: 255, nullable: true, name: 'callback_id' })
callbackId!: string | null;
// Timestamps
@Column('timestamp', { default: () => 'CURRENT_TIMESTAMP', name: 'initiated_at' })
initiatedAt!: Date;
@Column('timestamp', { nullable: true, name: 'completed_at' })
completedAt!: Date | null;
@Column('timestamp', { nullable: true, name: 'expires_at' })
expiresAt!: Date | null;
@CreateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', name: 'updated_at' })
updatedAt!: Date;
// Relationships
@ManyToOne(() => Customer, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'customer_id' })
customer!: Customer;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User;
}
+8
View File
@@ -0,0 +1,8 @@
export enum IncomeRange {
BELOW_2000 = 'SAR 2,000 and below',
RANGE_2000_5000 = 'SAR 2,000 to 5,000',
RANGE_5000_10000 = 'SAR 5,000 to 10,000',
RANGE_10000_20000 = 'SAR 10,000 to 20,000',
ABOVE_20000 = 'SAR 20,000 and above',
}
+9
View File
@@ -0,0 +1,9 @@
export enum IncomeSource {
SALARY = 'SALARY',
ANCESTRAL = 'ANCESTRAL',
REAL_ESTATE = 'REAL_ESTATE',
INVESTMENT_RETURNS = 'INVESTMENT_RETURNS',
RENTAL_INCOME = 'RENTAL_INCOME',
OTHER = 'OTHER',
}
+5
View File
@@ -1,3 +1,8 @@
export * from './customer-status.enum';
export * from './gender.enum';
export * from './kyc-status.enum';
export * from './poi-type.enum';
export * from './job-sector.enum';
export * from './income-source.enum';
export * from './job-category.enum';
export * from './income-range.enum';
+57
View File
@@ -0,0 +1,57 @@
export enum JobCategory {
ASSISTANT_MINISTER = 'ASSISTANT_MINISTER',
DEPUTY_MINISTER = 'DEPUTY_MINISTER',
UNDER_SECRETARY = 'UNDER_SECRETARY',
GENERAL_MANAGER = 'GENERAL_MANAGER',
CHAIRMAN = 'CHAIRMAN',
MANAGER = 'MANAGER',
PROFESSOR = 'PROFESSOR',
HEAD_OF_COURT = 'HEAD_OF_COURT',
JUDGE = 'JUDGE',
LAWYER = 'LAWYER',
SCIENTIST = 'SCIENTIST',
NOTARY = 'NOTARY',
BUSINESSMAN = 'BUSINESSMAN',
MERCHANT = 'MERCHANT',
PHARMACIST = 'PHARMACIST',
DOCTOR = 'DOCTOR',
MEDICAL_TECHNICIAN = 'MEDICAL_TECHNICIAN',
NURSE = 'NURSE',
ENGINEER = 'ENGINEER',
CHEMIST = 'CHEMIST',
CONTRACTOR = 'CONTRACTOR',
AUDITOR_ACCOUNTANT = 'AUDITOR_ACCOUNTANT',
RESEARCHER = 'RESEARCHER',
ACCOUNTANT = 'ACCOUNTANT',
JOURNALIST = 'JOURNALIST',
DESIGNER = 'DESIGNER',
COMPUTER_SPECIALIST = 'COMPUTER_SPECIALIST',
TRANSLATOR = 'TRANSLATOR',
TEACHER = 'TEACHER',
PILOT = 'PILOT',
HOST = 'HOST',
OFFICER = 'OFFICER',
SOLDIER = 'SOLDIER',
RETIRED = 'RETIRED',
SALESMAN = 'SALESMAN',
AUTHOR = 'AUTHOR',
CRAFTSMAN = 'CRAFTSMAN',
SECURITY = 'SECURITY',
LABORER = 'LABORER',
DRIVER = 'DRIVER',
FARMER = 'FARMER',
HOUSEWIFE = 'HOUSEWIFE',
DIPLOMAT = 'DIPLOMAT',
STUDENT = 'STUDENT',
FREELANCER = 'FREELANCER',
SHEPHERD = 'SHEPHERD',
HOUSEMAID_OR_BABYSITTER = 'HOUSEMAID_OR_BABYSITTER',
CAPTAIN = 'CAPTAIN',
AMBASSADOR = 'AMBASSADOR',
MARKETING = 'MARKETING',
CONSULTING = 'CONSULTING',
SUPERVISOR = 'SUPERVISOR',
BANKER = 'BANKER',
BODYGUARD_OR_PERSONAL_ASSISTANT = 'BODYGUARD_OR_PERSONAL_ASSISTANT',
}
+12
View File
@@ -0,0 +1,12 @@
export enum JobSector {
GOVERNMENT_SECTOR = 'GOVERNMENT_SECTOR',
HOME_MAKER = 'HOME_MAKER',
MILITARY = 'MILITARY',
PRIVATE_SECTOR = 'PRIVATE_SECTOR',
RETIRED = 'RETIRED',
SELF_EMPLOYED = 'SELF_EMPLOYED',
STUDENT = 'STUDENT',
HOUSEHOLD_LABOR = 'HOUSEHOLD_LABOR',
UNEMPLOYED = 'UNEMPLOYED',
}
+5
View File
@@ -0,0 +1,5 @@
export enum PoiType {
IQA = 'IQA', // Iqama (Resident ID)
NAT = 'NAT', // National ID
}
@@ -14,8 +14,7 @@ export class CustomerRepository {
findOne(where: FindOptionsWhere<Customer>) {
return this.customerRepository.findOne({
where,
relations: ['user', 'cards'],
relations: ['user', 'cards', 'cards.account'],
});
}
@@ -30,6 +29,7 @@ export class CustomerRepository {
lastName: body.lastName,
dateOfBirth: body.dateOfBirth,
countryOfResidence: body.countryOfResidence,
gender: body.gender,
}),
);
}
+3
View File
@@ -0,0 +1,3 @@
export * from './customer.repository';
export * from './kyc-transaction.repository';
@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { KycTransaction } from '../entities';
@Injectable()
export class KycTransactionRepository {
constructor(
@InjectRepository(KycTransaction)
private readonly kycTransactionRepository: Repository<KycTransaction>,
) {}
async create(data: Partial<KycTransaction>): Promise<KycTransaction> {
const transaction = this.kycTransactionRepository.create(data);
return this.kycTransactionRepository.save(transaction);
}
async findByStateId(stateId: string): Promise<KycTransaction | null> {
return this.kycTransactionRepository.findOne({
where: { stateId },
relations: ['customer', 'user'],
});
}
async findActiveByNationalId(nationalId: string): Promise<KycTransaction | null> {
return this.kycTransactionRepository.findOne({
where: {
nationalId,
status: 'IN_PROGRESS',
},
order: { initiatedAt: 'DESC' },
});
}
async updateByStateId(stateId: string, data: Partial<KycTransaction>): Promise<void> {
await this.kycTransactionRepository.update({ stateId }, data);
}
async findAllByCustomerId(customerId: string): Promise<KycTransaction[]> {
return this.kycTransactionRepository.find({
where: { customerId },
order: { initiatedAt: 'DESC' },
});
}
}
+86 -36
View File
@@ -1,4 +1,4 @@
import { BadRequestException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { BadRequestException, ConflictException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import moment from 'moment';
import { Transactional } from 'typeorm-transactional';
import { CountryIso } from '~/common/enums';
@@ -11,15 +11,18 @@ import { User } from '~/user/entities';
import { InitiateKycRequestDto } from '../dtos/request';
import { Customer } from '../entities';
import { Gender, KycStatus } from '../enums';
import { CustomerRepository } from '../repositories/customer.repository';
import { CustomerRepository, KycTransactionRepository } from '../repositories';
import { MetadataService } from './metadata.service';
@Injectable()
export class CustomerService {
private readonly logger = new Logger(CustomerService.name);
constructor(
private readonly customerRepository: CustomerRepository,
private readonly kycTransactionRepo: KycTransactionRepository,
private readonly guardianService: GuardianService,
@Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService,
private readonly metadataService: MetadataService,
) {}
async updateCustomer(userId: string, data: Partial<Customer>): Promise<Customer> {
@@ -33,7 +36,10 @@ export class CustomerService {
async createJuniorCustomer(guardianId: string, juniorId: string, body: CreateJuniorRequestDto) {
this.logger.log(`Creating junior customer for user ${juniorId}`);
return this.customerRepository.createCustomer(juniorId, body, false);
await this.customerRepository.createCustomer(juniorId, body, false);
this.logger.log(`Junior customer created for user ${juniorId} successfully`);
return this.customerRepository.findOne({ id: juniorId });
}
async findCustomerById(id: string) {
@@ -50,23 +56,68 @@ export class CustomerService {
}
async initiateKycRequest(customerId: string, body: InitiateKycRequestDto) {
this.logger.log(`Initiating KYC request for user ${customerId}`);
this.logger.log(`Initiating KYC request for customer ${customerId}`);
const customer = await this.findCustomerById(customerId);
// Validate customer is not already verified
if (customer.kycStatus === KycStatus.APPROVED) {
this.logger.error(`KYC for customer ${customerId} is already approved`);
throw new BadRequestException('CUSTOMER.KYC_ALREADY_APPROVED');
}
// I will assume the api for initiating KYC is not allowing me to send customerId as correlationId so I will store the nationalId in the customer entity
// Check for active KYC transaction by National ID
const activeTransaction = await this.kycTransactionRepo.findActiveByNationalId(body.poiNumber);
if (activeTransaction) {
this.logger.error(`KYC verification already in progress for National ID ${body.poiNumber}`);
throw new ConflictException('KYC verification already in progress for this National ID');
}
// Update customer with KYC data
await this.customerRepository.updateCustomer(customerId, {
nationalId: body.nationalId,
nationalId: body.poiNumber,
dateOfBirth: new Date(body.dateOfBirth),
nationalIdExpiry: new Date(body.nationalIdExpiry),
gender: body.gender,
countryOfResidence: CountryIso.SAUDI_ARABIA, // Always default to Saudi Arabia
mobileNumber: body.mobileNumber,
jobSector: body.jobSector,
employer: body.employer,
incomeSource: body.incomeSource,
jobCategory: body.jobCategory,
incomeRange: body.incomeRange,
kycStatus: KycStatus.PENDING,
});
return this.neoleapService.initiateKyc(customerId, body);
// Call Neoleap KYC API
const neoleapResponse = await this.neoleapService.initiateKycOnboarding(body);
// Create transaction record
const transaction = await this.kycTransactionRepo.create({
customerId,
userId: customer.userId,
nationalId: body.poiNumber,
stateId: neoleapResponse.stateId,
externalCustomerId: neoleapResponse.externalCustomerId,
nafathRandomCode: neoleapResponse.nafathRandomCode,
status: neoleapResponse.status,
formData: body,
initiatedAt: new Date(),
});
// Update customer with external ID
await this.customerRepository.updateCustomer(customerId, {
neoleapExternalCustomerId: neoleapResponse.externalCustomerId,
});
// Return formatted response
return {
transactionId: transaction.id,
stateId: neoleapResponse.stateId,
nafathRandomCode: neoleapResponse.nafathRandomCode,
status: neoleapResponse.status,
externalCustomerId: neoleapResponse.externalCustomerId,
};
}
@Transactional()
@@ -89,34 +140,34 @@ export class CustomerService {
}
async updateCustomerKyc(body: KycWebhookRequest) {
this.logger.log(`Updating KYC for customer with national ID ${body.nationalId}`);
this.logger.log(`Updating KYC for stateId ${body.stateId}`);
const customer = await this.customerRepository.findOne({ nationalId: body.nationalId });
// Find transaction by stateId
const transaction = await this.kycTransactionRepo.findByStateId(body.stateId);
if (!customer) {
throw new BadRequestException('CUSTOMER.NOT_FOUND');
if (!transaction) {
this.logger.error(`KYC transaction not found for stateId ${body.stateId}`);
throw new BadRequestException('KYC transaction not found');
}
await this.customerRepository.updateCustomer(customer.id, {
kycStatus: body.status === 'SUCCESS' ? KycStatus.APPROVED : KycStatus.REJECTED,
firstName: body.firstName,
lastName: body.lastName,
dateOfBirth: moment(body.dob, 'YYYYMMDD').toDate(),
nationalId: body.nationalId,
nationalIdExpiry: moment(body.nationalIdExpiry, 'YYYYMMDD').toDate(),
countryOfResidence: NumericToCountryIso[body.country],
country: NumericToCountryIso[body.country],
gender: body.gender === 'M' ? Gender.MALE : Gender.FEMALE,
sourceOfIncome: body.incomeSource,
profession: body.professionTitle,
professionType: body.professionType,
isPep: body.isPep === 'Y',
city: body.city,
region: body.region,
neighborhood: body.neighborhood,
street: body.street,
building: body.building,
const customer = await this.findCustomerById(transaction.customerId);
// Update transaction record
await this.kycTransactionRepo.updateByStateId(body.stateId, {
status: body.status,
callbackId: body.callbackId,
completedAt: new Date(),
});
// Update customer KYC status and external customer ID
const kycStatus = body.status === 'ONBOARDING_SUCCESS' ? KycStatus.APPROVED : KycStatus.REJECTED;
await this.customerRepository.updateCustomer(customer.id, {
kycStatus,
neoleapExternalCustomerId: body.entity.externalId,
});
this.logger.log(`KYC updated successfully for customer ${customer.id}, status: ${body.status}, externalId: ${body.entity.externalId}`);
}
// TO BE REMOVED: This function is for testing only and will be removed
@@ -129,12 +180,6 @@ export class CustomerService {
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, {
@@ -146,6 +191,11 @@ export class CustomerService {
return this.findCustomerById(userId);
}
getKycOnboardMetadata() {
this.logger.log('Getting KYC onboard metadata');
return this.metadataService.getKycOnboardMetadata();
}
// 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'
+1
View File
@@ -1 +1,2 @@
export * from './customer.service';
export * from './metadata.service';
+105
View File
@@ -0,0 +1,105 @@
import { Injectable } from '@nestjs/common';
import { IncomeRange, IncomeSource, JobCategory, JobSector, PoiType } from '../enums';
import { KycMetadataResponseDto, MetadataOptionDto } from '../dtos/response';
@Injectable()
export class MetadataService {
getKycOnboardMetadata(): KycMetadataResponseDto {
return {
poiTypes: this.enumToOptions(PoiType, {
[PoiType.IQA]: 'Iqama (Resident ID)',
[PoiType.NAT]: 'National ID',
}),
jobSectors: this.enumToOptions(JobSector, {
[JobSector.GOVERNMENT_SECTOR]: 'Government Sector',
[JobSector.HOME_MAKER]: 'Home Maker',
[JobSector.MILITARY]: 'Military',
[JobSector.PRIVATE_SECTOR]: 'Private Sector',
[JobSector.RETIRED]: 'Retired',
[JobSector.SELF_EMPLOYED]: 'Self Employed',
[JobSector.STUDENT]: 'Student',
[JobSector.HOUSEHOLD_LABOR]: 'Household Labor',
[JobSector.UNEMPLOYED]: 'Unemployed',
}),
incomeSources: this.enumToOptions(IncomeSource, {
[IncomeSource.SALARY]: 'Salary',
[IncomeSource.ANCESTRAL]: 'Ancestral/Inheritance',
[IncomeSource.REAL_ESTATE]: 'Real Estate',
[IncomeSource.INVESTMENT_RETURNS]: 'Investment Returns',
[IncomeSource.RENTAL_INCOME]: 'Rental Income',
[IncomeSource.OTHER]: 'Other',
}),
jobCategories: this.enumToOptions(JobCategory, {
[JobCategory.ASSISTANT_MINISTER]: 'Assistant Minister',
[JobCategory.DEPUTY_MINISTER]: 'Deputy Minister',
[JobCategory.UNDER_SECRETARY]: 'Under Secretary',
[JobCategory.GENERAL_MANAGER]: 'General Manager',
[JobCategory.CHAIRMAN]: 'Chairman',
[JobCategory.MANAGER]: 'Manager',
[JobCategory.PROFESSOR]: 'Professor',
[JobCategory.HEAD_OF_COURT]: 'Head of Court',
[JobCategory.JUDGE]: 'Judge',
[JobCategory.LAWYER]: 'Lawyer',
[JobCategory.SCIENTIST]: 'Scientist',
[JobCategory.NOTARY]: 'Notary',
[JobCategory.BUSINESSMAN]: 'Businessman',
[JobCategory.MERCHANT]: 'Merchant',
[JobCategory.PHARMACIST]: 'Pharmacist',
[JobCategory.DOCTOR]: 'Doctor',
[JobCategory.MEDICAL_TECHNICIAN]: 'Medical Technician',
[JobCategory.NURSE]: 'Nurse',
[JobCategory.ENGINEER]: 'Engineer',
[JobCategory.CHEMIST]: 'Chemist',
[JobCategory.CONTRACTOR]: 'Contractor',
[JobCategory.AUDITOR_ACCOUNTANT]: 'Auditor/Accountant',
[JobCategory.RESEARCHER]: 'Researcher',
[JobCategory.ACCOUNTANT]: 'Accountant',
[JobCategory.JOURNALIST]: 'Journalist',
[JobCategory.DESIGNER]: 'Designer',
[JobCategory.COMPUTER_SPECIALIST]: 'Computer Specialist',
[JobCategory.TRANSLATOR]: 'Translator',
[JobCategory.TEACHER]: 'Teacher',
[JobCategory.PILOT]: 'Pilot',
[JobCategory.HOST]: 'Host',
[JobCategory.OFFICER]: 'Officer',
[JobCategory.SOLDIER]: 'Soldier',
[JobCategory.RETIRED]: 'Retired',
[JobCategory.SALESMAN]: 'Salesman',
[JobCategory.AUTHOR]: 'Author',
[JobCategory.CRAFTSMAN]: 'Craftsman',
[JobCategory.SECURITY]: 'Security',
[JobCategory.LABORER]: 'Laborer',
[JobCategory.DRIVER]: 'Driver',
[JobCategory.FARMER]: 'Farmer',
[JobCategory.HOUSEWIFE]: 'Housewife',
[JobCategory.DIPLOMAT]: 'Diplomat',
[JobCategory.STUDENT]: 'Student',
[JobCategory.FREELANCER]: 'Freelancer',
[JobCategory.SHEPHERD]: 'Shepherd',
[JobCategory.HOUSEMAID_OR_BABYSITTER]: 'Housemaid/Babysitter',
[JobCategory.CAPTAIN]: 'Captain',
[JobCategory.AMBASSADOR]: 'Ambassador',
[JobCategory.MARKETING]: 'Marketing',
[JobCategory.CONSULTING]: 'Consulting',
[JobCategory.SUPERVISOR]: 'Supervisor',
[JobCategory.BANKER]: 'Banker',
[JobCategory.BODYGUARD_OR_PERSONAL_ASSISTANT]: 'Bodyguard/Personal Assistant',
}),
incomeRanges: this.enumToOptions(IncomeRange, {
[IncomeRange.BELOW_2000]: 'SAR 2,000 and below',
[IncomeRange.RANGE_2000_5000]: 'SAR 2,000 to 5,000',
[IncomeRange.RANGE_5000_10000]: 'SAR 5,000 to 10,000',
[IncomeRange.RANGE_10000_20000]: 'SAR 10,000 to 20,000',
[IncomeRange.ABOVE_20000]: 'SAR 20,000 and above',
}),
};
}
private enumToOptions(enumObj: any, labels: Record<string, string>): MetadataOptionDto[] {
return Object.keys(enumObj).map((key) => ({
value: enumObj[key],
label: labels[enumObj[key]] || enumObj[key],
}));
}
}
@@ -0,0 +1,65 @@
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
} from 'class-validator';
import { PoiType } from '../enums';
@ValidatorConstraint({ name: 'IsValidPoiNumber', async: false })
export class IsValidPoiNumberConstraint implements ValidatorConstraintInterface {
validate(poiNumber: string, args: ValidationArguments) {
const object = args.object as any;
const poiType = object.poiType;
if (!poiNumber || !poiType) {
return false;
}
// Saudi National ID: 10 digits, typically starts with 1 or 2
const nationalIdPattern = /^[12]\d{9}$/;
// Iqama (Resident ID): 10 digits, typically starts with other numbers (not 1 or 2)
const iqamaPattern = /^[3-9]\d{9}$/;
if (poiType === PoiType.NAT) {
return nationalIdPattern.test(poiNumber);
}
if (poiType === PoiType.IQA) {
return iqamaPattern.test(poiNumber);
}
return false;
}
defaultMessage(args: ValidationArguments) {
const object = args.object as any;
const poiType = object.poiType;
if (poiType === PoiType.NAT) {
return 'National ID must be 10 digits and start with 1 or 2';
}
if (poiType === PoiType.IQA) {
return 'Iqama number must be 10 digits and start with 3-9';
}
return 'Invalid POI number format';
}
}
export function IsValidPoiNumber(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsValidPoiNumberConstraint,
});
};
}
@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateMoneyRequestsTable1757349525708 implements MigrationInterface {
name = 'CreateMoneyRequestsTable1757349525708';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "money_requests" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "amount" numeric(10,2) NOT NULL, "reason" character varying NOT NULL, "status" character varying NOT NULL DEFAULT 'PENDING', "rejection_reason" text, "junior_id" uuid NOT NULL, "guardian_id" uuid NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_28cff23e9fb06cd5dbf73cd53e7" PRIMARY KEY ("id"))`,
);
await queryRunner.query(`ALTER TABLE "cards" ALTER COLUMN "color" SET DEFAULT 'DEEP_MAGENTA'`);
await queryRunner.query(
`ALTER TABLE "money_requests" ADD CONSTRAINT "FK_f7084c83efe7efaca37297d57ae" FOREIGN KEY ("junior_id") REFERENCES "juniors"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "money_requests" ADD CONSTRAINT "FK_09eadf4c4133b323f467ffc90f3" FOREIGN KEY ("guardian_id") REFERENCES "guardians"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "money_requests" DROP CONSTRAINT "FK_09eadf4c4133b323f467ffc90f3"`);
await queryRunner.query(`ALTER TABLE "money_requests" DROP CONSTRAINT "FK_f7084c83efe7efaca37297d57ae"`);
await queryRunner.query(`ALTER TABLE "cards" ALTER COLUMN "color" SET DEFAULT 'BLUE'`);
await queryRunner.query(`DROP TABLE "money_requests"`);
}
}
@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddReservationAmountToAccountEntity1757433339849 implements MigrationInterface {
name = 'AddReservationAmountToAccountEntity1757433339849';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "accounts" ADD "reserved_balance" numeric(10,2) NOT NULL DEFAULT '0'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "accounts" DROP COLUMN "reserved_balance"`);
}
}
@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDeletedAtColumnToJunior1757915357218 implements MigrationInterface {
name = 'AddDeletedAtColumnToJunior1757915357218';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "juniors" ADD "deleted_at" TIMESTAMP WITH TIME ZONE`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "juniors" DROP COLUMN "deleted_at"`);
}
}
@@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddMerchantInfoToTransactions1760869651296 implements MigrationInterface {
name = 'AddMerchantInfoToTransactions1760869651296'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "transactions" ADD COLUMN IF NOT EXISTS "merchant_name" character varying`);
await queryRunner.query(`ALTER TABLE "transactions" ADD COLUMN IF NOT EXISTS "merchant_category_code" character varying`);
await queryRunner.query(`ALTER TABLE "transactions" ADD COLUMN IF NOT EXISTS "merchant_city" character varying`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "merchant_city"`);
await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "merchant_category_code"`);
await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "merchant_name"`);
}
}
@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddUniqueConstraintToUserEmail1761032305682 implements MigrationInterface {
name = 'AddUniqueConstraintToUserEmail1761032305682'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE ("email")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3"`);
}
}
@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddKycFieldsAndTransactions1765804942393 implements MigrationInterface {
name = 'AddKycFieldsAndTransactions1765804942393'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "kyc_transactions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "customer_id" uuid NOT NULL, "user_id" uuid NOT NULL, "state_id" character varying(255) NOT NULL, "external_customer_id" character varying(255), "nafath_random_code" character varying(10), "status" character varying(50) NOT NULL DEFAULT 'INITIATED', "form_data" jsonb NOT NULL, "callback_id" character varying(255), "initiated_at" TIMESTAMP NOT NULL DEFAULT now(), "completed_at" TIMESTAMP, "expires_at" TIMESTAMP, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_231ce1d974b00919a8202e9ca3f" UNIQUE ("state_id"), CONSTRAINT "PK_aa56e3feebd4323c684ca146418" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "customers" ADD "neoleap_external_customer_id" character varying(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "job_sector" character varying(100)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "employer" character varying(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "income_source" character varying(100)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "job_category" character varying(100)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "income_range" character varying(100)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "mobile_number" character varying(20)`);
await queryRunner.query(`ALTER TABLE "kyc_transactions" ADD CONSTRAINT "FK_7651cf2e3ae6381377d8b9ed963" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "kyc_transactions" ADD CONSTRAINT "FK_336a3791fd94d386e5c428850db" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "kyc_transactions" DROP CONSTRAINT "FK_336a3791fd94d386e5c428850db"`);
await queryRunner.query(`ALTER TABLE "kyc_transactions" DROP CONSTRAINT "FK_7651cf2e3ae6381377d8b9ed963"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "mobile_number"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "income_range"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "job_category"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "income_source"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "employer"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "job_sector"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "neoleap_external_customer_id"`);
await queryRunner.query(`DROP TABLE "kyc_transactions"`);
}
}

Some files were not shown because too many files have changed in this diff Show More