Compare commits

...

99 Commits

Author SHA1 Message Date
45acf73a4a feat: enhance transaction notification logging and error handling
- Added console logging for emitted transaction creation events in TransactionService.
- Improved error handling in TransactionNotificationListener for i18n translation failures, providing fallback messages.
- Updated amount parsing in MoneyRequestNotificationListener to ensure consistent handling of string and numeric values.
2026-01-12 16:47:28 +03:00
d3ff755439 refactor: standardize notification message formatting
- Updated notification message arguments to use consistent object syntax for better readability.
- Modified Arabic and English translation files to reflect the new argument format in notification messages.
2026-01-12 16:28:26 +03:00
21653efc46 feat: implement money request notification system
- Added MoneyRequestNotificationListener to handle notifications for money request events (created, approved, declined).
- Introduced new notification event constants for money requests.
- Updated notification interfaces to include money request event payloads.
- Enhanced existing notification system to support money request notifications, notifying parents and children appropriately.
- Updated device service to support finding devices by ID for improved functionality.
2026-01-12 16:07:48 +03:00
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
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
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
170aa903c7 add eveint lestiner to the parent 2026-01-06 14:51:44 +03:00
2f74aa36a9 merge conflect 2026-01-06 12:57:13 +03:00
2562515574 Merge branch 'dev' of github.com:HamzaSha1/zod-backend into feature/notification-system-fcm-registration 2026-01-06 12:53:44 +03:00
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
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
d77d59a793 Merge pull request #70 from Zod-Alkhair/feature/kyc-onboarding
Feature/kyc onboarding
2025-12-18 14:26:27 +03:00
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
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
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
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
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
0fb76d712d Merge pull request #67 from Zod-Alkhair/feature/kyc-onboarding
Feature/kyc onboarding
2025-12-16 14:59:28 +03:00
5e708c16fe chore: remove migration from wrong directory
Migration already exists in correct location: src/db/migrations/
2025-12-16 14:57:03 +03:00
fe11f35b32 feat: send the adress data to noleap 2025-12-16 14:51:21 +03:00
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
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
e8127970f6 chore: remove email and dob from guardian signup flow 2025-12-11 12:13:51 +03:00
07d4a83cf9 test ssh 2025-12-07 20:18:59 +03:00
ce1f6341b7 Merge pull request #65 from HamzaSha1/dev
Dev to main
2025-11-26 09:58:33 +03:00
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
91dea22f45 refactor: remove unused PoiValidationRule class from KycMetadataResponseDto 2025-11-18 15:14:47 +03:00
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
c007ac584f feat: add KYC onboarding metadata endpoint with POI validation 2025-11-18 15:03:42 +03:00
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
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
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
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
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
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
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
d6fb5f48d9 add more loggs 2025-11-02 12:34:41 +03:00
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
99af65a300 money-request to use the parent card 2025-11-02 11:57:41 +03:00
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
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
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
a3a61b4923 Implement OTP generation and email verification logic in UserService 2025-10-28 15:52:24 +03:00
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
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
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
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
596562f6dc Merge pull request #48 from HamzaSha1/feat/neoleap-integration
Feat/neoleap integration
2025-10-21 14:56:38 +03:00
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
8a6b1cc900 Remove duplicate email cleanup logic and add unique constraint to user email 2025-10-21 14:10:14 +03:00
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
e966f95463 ZOD-341-Add unique constraint to user email and clean up duplicates 2025-10-21 10:49:43 +03:00
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
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
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
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
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
d2e084d3e4 git checkout -b ZOD-204-view-spending-from-child-login 2025-10-19 15:26:47 +03:00
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
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
7b57277a7f ZOD-339-child-profile-gender-update-is-not-reflected-after-editing 2025-10-19 11:01:52 +03:00
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
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
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
33b4f13ec8 Merge pull request #37 from HamzaSha1/feat/neoleap-integration
Feat/neoleap integration
2025-10-16 14:50:23 +03:00
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
15621124ad ZOD-309-child-transaction-history-parent-→-child-transfers 2025-10-16 12:25:16 +03:00
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
f6fa74897a feat: add guardian transactions feature with response DTOs and service integration 2025-10-15 14:14:59 +03:00
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
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
183f6b4475 fix: add gender property to UserResponseDto
fix: add gender property to UserResponseDto
2025-10-12 16:06:39 +03:00
918b15c315 fix: add swagger 2025-09-23 09:00:41 +03:00
1830d92cbd feat: weekly stats for junior 2025-09-23 08:56:57 +03:00
44124b9964 Merge branch 'dev' of github.com:HamzaSha0/zod-backend into dev 2025-09-18 10:03:47 +03:00
454ded627f fix: fix transfer to child bug 2025-09-16 21:01:32 +03:00
f1484e125b feat: soft delete junior 2025-09-15 09:02:56 +03:00
df4d2e3c1f feat: get card by child id 2025-09-15 08:47:56 +03:00
872d231f72 feat: show embossing information for child cards 2025-09-09 21:45:37 +03:00
cc4c8254f6 feat: view child active cards 2025-09-09 21:37:55 +03:00
039c95aa56 fix: calculating child and parent balance 2025-09-09 20:31:48 +03:00
e1f50decfa feat: money requests 2025-09-08 21:38:11 +03:00
11712bedf3 Merge pull request #31 from HamzaSha1/feat/neoleap-integration
Feat/neoleap integration
2025-09-08 21:34:46 +03:00
e6642b5a15 fix: fix controller name 2025-09-07 21:47:17 +03:00
954aa422a2 fix: fix mock request for funding decorators 2025-09-07 21:40:55 +03:00
15a48e4884 fix: validate card spending limit before transfering to child 2025-09-07 21:18:49 +03:00
d768da70f2 feat: transfer to parent 2025-09-07 20:23:11 +03:00
9b0e1791da feat: transfer money to child 2025-09-07 20:14:28 +03:00
44b5937f7a feat: finalize update junior 2025-09-07 18:13:28 +03:00
edddc2f457 feat: update junior 2025-09-07 09:12:14 +03:00
88730a2b2b fix: fix create application mock 2025-08-26 19:17:00 +03:00
3df34c0017 fix: fix duplicate iban 2025-08-26 12:19:47 +03:00
7dd309e0e3 fix: fix null assertion in creating junior 2025-08-24 20:14:59 +03:00
4552a7fc93 fix: fix issue with customer relationship in creating junior 2025-08-24 20:12:57 +03:00
740135051d feat: create card for children 2025-08-24 20:01:07 +03:00
3222aa4a66 fix: fix card embossing info endpoint 2025-08-24 18:40:49 +03:00
6602414779 feat: finialize creating juniors 2025-08-23 21:52:59 +03:00
7291447c5a Merge branch 'dev' into feat/neoleap-integration 2025-08-23 20:12:48 +03:00
d437b21dc3 fix: make kyc run on mocks 2025-08-23 19:30:29 +03:00
152 changed files with 11673 additions and 3644 deletions

9003
package-lock.json generated

File diff suppressed because it is too large Load Diff

0
queries/Query.sql Normal file
View File

View File

@ -28,6 +28,7 @@ import { HealthModule } from './health/health.module';
import { JuniorModule } from './junior/junior.module'; import { JuniorModule } from './junior/junior.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
import { WebhookModule } from './webhook/webhook.module'; import { WebhookModule } from './webhook/webhook.module';
import { MoneyRequestModule } from './money-request/money-request.module';
@Module({ @Module({
controllers: [], controllers: [],
@ -74,6 +75,7 @@ import { WebhookModule } from './webhook/webhook.module';
CronModule, CronModule,
NeoLeapModule, NeoLeapModule,
WebhookModule, WebhookModule,
MoneyRequestModule,
], ],
providers: [ providers: [
// Global Pipes // Global Pipes

View File

@ -9,9 +9,11 @@ import {
ChangePasswordRequestDto, ChangePasswordRequestDto,
CreateUnverifiedUserRequestDto, CreateUnverifiedUserRequestDto,
ForgetPasswordRequestDto, ForgetPasswordRequestDto,
JuniorLoginRequestDto,
LoginRequestDto, LoginRequestDto,
RefreshTokenRequestDto, RefreshTokenRequestDto,
SendForgetPasswordOtpRequestDto, SendForgetPasswordOtpRequestDto,
setJuniorPasswordRequestDto,
VerifyForgetPasswordOtpRequestDto, VerifyForgetPasswordOtpRequestDto,
VerifyUserRequestDto, VerifyUserRequestDto,
} from '../dtos/request'; } from '../dtos/request';
@ -69,10 +71,26 @@ export class AuthController {
@Post('change-password') @Post('change-password')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard) @UseGuards(AccessTokenGuard)
async changePassword(@AuthenticatedUser() { sub }: IJwtPayload, @Body() forgetPasswordDto: ChangePasswordRequestDto) { changePassword(@AuthenticatedUser() { sub }: IJwtPayload, @Body() forgetPasswordDto: ChangePasswordRequestDto) {
return this.authService.changePassword(sub, forgetPasswordDto); 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') @Post('refresh-token')
@Public() @Public()
async refreshToken(@Body() { refreshToken }: RefreshTokenRequestDto) { async refreshToken(@Body() { refreshToken }: RefreshTokenRequestDto) {

View File

@ -1,11 +1,11 @@
export * from './change-password.request.dto'; export * from './change-password.request.dto';
export * from './create-unverified-user.request.dto'; export * from './create-unverified-user.request.dto';
export * from './forget-password.request.dto'; export * from './forget-password.request.dto';
export * from './junior-login.request.dto';
export * from './login.request.dto'; export * from './login.request.dto';
export * from './refresh-token.request.dto'; export * from './refresh-token.request.dto';
export * from './send-forget-password-otp.request.dto'; export * from './send-forget-password-otp.request.dto';
export * from './set-junior-password.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-forget-password-otp.request.dto';
export * from './verify-otp.request.dto'; export * from './verify-otp.request.dto';
export * from './verify-user.request.dto'; export * from './verify-user.request.dto';

View File

@ -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: 'test@junior.com' })
@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;
}

View File

@ -21,4 +21,18 @@ export class LoginRequestDto {
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) })
@ValidateIf((o) => o.grantType === GrantType.PASSWORD) @ValidateIf((o) => o.grantType === GrantType.PASSWORD)
password!: string; 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;
} }

View File

@ -1,8 +1,11 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, PickType } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator'; import { IsNotEmpty, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n'; import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { SetPasscodeRequestDto } from './set-passcode.request.dto'; import { ChangePasswordRequestDto } from './change-password.request.dto';
export class setJuniorPasswordRequestDto extends SetPasscodeRequestDto { export class setJuniorPasswordRequestDto extends PickType(ChangePasswordRequestDto, [
'newPassword',
'confirmNewPassword',
]) {
@ApiProperty() @ApiProperty()
@IsString({ message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.qrToken' }) }) @IsString({ message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.qrToken' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.qrToken' }) }) @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.qrToken' }) })

