Compare commits

...

50 Commits

Author SHA1 Message Date
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
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
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
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
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
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
60 changed files with 1801 additions and 363 deletions

0
queries/Query.sql Normal file
View File

View File

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString } from 'class-validator';
import { IsEmail, IsOptional, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class JuniorLoginRequestDto {
@ApiProperty({ example: 'test@junior.com' })
@ -9,4 +9,18 @@ export class JuniorLoginRequestDto {
@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' }) })
@ValidateIf((o) => o.grantType === GrantType.PASSWORD)
password!: string;
@ApiProperty({ example: 'device-123', description: 'Unique device identifier', required: false })
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) })
deviceId?: string;
@ApiProperty({
example: 'cXYzABC:APA91bHunvwY7rKpn8N7y6vDxS0qmQ5RZx2C8K...',
description: 'Firebase Cloud Messaging token for push notifications',
required: false,
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) })
fcmToken?: string;
}

View File

@ -1,7 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsDateString,
IsEmail,
IsEnum,
IsNotEmpty,
IsNumberString,
@ -15,7 +13,7 @@ import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { COUNTRY_CODE_REGEX, PASSWORD_REGEX } from '~/auth/constants';
import { CountryIso } from '~/common/enums';
import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants';
import { IsAbove18, IsValidPhoneNumber } from '~/core/decorators/validations';
import { IsValidPhoneNumber } from '~/core/decorators/validations';
export class VerifyUserRequestDto {
@ApiProperty({ example: '+962' })
@ -39,11 +37,6 @@ export class VerifyUserRequestDto {
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) })
lastName!: string;
@ApiProperty({ example: '2001-01-01' })
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) })
@IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) })
dateOfBirth!: Date;
@ApiProperty({ example: 'JO' })
@IsEnum(CountryIso, {
message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }),
@ -51,10 +44,38 @@ export class VerifyUserRequestDto {
@IsOptional()
countryOfResidence: CountryIso = CountryIso.SAUDI_ARABIA;
@ApiProperty({ example: 'test@test.com' })
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) })
// Address fields (optional during registration, required for card creation)
@ApiProperty({ example: 'SA', description: 'Country code', required: false })
@IsEnum(CountryIso, {
message: i18n('validation.IsEnum', { path: 'general', property: 'customer.country' }),
})
@IsOptional()
email!: string;
country?: CountryIso;
@ApiProperty({ example: 'Riyadh', description: 'Region/Province', required: false })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.region' }) })
@IsOptional()
region?: string;
@ApiProperty({ example: 'Riyadh', description: 'City', required: false })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.city' }) })
@IsOptional()
city?: string;
@ApiProperty({ example: 'Al Olaya', description: 'Neighborhood/District', required: false })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.neighborhood' }) })
@IsOptional()
neighborhood?: string;
@ApiProperty({ example: 'King Fahd Road', description: 'Street name', required: false })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.street' }) })
@IsOptional()
street?: string;
@ApiProperty({ example: '123', description: 'Building number', required: false })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.building' }) })
@IsOptional()
building?: string;
@ApiProperty({ example: 'Abcd1234@' })
@Matches(PASSWORD_REGEX, {
@ -80,4 +101,18 @@ export class VerifyUserRequestDto {
message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }),
})
otp!: string;
@ApiProperty({ example: 'device-123', description: 'Unique device identifier', required: false })
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) })
deviceId?: string;
@ApiProperty({
example: 'cXYzABC:APA91bHunvwY7rKpn8N7y6vDxS0qmQ5RZx2C8K...',
description: 'Firebase Cloud Messaging token for push notifications',
required: false,
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) })
fcmToken?: string;
}

View File