View File

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

View File

@ -1,7 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { import {
IsDateString,
IsEmail,
IsEnum, IsEnum,
IsNotEmpty, IsNotEmpty,
IsNumberString, IsNumberString,
@ -15,7 +13,7 @@ import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { COUNTRY_CODE_REGEX, PASSWORD_REGEX } from '~/auth/constants'; import { COUNTRY_CODE_REGEX, PASSWORD_REGEX } from '~/auth/constants';
import { CountryIso } from '~/common/enums'; import { CountryIso } from '~/common/enums';
import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; 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 { export class VerifyUserRequestDto {
@ApiProperty({ example: '+962' }) @ApiProperty({ example: '+962' })
@ -39,11 +37,6 @@ export class VerifyUserRequestDto {
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) }) @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) })
lastName!: string; 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' }) @ApiProperty({ example: 'JO' })
@IsEnum(CountryIso, { @IsEnum(CountryIso, {
message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }), message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }),
@ -51,10 +44,38 @@ export class VerifyUserRequestDto {
@IsOptional() @IsOptional()
countryOfResidence: CountryIso = CountryIso.SAUDI_ARABIA; countryOfResidence: CountryIso = CountryIso.SAUDI_ARABIA;
@ApiProperty({ example: 'test@test.com' }) // Address fields (optional during registration, required for card creation)
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) @ApiProperty({ example: 'SA', description: 'Country code', required: false })
@IsEnum(CountryIso, {
message: i18n('validation.IsEnum', { path: 'general', property: 'customer.country' }),
})
@IsOptional() @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@' }) @ApiProperty({ example: 'Abcd1234@' })
@Matches(PASSWORD_REGEX, { @Matches(PASSWORD_REGEX, {
@ -80,4 +101,18 @@ export class VerifyUserRequestDto {
message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }),
}) })
otp!: string; 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;
} }

View File

@ -14,6 +14,7 @@ import {
ChangePasswordRequestDto, ChangePasswordRequestDto,
CreateUnverifiedUserRequestDto, CreateUnverifiedUserRequestDto,
ForgetPasswordRequestDto, ForgetPasswordRequestDto,
JuniorLoginRequestDto,
LoginRequestDto, LoginRequestDto,
SendForgetPasswordOtpRequestDto, SendForgetPasswordOtpRequestDto,
setJuniorPasswordRequestDto, setJuniorPasswordRequestDto,
@ -40,14 +41,6 @@ export class AuthService {
) {} ) {}
async sendRegisterOtp(body: CreateUnverifiedUserRequestDto) { 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) { if (body.password !== body.confirmPassword) {
this.logger.error('Password and confirm password do not match'); this.logger.error('Password and confirm password do not match');
throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
@ -93,6 +86,12 @@ export class AuthService {
const tokens = await this.generateAuthToken(user); const tokens = await this.generateAuthToken(user);
this.logger.log(`User with phone number ${user.fullPhoneNumber} verified successfully`); 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]; return [tokens, user];
} }
@ -196,11 +195,14 @@ export class AuthService {
this.logger.log(`Password changed successfully for user with id ${userId}`); 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}`); 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 juniorId = await this.userTokenService.validateToken(body.qrToken, UserType.JUNIOR);
const salt = bcrypt.genSaltSync(SALT_ROUNDS); 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.userService.setPassword(juniorId!, hashedPasscode, salt);
await this.userTokenService.invalidateToken(body.qrToken); await this.userTokenService.invalidateToken(body.qrToken);
this.logger.log(`Passcode set successfully for junior with id ${juniorId}`); this.logger.log(`Passcode set successfully for junior with id ${juniorId}`);
@ -275,9 +277,99 @@ export class AuthService {
const tokens = await this.generateAuthToken(user); const tokens = await this.generateAuthToken(user);
this.logger.log(`Password validated successfully for 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]; 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:
* 1. Device already exists for this user → Update FCM token
* 2. Device exists for different user → Transfer device to new user
* 3. Device doesn't exist → Create new device
*/
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}`);
// Step 1: Check if device already exists for this user
const existingDeviceForUser = await this.deviceService.findUserDeviceById(deviceId, userId);
if (existingDeviceForUser) {
// Device exists for this user → Update 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}`);
return;
}
// Step 2: Check if device exists for any user (different user scenario)
const existingDevice = await this.deviceService.findByDeviceId(deviceId);
if (existingDevice) {
// Device exists for different user → Transfer device to new user
this.logger.log(
`Device ${deviceId} exists for user ${existingDevice.userId}, transferring to user ${userId}`
);
await this.deviceService.updateDevice(deviceId, {
userId,
fcmToken,
lastAccessOn: new Date(),
});
this.logger.log(`Device ${deviceId} transferred from user ${existingDevice.userId} to user ${userId}`);
return;
}
// Step 3: Device doesn't exist → 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) { private async generateAuthToken(user: User) {
this.logger.log(`Generating auth token for user with id ${user.id}`); this.logger.log(`Generating auth token for user with id ${user.id}`);
const [accessToken, refreshToken] = await Promise.all([ const [accessToken, refreshToken] = await Promise.all([

View File

@ -1,12 +1,14 @@
import { Controller, Get, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces'; import { IJwtPayload } from '~/auth/interfaces';
import { AuthenticatedUser } from '~/common/decorators'; import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards'; import { AccessTokenGuard, RolesGuard } from '~/common/guards';
import { CardEmbossingDetailsResponseDto } from '~/common/modules/neoleap/dtos/response'; import { CardEmbossingDetailsResponseDto } from '~/common/modules/neoleap/dtos/response';
import { ApiDataResponse } from '~/core/decorators'; import { ApiDataResponse } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils'; 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'; import { CardService } from '../services';
@Controller('cards') @Controller('cards')
@ -23,6 +25,33 @@ export class CardsController {
return ResponseFactory.data(new CardResponseDto(card)); 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') @Get('current')
@ApiDataResponse(CardResponseDto) @ApiDataResponse(CardResponseDto)
async getCurrentCard(@AuthenticatedUser() { sub }: IJwtPayload) { async getCurrentCard(@AuthenticatedUser() { sub }: IJwtPayload) {
@ -36,4 +65,22 @@ export class CardsController {
const res = await this.cardService.getEmbossingInformation(sub); const res = await this.cardService.getEmbossingInformation(sub);
return ResponseFactory.data(res); 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);
}
} }

View File

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

View File

@ -0,0 +1 @@
export * from './fund-iban.request.dto';

View File

@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
export class AccountIbanResponseDto {
@ApiProperty({ example: 'DE89370400440532013000' })
iban!: string;
constructor(iban: string) {
this.iban = iban;
}
}

View File

@ -43,6 +43,13 @@ export class CardResponseDto {
}) })
balance!: number; 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) { constructor(card: Card) {
this.id = card.id; this.id = card.id;
this.firstSixDigits = card.firstSixDigits; this.firstSixDigits = card.firstSixDigits;
@ -51,6 +58,9 @@ export class CardResponseDto {
this.status = card.status; this.status = card.status;
this.statusDescription = CardStatusDescriptionMapper[card.statusDescription][UserLocale.ENGLISH].description; this.statusDescription = CardStatusDescriptionMapper[card.statusDescription][UserLocale.ENGLISH].description;
this.balance = 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;
} }
} }

View File

@ -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: 'test@example.com' })
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;
}
}

View File

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

View File

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

View File

@ -1 +1,15 @@
export * from './account-iban.response.dto';
export * from './card.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';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -22,9 +22,30 @@ export class Account {
@Column('varchar', { length: 255, nullable: false, name: 'currency' }) @Column('varchar', { length: 255, nullable: false, name: 'currency' })
currency!: string; 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; 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 }) @OneToMany(() => Card, (card) => card.account, { cascade: true })
cards!: Card[]; cards!: Card[];

View File

@ -39,7 +39,7 @@ export class Card {
@Column({ type: 'varchar', nullable: false, name: 'customer_type' }) @Column({ type: 'varchar', nullable: false, name: 'customer_type' })
customerType!: CustomerType; customerType!: CustomerType;
@Column({ type: 'varchar', nullable: false, default: CardColors.BLUE }) @Column({ type: 'varchar', nullable: false, default: CardColors.DEEP_MAGENTA })
color!: CardColors; color!: CardColors;
@Column({ type: 'varchar', nullable: false, default: CardStatus.PENDING }) @Column({ type: 'varchar', nullable: false, default: CardStatus.PENDING })

View File

@ -32,7 +32,16 @@ export class Transaction {
@Column({ name: 'rrn', nullable: true, type: 'varchar' }) @Column({ name: 'rrn', nullable: true, type: 'varchar' })
rrn!: string; 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; transactionAmount!: number;
@Column({ type: 'varchar', name: 'transaction_currency' }) @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 }) @Column({ type: 'decimal', name: 'vat_on_fees', precision: 12, scale: 2, default: 0.0 })
vatOnFees!: number; 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 }) @Column({ name: 'card_id', type: 'uuid', nullable: true })
cardId!: string; cardId!: string;

View File

@ -1,4 +1,13 @@
export enum CardColors { export enum CardColors {
RED = 'RED', RAINBOW_PASTEL = 'RAINBOW_PASTEL',
BLUE = 'BLUE', 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',
} }

View File

@ -6,3 +6,4 @@ export * from './card-status.enum';
export * from './customer-type.enum'; export * from './customer-type.enum';
export * from './transaction-scope.enum'; export * from './transaction-scope.enum';
export * from './transaction-type.enum'; export * from './transaction-type.enum';
export * from './parent-transaction-type.enum';

View File

@ -0,0 +1,6 @@
export enum ParentTransactionType {
PARENT_TRANSFER = 'PARENT_TRANSFER',
PARENT_TOPUP = 'PARENT_TOPUP',
}

View File