@ -41,14 +41,6 @@ export class AuthService {
) {}
async sendRegisterOtp(body: CreateUnverifiedUserRequestDto) {
if (body.email) {
const isEmailUsed = await this.userService.findUser({ email: body.email, isEmailVerified: true });
if (isEmailUsed) {
this.logger.error(`Email ${body.email} is already used`);
throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN');
}
}
if (body.password !== body.confirmPassword) {
this.logger.error('Password and confirm password do not match');
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
@ -94,6 +86,12 @@ export class AuthService {
const tokens = await this.generateAuthToken(user);
this.logger.log(`User with phone number ${user.fullPhoneNumber} verified successfully`);
// Register/update device with FCM token if provided
if (verifyUserDto.fcmToken && verifyUserDto.deviceId) {
await this.registerDeviceToken(user.id, verifyUserDto.deviceId, verifyUserDto.fcmToken);
}
return [tokens, user];
}
@ -279,6 +277,12 @@ export class AuthService {
const tokens = await this.generateAuthToken(user);
this.logger.log(`Password validated successfully for user`);
// Register/update device with FCM token if provided
if (loginDto.fcmToken && loginDto.deviceId) {
await this.registerDeviceToken(user.id, loginDto.deviceId, loginDto.fcmToken);
}
return [tokens, user];
}
@ -299,9 +303,52 @@ export class AuthService {
const tokens = await this.generateAuthToken(user);
this.logger.log(`Password validated successfully for user`);
// Register/update device with FCM token if provided
if (juniorLoginDto.fcmToken && juniorLoginDto.deviceId) {
await this.registerDeviceToken(user.id, juniorLoginDto.deviceId, juniorLoginDto.fcmToken);
}
return [tokens, user];
}
/**
* Register or update device with FCM token
* This method handles both new device registration and existing device updates
*/
private async registerDeviceToken(userId: string, deviceId: string, fcmToken: string): Promise<void> {
try {
this.logger.log(`Registering/updating device ${deviceId} with FCM token for user ${userId}`);
// Check if device already exists for this user
const existingDevice = await this.deviceService.findUserDeviceById(deviceId, userId);
if (existingDevice) {
// Update existing device with new FCM token and last access time
await this.deviceService.updateDevice(deviceId, {
fcmToken,
userId,
lastAccessOn: new Date(),
});
this.logger.log(`Device ${deviceId} updated with new FCM token for user ${userId}`);
} else {
// Create new device
await this.deviceService.createDevice({
deviceId,
userId,
fcmToken,
lastAccessOn: new Date(),
});
this.logger.log(`New device ${deviceId} registered with FCM token for user ${userId}`);
}
} catch (error) {
// Log error but don't fail the login/signup process
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
this.logger.error(`Failed to register device token for user ${userId}: ${errorMessage}`, errorStack);
}
}
private async generateAuthToken(user: User) {
this.logger.log(`Generating auth token for user with id ${user.id}`);
const [accessToken, refreshToken] = await Promise.all([

View File

@ -42,7 +42,18 @@ export class CardRepository {
}
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> {
@ -59,14 +70,30 @@ export class CardRepository {
getCardByVpan(vpan: string): Promise<Card | null> {
return this.cardRepository.findOne({
where: { vpan },
relations: ['account'],
relations: [
'account',
'customer',
'customer.user',
'customer.junior',
'customer.junior.guardian',
'customer.junior.guardian.customer',
'customer.junior.guardian.customer.user',
],
});
}
getCardByCustomerId(customerId: string): Promise<Card | null> {
return this.cardRepository.findOne({
where: { customerId },
relations: ['account'],
relations: [
'account',
'customer',
'customer.user',
'customer.junior',
'customer.junior.guardian',
'customer.junior.guardian.customer',
'customer.junior.guardian.customer.user',
],
});
}

View File

@ -64,9 +64,8 @@ export class AccountService {
}
increaseReservedBalance(account: Account, amount: number) {
if (account.balance < account.reservedBalance + amount) {
throw new UnprocessableEntityException('CARD.INSUFFICIENT_BALANCE');
}
// 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);
}

View File

@ -34,10 +34,26 @@ export class CardService {
throw new BadRequestException('CUSTOMER.KYC_NOT_APPROVED');
}
if (!customer.neoleapExternalCustomerId) {
throw new BadRequestException('CUSTOMER.KYC_NOT_COMPLETED');
}
if (customer.cards.length > 0) {
throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD');
}
// Validate required fields for card creation
const missingFields = [];
if (!customer.nationalId) missingFields.push('nationalId');
if (!customer.dateOfBirth) missingFields.push('dateOfBirth');
if (!customer.nationalIdExpiry) missingFields.push('nationalIdExpiry');
if (missingFields.length > 0) {
throw new BadRequestException(
`CUSTOMER.MISSING_REQUIRED_FIELDS: ${missingFields.join(', ')}. Please complete your profile.`
);
}
const data = await this.neoleapService.createApplication(customer);
const account = await this.accountService.createAccount(data);
const createdCard = await this.cardRepository.createCard(customerId, account.id, data);
@ -148,7 +164,18 @@ export class CardService {
async transferToChild(juniorId: string, amount: number) {
const card = await this.getCardByCustomerId(juniorId);
if (amount > card.account.balance - card.account.reservedBalance) {
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');
}
@ -156,15 +183,15 @@ export class CardService {
await Promise.all([
this.neoleapService.updateCardControl(card.cardReference, finalAmount.toNumber()),
this.updateCardLimit(card.id, finalAmount.toNumber()),
this.accountService.increaseReservedBalance(card.account, amount),
this.accountService.increaseReservedBalance(fundingAccount, amount),
this.transactionService.createInternalChildTransaction(card.id, amount),
]);
return finalAmount.toNumber();
}
getWeeklySummary(juniorId: string) {
return this.transactionService.getWeeklySummary(juniorId);
getWeeklySummary(juniorId: string, startDate?: Date, endDate?: Date) {
return this.transactionService.getWeeklySummary(juniorId, startDate, endDate);
}
fundIban(iban: string, amount: number) {

View File

@ -1,4 +1,5 @@
import { forwardRef, Inject, Injectable, UnprocessableEntityException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import Decimal from 'decimal.js';
import moment from 'moment';
import { Transactional } from 'typeorm-transactional';
@ -6,6 +7,8 @@ import {
AccountTransactionWebhookRequest,
CardTransactionWebhookRequest,
} from '~/common/modules/neoleap/dtos/requests';
import { NOTIFICATION_EVENTS } from '~/common/modules/notification/constants/event-names.constant';
import { ITransactionCreatedEvent } from '~/common/modules/notification/interfaces/notification-events.interface';
import { Transaction } from '../entities/transaction.entity';
import { CustomerType, TransactionType } from '../enums';
import { TransactionRepository } from '../repositories/transaction.repository';
@ -27,6 +30,7 @@ export class TransactionService {
private readonly transactionRepository: TransactionRepository,
private readonly accountService: AccountService,
@Inject(forwardRef(() => CardService)) private readonly cardService: CardService,
private readonly eventEmitter: EventEmitter2,
) {}
@Transactional()
@ -42,14 +46,32 @@ export class TransactionService {
const total = new Decimal(body.transactionAmount).plus(body.billingAmount).plus(body.fees).plus(body.vatOnFees);
if (card.customerType === CustomerType.CHILD) {
await Promise.all([
this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()),
this.accountService.decrementReservedBalance(card.account, total.toNumber()),
]);
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());
}
// Emit event for notification system
const event: ITransactionCreatedEvent = {
transaction,
card, // Pass card with all relations loaded
isTopUp: false, // Card transactions are spending
isChildSpending: card.customerType === CustomerType.CHILD,
timestamp: new Date(),
};
this.eventEmitter.emit(NOTIFICATION_EVENTS.TRANSACTION_CREATED, event);
return transaction;
}
@ -66,12 +88,43 @@ export class TransactionService {
const transaction = await this.transactionRepository.createAccountTransaction(account, body);
await this.accountService.creditAccountBalance(account.accountReference, body.amount);
// Get card for notification system by account ID
// Account transactions are top-ups, so we get the first card associated with the account
const accountWithCards = await this.accountService.getAccountByAccountNumber(body.accountId);
const card = accountWithCards.cards?.[0]
? await this.cardService.getCardById(accountWithCards.cards[0].id)
: null;
// Only emit event if card exists (we need card for user info)
if (card) {
// Emit event for notification system
const event: ITransactionCreatedEvent = {
transaction,
card, // Pass card with all relations loaded
isTopUp: true, // Account transactions are top-ups
isChildSpending: false, // Top-ups are typically not from children
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);
// Emit event for notification system
const event: ITransactionCreatedEvent = {
transaction,
card, // Pass card with all relations loaded
isTopUp: true, // Internal child transaction is a top-up to child's card
isChildSpending: true, // Child's card is being topped up
timestamp: new Date(),
};
this.eventEmitter.emit(NOTIFICATION_EVENTS.TRANSACTION_CREATED, event);
return transaction;
}
@ -84,15 +137,28 @@ export class TransactionService {
return existingTransaction;
}
async getWeeklySummary(juniorId: string) {
const startOfWeek = moment().startOf('week').toDate();
const endOfWeek = moment().endOf('week').toDate();
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,

View File

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

View File

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

View File

@ -48,47 +48,109 @@ export class NeoLeapService {
this.useKycMock = [true, 'true'].includes(this.configService.get<boolean>('USE_KYC_MOCK', true));
}
initiateKyc(customerId: string, body: InitiateKycRequestDto) {
const responseKey = 'InitiateKycResponseDetails';
async initiateKycOnboarding(dto: InitiateKycRequestDto) {
// Mock mode for development
if (this.useKycMock) {
const responseDto = plainToInstance(InitiateKycResponseDto, INITIATE_KYC_MOCK[responseKey], {
excludeExtraneousValues: true,
});
const mockResponse = {
externalCustomerId: `FIN-TECK-CUSTOMER-${Date.now()}`,
externalFintechId: '1100',
nafathRandomCode: '38',
stateId: uuid(),
status: 'IN_PROGRESS',
};
// Trigger mock webhook after 7 seconds
setTimeout(() => {
this.httpService
.post(`${this.zodApiUrl}/neoleap-webhooks/kyc`, getKycCallbackMock(body.nationalId), {
headers: {
'Content-Type': 'application/json',
.post(`${this.zodApiUrl}/neoleap-webhooks/kyc`, {
stateId: mockResponse.stateId,
callbackId: uuid(),
externalFintechId: '1100',
entity: {
type: 'INDIVIDUAL',
externalId: mockResponse.externalCustomerId,
},
status: 'ONBOARDING_SUCCESS',
})
.subscribe({
next: () => this.logger.log('Mock KYC webhook sent'),
error: (err) => console.error(err),
next: () => this.logger.log('Mock KYC webhook sent successfully'),
error: (err) => this.logger.error('Mock KYC webhook failed:', err.message),
});
}, 7000);
return responseDto;
return mockResponse;
}
// Real API call to Neoleap
const payload = {
InitiateKycRequestDetails: {
CustomerIdentifier: {
InstitutionCode: this.institutionCode,
Id: customerId,
NationalId: body.nationalId,
poiNumber: dto.poiNumber,
poiType: dto.poiType,
mobileNumber: dto.mobileNumber,
email: dto.email,
dateOfBirth: dto.dateOfBirth,
jobSector: dto.jobSector,
employer: dto.employer,
incomeSource: dto.incomeSource,
jobCategory: dto.jobCategory,
incomeRange: dto.incomeRange,
// Use default address values for Neoleap KYC
address: {
national: {
buildingNumber: '1',
additionalNumber: '',
street: 'King Fahd Road',
streetEn: 'King Fahd Road',
city: 'Riyadh',
cityEn: 'Riyadh',
zipcode: '',
unitNumber: '',
district: 'Al Olaya',
districtEn: 'Al Olaya',
},
general: {
address: '1, King Fahd Road, Al Olaya, Riyadh, Riyadh',
website: '',
email: dto.email || '',
telephone1: dto.mobileNumber || '',
telephone2: '',
fax1: '',
fax2: '',
postalBox1: '',
postalBox2: '',
zipcode: '',
},
},
RequestHeader: this.prepareHeaders('InitiateKyc'),
};
return this.sendRequestToNeoLeap<typeof payload, InitiateKycResponseDto>(
'kyc/InitiateKyc',
payload,
responseKey,
InitiateKycResponseDto,
);
try {
const { data } = await this.httpService.axiosRef.post(
`${this.gatewayBaseUrl}/kyc/onboardCustomer`,
payload,
{
headers: {
'Content-Type': 'application/json',
Authorization: this.apiKey,
'X-Request-id': uuid(),
'X-Session-Language': 'ar',
},
},
);
return data.data;
} catch (error: any) {
this.logger.error('Error initiating KYC:', error.response?.data || error.message);
// Handle specific Neoleap errors
if (error.response?.data?.errorCode === 'E810109') {
throw new BadRequestException('National ID is already registered with Neoleap');
}
if (error.response?.data?.error === 'schema validation failed') {
throw new BadRequestException('Invalid data format for KYC verification');
}
throw new InternalServerErrorException('Failed to initiate KYC verification');
}
}
createApplication(customer: Customer) {
@ -124,7 +186,9 @@ export class NeoLeapService {
},
BillingCycle: 'C1',
},
ApplicationOtherInfo: {},
ApplicationOtherInfo: {
ExternalCorporateId: customer.neoleapExternalCustomerId,
},
ApplicationCustomerDetails: {
FirstName: customer.firstName,
LastName: customer.lastName,
@ -137,14 +201,14 @@ export class NeoLeapService {
Title: customer.gender === Gender.MALE ? 'Mr' : 'Ms',
Gender: customer.gender === Gender.MALE ? 'M' : 'F',
LocalizedDateOfBirth: moment(customer.dateOfBirth).format('YYYY-MM-DD'),
Nationality: CountriesNumericISO[customer.countryOfResidence],
Nationality: CountriesNumericISO[customer.countryOfResidence || 'SA'],
},
ApplicationAddress: {
City: customer.city,
Country: CountriesNumericISO[customer.country],
Region: customer.region,
AddressLine1: `${customer.street} ${customer.building}`,
AddressLine2: customer.neighborhood,
City: 'Riyadh',
Country: CountriesNumericISO['SA'],
Region: 'Riyadh',
AddressLine1: 'King Fahd Road 1',
AddressLine2: 'Al Olaya',
AddressRole: 0,
Email: customer.user.email,
Phone1: customer.user.phoneNumber,
@ -213,14 +277,14 @@ export class NeoLeapService {
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],
Nationality: CountriesNumericISO[parent.countryOfResidence || 'SA'],
},
ApplicationAddress: {
City: parent.city,
Country: CountriesNumericISO[parent.country],
Region: parent.region,
AddressLine1: `${parent.street} ${parent.building}`,
AddressLine2: parent.neighborhood,
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,

View File

@ -0,0 +1,15 @@
/**
* Notification event names
* These are the event identifiers used throughout the notification system
*/
export const NOTIFICATION_EVENTS = {
// Transaction events
TRANSACTION_CREATED: 'notification.transaction.created',
} as const;
export type NotificationEventName =
typeof NOTIFICATION_EVENTS[keyof typeof NOTIFICATION_EVENTS];

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 })
userId!: string;
@Column('jsonb', { name: 'data', nullable: true })
data?: Record<string, any>;
@ManyToOne(() => User, (user) => user.notifications, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'user_id' })
user!: User;

View File

@ -1,7 +1,16 @@
export enum NotificationScope {
// Existing scopes
USER_REGISTERED = 'USER_REGISTERED',
TASK_COMPLETED = 'TASK_COMPLETED',
GIFT_RECEIVED = 'GIFT_RECEIVED',
OTP = 'OTP',
USER_INVITED = 'USER_INVITED',
}
// Transaction notifications - Top-up
CHILD_TOP_UP = 'CHILD_TOP_UP',
PARENT_TOP_UP_CONFIRMATION = 'PARENT_TOP_UP_CONFIRMATION',
// Transaction notifications - Spending
CHILD_SPENDING = 'CHILD_SPENDING',
PARENT_SPENDING_ALERT = 'PARENT_SPENDING_ALERT',
}

View File

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

View File

@ -0,0 +1,24 @@
import { Transaction } from '~/card/entities/transaction.entity';
import { Card } from '~/card/entities/card.entity';
/**
* Event payload for when a transaction is created
* Used to notify users about transactions (spending or top-ups)
*/
export interface ITransactionCreatedEvent {
/** The transaction that was created */
transaction: Transaction;
/** The card used in the transaction (with all relations loaded) */
card: Card;
/** True if this is a top-up/load transaction, false if spending */
isTopUp: boolean;
/** True if this transaction was made by a child (requires parent notification) */
isChildSpending: boolean;
/** When the event occurred */
timestamp: Date;
}

View File

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

View File

@ -31,7 +31,7 @@ export class NotificationCreatedListener {
return this.sendSMS(event.recipient!, event.message);
case NotificationChannel.PUSH:
return this.sendPushNotification(event.userId, event.title, event.message);
return this.sendPushNotification(event.userId, event.title, event.message, event.data);
case NotificationChannel.EMAIL:
return this.sendEmail({
@ -54,7 +54,12 @@ export class NotificationCreatedListener {
}
}
private async sendPushNotification(userId: string, title: string, body: string) {
private async sendPushNotification(
userId: string,
title: string,
body: string,
data?: Record<string, any>,
) {
this.logger.log(`Sending push notification to user ${userId}`);
const tokens = await this.deviceService.getTokens(userId);
@ -62,7 +67,19 @@ export class NotificationCreatedListener {
this.logger.log(`No device tokens found for user ${userId}, but notification was created in the DB.`);
return;
}
return this.firebaseService.sendNotification(tokens, title, body);
// Convert data to string values (Firebase requires string values in data payload)
const stringData: Record<string, string> | undefined = data
? Object.entries(data).reduce(
(acc, [key, value]) => {
acc[key] = String(value);
return acc;
},
{} as Record<string, string>,
)
: undefined;
return this.firebaseService.sendNotification(tokens, title, body, stringData);
}
private async sendSMS(to: string, body: string) {

View File

@ -0,0 +1,268 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { NotificationFactory, NotificationPreferences } from '../services/notification-factory.service';
import { UserService } from '~/user/services/user.service';
import { NOTIFICATION_EVENTS } from '../constants/event-names.constant';
import { ITransactionCreatedEvent } from '../interfaces/notification-events.interface';
import { NotificationScope } from '../enums/notification-scope.enum';
import { Transaction } from '~/card/entities/transaction.entity';
import { Card } from '~/card/entities/card.entity';
import { User } from '~/user/entities';
/**
* TransactionNotificationListener
*
* Handles notifications for transaction events.
* Determines who should be notified and what message to send.
*
* Responsibilities:
* - Listen for transaction events
* - Determine notification recipients (child, parent, or both)
* - Construct appropriate messages
* - Fetch user preferences
* - Call NotificationFactory to send
*/
@Injectable()
export class TransactionNotificationListener {
private readonly logger = new Logger(TransactionNotificationListener.name);
constructor(
private readonly notificationFactory: NotificationFactory,
private readonly userService: UserService,
) {}
/**
* Main event handler for transaction created events
* Routes to appropriate notification logic based on transaction type
*/
@OnEvent(NOTIFICATION_EVENTS.TRANSACTION_CREATED)
async handleTransactionCreated(event: ITransactionCreatedEvent): Promise<void> {
try {
const { transaction, card, isTopUp, isChildSpending } = event;
this.logger.log(
`Processing transaction notification for transaction ${transaction.id} - ` +
`isTopUp: ${isTopUp}, isChildSpending: ${isChildSpending}`
);
// Notify the transaction owner (child or parent)
await this.notifyTransactionOwner(transaction, card, isTopUp, isChildSpending);
// If child transaction, also notify parent
if (isChildSpending) {
if (isTopUp) {
// Parent topped up child's card - send confirmation to parent
await this.notifyParentOfTopUp(transaction, card);
} else {
// Child spent money - send spending alert to parent
await this.notifyParentOfChildSpending(transaction, card);
}
}
this.logger.log(
`Transaction notification processed successfully for transaction ${transaction.id}`
);
} catch (error: any) {
this.logger.error(
`Failed to process transaction notification: ${error?.message || 'Unknown error'}`,
error?.stack
);
// Don't throw - notification failures should not break the main flow
}
}
/**
* 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 {
// Extract user from card
const user = card?.customer?.user;
if (!user) {
this.logger.warn(`No user found for transaction ${transaction.id}, skipping notification`);
return;
}
// Determine the scope based on transaction type
const scope = isTopUp
? NotificationScope.CHILD_TOP_UP
: NotificationScope.CHILD_SPENDING;
// Construct title
const title = isTopUp ? 'Card Topped Up' : 'Purchase Successful';
// Extract data
const amount = transaction.transactionAmount;
const merchant = transaction.merchantName || 'merchant';
const balance = card.account?.balance || 0;
// Construct message
const message = isTopUp
? `Your card has been topped up with $${amount.toFixed(2)}`
: `You spent $${amount.toFixed(2)} at ${merchant}. Balance: $${balance.toFixed(2)}`;
this.logger.debug(
`Notifying transaction owner (user ${user.id}) - Amount: $${amount}, Merchant: ${merchant}`
);
// Send notification
await this.notificationFactory.send({
userId: user.id,
title,
message,
scope,
preferences: this.getUserPreferences(user),
data: {
transactionId: transaction.id,
amount: amount.toString(),
merchant: merchant,
merchantCategory: transaction.merchantCategoryCode || 'OTHER',
balance: balance.toString(),
timestamp: transaction.transactionDate.toISOString(),
type: isTopUp ? 'TOP_UP' : 'SPENDING',
action: 'OPEN_TRANSACTION',
},
});
this.logger.log(`✅ Notified user ${user.id} for transaction ${transaction.id}`);
} catch (error: any) {
this.logger.error(
`Failed to notify transaction owner: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Notify parent when their child makes a purchase
* This is a spending alert for parents to monitor their children's expenses
*/
private async notifyParentOfChildSpending(transaction: Transaction, card: Card): Promise<void> {
try {
this.logger.debug(`Checking for parent to notify about child spending`);
// Check if child has guardian
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;
}
// Get child info
const childUser = customer.user;
const childName = childUser?.firstName || 'Your child';
const amount = transaction.transactionAmount;
const merchant = transaction.merchantName || 'a merchant';
this.logger.debug(
`Notifying parent (user ${parentUser.id}): ${childName} spent $${amount} at ${merchant}`
);
// Send notification to parent
await this.notificationFactory.send({
userId: parentUser.id,
title: 'Child Spending Alert',
message: `${childName} spent $${amount.toFixed(2)} at ${merchant}`,
scope: NotificationScope.PARENT_SPENDING_ALERT,
preferences: this.getUserPreferences(parentUser),
data: {
transactionId: transaction.id,
childId: childUser.id,
childName: childName,
amount: amount.toString(),
merchant: merchant,
merchantCategory: transaction.merchantCategoryCode || 'OTHER',
timestamp: transaction.transactionDate.toISOString(),
type: 'CHILD_SPENDING',
action: 'OPEN_TRANSACTION',
},
});
this.logger.log(`✅ Notified parent ${parentUser.id} about child spending`);
} catch (error: any) {
this.logger.error(
`Failed to notify parent of child spending: ${error?.message || 'Unknown error'}`,
error?.stack
);
// Don't throw - parent notification failure should not break child notification
}
}
/**
* 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`);
// Check if child has guardian
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;
}
// Get child info
const childUser = customer.user;
const childName = childUser?.firstName || 'Your child';
const amount = transaction.transactionAmount;
const balance = card.account?.balance || 0;
this.logger.debug(
`Notifying parent (user ${parentUser.id}): Topped up ${childName}'s card with $${amount}`
);
// Send notification to parent
await this.notificationFactory.send({
userId: parentUser.id,
title: 'Top-Up Confirmation',
message: `You topped up ${childName}'s card with $${amount.toFixed(2)}. New balance: $${balance.toFixed(2)}`,
scope: NotificationScope.PARENT_TOP_UP_CONFIRMATION,
preferences: this.getUserPreferences(parentUser),
data: {
transactionId: transaction.id,
childId: childUser.id,
childName: childName,
amount: amount.toString(),
balance: balance.toString(),
timestamp: transaction.transactionDate.toISOString(),
type: 'TOP_UP',
action: 'OPEN_TRANSACTION',
},
});
this.logger.log(`✅ Notified parent ${parentUser.id} about top-up`);
} catch (error: any) {
this.logger.error(
`Failed to notify parent of top-up: ${error?.message || 'Unknown error'}`,
error?.stack
);
// Don't throw - parent notification failure should not break child notification
}
}
/**
* 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

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

View File

@ -1,29 +1,77 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as admin from 'firebase-admin';
@Injectable()
export class FirebaseService {
private readonly logger = new Logger(FirebaseService.name);
constructor(private readonly configService: ConfigService) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: this.configService.get('FIREBASE_PROJECT_ID'),
clientEmail: this.configService.get('FIREBASE_CLIENT_EMAIL'),
privateKey: this.configService.get('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'),
}),
});
try {
this.logger.log('🔥 Initializing Firebase Admin SDK...');
const projectId = this.configService.get('FIREBASE_PROJECT_ID');
const clientEmail = this.configService.get('FIREBASE_CLIENT_EMAIL');
const privateKey = this.configService.get('FIREBASE_PRIVATE_KEY');
// Log configuration (without exposing sensitive data)
this.logger.log(`📋 Project ID: ${projectId}`);
this.logger.log(`📋 Client Email: ${clientEmail}`);
this.logger.log(`📋 Private Key: ${privateKey ? 'SET ✅' : 'MISSING ❌'}`);
admin.initializeApp({
credential: admin.credential.cert({
projectId,
clientEmail,
privateKey: privateKey.replace(/\\n/g, '\n'),
}),
});
this.logger.log('✅ Firebase Admin SDK initialized successfully!');
this.logger.log(`📱 Connected to project: ${projectId}`);
} catch (error: any) {
this.logger.error('❌ Failed to initialize Firebase Admin SDK');
this.logger.error(`Error: ${error.message}`);
throw error;
}
}
sendNotification(tokens: string | string[], title: string, body: string) {
this.logger.log(`Sending push notification to ${tokens}`);
const message = {
notification: {
title,
body,
},
tokens: Array.isArray(tokens) ? tokens : [tokens],
};
async sendNotification(tokens: string | string[], title: string, body: string, data?: Record<string, string>) {
this.logger.log(
`Sending push notification to ${Array.isArray(tokens) ? tokens.length : 1} device(s)`,
);
admin.messaging().sendEachForMulticast(message);
try {
const message = {
notification: {
title,
body,
},
data: data || {},
tokens: Array.isArray(tokens) ? tokens : [tokens],
};
const response = await admin.messaging().sendEachForMulticast(message);
this.logger.log(
`✅ Push sent! Success: ${response.successCount}, Failed: ${response.failureCount}`,
);
// Log failed tokens for debugging
if (response.failureCount > 0) {
response.responses.forEach((resp, idx) => {
if (!resp.success) {
this.logger.warn(
`Failed to send to token ${idx}: ${resp.error?.code} - ${resp.error?.message}`,
);
}
});
}
return response;
} catch (error: any) {
this.logger.error(`❌ Failed to send push notification: ${error.message}`);
throw error;
}
}
}

View File

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

View File

@ -0,0 +1,160 @@
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}`);
// Use provided preferences or default to push-only
const preferences = payload.preferences || {
isPushEnabled: true,
isEmailEnabled: false,
isSmsEnabled: false,
};
const promises: Promise<any>[] = [];
// Route to enabled channels based on preferences
// Currently only PUSH is implemented (extensible for EMAIL, SMS later)
if (preferences.isPushEnabled) {
this.logger.debug(`Routing to PUSH channel for user ${payload.userId}`);
promises.push(
this.sendToChannel(payload, NotificationChannel.PUSH)
);
}
// Future: Add EMAIL channel
// if (preferences.isEmailEnabled) {
// this.logger.debug(`Routing to EMAIL channel for user ${payload.userId}`);
// promises.push(
// this.sendToChannel(payload, NotificationChannel.EMAIL)
// );
// }
// Future: Add SMS channel
// if (preferences.isSmsEnabled) {
// this.logger.debug(`Routing to SMS channel for user ${payload.userId}`);
// promises.push(
// this.sendToChannel(payload, NotificationChannel.SMS)
// );
// }
// Send all notificaetions in parallel
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

@ -31,9 +31,24 @@ export class NotificationsService {
return { notifications, count, unreadCount };
}
createNotification(notification: Partial<Notification>) {
async createNotification(notification: Partial<Notification>) {
this.logger.log(`Creating notification for user ${notification.userId}`);
return this.notificationRepository.createNotification(notification);
const savedNotification = await this.notificationRepository.createNotification(notification);
// Publish to Redis PubSub for delivery (Firebase, Email, SMS)
this.logger.log(`Publishing ${EventType.NOTIFICATION_CREATED} event to Redis`);
this.redisPubSubService.publishEvent(EventType.NOTIFICATION_CREATED, {
...savedNotification,
data: notification.data || savedNotification.data,
}).catch((error) => {
// Log error but don't throw - notification is saved in DB
this.logger.error(
`Failed to publish notification ${savedNotification.id} to Redis: ${error?.message || 'Unknown error'}`,
error?.stack
);
});
return savedNotification;
}
markAsRead(userId: string) {
@ -42,35 +57,28 @@ export class NotificationsService {
}
async sendEmailAsync(data: SendEmailRequestDto) {
this.logger.log(`emitting ${EventType.NOTIFICATION_CREATED} event`);
const notification = await this.createNotification({
this.logger.log(`Creating email notification for ${data.to}`);
// createNotification now automatically publishes to Redis
await this.createNotification({
recipient: data.to,
title: data.subject,
message: '',
scope: NotificationScope.USER_INVITED,
channel: NotificationChannel.EMAIL,
});
// return this.redisPubSubService.emit(EventType.NOTIFICATION_CREATED, notification, data.data);
this.redisPubSubService.publishEvent(EventType.NOTIFICATION_CREATED, {
...notification,
data,
data: data.data, // Pass data in notification object
});
}
async sendOtpNotification(sendOtpRequest: ISendOtp, otp: string) {
this.logger.log(`Sending OTP to ${sendOtpRequest.recipient}`);
const notification = await this.createNotification({
// createNotification now automatically publishes to Redis
return this.createNotification({
recipient: sendOtpRequest.recipient,
title: OTP_TITLE,
message: OTP_BODY.replace('{otp}', otp),
scope: NotificationScope.OTP,
channel: sendOtpRequest.otpType === OtpType.EMAIL ? NotificationChannel.EMAIL : NotificationChannel.SMS,
});
this.logger.log(`emitting ${EventType.NOTIFICATION_CREATED} event`);
return this.redisPubSubService.publishEvent(EventType.NOTIFICATION_CREATED, {
...notification,
data: { otp },
data: { otp }, // Pass data in notification object
});
}
}

View File

@ -1,12 +1,12 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { IJwtPayload } from '~/auth/interfaces';
import { AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards';
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import { InitiateKycRequestDto } from '../dtos/request';
import { CustomerResponseDto, InitiateKycResponseDto } from '../dtos/response';
import { CustomerResponseDto, InitiateKycResponseDto, KycMetadataResponseDto } from '../dtos/response';
import { CustomerService } from '../services';
@Controller('customers')
@ -30,6 +30,16 @@ export class CustomerController {
async initiateKyc(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: InitiateKycRequestDto) {
const res = await this.customerService.initiateKycRequest(sub, body);
return ResponseFactory.data(new InitiateKycResponseDto(res.randomNumber));
return ResponseFactory.data(new InitiateKycResponseDto(res));
}
@Get('/kyc/onboard-metadata')
@UseGuards(AccessTokenGuard)
@ApiOperation({ summary: 'Get KYC onboarding form metadata' })
@ApiDataResponse(KycMetadataResponseDto)
async getKycMetadata() {
const metadata = await this.customerService.getKycOnboardMetadata();
return ResponseFactory.data(metadata);
}
}

View File

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

View File

@ -1,8 +1,55 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDateString, IsEmail, IsEnum, IsOptional, IsString, Matches } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { isValidSaudiId } from '~/core/decorators/validations';
import { Gender, IncomeRange, IncomeSource, JobCategory, JobSector, PoiType } from '~/customer/enums';
export class InitiateKycRequestDto {
@ApiProperty({ example: '999300024' })
@isValidSaudiId({ message: i18n('validation.isValidSaudiId', { path: 'general', property: 'customer.nationalId' }) })
nationalId!: string;
@ApiProperty({ example: '2586234623', description: 'Saudi National ID or Iqama number' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.poiNumber' }) })
poiNumber!: string;
@ApiProperty({ enum: PoiType, example: PoiType.NAT, default: PoiType.NAT })
@IsEnum(PoiType, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.poiType' }) })
poiType!: PoiType;
@ApiProperty({ example: '0512345678', pattern: '^05\\d{8}$' })
@Matches(/^05\d{8}$/, { message: i18n('validation.Matches', { path: 'general', property: 'customer.mobileNumber' }) })
mobileNumber!: string;
@ApiProperty({ example: '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' })
countryOfResidence!: string;
@ApiProperty({ example: 'Employee' })
sourceOfIncome!: string;
@ApiProperty({ example: 'Software Development' })
profession!: string;
@ApiProperty({ example: 'Full-time' })
professionType!: string;
@ApiProperty({ example: false })
isPep!: boolean;
@ -58,24 +49,6 @@ export class CustomerResponseDto {
@ApiProperty({ example: 12345 })
waitingNumber!: number;
@ApiProperty({ example: 'SA' })
country!: string | null;
@ApiProperty({ example: 'Riyadh' })
region!: string | null;
@ApiProperty({ example: 'Riyadh City' })
city!: string | null;
@ApiProperty({ example: 'Al-Masif' })
neighborhood!: string | null;
@ApiProperty({ example: 'King Fahd Road' })
street!: string | null;
@ApiProperty({ example: '123' })
building!: string | null;
@ApiPropertyOptional({ type: DocumentMetaResponseDto })
profilePicture!: DocumentMetaResponseDto | null;
@ -90,19 +63,10 @@ export class CustomerResponseDto {
this.nationalId = customer.nationalId;
this.nationalIdExpiry = customer.nationalIdExpiry;
this.countryOfResidence = customer.countryOfResidence;
this.sourceOfIncome = customer.sourceOfIncome;
this.profession = customer.profession;
this.professionType = customer.professionType;
this.isPep = customer.isPep;
this.gender = customer.gender;
this.isJunior = customer.isJunior;
this.isGuardian = customer.isGuardian;
this.waitingNumber = customer.applicationNumber;
this.country = customer.country;
this.region = customer.region;
this.city = customer.city;
this.neighborhood = customer.neighborhood;
this.street = customer.street;
this.building = customer.building;
}
}

View File

@ -1,2 +1,3 @@
export * from './customer.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 { Expose } from 'class-transformer';
export class InitiateKycResponseDto {
@ApiProperty()
randomNumber!: string;
@ApiProperty({ description: 'Internal transaction ID to track this KYC attempt' })
@Expose()
transactionId!: string;
constructor(randomNumber: string) {
this.randomNumber = randomNumber;
@ApiProperty({ description: 'Neoleap state ID for tracking' })
@Expose()
stateId!: string;
@ApiProperty({ description: 'Nafath random code to show to the user', example: '38' })
@Expose()
nafathRandomCode!: string;
@ApiProperty({ description: 'Current status', example: 'IN_PROGRESS' })
@Expose()
status!: string;
@ApiProperty({ description: 'External customer ID from Neoleap' })
@Expose()
externalCustomerId!: string;
constructor(data: Partial<InitiateKycResponseDto>) {
Object.assign(this, data);
}
}

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

@ -49,15 +49,6 @@ export class Customer extends BaseEntity {
@Column('varchar', { length: 255, nullable: true, name: 'country_of_residence' })
countryOfResidence!: CountryIso;
@Column('varchar', { length: 255, nullable: true, name: 'source_of_income' })
sourceOfIncome!: string;
@Column('varchar', { length: 255, nullable: true, name: 'profession' })
profession!: string;
@Column('varchar', { length: 255, nullable: true, name: 'profession_type' })
professionType!: string;
@Column('boolean', { default: false, name: 'is_pep' })
isPep!: boolean;
@ -77,23 +68,27 @@ export class Customer extends BaseEntity {
@Column('varchar', { name: 'user_id' })
userId!: string;
@Column('varchar', { name: 'country', length: 255, nullable: true })
country!: CountryIso;
// KYC-specific fields
@Column('varchar', { length: 255, nullable: true, name: 'neoleap_external_customer_id' })
neoleapExternalCustomerId!: string | null;
@Column('varchar', { name: 'region', length: 255, nullable: true })
region!: string;
@Column('varchar', { length: 100, nullable: true, name: 'job_sector' })
jobSector!: string | null;
@Column('varchar', { name: 'city', length: 255, nullable: true })
city!: string;
@Column('varchar', { length: 255, nullable: true, name: 'employer' })
employer!: string | null;
@Column('varchar', { name: 'neighborhood', length: 255, nullable: true })
neighborhood!: string;
@Column('varchar', { length: 100, nullable: true, name: 'income_source' })
incomeSource!: string | null;
@Column('varchar', { name: 'street', length: 255, nullable: true })
street!: string;
@Column('varchar', { length: 100, nullable: true, name: 'job_category' })
jobCategory!: string | null;
@Column('varchar', { name: 'building', length: 255, nullable: true })
building!: string;
@Column('varchar', { length: 100, nullable: true, name: 'income_range' })
incomeRange!: string | null;
@Column('varchar', { length: 20, nullable: true, name: 'mobile_number' })
mobileNumber!: string | null;
@OneToOne(() => User, (user) => user.customer, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })

View File

@ -1 +1,2 @@
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 './gender.enum';
export * from './kyc-status.enum';
export * from './poi-type.enum';
export * from './job-sector.enum';
export * from './income-source.enum';
export * from './job-category.enum';
export * from './income-range.enum';

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

@ -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 { Transactional } from 'typeorm-transactional';
import { CountryIso } from '~/common/enums';
@ -11,15 +11,18 @@ import { User } from '~/user/entities';
import { InitiateKycRequestDto } from '../dtos/request';
import { Customer } from '../entities';
import { Gender, KycStatus } from '../enums';
import { CustomerRepository } from '../repositories/customer.repository';
import { CustomerRepository, KycTransactionRepository } from '../repositories';
import { MetadataService } from './metadata.service';
@Injectable()
export class CustomerService {
private readonly logger = new Logger(CustomerService.name);
constructor(
private readonly customerRepository: CustomerRepository,
private readonly kycTransactionRepo: KycTransactionRepository,
private readonly guardianService: GuardianService,
@Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService,
private readonly metadataService: MetadataService,
) {}
async updateCustomer(userId: string, data: Partial<Customer>): Promise<Customer> {
@ -53,23 +56,68 @@ export class CustomerService {
}
async initiateKycRequest(customerId: string, body: InitiateKycRequestDto) {
this.logger.log(`Initiating KYC request for user ${customerId}`);
this.logger.log(`Initiating KYC request for customer ${customerId}`);
const customer = await this.findCustomerById(customerId);
// Validate customer is not already verified
if (customer.kycStatus === KycStatus.APPROVED) {
this.logger.error(`KYC for customer ${customerId} is already approved`);
throw new BadRequestException('CUSTOMER.KYC_ALREADY_APPROVED');
}
// I will assume the api for initiating KYC is not allowing me to send customerId as correlationId so I will store the nationalId in the customer entity
// Check for active KYC transaction by National ID
const activeTransaction = await this.kycTransactionRepo.findActiveByNationalId(body.poiNumber);
if (activeTransaction) {
this.logger.error(`KYC verification already in progress for National ID ${body.poiNumber}`);
throw new ConflictException('KYC verification already in progress for this National ID');
}
// Update customer with KYC data
await this.customerRepository.updateCustomer(customerId, {
nationalId: body.nationalId,
nationalId: body.poiNumber,
dateOfBirth: new Date(body.dateOfBirth),
nationalIdExpiry: new Date(body.nationalIdExpiry),
gender: body.gender,
countryOfResidence: CountryIso.SAUDI_ARABIA, // Always default to Saudi Arabia
mobileNumber: body.mobileNumber,
jobSector: body.jobSector,
employer: body.employer,
incomeSource: body.incomeSource,
jobCategory: body.jobCategory,
incomeRange: body.incomeRange,
kycStatus: KycStatus.PENDING,
});
return this.neoleapService.initiateKyc(customerId, body);
// Call Neoleap KYC API
const neoleapResponse = await this.neoleapService.initiateKycOnboarding(body);
// Create transaction record
const transaction = await this.kycTransactionRepo.create({
customerId,
userId: customer.userId,
nationalId: body.poiNumber,
stateId: neoleapResponse.stateId,
externalCustomerId: neoleapResponse.externalCustomerId,
nafathRandomCode: neoleapResponse.nafathRandomCode,
status: neoleapResponse.status,
formData: body,
initiatedAt: new Date(),
});
// Update customer with external ID
await this.customerRepository.updateCustomer(customerId, {
neoleapExternalCustomerId: neoleapResponse.externalCustomerId,
});
// Return formatted response
return {
transactionId: transaction.id,
stateId: neoleapResponse.stateId,
nafathRandomCode: neoleapResponse.nafathRandomCode,
status: neoleapResponse.status,
externalCustomerId: neoleapResponse.externalCustomerId,
};
}
@Transactional()
@ -92,34 +140,34 @@ export class CustomerService {
}
async updateCustomerKyc(body: KycWebhookRequest) {
this.logger.log(`Updating KYC for customer with national ID ${body.nationalId}`);
this.logger.log(`Updating KYC for stateId ${body.stateId}`);
const customer = await this.customerRepository.findOne({ nationalId: body.nationalId });
if (!customer) {
throw new BadRequestException('CUSTOMER.NOT_FOUND');
// Find transaction by stateId
const transaction = await this.kycTransactionRepo.findByStateId(body.stateId);
if (!transaction) {
this.logger.error(`KYC transaction not found for stateId ${body.stateId}`);
throw new BadRequestException('KYC transaction not found');
}
await this.customerRepository.updateCustomer(customer.id, {
kycStatus: body.status === 'SUCCESS' ? KycStatus.APPROVED : KycStatus.REJECTED,
firstName: body.firstName,
lastName: body.lastName,
dateOfBirth: moment(body.dob, 'YYYYMMDD').toDate(),
nationalId: body.nationalId,
nationalIdExpiry: moment(body.nationalIdExpiry, 'YYYYMMDD').toDate(),
countryOfResidence: NumericToCountryIso[body.country],
country: NumericToCountryIso[body.country],
gender: body.gender === 'M' ? Gender.MALE : Gender.FEMALE,
sourceOfIncome: body.incomeSource,
profession: body.professionTitle,
professionType: body.professionType,
isPep: body.isPep === 'Y',
city: body.city,
region: body.region,
neighborhood: body.neighborhood,
street: body.street,
building: body.building,
const customer = await this.findCustomerById(transaction.customerId);
// Update transaction record
await this.kycTransactionRepo.updateByStateId(body.stateId, {
status: body.status,
callbackId: body.callbackId,
completedAt: new Date(),
});
// Update customer KYC status and external customer ID
const kycStatus = body.status === 'ONBOARDING_SUCCESS' ? KycStatus.APPROVED : KycStatus.REJECTED;
await this.customerRepository.updateCustomer(customer.id, {
kycStatus,
neoleapExternalCustomerId: body.entity.externalId,
});
this.logger.log(`KYC updated successfully for customer ${customer.id}, status: ${body.status}, externalId: ${body.entity.externalId}`);
}
// TO BE REMOVED: This function is for testing only and will be removed
@ -132,12 +180,6 @@ export class CustomerService {
nationalId: '1089055972',
nationalIdExpiry: moment('2031-09-17').toDate(),
countryOfResidence: CountryIso.SAUDI_ARABIA,
country: CountryIso.SAUDI_ARABIA,
region: 'Mecca',
city: 'AT Taif',
neighborhood: 'Al Faisaliah',
street: 'Al Faisaliah Street',
building: '4',
});
await User.update(userId, {
@ -149,6 +191,11 @@ export class CustomerService {
return this.findCustomerById(userId);
}
getKycOnboardMetadata() {
this.logger.log('Getting KYC onboard metadata');
return this.metadataService.getKycOnboardMetadata();
}
// TO BE REMOVED: This function is for testing only and will be removed
private generateSaudiPhoneNumber(): string {
// Saudi mobile numbers are 9 digits, always starting with '5'

View File

@ -1 +1,2 @@
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,32 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddKycFieldsAndTransactions1765804942393 implements MigrationInterface {
name = 'AddKycFieldsAndTransactions1765804942393'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "kyc_transactions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "customer_id" uuid NOT NULL, "user_id" uuid NOT NULL, "state_id" character varying(255) NOT NULL, "external_customer_id" character varying(255), "nafath_random_code" character varying(10), "status" character varying(50) NOT NULL DEFAULT 'INITIATED', "form_data" jsonb NOT NULL, "callback_id" character varying(255), "initiated_at" TIMESTAMP NOT NULL DEFAULT now(), "completed_at" TIMESTAMP, "expires_at" TIMESTAMP, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_231ce1d974b00919a8202e9ca3f" UNIQUE ("state_id"), CONSTRAINT "PK_aa56e3feebd4323c684ca146418" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "customers" ADD "neoleap_external_customer_id" character varying(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "job_sector" character varying(100)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "employer" character varying(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "income_source" character varying(100)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "job_category" character varying(100)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "income_range" character varying(100)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "mobile_number" character varying(20)`);
await queryRunner.query(`ALTER TABLE "kyc_transactions" ADD CONSTRAINT "FK_7651cf2e3ae6381377d8b9ed963" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "kyc_transactions" ADD CONSTRAINT "FK_336a3791fd94d386e5c428850db" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "kyc_transactions" DROP CONSTRAINT "FK_336a3791fd94d386e5c428850db"`);
await queryRunner.query(`ALTER TABLE "kyc_transactions" DROP CONSTRAINT "FK_7651cf2e3ae6381377d8b9ed963"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "mobile_number"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "income_range"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "job_category"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "income_source"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "employer"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "job_sector"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "neoleap_external_customer_id"`);
await queryRunner.query(`DROP TABLE "kyc_transactions"`);
}
}

View File

@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddNationalIdToKycTransactions1765877128065 implements MigrationInterface {
name = 'AddNationalIdToKycTransactions1765877128065'
public async up(queryRunner: QueryRunner): Promise<void> {
// Add column as nullable first (to handle existing records)
await queryRunner.query(`ALTER TABLE "kyc_transactions" ADD "national_id" character varying(50)`);
// Backfill existing records from form_data->poiNumber
await queryRunner.query(`
UPDATE "kyc_transactions"
SET "national_id" = form_data->>'poiNumber'
WHERE "national_id" IS NULL AND form_data->>'poiNumber' IS NOT NULL
`);
// Now make it NOT NULL with a default empty string for safety
await queryRunner.query(`ALTER TABLE "kyc_transactions" ALTER COLUMN "national_id" SET DEFAULT ''`);
await queryRunner.query(`ALTER TABLE "kyc_transactions" ALTER COLUMN "national_id" SET NOT NULL`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "kyc_transactions" DROP COLUMN "national_id"`);
}
}

View File

@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveOldCustomerColumns1765891028260 implements MigrationInterface {
name = 'RemoveOldCustomerColumns1765891028260';
public async up(queryRunner: QueryRunner): Promise<void> {
// Remove duplicate/unused columns that were replaced by KYC-specific fields
// source_of_income -> replaced by income_source
// profession -> replaced by job_sector
// profession_type -> replaced by job_category
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN IF EXISTS "source_of_income"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN IF EXISTS "profession"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN IF EXISTS "profession_type"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Restore columns if migration is rolled back
await queryRunner.query(`ALTER TABLE "customers" ADD "source_of_income" character varying(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "profession" character varying(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "profession_type" character varying(255)`);
}
}

View File

@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveAddressColumns1765975126402 implements MigrationInterface {
name = 'RemoveAddressColumns1765975126402';
public async up(queryRunner: QueryRunner): Promise<void> {
// Drop address columns from customers table
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN IF EXISTS "country"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN IF EXISTS "region"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN IF EXISTS "city"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN IF EXISTS "neighborhood"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN IF EXISTS "street"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN IF EXISTS "building"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Re-add address columns in case of rollback
await queryRunner.query(`ALTER TABLE "customers" ADD "country" varchar(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "region" varchar(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "city" varchar(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "neighborhood" varchar(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "street" varchar(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "building" varchar(255)`);
}
}

View File

@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddDataColumnToNotifications1767172707881 implements MigrationInterface {
name = 'AddDataColumnToNotifications1767172707881'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "notifications" ADD "data" jsonb`);
await queryRunner.query(`ALTER TABLE "kyc_transactions" ALTER COLUMN "national_id" DROP DEFAULT`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "kyc_transactions" ALTER COLUMN "national_id" SET DEFAULT ''`);
await queryRunner.query(`ALTER TABLE "notifications" DROP COLUMN "data"`);
}
}

View File

@ -5,4 +5,9 @@ export * from './1757349525708-create-money-requests-table';
export * from './1757433339849-add-reservation-amount-to-account-entity';
export * from './1757915357218-add-deleted-at-column-to-junior';
export * from './1760869651296-AddMerchantInfoToTransactions';
export * from './1761032305682-AddUniqueConstraintToUserEmail';
export * from './1761032305682-AddUniqueConstraintToUserEmail';
export * from './1767172707881-AddDataColumnToNotifications';
export * from './1765804942393-AddKycFieldsAndTransactions';
export * from './1765877128065-AddNationalIdToKycTransactions';
export * from './1765891028260-RemoveOldCustomerColumns';
export * from './1765975126402-RemoveAddressColumns';

View File

@ -19,6 +19,10 @@
"TOKEN_EXPIRED": "رمز المستخدم منتهي الصلاحية."
},
"QR": {
"CODE_USED_OR_EXPIRED": "تم استخدام رمز QR مسبقًا أو انتهت صلاحيته."
},
"USER": {
"PHONE_ALREADY_VERIFIED": "تم التحقق من رقم الهاتف بالفعل.",
"EMAIL_ALREADY_VERIFIED": "تم التحقق من عنوان البريد الإلكتروني بالفعل.",

View File

@ -19,6 +19,10 @@
"TOKEN_EXPIRED": "The user token has expired."
},
"QR": {
"CODE_USED_OR_EXPIRED": "The QR code has already been used or expired."
},
"USER": {
"PHONE_ALREADY_VERIFIED": "The phone number has already been verified.",
"EMAIL_ALREADY_VERIFIED": "The email address has already been verified.",

View File

@ -151,11 +151,17 @@ export class JuniorController {
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(WeeklySummaryResponseDto)
@ApiQuery({ name: 'startUtc', required: false, type: String, example: '2025-10-20T00:00:00.000Z', description: 'Start date (defaults to start of current week)' })
@ApiQuery({ name: 'endUtc', required: false, type: String, example: '2025-10-26T23:59:59.999Z', description: 'End date (defaults to end of current week)' })
async getWeeklySummary(
@Param('juniorId', CustomParseUUIDPipe) juniorId: string,
@AuthenticatedUser() user: IJwtPayload,
@Query('startUtc') startUtc?: string,
@Query('endUtc') endUtc?: string,
) {
const summary = await this.juniorService.getWeeklySummary(juniorId, user.sub);
const startDate = startUtc ? new Date(startUtc) : undefined;
const endDate = endUtc ? new Date(endUtc) : undefined;
const summary = await this.juniorService.getWeeklySummary(juniorId, user.sub, startDate, endDate);
return ResponseFactory.data(summary);
}

View File

@ -5,6 +5,7 @@ import { Roles } from '~/auth/enums';
import { CardService, TransactionService } from '~/card/services';
import { NeoLeapService } from '~/common/modules/neoleap/services';
import { PageOptionsRequestDto } from '~/core/dtos';
import { ErrorCategory } from '~/core/enums';
import { setIf } from '~/core/utils';
import { CustomerService } from '~/customer/services';
import { DocumentService, OciService } from '~/document/services';
@ -113,7 +114,28 @@ export class JuniorService {
}
junior.customer.user.email = body.email;
}
setIf(user, 'profilePictureId', body.profilePictureId);
// Update profile picture: ensure FK and relation are consistent to avoid TypeORM overriding the FK
if (typeof body.profilePictureId !== 'undefined') {
if (body.profilePictureId) {
const document = await this.documentService.findDocumentById(body.profilePictureId);
if (!document) {
this.logger.error(`Document with id ${body.profilePictureId} not found`);
throw new BadRequestException('DOCUMENT.NOT_FOUND');
}
if (document.createdById !== juniorId) {
this.logger.error(
`Document with id ${body.profilePictureId} does not belong to user ${juniorId}`,
);
}
user.profilePictureId = body.profilePictureId;
// assign relation to keep it consistent with FK during save
user.profilePicture = document as any;
} else {
// if empty string provided (unlikely), clear relation and FK
user.profilePicture = null as any;
user.profilePictureId = null as any;
}
}
setIf(user, 'firstName', body.firstName);
setIf(user, 'lastName', body.lastName);
@ -125,7 +147,7 @@ export class JuniorService {
setIf(junior, 'relationship', body.relationship);
await Promise.all([junior.save(), customer.save(), user.save()]);
this.logger.log(`Junior ${juniorId} updated successfully`);
return junior;
return this.findJuniorById(juniorId, false, guardianId);
}
@Transactional()
@ -158,7 +180,14 @@ export class JuniorService {
async validateToken(token: string) {
this.logger.log(`Validating token ${token}`);
const juniorId = await this.userTokenService.validateToken(token, UserType.JUNIOR);
return this.findJuniorById(juniorId!, true);
const junior = await this.findJuniorById(juniorId!, true);
if (junior.customer?.user?.password) {
this.logger.error(`Token ${token} already used for junior ${juniorId}`);
throw new BadRequestException({ message: 'QR.CODE_USED_OR_EXPIRED', category: ErrorCategory.BUSINESS_ERROR });
}
return junior;
}
async generateToken(juniorId: string) {
@ -212,8 +241,8 @@ export class JuniorService {
this.logger.log(`Junior ${juniorId} deleted successfully`);
}
getWeeklySummary(juniorId: string, guardianId: string) {
const doesBelong = this.doesJuniorBelongToGuardian(guardianId, juniorId);
async getWeeklySummary(juniorId: string, guardianId: string, startDate?: Date, endDate?: Date) {
const doesBelong = await this.doesJuniorBelongToGuardian(guardianId, juniorId);
if (!doesBelong) {
this.logger.error(`Junior ${juniorId} does not belong to guardian ${guardianId}`);
@ -221,7 +250,7 @@ export class JuniorService {
}
this.logger.log(`Getting weekly summary for junior ${juniorId}`);
return this.cardService.getWeeklySummary(juniorId);
return this.cardService.getWeeklySummary(juniorId, startDate, endDate);
}
async getJuniorHome(juniorId: string, userId: string, size: number): Promise<JuniorHomeResponseDto> {

View File

@ -64,14 +64,12 @@ export class UserService {
this.customerService.createGuardianCustomer(userId, {
firstName: body.firstName,
lastName: body.lastName,
dateOfBirth: body.dateOfBirth,
countryOfResidence: body.countryOfResidence,
}),
this.userRepository.update(userId, {
isPhoneVerified: true,
password: hashedPassword,
salt,
...(body.email && { email: body.email }),
}),
]);
}
@ -102,7 +100,6 @@ export class UserService {
return this.userRepository.createUnverifiedUser({
phoneNumber: body.phoneNumber,
countryCode: body.countryCode,
email: body.email,
firstName: body.firstName,
lastName: body.lastName,
roles: [Roles.GUARDIAN],
@ -226,7 +223,15 @@ export class UserService {
if (userWithEmail) {
if (userWithEmail.id === userId) {
return;
this.logger.log(`Generating OTP for current email ${email} for user ${userId}`);
await this.userRepository.update(userId, { isEmailVerified: false });
return this.otpService.generateAndSendOtp({
userId,
recipient: email,
otpType: OtpType.EMAIL,
scope: OtpScope.VERIFY_EMAIL,
});
}
this.logger.error(`Email ${email} is already taken by another user`);

View File

@ -1,5 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from './../src/app.module';
@ -18,4 +18,6 @@ describe('AppController (e2e)', () => {
it('/ (GET)', () => {
return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!');
});
});