@ -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> { getAccountByAccountNumber(accountNumber: string): Promise<Account | null> {
return this.accountRepository.findOne({ return this.accountRepository.findOne({
where: { accountNumber }, 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) { topUpAccountBalance(accountReference: string, amount: number) {
return this.accountRepository.increment({ accountReference }, 'balance', amount); return this.accountRepository.increment({ accountReference }, 'balance', amount);
} }
@ -41,4 +55,12 @@ export class AccountRepository {
decreaseAccountBalance(accountReference: string, amount: number) { decreaseAccountBalance(accountReference: string, amount: number) {
return this.accountRepository.decrement({ accountReference }, 'balance', amount); 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);
}
} }

View File

@ -9,26 +9,58 @@ import { CardColors, CardIssuers, CardScheme, CardStatus, CardStatusDescription,
export class CardRepository { export class CardRepository {
constructor(@InjectRepository(Card) private readonly cardRepository: Repository<Card>) {} 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( return this.cardRepository.save(
this.cardRepository.create({ this.cardRepository.create({
customerId: customerId, customerId: customerId,
expiry: card.expiryDate, expiry: card.expiryDate,
cardReference: card.cardId, cardReference: card.cardId,
customerType: CustomerType.PARENT, customerType: parentId ? CustomerType.CHILD : CustomerType.PARENT,
firstSixDigits: card.firstSixDigits, firstSixDigits: card.firstSixDigits,
lastFourDigits: card.lastFourDigits, lastFourDigits: card.lastFourDigits,
color: CardColors.BLUE, color: cardColor ? cardColor : CardColors.DEEP_MAGENTA,
scheme: CardScheme.VISA, scheme: CardScheme.VISA,
issuer: CardIssuers.NEOLEAP, issuer: CardIssuers.NEOLEAP,
accountId: accountId, accountId: accountId,
vpan: card.vpan, 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> { 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> { getCardByReferenceNumber(referenceNumber: string): Promise<Card | null> {
@ -38,14 +70,30 @@ export class CardRepository {
getCardByVpan(vpan: string): Promise<Card | null> { getCardByVpan(vpan: string): Promise<Card | null> {
return this.cardRepository.findOne({ return this.cardRepository.findOne({
where: { vpan }, 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> { getCardByCustomerId(customerId: string): Promise<Card | null> {
return this.cardRepository.findOne({ return this.cardRepository.findOne({
where: { customerId }, 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, statusDescription: statusDescription,
}); });
} }
updateCardLimit(cardId: string, newLimit: number) {
return this.cardRepository.update(cardId, {
limit: newLimit,
});
}
} }

View File

@ -1 +1,3 @@
export * from './card.repository'; export * from './card.repository';
export * from './transaction.repository';
export * from './account.repository';

View File

@ -34,6 +34,9 @@ export class TransactionRepository {
accountReference: card.account!.accountReference, accountReference: card.account!.accountReference,
transactionScope: TransactionScope.CARD, transactionScope: TransactionScope.CARD,
vatOnFees: transactionData.vatOnFees, 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> { findTransactionByReference(transactionId: string, accountReference: string): Promise<Transaction | null> {
return this.transactionRepository.findOne({ return this.transactionRepository.findOne({
where: { transactionId, accountReference }, 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();
}
} }

View File

@ -27,15 +27,33 @@ export class AccountService {
return account; 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); 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) { async decreaseAccountBalance(accountReference: string, amount: number) {
const account = await this.getAccountByReferenceNumber(accountReference); const account = await this.getAccountByReferenceNumber(accountReference);
/** /**
*
* While there is no need to check for insufficient balance because this is a webhook handler, * 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) { if (account.balance < amount) {
@ -44,4 +62,20 @@ export class AccountService {
return this.accountRepository.decreaseAccountBalance(accountReference, amount); 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);
}
} }

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 { Transactional } from 'typeorm-transactional';
import { AccountCardStatusChangedWebhookRequest } from '~/common/modules/neoleap/dtos/requests'; import { AccountCardStatusChangedWebhookRequest } from '~/common/modules/neoleap/dtos/requests';
import { NeoLeapService } from '~/common/modules/neoleap/services'; import { NeoLeapService } from '~/common/modules/neoleap/services';
import { Customer } from '~/customer/entities';
import { KycStatus } from '~/customer/enums'; import { KycStatus } from '~/customer/enums';
import { CustomerService } from '~/customer/services'; import { CustomerService } from '~/customer/services';
import { OciService } from '~/document/services';
import { Card } from '../entities'; import { Card } from '../entities';
import { CardColors } from '../enums';
import { CardStatusMapper } from '../mappers/card-status.mapper'; import { CardStatusMapper } from '../mappers/card-status.mapper';
import { CardRepository } from '../repositories'; import { CardRepository } from '../repositories';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
import { TransactionService } from './transaction.service';
@Injectable() @Injectable()
export class CardService { export class CardService {
private readonly logger = new Logger(CardService.name);
constructor( constructor(
private readonly cardRepository: CardRepository, private readonly cardRepository: CardRepository,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly ociService: OciService,
@Inject(forwardRef(() => TransactionService)) private readonly transactionService: TransactionService,
@Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService, @Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService,
@Inject(forwardRef(() => CustomerService)) private readonly customerService: CustomerService, @Inject(forwardRef(() => CustomerService)) private readonly customerService: CustomerService,
) {} ) {}
@ -26,10 +34,26 @@ export class CardService {
throw new BadRequestException('CUSTOMER.KYC_NOT_APPROVED'); throw new BadRequestException('CUSTOMER.KYC_NOT_APPROVED');
} }
if (!customer.neoleapExternalCustomerId) {
throw new BadRequestException('CUSTOMER.KYC_NOT_COMPLETED');
}
if (customer.cards.length > 0) { if (customer.cards.length > 0) {
throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD'); 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 data = await this.neoleapService.createApplication(customer);
const account = await this.accountService.createAccount(data); const account = await this.accountService.createAccount(data);
const createdCard = await this.cardRepository.createCard(customerId, account.id, data); const createdCard = await this.cardRepository.createCard(customerId, account.id, data);
@ -37,6 +61,33 @@ export class CardService {
return this.getCardById(createdCard.id); 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> { async getCardById(id: string): Promise<Card> {
const card = await this.cardRepository.getCardById(id); const card = await this.cardRepository.getCardById(id);
@ -71,6 +122,7 @@ export class CardService {
if (!card) { if (!card) {
throw new BadRequestException('CARD.NOT_FOUND'); throw new BadRequestException('CARD.NOT_FOUND');
} }
return card; return card;
} }
@ -86,4 +138,76 @@ export class CardService {
return this.neoleapService.getEmbossingInformation(card); 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);
}
}),
);
}
} }

View File

@ -1 +1,3 @@
export * from './card.service'; export * from './card.service';
export * from './transaction.service';
export * from './account.service';

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 Decimal from 'decimal.js';
import moment from 'moment';
import { Transactional } from 'typeorm-transactional'; import { Transactional } from 'typeorm-transactional';
import { import {
AccountTransactionWebhookRequest, AccountTransactionWebhookRequest,
CardTransactionWebhookRequest, CardTransactionWebhookRequest,
} from '~/common/modules/neoleap/dtos/requests'; } 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 { Transaction } from '../entities/transaction.entity';
import { CustomerType, TransactionType } from '../enums';
import { TransactionRepository } from '../repositories/transaction.repository'; import { TransactionRepository } from '../repositories/transaction.repository';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
import { CardService } from './card.service'; import { CardService } from './card.service';
import {
TransactionItemResponseDto,
PagedTransactionsResponseDto,
ParentTransferItemDto,
PagedParentTransfersResponseDto,
ChildTransferItemDto,
PagedChildTransfersResponseDto,
} from '../dtos/responses';
import { ParentTransactionType } from '../enums';
@Injectable() @Injectable()
export class TransactionService { export class TransactionService {
constructor( constructor(
private readonly transactionRepository: TransactionRepository, private readonly transactionRepository: TransactionRepository,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly cardService: CardService, @Inject(forwardRef(() => CardService)) private readonly cardService: CardService,
private readonly eventEmitter: EventEmitter2,
) {} ) {}
@Transactional() @Transactional()
@ -30,7 +45,31 @@ export class TransactionService {
const transaction = await this.transactionRepository.createCardTransaction(card, body); const transaction = await this.transactionRepository.createCardTransaction(card, body);
const total = new Decimal(body.transactionAmount).plus(body.billingAmount).plus(body.fees).plus(body.vatOnFees); const total = new Decimal(body.transactionAmount).plus(body.billingAmount).plus(body.fees).plus(body.vatOnFees);
await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()); 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; return transaction;
} }
@ -48,6 +87,41 @@ export class TransactionService {
const transaction = await this.transactionRepository.createAccountTransaction(account, body); const transaction = await this.transactionRepository.createAccountTransaction(account, body);
await this.accountService.creditAccountBalance(account.accountReference, body.amount); 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(),
};
console.log(`[TransactionService] Emitting TRANSACTION_CREATED event for transaction ${transaction.id}`);
this.eventEmitter.emit(NOTIFICATION_EVENTS.TRANSACTION_CREATED, event);
console.log(`[TransactionService] Event emitted successfully`);
return transaction; return transaction;
} }
@ -59,4 +133,233 @@ export class TransactionService {
return existingTransaction; 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;
}
} }

View File

@ -1,750 +1,198 @@
export const CREATE_APPLICATION_MOCK = { import { randomInt, randomUUID } from 'crypto';
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,
},
CreateNewApplicationResponseDetails: { /** Generate a string of `n` random digits (first digit never 0). */
InstitutionCode: '1100', function randomDigits(n: number): string {
ApplicationTypeDetails: { if (n <= 0) return '0';
TypeCode: '01', let s = String(randomInt(1, 10)); // first digit 19
Description: 'Normal Primary', for (let i = 1; i < n; i++) s += String(randomInt(0, 10));
Additional: false, return s;
Corporate: false, }
UserData: null,
/** 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, CreateNewApplicationResponseDetails: {
ApplicationNumber: '3300000000073', InstitutionCode: '1100',
ExternalApplicationNumber: '3', ApplicationTypeDetails: {
ApplicationStatus: '04', TypeCode: '01',
Organization: 0, Description: 'Normal Primary',
Product: '1101', Additional: false,
ApplicatonDate: '2025-05-29', Corporate: false,
ApplicationSource: 'O', UserData: null,
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',
}, },
RequestedCurrencyList: null, ApplicationDetails: {
CreditAccountNumber: '6000000000000000', cif: null,
AccountType: '30', ApplicationNumber: '3300000000073',
OpenDate: null, ExternalApplicationNumber: '3',
Income: 0, ApplicationStatus: '04',
AdditionalIncome: 0, Organization: 0,
TotalIncome: 0, Product: '1101',
CurrentBalance: 0, ApplicatonDate: '2025-05-29',
AverageBalance: 0, ApplicationSource: 'O',
AssetsBalance: 0, SalesSource: null,
InsuranceBalance: 0, DeliveryMethod: 'V',
DepositAmount: 0, ProgramCode: null,
GuarenteeAccountNumber: null, Campaign: null,
GuarenteeAmount: 0, Plastic: null,
InstalmentAmount: 0, Design: null,
AutoDebit: 'N', ProcessStage: '99',
PaymentMethod: '2', ProcessStageStatus: 'S',
BillingCycle: 'C1', Score: null,
OldIssueDate: null, ExternalScore: null,
OtherPaymentsDate: null, RequestedLimit: 0,
MaximumDelinquency: null, SuggestedLimit: null,
CreditBureauDecision: null, AssignedLimit: 0,
CreditBureauUserData: null, AllowedLimitList: null,
ECommerce: 'N', EligibilityCheckResult: '00',
NumberOfCards: 0, EligibilityCheckDescription: null,
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',
Title: 'Mr.', Title: 'Mr.',
FirstName: 'Abdalhamid', FirstName: 'Abdalhamid',
SecondName: null,
ThirdName: null,
LastName: ' Ahmad', LastName: ' Ahmad',
Additional: false, FullName: 'Abdalhamid Ahmad',
BatchNumber: 8849, EmbossName: 'ABDALHAMID AHMAD',
ServiceCode: '226', PlaceOfBirth: null,
Kinship: null,
DateOfBirth: '1999-01-07', DateOfBirth: '1999-01-07',
LastActivity: null, LocalizedDateOfBirth: '1999-01-07',
LastStatusChangeDate: '2025-06-03', Age: 26,
ActivationDate: null, Gender: 'M',
DateLastIssued: null, Married: 'U',
PVV: null, Nationality: '682',
UserData: '4', IdType: '01',
UserData1: '3', IdNumber: '1089055972',
UserData2: null, IdExpiryDate: '2031-09-17',
UserData3: null, EducationLevel: null,
UserData4: null, ProfessionCode: 0,
UserData5: null, NumberOfDependents: 0,
Memo: null, EmployerName: 'N/A',
CardAuthorizationParameters: null, EmploymentYears: 0,
L10NTitle: null, EmploymentMonths: 0,
L10NFirstName: null, CIF: null,
L10NLastName: null, BankAccountNumber: ' ',
PinStatus: '40', Currency: { CurrCode: '682', AlphaCode: 'SAR' },
OldPinStatus: '0', CreditAccountNumber: '6000000000000000',
CustomerIdNumber: '1089055972', AccountType: '30',
Language: 0, 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,
},
],
},
};
}

View File

@ -17,7 +17,6 @@ export const getKycCallbackMock = (nationalId: string) => {
salaryMax: '1000', salaryMax: '1000',
incomeSource: 'Salary', incomeSource: 'Salary',
professionTitle: 'Software Engineer', professionTitle: 'Software Engineer',
professionType: 'Full-Time',
isPep: 'N', isPep: 'N',
country: '682', country: '682',
region: 'Mecca', region: 'Mecca',

View File

@ -56,12 +56,12 @@ export class AccountTransactionWebhookRequest {
@ApiProperty({ example: '682' }) @ApiProperty({ example: '682' })
currency!: string; currency!: string;
@Expose() @Expose({ name: 'Date' })
@IsString() @IsString()
@ApiProperty({ name: 'Date', example: '20241112' }) @ApiProperty({ name: 'Date', example: '20241112' })
date!: string; date!: string;
@Expose() @Expose({ name: 'Time' })
@IsString() @IsString()
@ApiProperty({ name: 'Time', example: '125340' }) @ApiProperty({ name: 'Time', example: '125340' })
time!: string; time!: string;

View File

@ -1,129 +1,50 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Expose } from 'class-transformer'; import { IsEnum, IsObject, IsString } from 'class-validator';
import { IsString } from 'class-validator';
export class KycWebhookRequest {
@Expose({ name: 'InstId' })
@IsString()
@ApiProperty({ name: 'InstId', example: '1100' })
instId!: string;
@Expose() export enum NeoleapKycWebhookStatus {
@IsString() ONBOARDING_SUCCESS = 'ONBOARDING_SUCCESS',
@ApiProperty({ example: '3136fd60-3f89-4d24-a92f-b9c63a53807f' }) ONBOARDING_FAILURE = 'ONBOARDING_FAILURE',
transId!: string; IN_PROGRESS = 'IN_PROGRESS',
}
@Expose()
@IsString() class KycEntityDto {
@ApiProperty({ example: '20250807' }) @ApiProperty({ example: 'INDIVIDUAL', description: 'Entity type - INDIVIDUAL for KYC' })
date!: string; @IsString()
type!: string;
@Expose()
@IsString() @ApiProperty({ example: 'FIN-TECK-CUSTOMER-20393', description: 'Customer external ID from Neoleap' })
@ApiProperty({ example: '150000' }) @IsString()
time!: string; externalId!: string;
}
@Expose()
@IsString() export class KycWebhookRequest {
@ApiProperty({ example: 'SUCCESS' }) @ApiProperty({
status!: string; example: '8a745b1b-1252-4921-a569-b3d4406c25fd',
description: 'Transaction ID, the same as returned from onboard API response'
@Expose() })
@IsString() @IsString()
@ApiProperty({ example: 'John' }) stateId!: string;
firstName!: string;
@ApiProperty({
@Expose() example: '8a745b1b-1252-4921-a569-b3d4406c25fd',
@IsString() description: 'Unique callback ID used as reference and for tracking'
@ApiProperty({ example: 'Doe' }) })
lastName!: string; @IsString()
callbackId!: string;
@Expose()
@IsString() @ApiProperty({ example: '1100', description: 'Fintech ID (1100 for ZOD)' })
@ApiProperty({ example: '19990107' }) @IsString()
dob!: string; externalFintechId!: string;
@Expose() @ApiProperty({ type: KycEntityDto })
@IsString() @IsObject()
@ApiProperty({ example: '682' }) entity!: KycEntityDto;
nationality!: string;
@ApiProperty({
@Expose() enum: NeoleapKycWebhookStatus,
@IsString() example: NeoleapKycWebhookStatus.ONBOARDING_SUCCESS,
@ApiProperty({ example: 'M' }) description: 'Status of onboarding: ONBOARDING_SUCCESS or ONBOARDING_FAILURE'
gender!: string; })
@IsEnum(NeoleapKycWebhookStatus)
@Expose() status!: NeoleapKycWebhookStatus;
@IsString()
@ApiProperty({ example: '20310917' })
nationalIdExpiry!: string;
@Expose()
@IsString()
@ApiProperty({ example: '1250820840' })
nationalId!: string;
@Expose()
@IsString()
@ApiProperty({ example: '+962798765432' })
mobile!: string;
@Expose()
@IsString()
@ApiProperty({ example: '500' })
salaryMin!: string;
@Expose()
@IsString()
@ApiProperty({ example: '1000' })
salaryMax!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'Salary' })
incomeSource!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'Software Engineer' })
professionTitle!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'Full-Time' })
professionType!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'N' })
isPep!: string;
@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;
} }

View File

@ -10,8 +10,8 @@ import { InitiateKycRequestDto } from '~/customer/dtos/request';
import { Customer } from '~/customer/entities'; import { Customer } from '~/customer/entities';
import { Gender } from '~/customer/enums'; import { Gender } from '~/customer/enums';
import { import {
buildCreateApplicationMock,
CARD_EMBOSSING_DETAILS_MOCK, CARD_EMBOSSING_DETAILS_MOCK,
CREATE_APPLICATION_MOCK,
INITIATE_KYC_MOCK, INITIATE_KYC_MOCK,
INQUIRE_APPLICATION_MOCK, INQUIRE_APPLICATION_MOCK,
} from '../__mocks__/'; } from '../__mocks__/';
@ -36,6 +36,7 @@ export class NeoLeapService {
private readonly zodApiUrl: string; private readonly zodApiUrl: string;
private readonly apiKey: string; private readonly apiKey: string;
private readonly useGateway: boolean; private readonly useGateway: boolean;
private readonly useKycMock: boolean;
private readonly institutionCode = '1100'; private readonly institutionCode = '1100';
useLocalCert: boolean; useLocalCert: boolean;
constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { 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.useGateway = [true, 'true'].includes(this.configService.get<boolean>('USE_GATEWAY', false));
this.useLocalCert = this.configService.get<boolean>('USE_LOCAL_CERT', false); this.useLocalCert = this.configService.get<boolean>('USE_LOCAL_CERT', false);
this.zodApiUrl = this.configService.getOrThrow<string>('ZOD_API_URL'); 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) { async initiateKycOnboarding(dto: InitiateKycRequestDto) {
const responseKey = 'InitiateKycResponseDetails'; // Mock mode for development
if (this.useKycMock) {
if (!this.useGateway) { const mockResponse = {
const responseDto = plainToInstance(InitiateKycResponseDto, INITIATE_KYC_MOCK[responseKey], { externalCustomerId: `FIN-TECK-CUSTOMER-${Date.now()}`,
excludeExtraneousValues: true, externalFintechId: '1100',
}); nafathRandomCode: '38',
stateId: uuid(),
status: 'IN_PROGRESS',
};
// Trigger mock webhook after 7 seconds
setTimeout(() => { setTimeout(() => {
this.httpService this.httpService
.post(`${this.zodApiUrl}/neoleap-webhooks/kyc`, getKycCallbackMock(body.nationalId), { .post(`${this.zodApiUrl}/neoleap-webhooks/kyc`, {
headers: { stateId: mockResponse.stateId,
'Content-Type': 'application/json', callbackId: uuid(),
externalFintechId: '1100',
entity: {
type: 'INDIVIDUAL',
externalId: mockResponse.externalCustomerId,
}, },
status: 'ONBOARDING_SUCCESS',
}) })
.subscribe({ .subscribe({
next: () => this.logger.log('Mock KYC webhook sent'), next: () => this.logger.log('Mock KYC webhook sent successfully'),
error: (err) => console.error(err), error: (err) => this.logger.error('Mock KYC webhook failed:', err.message),
}); });
}, 7000); }, 7000);
return responseDto; return mockResponse;
} }
// Real API call to Neoleap
const payload = { const payload = {
InitiateKycRequestDetails: { poiNumber: dto.poiNumber,
CustomerIdentifier: { poiType: dto.poiType,
InstitutionCode: this.institutionCode, mobileNumber: dto.mobileNumber,
Id: customerId, email: dto.email,
NationalId: body.nationalId, 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>( try {
'kyc/InitiateKyc', const { data } = await this.httpService.axiosRef.post(
payload, `${this.gatewayBaseUrl}/kyc/onboardCustomer`,
responseKey, payload,
InitiateKycResponseDto, {
); 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'; const responseKey = 'CreateNewApplicationResponseDetails';
if (!this.useGateway) { if (!this.useGateway) {
return plainToInstance(CreateApplicationResponse, CREATE_APPLICATION_MOCK[responseKey], { return plainToInstance(CreateApplicationResponse, buildCreateApplicationMock()[responseKey], {
excludeExtraneousValues: true, excludeExtraneousValues: true,
}); });
} }
@ -122,7 +186,9 @@ export class NeoLeapService {
}, },
BillingCycle: 'C1', BillingCycle: 'C1',
}, },
ApplicationOtherInfo: {}, ApplicationOtherInfo: {
ExternalCorporateId: customer.neoleapExternalCustomerId,
},
ApplicationCustomerDetails: { ApplicationCustomerDetails: {
FirstName: customer.firstName, FirstName: customer.firstName,
LastName: customer.lastName, LastName: customer.lastName,
@ -135,14 +201,14 @@ export class NeoLeapService {
Title: customer.gender === Gender.MALE ? 'Mr' : 'Ms', Title: customer.gender === Gender.MALE ? 'Mr' : 'Ms',
Gender: customer.gender === Gender.MALE ? 'M' : 'F', Gender: customer.gender === Gender.MALE ? 'M' : 'F',
LocalizedDateOfBirth: moment(customer.dateOfBirth).format('YYYY-MM-DD'), LocalizedDateOfBirth: moment(customer.dateOfBirth).format('YYYY-MM-DD'),
Nationality: CountriesNumericISO[customer.countryOfResidence], Nationality: CountriesNumericISO[customer.countryOfResidence || 'SA'],
}, },
ApplicationAddress: { ApplicationAddress: {
City: customer.city, City: 'Riyadh',
Country: CountriesNumericISO[customer.country], Country: CountriesNumericISO['SA'],
Region: customer.region, Region: 'Riyadh',
AddressLine1: `${customer.street} ${customer.building}`, AddressLine1: 'King Fahd Road 1',
AddressLine2: customer.neighborhood, AddressLine2: 'Al Olaya',
AddressRole: 0, AddressRole: 0,
Email: customer.user.email, Email: customer.user.email,
Phone1: customer.user.phoneNumber, 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'; const responseKey = 'InquireApplicationResponseDetails';
if (!this.useGateway) { if (!this.useGateway) {
return plainToInstance(InquireApplicationResponse, INQUIRE_APPLICATION_MOCK[responseKey], { 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'; const responseKey = 'UpdateCardControlResponseDetails';
if (!this.useGateway) { if (!this.useGateway) {
return; return;
@ -224,7 +365,7 @@ export class NeoLeapService {
); );
} }
async getEmbossingInformation(card: Card) { getEmbossingInformation(card: Card) {
const responseKey = 'GetEmbossingInformationResponseDetails'; const responseKey = 'GetEmbossingInformationResponseDetails';
if (!this.useGateway) { if (!this.useGateway) {
return plainToInstance(CardEmbossingDetailsResponseDto, CARD_EMBOSSING_DETAILS_MOCK[responseKey], { return plainToInstance(CardEmbossingDetailsResponseDto, CARD_EMBOSSING_DETAILS_MOCK[responseKey], {
@ -244,7 +385,7 @@ export class NeoLeapService {
}; };
return this.sendRequestToNeoLeap<typeof payload, CardEmbossingDetailsResponseDto>( return this.sendRequestToNeoLeap<typeof payload, CardEmbossingDetailsResponseDto>(
'cardembossing/CardEmbossingDetails', 'issuance/GetEmbossingInformation',
payload, payload,
responseKey, responseKey,
CardEmbossingDetailsResponseDto, CardEmbossingDetailsResponseDto,

View File

@ -0,0 +1,20 @@
/**
* Notification event names
* These are the event identifiers used throughout the notification system
*/
export const NOTIFICATION_EVENTS = {
// Transaction events
TRANSACTION_CREATED: 'notification.transaction.created',
// Money Request events
MONEY_REQUEST_CREATED: 'notification.money-request.created',
MONEY_REQUEST_APPROVED: 'notification.money-request.approved',
MONEY_REQUEST_DECLINED: 'notification.money-request.declined',
} as const;
export type NotificationEventName =
typeof NOTIFICATION_EVENTS[keyof typeof NOTIFICATION_EVENTS];

View File

@ -0,0 +1,3 @@
// Export all constants from this folder
export * from './event-names.constant';

View File

@ -36,6 +36,9 @@ export class Notification {
@Column('uuid', { name: 'user_id', nullable: true }) @Column('uuid', { name: 'user_id', nullable: true })
userId!: string; userId!: string;
@Column('jsonb', { name: 'data', nullable: true })
data?: Record<string, any>;
@ManyToOne(() => User, (user) => user.notifications, { onDelete: 'CASCADE', nullable: true }) @ManyToOne(() => User, (user) => user.notifications, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'user_id' }) @JoinColumn({ name: 'user_id' })
user!: User; user!: User;

View File

@ -1,7 +1,46 @@
export enum NotificationScope { export enum NotificationScope {
// Existing scopes
USER_REGISTERED = 'USER_REGISTERED', USER_REGISTERED = 'USER_REGISTERED',
TASK_COMPLETED = 'TASK_COMPLETED', TASK_COMPLETED = 'TASK_COMPLETED',
GIFT_RECEIVED = 'GIFT_RECEIVED', GIFT_RECEIVED = 'GIFT_RECEIVED',
OTP = 'OTP', OTP = 'OTP',
USER_INVITED = 'USER_INVITED', 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',
// Money Request notifications
MONEY_REQUEST_CREATED = 'MONEY_REQUEST_CREATED',
MONEY_REQUEST_APPROVED = 'MONEY_REQUEST_APPROVED',
MONEY_REQUEST_DECLINED = 'MONEY_REQUEST_DECLINED',
} }
/**
* 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);
}

View File

@ -1 +1,3 @@
export * from './notification-page-meta.interface'; export * from './notification-page-meta.interface';
export * from './notification-events.interface';
export * from './messaging-system.interface';

View File

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

View File

@ -0,0 +1,63 @@
import { Transaction } from '~/card/entities/transaction.entity';
import { Card } from '~/card/entities/card.entity';
import { MoneyRequest } from '~/money-request/entities/money-request.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;
}
/**
* Event payload for when a money request is created
* Used to notify parents when their child requests money
*/
export interface IMoneyRequestCreatedEvent {
/** The money request that was created */
moneyRequest: MoneyRequest;
/** When the event occurred */
timestamp: Date;
}
/**
* Event payload for when a money request is approved
* Used to notify children when their money request is approved
*/
export interface IMoneyRequestApprovedEvent {
/** The money request that was approved */
moneyRequest: MoneyRequest;
/** When the event occurred */
timestamp: Date;
}
/**
* Event payload for when a money request is declined
* Used to notify children when their money request is declined
*/
export interface IMoneyRequestDeclinedEvent {
/** The money request that was declined */
moneyRequest: MoneyRequest;
/** Rejection reason provided by parent */
rejectionReason?: string;
/** When the event occurred */
timestamp: Date;
}

View File

@ -1 +1,3 @@
export * from './notification-created.listener'; export * from './notification-created.listener';
export * from './transaction-notification.listener';
export * from './money-request-notification.listener';

View File

@ -0,0 +1,264 @@
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 {
IMoneyRequestApprovedEvent,
IMoneyRequestCreatedEvent,
IMoneyRequestDeclinedEvent,
} from '../interfaces/notification-events.interface';
import { NotificationScope } from '../enums/notification-scope.enum';
import { User } from '~/user/entities';
/**
* MoneyRequestNotificationListener
*
* Handles notifications for money request events.
* Notifies parents when children request money, and children when requests are approved/declined.
*
* Responsibilities:
* - Listen for money request events (created, approved, declined)
* - Determine notification recipients (parent or child)
* - Construct appropriate messages
* - Fetch user preferences
* - Call NotificationFactory to send
*/
@Injectable()
export class MoneyRequestNotificationListener {
private readonly logger = new Logger(MoneyRequestNotificationListener.name);
constructor(
private readonly notificationFactory: NotificationFactory,
private readonly userService: UserService,
) {}
/**
* Handle money request created event
* Notifies parent when child requests money
*/
@OnEvent(NOTIFICATION_EVENTS.MONEY_REQUEST_CREATED)
async handleMoneyRequestCreated(event: IMoneyRequestCreatedEvent): Promise<void> {
try {
const { moneyRequest } = event;
this.logger.log(
`Processing money request notification for request ${moneyRequest.id} - ` +
`Amount: $${moneyRequest.amount}, Reason: ${moneyRequest.reason}`
);
await this.notifyParentOfMoneyRequest(moneyRequest);
this.logger.log(
`Money request notification processed successfully for request ${moneyRequest.id}`
);
} catch (error: any) {
this.logger.error(
`Failed to process money request notification: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Handle money request approved event
* Notifies child when their money request is approved
*/
@OnEvent(NOTIFICATION_EVENTS.MONEY_REQUEST_APPROVED)
async handleMoneyRequestApproved(event: IMoneyRequestApprovedEvent): Promise<void> {
try {
const { moneyRequest } = event;
this.logger.log(
`Processing money request approved notification for request ${moneyRequest.id}`
);
await this.notifyChildOfApproval(moneyRequest);
this.logger.log(
`Money request approved notification processed successfully for request ${moneyRequest.id}`
);
} catch (error: any) {
this.logger.error(
`Failed to process money request approved notification: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Handle money request declined event
* Notifies child when their money request is declined
*/
@OnEvent(NOTIFICATION_EVENTS.MONEY_REQUEST_DECLINED)
async handleMoneyRequestDeclined(event: IMoneyRequestDeclinedEvent): Promise<void> {
try {
const { moneyRequest, rejectionReason } = event;
this.logger.log(
`Processing money request declined notification for request ${moneyRequest.id}`
);
await this.notifyChildOfRejection(moneyRequest, rejectionReason);
this.logger.log(
`Money request declined notification processed successfully for request ${moneyRequest.id}`
);
} catch (error: any) {
this.logger.error(
`Failed to process money request declined notification: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Notify parent when child requests money
*/
private async notifyParentOfMoneyRequest(moneyRequest: any): Promise<void> {
try {
const guardian = moneyRequest?.guardian;
const parentUser = guardian?.customer?.user;
if (!parentUser) {
this.logger.warn(`No parent user found for money request ${moneyRequest.id}, skipping notification`);
return;
}
const child = moneyRequest?.junior;
const childUser = child?.customer?.user;
const childName = childUser?.firstName || 'Your child';
const amount = typeof moneyRequest.amount === 'string' ? parseFloat(moneyRequest.amount) : moneyRequest.amount;
const reason = moneyRequest.reason || 'No reason provided';
this.logger.debug(
`Notifying parent (user ${parentUser.id}): ${childName} requested $${amount} - ${reason}`
);
await this.notificationFactory.send({
userId: parentUser.id,
title: 'Money Request',
message: `${childName} requested $${amount.toFixed(2)}. Reason: ${reason}`,
scope: NotificationScope.MONEY_REQUEST_CREATED,
preferences: this.getUserPreferences(parentUser),
data: {
moneyRequestId: moneyRequest.id,
childId: childUser?.id,
childName: childName,
amount: amount.toString(),
reason: reason,
timestamp: moneyRequest.createdAt.toISOString(),
type: 'MONEY_REQUEST',
action: 'VIEW_MONEY_REQUEST',
},
});
this.logger.log(`✅ Notified parent ${parentUser.id} about money request ${moneyRequest.id}`);
} catch (error: any) {
this.logger.error(
`Failed to notify parent of money request: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Notify child when their money request is approved
*/
private async notifyChildOfApproval(moneyRequest: any): Promise<void> {
try {
const child = moneyRequest?.junior;
const childUser = child?.customer?.user;
if (!childUser) {
this.logger.warn(`No child user found for money request ${moneyRequest.id}, skipping notification`);
return;
}
const amount = typeof moneyRequest.amount === 'string' ? parseFloat(moneyRequest.amount) : moneyRequest.amount;
this.logger.debug(
`Notifying child (user ${childUser.id}): Money request of $${amount} was approved`
);
await this.notificationFactory.send({
userId: childUser.id,
title: 'Money Request Approved',
message: `Your request for $${amount.toFixed(2)} has been approved. The money has been added to your account.`,
scope: NotificationScope.MONEY_REQUEST_APPROVED,
preferences: this.getUserPreferences(childUser),
data: {
moneyRequestId: moneyRequest.id,
amount: amount.toString(),
timestamp: moneyRequest.updatedAt.toISOString(),
type: 'MONEY_REQUEST_APPROVED',
action: 'VIEW_MONEY_REQUEST',
},
});
this.logger.log(`✅ Notified child ${childUser.id} about approved money request ${moneyRequest.id}`);
} catch (error: any) {
this.logger.error(
`Failed to notify child of approval: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Notify child when their money request is declined
*/
private async notifyChildOfRejection(moneyRequest: any, rejectionReason?: string): Promise<void> {
try {
const child = moneyRequest?.junior;
const childUser = child?.customer?.user;
if (!childUser) {
this.logger.warn(`No child user found for money request ${moneyRequest.id}, skipping notification`);
return;
}
const amount = typeof moneyRequest.amount === 'string' ? parseFloat(moneyRequest.amount) : moneyRequest.amount;
const reason = rejectionReason || 'No reason provided';
this.logger.debug(
`Notifying child (user ${childUser.id}): Money request of $${amount} was declined`
);
await this.notificationFactory.send({
userId: childUser.id,
title: 'Money Request Declined',
message: `Your request for $${amount.toFixed(2)} has been declined. Reason: ${reason}`,
scope: NotificationScope.MONEY_REQUEST_DECLINED,
preferences: this.getUserPreferences(childUser),
data: {
moneyRequestId: moneyRequest.id,
amount: amount.toString(),
rejectionReason: reason,
timestamp: moneyRequest.updatedAt.toISOString(),
type: 'MONEY_REQUEST_DECLINED',
action: 'VIEW_MONEY_REQUEST',
},
});
this.logger.log(`✅ Notified child ${childUser.id} about declined money request ${moneyRequest.id}`);
} catch (error: any) {
this.logger.error(
`Failed to notify child of rejection: ${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,
};
}
}

View File

@ -21,7 +21,7 @@ export class NotificationCreatedListener {
/** /**
* Handles the NOTIFICATION_CREATED event by calling the appropriate channel logic. * Handles the NOTIFICATION_CREATED event by calling the appropriate channel logic.
*/ */
async handle(event: IEventInterface) { handle(event: IEventInterface) {
this.logger.log( this.logger.log(
`Handling ${EventType.NOTIFICATION_CREATED} event for notification ${event.id} (channel: ${event.channel})`, `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); return this.sendSMS(event.recipient!, event.message);
case NotificationChannel.PUSH: 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: case NotificationChannel.EMAIL:
return this.sendEmail({ 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}`); this.logger.log(`Sending push notification to user ${userId}`);
const tokens = await this.deviceService.getTokens(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.`); this.logger.log(`No device tokens found for user ${userId}, but notification was created in the DB.`);
return; 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) { private async sendSMS(to: string, body: string) {

View File

@ -0,0 +1,353 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { I18nService } from 'nestjs-i18n';
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';
import { UserLocale } from '~/core/enums/user-locale.enum';
/**
* 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,
private readonly i18n: I18nService,
) {}
/**
* 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 {
console.log(`[TransactionNotificationListener] Event received: ${NOTIFICATION_EVENTS.TRANSACTION_CREATED}`);
const { transaction, card, isTopUp, isChildSpending } = event;
this.logger.log(
`Processing transaction notification for transaction ${transaction.id} - ` +
`isTopUp: ${isTopUp}, isChildSpending: ${isChildSpending}`
);
console.log(`[TransactionNotificationListener] Transaction: ${transaction.id}, Card: ${card?.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) {
console.error(`[TransactionNotificationListener] ERROR:`, error);
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 locale = this.getUserLocale(user);
const amount = transaction.transactionAmount;
const merchant = transaction.merchantName || 'merchant';
const balance = card.account?.balance || 0;
const currency = card.account?.currency || transaction.transactionCurrency || 'SAR';
let title: string;
let message: string;
try {
title = isTopUp
? this.i18n.t('app.NOTIFICATION.CHILD_TOP_UP_TITLE', { lang: locale })
: this.i18n.t('app.NOTIFICATION.CHILD_SPENDING_TITLE', { lang: locale });
message = isTopUp
? this.i18n.t('app.NOTIFICATION.CHILD_TOP_UP_MESSAGE', {
lang: locale,
args: {
amount: amount.toString(),
currency: currency,
balance: balance.toString(),
},
})
: this.i18n.t('app.NOTIFICATION.CHILD_SPENDING_MESSAGE', {
lang: locale,
args: {
amount: amount.toString(),
currency: currency,
merchant: merchant,
balance: balance.toString(),
},
});
} catch (i18nError: any) {
console.error(`[TransactionNotificationListener] i18n error:`, i18nError);
this.logger.error(`i18n translation failed: ${i18nError?.message}`, i18nError?.stack);
// Fallback to English without i18n
title = isTopUp ? 'Card Topped Up' : 'Purchase Successful';
message = isTopUp
? `You received ${amount} ${currency}. Total balance: ${balance} ${currency}`
: `You spent ${amount} ${currency} at ${merchant}. Balance: ${balance} ${currency}`;
}
this.logger.debug(
`Notifying transaction owner (user ${user.id}) - Amount: ${amount} ${currency}, Merchant: ${merchant}`
);
await this.notificationFactory.send({
userId: user.id,
title,
message,
scope,
preferences: this.getUserPreferences(user),
data: {
transactionId: transaction.id,
amount: amount.toString(),
currency: currency,
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 locale = this.getUserLocale(parentUser);
const defaultChildName = this.i18n.t('app.NOTIFICATION.YOUR_CHILD', { lang: locale });
const childName = childUser?.firstName || defaultChildName;
const amount = transaction.transactionAmount;
const merchant = transaction.merchantName || 'a merchant';
const balance = card.account?.balance || 0;
const currency = card.account?.currency || transaction.transactionCurrency || 'SAR';
this.logger.debug(
`Notifying parent (user ${parentUser.id}): ${childName} spent ${amount} ${currency} at ${merchant}`
);
let title: string;
let message: string;
try {
title = this.i18n.t('app.NOTIFICATION.PARENT_SPENDING_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.PARENT_SPENDING_MESSAGE', {
lang: locale,
args: {
childName: childName,
amount: amount.toString(),
currency: currency,
merchant: merchant,
balance: balance.toString(),
},
});
} catch (i18nError: any) {
console.error(`[TransactionNotificationListener] i18n error in parent spending:`, i18nError);
this.logger.error(`i18n translation failed: ${i18nError?.message}`, i18nError?.stack);
title = 'Child Spending Alert';
message = `${childName} spent ${amount} ${currency} at ${merchant}. Balance: ${balance} ${currency}`;
}
await this.notificationFactory.send({
userId: parentUser.id,
title,
message,
scope: NotificationScope.PARENT_SPENDING_ALERT,
preferences: this.getUserPreferences(parentUser),
data: {
transactionId: transaction.id,
childId: childUser.id,
childName: childName,
amount: amount.toString(),
currency: currency,
merchant: merchant,
merchantCategory: transaction.merchantCategoryCode || 'OTHER',
balance: balance.toString(),
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 locale = this.getUserLocale(parentUser);
const defaultChildName = this.i18n.t('app.NOTIFICATION.YOUR_CHILD', { lang: locale });
const childName = childUser?.firstName || defaultChildName;
const amount = transaction.transactionAmount;
const balance = card.account?.balance || 0;
const currency = card.account?.currency || transaction.transactionCurrency || 'SAR';
this.logger.debug(
`Notifying parent (user ${parentUser.id}): Transferred ${amount} ${currency} to ${childName}`
);
let title: string;
let message: string;
try {
title = this.i18n.t('app.NOTIFICATION.PARENT_TOP_UP_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.PARENT_TOP_UP_MESSAGE', {
lang: locale,
args: {
amount: amount.toString(),
currency: currency,
childName: childName,
balance: balance.toString(),
},
});
} catch (i18nError: any) {
console.error(`[TransactionNotificationListener] i18n error in parent top-up:`, i18nError);
this.logger.error(`i18n translation failed: ${i18nError?.message}`, i18nError?.stack);
title = 'Top-Up Confirmation';
message = `You transferred ${amount} ${currency} to ${childName}. Balance: ${balance} ${currency}`;
}
await this.notificationFactory.send({
userId: parentUser.id,
title,
message,
scope: NotificationScope.PARENT_TOP_UP_CONFIRMATION,
preferences: this.getUserPreferences(parentUser),
data: {
transactionId: transaction.id,
childId: childUser.id,
childName: childName,
amount: amount.toString(),
currency: currency,
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,
};
}
/**
* Get user locale for i18n translations
* Defaults to English if not specified
* TODO: Add locale field to User entity in the future
*/
private getUserLocale(user: User): UserLocale {
// For now, default to English
// In the future, this can read from user.locale or user.preferences.locale
return UserLocale.ENGLISH;
}
}

View File

@ -8,9 +8,14 @@ import { buildMailerOptions, buildTwilioOptions } from '~/core/module-options';
import { UserModule } from '~/user/user.module'; import { UserModule } from '~/user/user.module';
import { NotificationsController } from './controllers'; import { NotificationsController } from './controllers';
import { Notification } from './entities'; import { Notification } from './entities';
import { NotificationCreatedListener } from './listeners'; import {
MoneyRequestNotificationListener,
NotificationCreatedListener,
TransactionNotificationListener,
} from './listeners';
import { NotificationsRepository } from './repositories'; import { NotificationsRepository } from './repositories';
import { FirebaseService, NotificationsService, TwilioService } from './services'; import { FirebaseService, NotificationFactory, NotificationsService, TwilioService } from './services';
import { MessagingSystemFactory, RedisPubSubMessagingService } from './services/messaging';
@Module({ @Module({
imports: [ imports: [
@ -28,12 +33,17 @@ import { FirebaseService, NotificationsService, TwilioService } from './services
], ],
providers: [ providers: [
NotificationsService, NotificationsService,
NotificationFactory,
FirebaseService, FirebaseService,
NotificationsRepository, NotificationsRepository,
TwilioService, TwilioService,
NotificationCreatedListener, NotificationCreatedListener,
TransactionNotificationListener,
MoneyRequestNotificationListener,
RedisPubSubMessagingService,
MessagingSystemFactory,
], ],
exports: [NotificationsService, NotificationCreatedListener], exports: [NotificationsService, NotificationFactory, NotificationCreatedListener],
controllers: [NotificationsController], controllers: [NotificationsController],
}) })
export class NotificationModule {} export class NotificationModule {}

View File

@ -1,29 +1,77 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as admin from 'firebase-admin'; import * as admin from 'firebase-admin';
@Injectable() @Injectable()
export class FirebaseService { export class FirebaseService {
private readonly logger = new Logger(FirebaseService.name); private readonly logger = new Logger(FirebaseService.name);
constructor(private readonly configService: ConfigService) { constructor(private readonly configService: ConfigService) {
admin.initializeApp({ try {
credential: admin.credential.cert({ this.logger.log('🔥 Initializing Firebase Admin SDK...');
projectId: this.configService.get('FIREBASE_PROJECT_ID'),
clientEmail: this.configService.get('FIREBASE_CLIENT_EMAIL'), const projectId = this.configService.get('FIREBASE_PROJECT_ID');
privateKey: this.configService.get('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'), 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) { async sendNotification(tokens: string | string[], title: string, body: string, data?: Record<string, string>) {
this.logger.log(`Sending push notification to ${tokens}`); this.logger.log(
const message = { `Sending push notification to ${Array.isArray(tokens) ? tokens.length : 1} device(s)`,
notification: { );
title,
body,
},
tokens: Array.isArray(tokens) ? tokens : [tokens],
};
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;
}
} }
} }

View File

@ -1,3 +1,4 @@
export * from './firebase.service'; export * from './firebase.service';
export * from './notification-factory.service';
export * from './notifications.service'; export * from './notifications.service';
export * from './twilio.service'; export * from './twilio.service';

View File

@ -0,0 +1,3 @@
export * from './redis-pubsub-messaging.service';
export * from './messaging-system-factory.service';

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import { SendEmailRequestDto } from '../dtos/request';
import { Notification } from '../entities'; import { Notification } from '../entities';
import { EventType, NotificationChannel, NotificationScope } from '../enums'; import { EventType, NotificationChannel, NotificationScope } from '../enums';
import { NotificationsRepository } from '../repositories'; import { NotificationsRepository } from '../repositories';
import { MessagingSystemFactory } from './messaging/messaging-system-factory.service';
@Injectable() @Injectable()
export class NotificationsService { export class NotificationsService {
@ -17,6 +18,7 @@ export class NotificationsService {
@Inject(forwardRef(() => RedisPubSubService)) @Inject(forwardRef(() => RedisPubSubService))
private readonly redisPubSubService: RedisPubSubService, private readonly redisPubSubService: RedisPubSubService,
private readonly messagingSystemFactory: MessagingSystemFactory,
) {} ) {}
async getNotifications(userId: string, pageOptionsDto: PageOptionsRequestDto) { async getNotifications(userId: string, pageOptionsDto: PageOptionsRequestDto) {
@ -31,9 +33,29 @@ export class NotificationsService {
return { notifications, count, unreadCount }; return { notifications, count, unreadCount };
} }
createNotification(notification: Partial<Notification>) { async createNotification(notification: Partial<Notification>) {
this.logger.log(`Creating notification for user ${notification.userId}`); 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) { markAsRead(userId: string) {
@ -42,35 +64,25 @@ export class NotificationsService {
} }
async sendEmailAsync(data: SendEmailRequestDto) { async sendEmailAsync(data: SendEmailRequestDto) {
this.logger.log(`emitting ${EventType.NOTIFICATION_CREATED} event`); this.logger.log(`Creating email notification for ${data.to}`);
const notification = await this.createNotification({ await this.createNotification({
recipient: data.to, recipient: data.to,
title: data.subject, title: data.subject,
message: '', message: '',
scope: NotificationScope.USER_INVITED, scope: NotificationScope.USER_INVITED,
channel: NotificationChannel.EMAIL, channel: NotificationChannel.EMAIL,
}); data: data.data,
// return this.redisPubSubService.emit(EventType.NOTIFICATION_CREATED, notification, data.data);
this.redisPubSubService.publishEvent(EventType.NOTIFICATION_CREATED, {
...notification,
data,
}); });
} }
async sendOtpNotification(sendOtpRequest: ISendOtp, otp: string) { async sendOtpNotification(sendOtpRequest: ISendOtp, otp: string) {
this.logger.log(`Sending OTP to ${sendOtpRequest.recipient}`); this.logger.log(`Sending OTP to ${sendOtpRequest.recipient}`);
const notification = await this.createNotification({ return this.createNotification({
recipient: sendOtpRequest.recipient, recipient: sendOtpRequest.recipient,
title: OTP_TITLE, title: OTP_TITLE,
message: OTP_BODY.replace('{otp}', otp), message: OTP_BODY.replace('{otp}', otp),
scope: NotificationScope.OTP, scope: NotificationScope.OTP,
channel: sendOtpRequest.otpType === OtpType.EMAIL ? NotificationChannel.EMAIL : NotificationChannel.SMS, 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 }, data: { otp },
}); });
} }

View File

@ -19,7 +19,6 @@ export class OtpService {
async generateAndSendOtp(sendOtpRequest: ISendOtp): Promise<string> { async generateAndSendOtp(sendOtpRequest: ISendOtp): Promise<string> {
this.logger.log(`invalidate OTP for ${sendOtpRequest.recipient} and ${sendOtpRequest.otpType}`); this.logger.log(`invalidate OTP for ${sendOtpRequest.recipient} and ${sendOtpRequest.otpType}`);
await this.otpRepository.invalidateOtp(sendOtpRequest); await this.otpRepository.invalidateOtp(sendOtpRequest);
this.logger.log(`Generating OTP for ${sendOtpRequest.recipient}`); this.logger.log(`Generating OTP for ${sendOtpRequest.recipient}`);
const otp = this.useMock ? DEFAULT_OTP_DIGIT.repeat(DEFAULT_OTP_LENGTH) : generateRandomOtp(DEFAULT_OTP_LENGTH); const otp = this.useMock ? DEFAULT_OTP_DIGIT.repeat(DEFAULT_OTP_LENGTH) : generateRandomOtp(DEFAULT_OTP_LENGTH);

View File

@ -32,7 +32,11 @@ export class RedisModule {
}, },
RedisPubSubService, RedisPubSubService,
], ],
exports: [RedisPubSubService], exports: [
RedisPubSubService,
'REDIS_PUBLISHER',
'REDIS_SUBSCRIBER',
],
imports: [NotificationModule], imports: [NotificationModule],
}; };
} }

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 './class-validator-formatter.util';
export * from './i18n-context-wrapper.util';
export * from './patch.util';
export * from './response.factory.util';

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

View File

@ -1,12 +1,12 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; 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 { IJwtPayload } from '~/auth/interfaces';
import { AuthenticatedUser } from '~/common/decorators'; import { AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards'; import { AccessTokenGuard } from '~/common/guards';
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators'; import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils'; import { ResponseFactory } from '~/core/utils';
import { InitiateKycRequestDto } from '../dtos/request'; import { InitiateKycRequestDto } from '../dtos/request';
import { CustomerResponseDto, InitiateKycResponseDto } from '../dtos/response'; import { CustomerResponseDto, InitiateKycResponseDto, KycMetadataResponseDto } from '../dtos/response';
import { CustomerService } from '../services'; import { CustomerService } from '../services';
@Controller('customers') @Controller('customers')
@ -30,6 +30,16 @@ export class CustomerController {
async initiateKyc(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: InitiateKycRequestDto) { async initiateKyc(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: InitiateKycRequestDto) {
const res = await this.customerService.initiateKycRequest(sub, body); 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);
} }
} }

View File

@ -4,14 +4,14 @@ import { NeoLeapModule } from '~/common/modules/neoleap/neoleap.module';
import { GuardianModule } from '~/guardian/guardian.module'; import { GuardianModule } from '~/guardian/guardian.module';
import { UserModule } from '~/user/user.module'; import { UserModule } from '~/user/user.module';
import { CustomerController } from './controllers'; import { CustomerController } from './controllers';
import { Customer } from './entities'; import { Customer, KycTransaction } from './entities';
import { CustomerRepository } from './repositories/customer.repository'; import { CustomerRepository, KycTransactionRepository } from './repositories';
import { CustomerService } from './services'; import { CustomerService, MetadataService } from './services';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Customer]), GuardianModule, forwardRef(() => UserModule), NeoLeapModule], imports: [TypeOrmModule.forFeature([Customer, KycTransaction]), GuardianModule, forwardRef(() => UserModule), NeoLeapModule],
controllers: [CustomerController], controllers: [CustomerController],
providers: [CustomerService, CustomerRepository], providers: [CustomerService, CustomerRepository, KycTransactionRepository, MetadataService],
exports: [CustomerService], exports: [CustomerService],
}) })
export class CustomerModule {} export class CustomerModule {}

View File

@ -1,8 +1,55 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsDateString, IsEmail, IsEnum, IsOptional, IsString, Matches } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n'; 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 { export class InitiateKycRequestDto {
@ApiProperty({ example: '999300024' }) @ApiProperty({ example: '2586234623', description: 'Saudi National ID or Iqama number' })
@isValidSaudiId({ message: i18n('validation.isValidSaudiId', { path: 'general', property: 'customer.nationalId' }) }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.poiNumber' }) })
nationalId!: string; 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: 'user@zodwallet.com', 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;
} }

View File

@ -34,15 +34,6 @@ export class CustomerResponseDto {
@ApiProperty({ example: 'JO' }) @ApiProperty({ example: 'JO' })
countryOfResidence!: string; countryOfResidence!: string;
@ApiProperty({ example: 'Employee' })
sourceOfIncome!: string;
@ApiProperty({ example: 'Software Development' })
profession!: string;
@ApiProperty({ example: 'Full-time' })
professionType!: string;
@ApiProperty({ example: false }) @ApiProperty({ example: false })
isPep!: boolean; isPep!: boolean;
@ -58,24 +49,6 @@ export class CustomerResponseDto {
@ApiProperty({ example: 12345 }) @ApiProperty({ example: 12345 })
waitingNumber!: number; 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 }) @ApiPropertyOptional({ type: DocumentMetaResponseDto })
profilePicture!: DocumentMetaResponseDto | null; profilePicture!: DocumentMetaResponseDto | null;
@ -90,19 +63,10 @@ export class CustomerResponseDto {
this.nationalId = customer.nationalId; this.nationalId = customer.nationalId;
this.nationalIdExpiry = customer.nationalIdExpiry; this.nationalIdExpiry = customer.nationalIdExpiry;
this.countryOfResidence = customer.countryOfResidence; this.countryOfResidence = customer.countryOfResidence;
this.sourceOfIncome = customer.sourceOfIncome;
this.profession = customer.profession;
this.professionType = customer.professionType;
this.isPep = customer.isPep; this.isPep = customer.isPep;
this.gender = customer.gender; this.gender = customer.gender;
this.isJunior = customer.isJunior; this.isJunior = customer.isJunior;
this.isGuardian = customer.isGuardian; this.isGuardian = customer.isGuardian;
this.waitingNumber = customer.applicationNumber; 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;
} }
} }

View File

@ -1,2 +1,3 @@
export * from './customer.response.dto'; export * from './customer.response.dto';
export * from './initiate-kyc.response.dto'; export * from './initiate-kyc.response.dto';
export * from './kyc-metadata.response.dto';

View File

@ -1,10 +1,28 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Expose } from 'class-transformer';
export class InitiateKycResponseDto { export class InitiateKycResponseDto {
@ApiProperty() @ApiProperty({ description: 'Internal transaction ID to track this KYC attempt' })
randomNumber!: string; @Expose()
transactionId!: string;
constructor(randomNumber: string) { @ApiProperty({ description: 'Neoleap state ID for tracking' })
this.randomNumber = randomNumber; @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);
} }
} }

View File

@ -0,0 +1,12 @@
export class MetadataOptionDto {
value!: string;
label!: string;
}
export class KycMetadataResponseDto {
poiTypes!: MetadataOptionDto[];
jobSectors!: MetadataOptionDto[];
incomeSources!: MetadataOptionDto[];
jobCategories!: MetadataOptionDto[];
incomeRanges!: MetadataOptionDto[];
}

View File

@ -15,7 +15,7 @@ import { CountryIso } from '~/common/enums';
import { Guardian } from '~/guardian/entities/guradian.entity'; import { Guardian } from '~/guardian/entities/guradian.entity';
import { Junior } from '~/junior/entities'; import { Junior } from '~/junior/entities';
import { User } from '~/user/entities'; import { User } from '~/user/entities';
import { CustomerStatus, KycStatus } from '../enums'; import { CustomerStatus, Gender, KycStatus } from '../enums';
@Entity('customers') @Entity('customers')
export class Customer extends BaseEntity { export class Customer extends BaseEntity {
@ -49,20 +49,11 @@ export class Customer extends BaseEntity {
@Column('varchar', { length: 255, nullable: true, name: 'country_of_residence' }) @Column('varchar', { length: 255, nullable: true, name: 'country_of_residence' })
countryOfResidence!: CountryIso; 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' }) @Column('boolean', { default: false, name: 'is_pep' })
isPep!: boolean; isPep!: boolean;
@Column('varchar', { length: 255, nullable: true, name: 'gender' }) @Column('varchar', { length: 255, nullable: true, name: 'gender' })
gender!: string; gender!: Gender;
@Column('boolean', { default: false, name: 'is_junior' }) @Column('boolean', { default: false, name: 'is_junior' })
isJunior!: boolean; isJunior!: boolean;
@ -77,23 +68,27 @@ export class Customer extends BaseEntity {
@Column('varchar', { name: 'user_id' }) @Column('varchar', { name: 'user_id' })
userId!: string; userId!: string;
@Column('varchar', { name: 'country', length: 255, nullable: true }) // KYC-specific fields
country!: CountryIso; @Column('varchar', { length: 255, nullable: true, name: 'neoleap_external_customer_id' })
neoleapExternalCustomerId!: string | null;
@Column('varchar', { name: 'region', length: 255, nullable: true }) @Column('varchar', { length: 100, nullable: true, name: 'job_sector' })
region!: string; jobSector!: string | null;
@Column('varchar', { name: 'city', length: 255, nullable: true }) @Column('varchar', { length: 255, nullable: true, name: 'employer' })
city!: string; employer!: string | null;
@Column('varchar', { name: 'neighborhood', length: 255, nullable: true }) @Column('varchar', { length: 100, nullable: true, name: 'income_source' })
neighborhood!: string; incomeSource!: string | null;
@Column('varchar', { name: 'street', length: 255, nullable: true }) @Column('varchar', { length: 100, nullable: true, name: 'job_category' })
street!: string; jobCategory!: string | null;
@Column('varchar', { name: 'building', length: 255, nullable: true }) @Column('varchar', { length: 100, nullable: true, name: 'income_range' })
building!: string; incomeRange!: string | null;
@Column('varchar', { length: 20, nullable: true, name: 'mobile_number' })
mobileNumber!: string | null;
@OneToOne(() => User, (user) => user.customer, { onDelete: 'CASCADE' }) @OneToOne(() => User, (user) => user.customer, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' }) @JoinColumn({ name: 'user_id' })

View File

@ -1 +1,2 @@
export * from './customer.entity'; export * from './customer.entity';
export * from './kyc-transaction.entity';

View File

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

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',
}

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',
}

View File

@ -1,3 +1,8 @@
export * from './customer-status.enum'; export * from './customer-status.enum';
export * from './gender.enum'; export * from './gender.enum';
export * from './kyc-status.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';

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',
}

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',
}

View File

@ -0,0 +1,5 @@
export enum PoiType {
IQA = 'IQA', // Iqama (Resident ID)
NAT = 'NAT', // National ID
}

View File

@ -14,8 +14,7 @@ export class CustomerRepository {
findOne(where: FindOptionsWhere<Customer>) { findOne(where: FindOptionsWhere<Customer>) {
return this.customerRepository.findOne({ return this.customerRepository.findOne({
where, where,
relations: ['user', 'cards'], relations: ['user', 'cards', 'cards.account'],
}); });
} }
@ -30,6 +29,7 @@ export class CustomerRepository {
lastName: body.lastName, lastName: body.lastName,
dateOfBirth: body.dateOfBirth, dateOfBirth: body.dateOfBirth,
countryOfResidence: body.countryOfResidence, countryOfResidence: body.countryOfResidence,
gender: body.gender,
}), }),
); );
} }

View File

@ -0,0 +1,3 @@
export * from './customer.repository';
export * from './kyc-transaction.repository';

View File

@ -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' },
});
}
}

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 moment from 'moment';
import { Transactional } from 'typeorm-transactional'; import { Transactional } from 'typeorm-transactional';
import { CountryIso } from '~/common/enums'; import { CountryIso } from '~/common/enums';
@ -11,15 +11,18 @@ import { User } from '~/user/entities';
import { InitiateKycRequestDto } from '../dtos/request'; import { InitiateKycRequestDto } from '../dtos/request';
import { Customer } from '../entities'; import { Customer } from '../entities';
import { Gender, KycStatus } from '../enums'; import { Gender, KycStatus } from '../enums';
import { CustomerRepository } from '../repositories/customer.repository'; import { CustomerRepository, KycTransactionRepository } from '../repositories';
import { MetadataService } from './metadata.service';
@Injectable() @Injectable()
export class CustomerService { export class CustomerService {
private readonly logger = new Logger(CustomerService.name); private readonly logger = new Logger(CustomerService.name);
constructor( constructor(
private readonly customerRepository: CustomerRepository, private readonly customerRepository: CustomerRepository,
private readonly kycTransactionRepo: KycTransactionRepository,
private readonly guardianService: GuardianService, private readonly guardianService: GuardianService,
@Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService, @Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService,
private readonly metadataService: MetadataService,
) {} ) {}
async updateCustomer(userId: string, data: Partial<Customer>): Promise<Customer> { async updateCustomer(userId: string, data: Partial<Customer>): Promise<Customer> {
@ -33,7 +36,10 @@ export class CustomerService {
async createJuniorCustomer(guardianId: string, juniorId: string, body: CreateJuniorRequestDto) { async createJuniorCustomer(guardianId: string, juniorId: string, body: CreateJuniorRequestDto) {
this.logger.log(`Creating junior customer for user ${juniorId}`); 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) { async findCustomerById(id: string) {
@ -50,23 +56,68 @@ export class CustomerService {
} }
async initiateKycRequest(customerId: string, body: InitiateKycRequestDto) { 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); const customer = await this.findCustomerById(customerId);
// Validate customer is not already verified
if (customer.kycStatus === KycStatus.APPROVED) { if (customer.kycStatus === KycStatus.APPROVED) {
this.logger.error(`KYC for customer ${customerId} is already approved`); this.logger.error(`KYC for customer ${customerId} is already approved`);
throw new BadRequestException('CUSTOMER.KYC_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, { 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, 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() @Transactional()
@ -89,34 +140,34 @@ export class CustomerService {
} }
async updateCustomerKyc(body: KycWebhookRequest) { 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, { const customer = await this.findCustomerById(transaction.customerId);
kycStatus: body.status === 'SUCCESS' ? KycStatus.APPROVED : KycStatus.REJECTED,
firstName: body.firstName, // Update transaction record
lastName: body.lastName, await this.kycTransactionRepo.updateByStateId(body.stateId, {
dateOfBirth: moment(body.dob, 'YYYYMMDD').toDate(), status: body.status,
nationalId: body.nationalId, callbackId: body.callbackId,
nationalIdExpiry: moment(body.nationalIdExpiry, 'YYYYMMDD').toDate(), completedAt: new Date(),
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,
}); });
// 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 // TO BE REMOVED: This function is for testing only and will be removed
@ -129,12 +180,6 @@ export class CustomerService {
nationalId: '1089055972', nationalId: '1089055972',
nationalIdExpiry: moment('2031-09-17').toDate(), nationalIdExpiry: moment('2031-09-17').toDate(),
countryOfResidence: CountryIso.SAUDI_ARABIA, 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, { await User.update(userId, {
@ -146,6 +191,11 @@ export class CustomerService {
return this.findCustomerById(userId); 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 // TO BE REMOVED: This function is for testing only and will be removed
private generateSaudiPhoneNumber(): string { private generateSaudiPhoneNumber(): string {
// Saudi mobile numbers are 9 digits, always starting with '5' // Saudi mobile numbers are 9 digits, always starting with '5'

View File

@ -1 +1,2 @@
export * from './customer.service'; export * from './customer.service';
export * from './metadata.service';

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],
}));
}
}

View File

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

View File

@ -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"`);
}
}

View File

@ -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"`);
}
}

View File

@ -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"`);
}
}

View File

@ -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"`);
}
}

View File

@ -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"`);
}
}

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