Compare commits

..

29 Commits

Author SHA1 Message Date
bf43e62b17 feat: handle card status changed webhook 2025-07-21 15:30:55 +03:00
5a780eeb17 feat/working on update card control 2025-07-14 11:57:51 +03:00
038b8ef6e3 feat: finish working on account transaction webhook 2025-07-09 13:31:08 +03:00
3b3f8c0104 fix: remove host from request 2025-07-07 16:34:45 +03:00
2770cf8774 fix:fix card migration 2025-07-07 12:06:01 +03:00
bea3ccfbbc Merge branch 'waiting-list' into feat/neoleap-integration 2025-07-06 16:45:37 +03:00
492e538eb8 feat: send request via gateway 2025-07-06 16:44:23 +03:00
d3057beb54 feat: add transaction, card , and account entities 2025-07-02 18:42:38 +03:00
19fa53c981 fix: fix apple client audience 2025-06-17 11:39:51 +03:00
d2cc02fb60 fix: localizze error messages 2025-06-17 09:43:36 +03:00
4cbbfd8136 Merge branch 'waiting-list' into feat/neoleap-integration 2025-06-11 11:15:10 +03:00
6c859a25d2 feat: handle oauth2 login 2025-06-04 14:52:09 +03:00
d1a6d3e715 feat: add test controller for integartion 2025-06-04 10:04:45 +03:00
1ea1f42169 feat: finish create and inquire application api and handle response and errors 2025-06-03 14:51:36 +03:00
d4fe3b3fc3 feat: finish working on mocking inquire application api 2025-05-26 16:34:09 +03:00
b44bc5d5cc fix: localize customer already exist message 2025-05-26 15:24:20 +03:00
9aa6c487ed Merge branch 'waiting-list' into feat/neoleap-integration 2025-05-26 12:11:53 +03:00
42e4d75d70 feat: add country iso enum 2025-05-26 12:10:09 +03:00
a358cd2e7a feat: add neoleap service and mock create application api 2025-05-26 12:04:00 +03:00
641a665beb Merge branch 'waiting-list' into feat/neoleap-integration 2025-05-21 09:59:18 +03:00
49326e983f feat: handle new registration flow 2025-05-19 17:00:32 +03:00
881d88c8d8 feat: add customer details to customer entity 2025-05-19 14:16:18 +03:00
35ab3df7c1 feat: update create junior payload and add new document type 2025-05-14 14:53:22 +03:00
cbade0a87d feat: blacklist refresh tokens 2025-04-09 16:20:29 +03:00
4c6ef17525 refactor: remove login by password and biometric 2025-04-09 15:07:48 +03:00
ffca6996fd feat: styling email template 2025-04-06 13:33:57 +03:00
a3f88c774c feat: handle notification using redis 2025-03-27 12:33:56 +03:00
ec38b82a7b feat: add waiting number and handle resent otp 2025-03-16 11:34:08 +03:00
9b5f863577 add login flow for waiting list demo app 2025-03-04 14:42:02 +03:00
117 changed files with 5039 additions and 357 deletions

2
.gitignore vendored
View File

@ -53,3 +53,5 @@ pids
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
zod-certs

View File

@ -10,8 +10,8 @@
"include": "config",
"exclude": "**/*.md"
},
{ "include": "common/modules/**/templates/*", "watchAssets": true }
,
{ "include": "common/modules/**/templates/**/*", "watchAssets": true },
{ "include": "common/modules/neoleap/zod-certs" },
"i18n",
"files"
]

42
package-lock.json generated
View File

@ -33,10 +33,11 @@
"cacheable": "^1.8.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"decimal.js": "^10.6.0",
"firebase-admin": "^13.0.2",
"google-libphonenumber": "^3.2.39",
"handlebars": "^4.7.8",
"ioredis": "^5.4.1",
"handlebars-layouts": "^3.1.4",
"jwk-to-pem": "^2.0.7",
"lodash": "^4.17.21",
"moment": "^2.30.1",
@ -1187,7 +1188,9 @@
},
"node_modules/@ioredis/commands": {
"version": "1.2.0",
"license": "MIT"
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
@ -5165,6 +5168,12 @@
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT"
},
"node_modules/dedent": {
"version": "1.5.3",
"dev": true,
@ -5239,6 +5248,8 @@
"node_modules/denque": {
"version": "2.1.0",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.10"
}
@ -6980,6 +6991,15 @@
"uglify-js": "^3.1.4"
}
},
"node_modules/handlebars-layouts": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/handlebars-layouts/-/handlebars-layouts-3.1.4.tgz",
"integrity": "sha512-2llBmvnj8ueOfxNHdRzJOcgalzZjYVd9+WAl93kPYmlX4WGx7FTHTzNxhK+i9YKY2OSjzfehgpLiIwP/OJr6tw==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/handlebars/node_modules/source-map": {
"version": "0.6.1",
"license": "BSD-3-Clause",
@ -7381,6 +7401,8 @@
"node_modules/ioredis": {
"version": "5.4.1",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@ioredis/commands": "^1.1.1",
"cluster-key-slot": "^1.1.0",
@ -9105,7 +9127,9 @@
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"license": "MIT"
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/lodash.includes": {
"version": "4.3.0",
@ -9113,7 +9137,9 @@
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"license": "MIT"
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
@ -15708,6 +15734,8 @@
"node_modules/redis-errors": {
"version": "1.2.0",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=4"
}
@ -15715,6 +15743,8 @@
"node_modules/redis-parser": {
"version": "3.0.0",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"redis-errors": "^1.0.0"
},
@ -16499,7 +16529,9 @@
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"license": "MIT"
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/statuses": {
"version": "2.0.1",

View File

@ -51,10 +51,11 @@
"cacheable": "^1.8.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"decimal.js": "^10.6.0",
"firebase-admin": "^13.0.2",
"google-libphonenumber": "^3.2.39",
"handlebars": "^4.7.8",
"ioredis": "^5.4.1",
"handlebars-layouts": "^3.1.4",
"jwk-to-pem": "^2.0.7",
"lodash": "^4.17.21",
"moment": "^2.30.1",

View File

@ -12,6 +12,7 @@ import { AllowanceModule } from './allowance/allowance.module';
import { AuthModule } from './auth/auth.module';
import { CacheModule } from './common/modules/cache/cache.module';
import { LookupModule } from './common/modules/lookup/lookup.module';
import { NeoLeapModule } from './common/modules/neoleap/neoleap.module';
import { NotificationModule } from './common/modules/notification/notification.module';
import { OtpModule } from './common/modules/otp/otp.module';
import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters';
@ -30,6 +31,7 @@ import { MoneyRequestModule } from './money-request/money-request.module';
import { SavingGoalsModule } from './saving-goals/saving-goals.module';
import { TaskModule } from './task/task.module';
import { UserModule } from './user/user.module';
import { CardModule } from './card/card.module';
@Module({
controllers: [],
@ -80,6 +82,8 @@ import { UserModule } from './user/user.module';
UserModule,
CronModule,
NeoLeapModule,
CardModule,
],
providers: [
// Global Pipes

View File

@ -1,23 +1,24 @@
import { Body, Controller, Headers, HttpCode, HttpStatus, Post, Req, UseGuards } from '@nestjs/common';
import { Body, Controller, HttpCode, HttpStatus, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { DEVICE_ID_HEADER } from '~/common/constants';
import { AuthenticatedUser, Public } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards';
import { ApiLangRequestHeader } from '~/core/decorators';
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import {
AppleLoginRequestDto,
CreateUnverifiedUserRequestDto,
DisableBiometricRequestDto,
EnableBiometricRequestDto,
ForgetPasswordRequestDto,
LoginRequestDto,
GoogleLoginRequestDto,
RefreshTokenRequestDto,
SendForgetPasswordOtpRequestDto,
SendLoginOtpRequestDto,
SetEmailRequestDto,
setJuniorPasswordRequestDto,
SetPasscodeRequestDto,
VerifyOtpRequestDto,
VerifyLoginOtpRequestDto,
VerifyUserRequestDto,
} from '../dtos/request';
import { SendForgetPasswordOtpResponseDto, SendRegisterOtpResponseDto } from '../dtos/response';
@ -43,6 +44,36 @@ export class AuthController {
return ResponseFactory.data(new LoginResponseDto(res, user));
}
@Post('login/otp')
@HttpCode(HttpStatus.NO_CONTENT)
async sendLoginOtp(@Body() data: SendLoginOtpRequestDto) {
return this.authService.sendLoginOtp(data);
}
@Post('login/verify')
@HttpCode(HttpStatus.OK)
@ApiDataResponse(LoginResponseDto)
async verifyLoginOtp(@Body() data: VerifyLoginOtpRequestDto) {
const [token, user] = await this.authService.verifyLoginOtp(data);
return ResponseFactory.data(new LoginResponseDto(token, user));
}
@Post('login/google')
@HttpCode(HttpStatus.OK)
@ApiDataResponse(LoginResponseDto)
async loginWithGoogle(@Body() data: GoogleLoginRequestDto) {
const [token, user] = await this.authService.loginWithGoogle(data);
return ResponseFactory.data(new LoginResponseDto(token, user));
}
@Post('login/apple')
@HttpCode(HttpStatus.OK)
@ApiDataResponse(LoginResponseDto)
async loginWithApple(@Body() data: AppleLoginRequestDto) {
const [token, user] = await this.authService.loginWithApple(data);
return ResponseFactory.data(new LoginResponseDto(token, user));
}
@Post('register/set-email')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard)
@ -57,22 +88,22 @@ export class AuthController {
await this.authService.setPasscode(sub, passcode);
}
@Post('register/set-phone/otp')
@UseGuards(AccessTokenGuard)
async setPhoneNumber(
@AuthenticatedUser() { sub }: IJwtPayload,
@Body() setPhoneNumberDto: CreateUnverifiedUserRequestDto,
) {
const phoneNumber = await this.authService.setPhoneNumber(sub, setPhoneNumberDto);
return ResponseFactory.data(new SendRegisterOtpResponseDto(phoneNumber));
}
// @Post('register/set-phone/otp')
// @UseGuards(AccessTokenGuard)
// async setPhoneNumber(
// @AuthenticatedUser() { sub }: IJwtPayload,
// @Body() setPhoneNumberDto: CreateUnverifiedUserRequestDto,
// ) {
// const phoneNumber = await this.authService.setPhoneNumber(sub, setPhoneNumberDto);
// return ResponseFactory.data(new SendRegisterOtpResponseDto(phoneNumber));
// }
@Post('register/set-phone/verify')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard)
async verifyPhoneNumber(@AuthenticatedUser() { sub }: IJwtPayload, @Body() { otp }: VerifyOtpRequestDto) {
await this.authService.verifyPhoneNumber(sub, otp);
}
// @Post('register/set-phone/verify')
// @HttpCode(HttpStatus.NO_CONTENT)
// @UseGuards(AccessTokenGuard)
// async verifyPhoneNumber(@AuthenticatedUser() { sub }: IJwtPayload, @Body() { otp }: VerifyOtpRequestDto) {
// await this.authService.verifyPhoneNumber(sub, otp);
// }
@Post('biometric/enable')
@HttpCode(HttpStatus.NO_CONTENT)
@ -114,12 +145,6 @@ export class AuthController {
return ResponseFactory.data(new LoginResponseDto(res, user));
}
@Post('login')
async login(@Body() loginDto: LoginRequestDto, @Headers(DEVICE_ID_HEADER) deviceId: string) {
const [res, user] = await this.authService.login(loginDto, deviceId);
return ResponseFactory.data(new LoginResponseDto(res, user));
}
@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard)

View File

@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class AppleAdditionalData {
@ApiProperty({ example: 'Ahmad' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) })
firstName!: string;
@ApiProperty({ example: 'Khan' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) })
lastName!: string;
}

View File

@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { AppleAdditionalData } from './apple-additional-data.request.dto';
export class AppleLoginRequestDto {
@ApiProperty({ example: 'apple_token' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.appleToken' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.appleToken' }) })
appleToken!: string;
@ApiProperty({ type: AppleAdditionalData })
@ValidateNested({
each: true,
message: i18n('validation.ValidateNested', { path: 'general', property: 'auth.apple.additionalData' }),
})
@IsOptional()
@Type(() => AppleAdditionalData)
additionalData?: AppleAdditionalData;
}

View File

@ -1,19 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { Matches } from 'class-validator';
import { IsEmail } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { COUNTRY_CODE_REGEX } from '~/auth/constants';
import { IsValidPhoneNumber } from '~/core/decorators/validations';
export class CreateUnverifiedUserRequestDto {
@ApiProperty({ example: '+962' })
@Matches(COUNTRY_CODE_REGEX, {
message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }),
})
countryCode: string = '+966';
@ApiProperty({ example: '787259134' })
@IsValidPhoneNumber({
message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }),
})
phoneNumber!: string;
@ApiProperty({ example: 'test@test.com' })
@IsEmail(
{},
{
message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }),
},
)
email!: string;
}

View File

@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class GoogleLoginRequestDto {
@ApiProperty({ example: 'google_token' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.googleToken' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.googleToken' }) })
googleToken!: string;
}

View File

@ -1,12 +1,16 @@
export * from './apple-login.request.dto';
export * from './create-unverified-user.request.dto';
export * from './disable-biometric.request.dto';
export * from './enable-biometric.request.dto';
export * from './forget-password.request.dto';
export * from './google-login.request.dto';
export * from './login.request.dto';
export * from './refresh-token.request.dto';
export * from './send-forget-password-otp.request.dto';
export * from './send-login-otp.request.dto';
export * from './set-email.request.dto';
export * from './set-junior-password.request.dto';
export * from './set-passcode.request.dto';
export * from './verify-login-otp.request.dto';
export * from './verify-otp.request.dto';
export * from './verify-user.request.dto';

View File

@ -3,7 +3,7 @@ import { IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, ValidateIf } from 'c
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { GrantType } from '~/auth/enums';
export class LoginRequestDto {
@ApiProperty({ example: GrantType.PASSWORD })
@ApiProperty({ example: GrantType.APPLE })
@IsEnum(GrantType, { message: i18n('validation.IsEnum', { path: 'general', property: 'auth.grantType' }) })
grantType!: GrantType;

View File

@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class SendLoginOtpRequestDto {
@ApiProperty({ example: 'test@test.com' })
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) })
email!: string;
}

View File

@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumberString, MaxLength, MinLength } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants';
import { SendLoginOtpRequestDto } from './send-login-otp.request.dto';
export class VerifyLoginOtpRequestDto extends SendLoginOtpRequestDto {
@ApiProperty({ example: '111111' })
@IsNumberString(
{ no_symbols: true },
{ message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.otp' }) },
)
@MaxLength(DEFAULT_OTP_LENGTH, {
message: i18n('validation.MaxLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }),
})
@MinLength(DEFAULT_OTP_LENGTH, {
message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }),
})
otp!: string;
}

View File

@ -1,10 +1,43 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumberString, MaxLength, MinLength } from 'class-validator';
import { ApiProperty, PickType } from '@nestjs/swagger';
import {
IsDateString,
IsEnum,
IsNotEmpty,
IsNumberString,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { CountryIso } from '~/common/enums';
import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants';
import { IsAbove18 } from '~/core/decorators/validations';
import { CreateUnverifiedUserRequestDto } from './create-unverified-user.request.dto';
export class VerifyUserRequestDto extends CreateUnverifiedUserRequestDto {
export class VerifyUserRequestDto extends PickType(CreateUnverifiedUserRequestDto, ['email']) {
@ApiProperty({ example: 'John' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) })
firstName!: string;
@ApiProperty({ example: 'Doe' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) })
lastName!: string;
@ApiProperty({ example: '2021-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' }),
})
@IsOptional()
countryOfResidence: CountryIso = CountryIso.SAUDI_ARABIA;
@ApiProperty({ example: '111111' })
@IsNumberString(
{ no_symbols: true },

View File

@ -2,9 +2,9 @@ import { ApiProperty } from '@nestjs/swagger';
export class SendRegisterOtpResponseDto {
@ApiProperty()
phoneNumber!: string;
email!: string;
constructor(phoneNumber: string) {
this.phoneNumber = phoneNumber;
constructor(email: string) {
this.email = email;
}
}

View File

@ -12,17 +12,21 @@ import { DeviceService, UserService, UserTokenService } from '~/user/services';
import { User } from '../../user/entities';
import { PASSCODE_REGEX } from '../constants';
import {
AppleLoginRequestDto,
CreateUnverifiedUserRequestDto,
DisableBiometricRequestDto,
EnableBiometricRequestDto,
ForgetPasswordRequestDto,
GoogleLoginRequestDto,
LoginRequestDto,
SendForgetPasswordOtpRequestDto,
SendLoginOtpRequestDto,
SetEmailRequestDto,
setJuniorPasswordRequestDto,
VerifyLoginOtpRequestDto,
VerifyUserRequestDto,
} from '../dtos/request';
import { GrantType, Roles } from '../enums';
import { Roles } from '../enums';
import { IJwtPayload, ILoginResponse } from '../interfaces';
import { removePadding, verifySignature } from '../utils';
import { Oauth2Service } from './oauth2.service';
@ -43,62 +47,45 @@ export class AuthService {
private readonly cacheService: CacheService,
private readonly oauth2Service: Oauth2Service,
) {}
async sendRegisterOtp({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) {
this.logger.log(`Sending OTP to ${countryCode + phoneNumber}`);
const user = await this.userService.findOrCreateUser({ phoneNumber, countryCode });
async sendRegisterOtp(body: CreateUnverifiedUserRequestDto) {
this.logger.log(`Sending OTP to ${body.email}`);
const user = await this.userService.findOrCreateUser(body);
return this.otpService.generateAndSendOtp({
userId: user.id,
recipient: user.countryCode + user.phoneNumber,
scope: OtpScope.VERIFY_PHONE,
otpType: OtpType.SMS,
recipient: user.email,
scope: OtpScope.VERIFY_EMAIL,
otpType: OtpType.EMAIL,
});
}
async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> {
this.logger.log(`Verifying user with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber}`);
const user = await this.userService.findUserOrThrow({ phoneNumber: verifyUserDto.phoneNumber });
this.logger.log(`Verifying user with email ${verifyUserDto.email}`);
const user = await this.userService.findUserOrThrow({ email: verifyUserDto.email });
if (user.isProfileCompleted) {
this.logger.error(
`User with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber} already verified`,
);
throw new BadRequestException('USER.PHONE_ALREADY_VERIFIED');
if (user.isEmailVerified) {
this.logger.error(`User with email ${verifyUserDto.email} already verified`);
throw new BadRequestException('USER.EMAIL_ALREADY_VERIFIED');
}
const isOtpValid = await this.otpService.verifyOtp({
userId: user.id,
scope: OtpScope.VERIFY_PHONE,
otpType: OtpType.SMS,
scope: OtpScope.VERIFY_EMAIL,
otpType: OtpType.EMAIL,
value: verifyUserDto.otp,
});
if (!isOtpValid) {
this.logger.error(
`Invalid OTP for user with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber}`,
);
this.logger.error(`Invalid OTP for user with email ${verifyUserDto.email}`);
throw new BadRequestException('OTP.INVALID_OTP');
}
if (user.isPhoneVerified) {
this.logger.log(
`User with phone number ${
verifyUserDto.countryCode + verifyUserDto.phoneNumber
} already verified but did not complete registration process`,
);
const tokens = await this.generateAuthToken(user);
return [tokens, user];
}
await this.userService.verifyPhoneNumber(user.id);
await this.userService.verifyUser(user.id, verifyUserDto);
await user.reload();
const tokens = await this.generateAuthToken(user);
this.logger.log(
`User with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber} verified successfully`,
);
this.logger.log(`User with email ${verifyUserDto.email} verified successfully`);
return [tokens, user];
}
@ -136,46 +123,46 @@ export class AuthService {
this.logger.log(`Passcode set successfully for user with id ${userId}`);
}
async setPhoneNumber(userId: string, { phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) {
const user = await this.userService.findUserOrThrow({ id: userId });
// async setPhoneNumber(userId: string, { phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) {
// const user = await this.userService.findUserOrThrow({ id: userId });
if (user.phoneNumber || user.countryCode) {
this.logger.error(`Phone number already set for user with id ${userId}`);
throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_SET');
}
// if (user.phoneNumber || user.countryCode) {
// this.logger.error(`Phone number already set for user with id ${userId}`);
// throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_SET');
// }
const existingUser = await this.userService.findUser({ phoneNumber, countryCode });
// const existingUser = await this.userService.findUser({ phoneNumber, countryCode });
if (existingUser) {
this.logger.error(`Phone number ${countryCode + phoneNumber} already taken`);
throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_TAKEN');
}
// if (existingUser) {
// this.logger.error(`Phone number ${countryCode + phoneNumber} already taken`);
// throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_TAKEN');
// }
await this.userService.setPhoneNumber(userId, phoneNumber, countryCode);
// await this.userService.setPhoneNumber(userId, phoneNumber, countryCode);
return this.otpService.generateAndSendOtp({
userId,
recipient: countryCode + phoneNumber,
scope: OtpScope.VERIFY_PHONE,
otpType: OtpType.SMS,
});
}
// return this.otpService.generateAndSendOtp({
// userId,
// recipient: countryCode + phoneNumber,
// scope: OtpScope.VERIFY_PHONE,
// otpType: OtpType.SMS,
// });
// }
async verifyPhoneNumber(userId: string, otp: string) {
const isOtpValid = await this.otpService.verifyOtp({
otpType: OtpType.SMS,
scope: OtpScope.VERIFY_PHONE,
userId,
value: otp,
});
// async verifyPhoneNumber(userId: string, otp: string) {
// const isOtpValid = await this.otpService.verifyOtp({
// otpType: OtpType.SMS,
// scope: OtpScope.VERIFY_PHONE,
// userId,
// value: otp,
// });
if (!isOtpValid) {
this.logger.error(`Invalid OTP for user with id ${userId}`);
throw new BadRequestException('OTP.INVALID_OTP');
}
// if (!isOtpValid) {
// this.logger.error(`Invalid OTP for user with id ${userId}`);
// throw new BadRequestException('OTP.INVALID_OTP');
// }
return this.userService.verifyPhoneNumber(userId);
}
// return this.userService.verifyPhoneNumber(userId);
// }
async enableBiometric(userId: string, { deviceId, publicKey }: EnableBiometricRequestDto) {
this.logger.log(`Enabling biometric for user with id ${userId}`);
@ -258,41 +245,6 @@ export class AuthService {
this.logger.log(`Passcode updated successfully for user with email ${email}`);
}
async login(loginDto: LoginRequestDto, deviceId: string): Promise<[ILoginResponse, User]> {
let user: User;
let tokens: ILoginResponse;
if (loginDto.grantType === GrantType.GOOGLE) {
this.logger.log(`Logging in user with email ${loginDto.email} using google`);
[tokens, user] = await this.loginWithGoogle(loginDto);
}
if (loginDto.grantType === GrantType.APPLE) {
this.logger.log(`Logging in user with email ${loginDto.email} using apple`);
[tokens, user] = await this.loginWithApple(loginDto);
}
if (loginDto.grantType === GrantType.PASSWORD) {
this.logger.log(`Logging in user with email ${loginDto.email} using password`);
[tokens, user] = await this.loginWithPassword(loginDto);
}
if (loginDto.grantType === GrantType.BIOMETRIC) {
this.logger.log(`Logging in user with email ${loginDto.email} using biometric`);
[tokens, user] = await this.loginWithBiometric(loginDto, deviceId);
}
await this.deviceService.updateDevice(deviceId, {
lastAccessOn: new Date(),
fcmToken: loginDto.fcmToken,
userId: user!.id,
});
this.logger.log(`User with email ${loginDto.email} logged in successfully`);
return [tokens!, user!];
}
async setJuniorPasscode(body: setJuniorPasswordRequestDto) {
this.logger.log(`Setting passcode for junior with qrToken ${body.qrToken}`);
const juniorId = await this.userTokenService.validateToken(body.qrToken, UserType.JUNIOR);
@ -305,6 +257,14 @@ export class AuthService {
async refreshToken(refreshToken: string): Promise<[ILoginResponse, User]> {
this.logger.log('Refreshing token');
const isBlackListed = await this.cacheService.get(refreshToken);
if (isBlackListed) {
this.logger.error('Refresh token is blacklisted');
throw new BadRequestException('AUTH.INVALID_REFRESH_TOKEN');
}
try {
const isValid = await this.jwtService.verifyAsync<IJwtPayload>(refreshToken, {
secret: this.configService.getOrThrow('JWT_REFRESH_TOKEN_SECRET'),
@ -316,6 +276,12 @@ export class AuthService {
const tokens = await this.generateAuthToken(user);
this.logger.log(`Blacklisting old tokens for user with id ${isValid.sub}`);
const refreshTokenExpiry = this.jwtService.decode(refreshToken).exp - Date.now() / ONE_THOUSAND;
await this.cacheService.set(refreshToken, 'BLACKLISTED', refreshTokenExpiry);
this.logger.log(`Token refreshed successfully for user with id ${isValid.sub}`);
return [tokens, user];
@ -325,11 +291,45 @@ export class AuthService {
}
}
async sendLoginOtp({ email }: SendLoginOtpRequestDto) {
const user = await this.userService.findUserOrThrow({ email });
this.logger.log(`Sending login OTP to ${email}`);
return this.otpService.generateAndSendOtp({
recipient: email,
scope: OtpScope.LOGIN,
otpType: OtpType.EMAIL,
userId: user.id,
});
}
async verifyLoginOtp({ email, otp }: VerifyLoginOtpRequestDto): Promise<[ILoginResponse, User]> {
const user = await this.userService.findUserOrThrow({ email });
this.logger.log(`Verifying login OTP for ${email}`);
const isOtpValid = await this.otpService.verifyOtp({
otpType: OtpType.EMAIL,
scope: OtpScope.LOGIN,
userId: user.id,
value: otp,
});
if (!isOtpValid) {
this.logger.error(`Invalid OTP for user with email ${email}`);
throw new BadRequestException('OTP.INVALID_OTP');
}
this.logger.log(`Login OTP verified successfully for ${email}`);
const token = await this.generateAuthToken(user);
return [token, user];
}
logout(req: Request) {
this.logger.log('Logging out');
const accessToken = req.headers.authorization?.split(' ')[1] as string;
const expiryInTtl = this.jwtService.decode(accessToken).exp - Date.now() / ONE_THOUSAND;
return this.cacheService.set(accessToken, 'LOGOUT', expiryInTtl);
return this.cacheService.set(accessToken, 'BLACKLISTED', expiryInTtl);
}
private async loginWithPassword(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> {
@ -382,65 +382,96 @@ export class AuthService {
return [tokens, user];
}
private async loginWithGoogle(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> {
const { email, sub } = await this.oauth2Service.verifyGoogleToken(loginDto.googleToken);
const [existingUser, isJunior] = await Promise.all([
async loginWithGoogle(loginDto: GoogleLoginRequestDto): Promise<[ILoginResponse, User]> {
const {
email,
sub,
given_name: firstName,
family_name: lastName,
} = await this.oauth2Service.verifyGoogleToken(loginDto.googleToken);
const [existingUser, isJunior, existingUserWithEmail] = await Promise.all([
this.userService.findUser({ googleId: sub }),
this.userService.findUser({ email, roles: ArrayContains([Roles.JUNIOR]) }),
this.userService.findUser({ email }),
]);
if (isJunior && email) {
if (isJunior) {
this.logger.error(`User with email ${email} is an already registered junior`);
throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET');
}
if (!existingUser) {
this.logger.debug(`User with google id ${sub} not found, creating new user`);
const user = await this.userService.createGoogleUser(sub, email);
if (!existingUser && existingUserWithEmail) {
this.logger.error(`User with email ${email} already exists adding google id to existing user`);
await this.userService.updateUser(existingUserWithEmail.id, { googleId: sub });
const tokens = await this.generateAuthToken(existingUserWithEmail);
return [tokens, existingUserWithEmail];
}
if (!existingUser && !existingUserWithEmail) {
this.logger.debug(`User with google id ${sub} or email ${email} not found, creating new user`);
const user = await this.userService.createGoogleUser(sub, email, firstName, lastName);
const tokens = await this.generateAuthToken(user);
return [tokens, user];
}
const tokens = await this.generateAuthToken(existingUser);
const tokens = await this.generateAuthToken(existingUser!);
return [tokens, existingUser];
return [tokens, existingUser!];
}
private async loginWithApple(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> {
async loginWithApple(loginDto: AppleLoginRequestDto): Promise<[ILoginResponse, User]> {
const { sub, email } = await this.oauth2Service.verifyAppleToken(loginDto.appleToken);
const [existingUser, isJunior] = await Promise.all([
const [existingUserWithSub, isJunior] = await Promise.all([
this.userService.findUser({ appleId: sub }),
this.userService.findUser({ email, roles: ArrayContains([Roles.JUNIOR]) }),
]);
if (isJunior && email) {
this.logger.error(`User with email ${email} is an already registered junior`);
if (isJunior) {
this.logger.error(`User with apple id ${sub} is an already registered junior`);
throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET');
}
if (!existingUser) {
if (email) {
const existingUserWithEmail = await this.userService.findUser({ email });
if (existingUserWithEmail && !existingUserWithSub) {
{
this.logger.error(`User with email ${email} already exists adding apple id to existing user`);
await this.userService.updateUser(existingUserWithEmail.id, { appleId: sub });
const tokens = await this.generateAuthToken(existingUserWithEmail);
return [tokens, existingUserWithEmail];
}
}
}
if (!existingUserWithSub) {
// Apple only provides email if user authorized zod for the first time
if (!email) {
if (!email || !loginDto.additionalData) {
this.logger.error(`User authorized zod before but his email is not stored in the database`);
throw new BadRequestException('AUTH.APPLE_RE-CONSENT_REQUIRED');
}
this.logger.debug(`User with apple id ${sub} not found, creating new user`);
const user = await this.userService.createAppleUser(sub, email);
const user = await this.userService.createAppleUser(
sub,
email,
loginDto.additionalData.firstName,
loginDto.additionalData.lastName,
);
const tokens = await this.generateAuthToken(user);
return [tokens, user];
}
const tokens = await this.generateAuthToken(existingUser);
const tokens = await this.generateAuthToken(existingUserWithSub);
this.logger.log(`User with apple id ${sub} logged in successfully`);
return [tokens, existingUser];
return [tokens, existingUserWithSub];
}
private async generateAuthToken(user: User) {
@ -478,4 +509,6 @@ export class AuthService {
throw new BadRequestException('AUTH.INVALID_PASSCODE');
}
}
private validateGoogleToken(googleToken: string) {}
}

View File

@ -49,7 +49,7 @@ export class Oauth2Service {
const payload = this.jwtService.verify(appleToken, {
publicKey,
algorithms: ['RS256'],
audience: this.configService.getOrThrow('APPLE_CLIENT_ID'),
audience: this.configService.getOrThrow('APPLE_CLIENT_ID').split(','),
issuer: this.appleIssuer,
});

View File

@ -1,12 +1,13 @@
import { Injectable } from '@nestjs/common';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { UserService } from '~/user/services';
import { IJwtPayload } from '../interfaces';
@Injectable()
export class AccessTokenStrategy extends PassportStrategy(Strategy, 'access-token') {
constructor(configService: ConfigService) {
constructor(configService: ConfigService, private userService: UserService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
@ -14,7 +15,13 @@ export class AccessTokenStrategy extends PassportStrategy(Strategy, 'access-toke
});
}
validate(payload: IJwtPayload) {
async validate(payload: IJwtPayload) {
const user = await this.userService.findUser({ id: payload.sub });
if (!user) {
throw new UnauthorizedException();
}
return payload;
}
}

25
src/card/card.module.ts Normal file
View File

@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Card } from './entities';
import { Account } from './entities/account.entity';
import { Transaction } from './entities/transaction.entity';
import { CardRepository } from './repositories';
import { AccountRepository } from './repositories/account.repository';
import { TransactionRepository } from './repositories/transaction.repository';
import { CardService } from './services';
import { AccountService } from './services/account.service';
import { TransactionService } from './services/transaction.service';
@Module({
imports: [TypeOrmModule.forFeature([Card, Account, Transaction])],
providers: [
CardService,
CardRepository,
TransactionService,
TransactionRepository,
AccountService,
AccountRepository,
],
exports: [CardService, TransactionService],
})
export class CardModule {}

View File

@ -0,0 +1,31 @@
import { Column, CreateDateColumn, Entity, Index, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
import { Card } from './card.entity';
import { Transaction } from './transaction.entity';
@Entity('accounts')
export class Account {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column('varchar', { length: 255, nullable: false, unique: true, name: 'account_reference' })
@Index({ unique: true })
accountReference!: string;
@Column('varchar', { length: 255, nullable: false, name: 'currency' })
currency!: string;
@Column('decimal', { precision: 10, scale: 2, default: 0.0, name: 'balance' })
balance!: number;
@OneToMany(() => Card, (card) => card.account, { cascade: true })
cards!: Card[];
@OneToMany(() => Transaction, (transaction) => transaction.account, { cascade: true })
transactions!: Transaction[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone' })
updatedAt!: Date;
}

View File

@ -0,0 +1,85 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Customer } from '~/customer/entities';
import { CardColors, CardIssuers, CardScheme, CardStatus, CardStatusDescription, CustomerType } from '../enums';
import { Account } from './account.entity';
import { Transaction } from './transaction.entity';
@Entity('cards')
export class Card {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Index({ unique: true })
@Column({ name: 'card_reference', nullable: false, type: 'varchar' })
cardReference!: string;
@Column({ length: 6, name: 'first_six_digits', nullable: false, type: 'varchar' })
firstSixDigits!: string;
@Column({ length: 4, name: 'last_four_digits', nullable: false, type: 'varchar' })
lastFourDigits!: string;
@Column({ type: 'varchar', nullable: false })
expiry!: string;
@Column({ type: 'varchar', nullable: false, name: 'customer_type' })
customerType!: CustomerType;
@Column({ type: 'varchar', nullable: false, default: CardColors.BLUE })
color!: CardColors;
@Column({ type: 'varchar', nullable: false, default: CardStatus.PENDING })
status!: CardStatus;
@Column({ type: 'varchar', nullable: false, default: CardStatusDescription.PENDING_ACTIVATION })
statusDescription!: CardStatusDescription;
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0.0, name: 'limit' })
limit!: number;
@Column({ type: 'varchar', nullable: false, default: CardScheme.VISA })
scheme!: CardScheme;
@Column({ type: 'varchar', nullable: false })
issuer!: CardIssuers;
@Column({ type: 'uuid', name: 'customer_id', nullable: false })
customerId!: string;
@Column({ type: 'uuid', name: 'parent_id', nullable: true })
parentId?: string;
@Column({ type: 'uuid', name: 'account_id', nullable: false })
accountId!: string;
@ManyToOne(() => Customer, (customer) => customer.childCards)
@JoinColumn({ name: 'parent_id' })
parentCustomer?: Customer;
@ManyToOne(() => Customer, (customer) => customer.cards, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'customer_id' })
customer!: Customer;
@ManyToOne(() => Account, (account) => account.cards, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'account_id' })
account!: Account;
@OneToMany(() => Transaction, (transaction) => transaction.card, { cascade: true })
transactions!: Transaction[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamp with time zone', name: 'updated_at' })
updatedAt!: Date;
}

View File

@ -0,0 +1 @@
export * from './card.entity';

View File

@ -0,0 +1,69 @@
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { TransactionScope, TransactionType } from '../enums';
import { Account } from './account.entity';
import { Card } from './card.entity';
@Entity('transactions')
export class Transaction {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'transaction_scope', type: 'varchar', nullable: false })
transactionScope!: TransactionScope;
@Column({ name: 'transaction_type', type: 'varchar', default: TransactionType.EXTERNAL })
transactionType!: TransactionType;
@Column({ name: 'card_reference', nullable: true, type: 'varchar' })
cardReference!: string;
@Column({ name: 'account_reference', nullable: true, type: 'varchar' })
accountReference!: string;
@Column({ name: 'transaction_id', unique: true, nullable: true, type: 'varchar' })
transactionId!: string;
@Column({ name: 'card_masked_number', nullable: true, type: 'varchar' })
cardMaskedNumber!: string;
@Column({ type: 'timestamp with time zone', name: 'transaction_date', nullable: true })
transactionDate!: Date;
@Column({ name: 'rrn', nullable: true, type: 'varchar' })
rrn!: string;
@Column({ type: 'decimal', precision: 12, scale: 2, name: 'transaction_amount' })
transactionAmount!: number;
@Column({ type: 'varchar', name: 'transaction_currency' })
transactionCurrency!: string;
@Column({ type: 'decimal', name: 'billing_amount', precision: 12, scale: 2 })
billingAmount!: number;
@Column({ type: 'decimal', name: 'settlement_amount', precision: 12, scale: 2 })
settlementAmount!: number;
@Column({ type: 'decimal', name: 'fees', precision: 12, scale: 2 })
fees!: number;
@Column({ type: 'decimal', name: 'vat_on_fees', precision: 12, scale: 2, default: 0.0 })
vatOnFees!: number;
@Column({ name: 'card_id', type: 'uuid', nullable: true })
cardId!: string;
@Column({ name: 'account_id', type: 'uuid', nullable: true })
accountId!: string;
@ManyToOne(() => Card, (card) => card.transactions, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'card_id' })
card!: Card;
@ManyToOne(() => Account, (account) => account.transactions, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'account_id' })
account!: Account;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt!: Date;
}

View File

@ -0,0 +1,4 @@
export enum CardColors {
RED = 'RED',
BLUE = 'BLUE',
}

View File

@ -0,0 +1,3 @@
export enum CardIssuers {
NEOLEAP = 'NEOLEAP',
}

View File

@ -0,0 +1,4 @@
export enum CardScheme {
VISA = 'VISA',
MASTERCARD = 'MASTERCARD',
}

View File

@ -0,0 +1,68 @@
/**
* import { CardStatus, CardStatusDescription } from '../enums';
export const CardStatusMapper: Record<string, { description: CardStatusDescription; status: CardStatus }> = {
//ACTIVE
'00': { description: 'NORMAL', status: CardStatus.ACTIVE },
//PENDING
'02': { description: 'NOT_YET_ISSUED', status: CardStatus.PENDING },
'20': { description: 'PENDING_ISSUANCE', status: CardStatus.PENDING },
'21': { description: 'CARD_EXTRACTED', status: CardStatus.PENDING },
'22': { description: 'EXTRACTION_FAILED', status: CardStatus.PENDING },
'23': { description: 'FAILED_PRINTING_BULK', status: CardStatus.PENDING },
'24': { description: 'FAILED_PRINTING_INST', status: CardStatus.PENDING },
'30': { description: 'PENDING_ACTIVATION', status: CardStatus.PENDING },
'27': { description: 'PENDING_PIN', status: CardStatus.PENDING },
'16': { description: 'PREPARE_TO_CLOSE', status: CardStatus.PENDING },
//BLOCKED
'01': { description: 'PIN_TRIES_EXCEEDED', status: CardStatus.BLOCKED },
'03': { description: 'CARD_EXPIRED', status: CardStatus.BLOCKED },
'04': { description: 'LOST', status: CardStatus.BLOCKED },
'05': { description: 'STOLEN', status: CardStatus.BLOCKED },
'06': { description: 'CUSTOMER_CLOSE', status: CardStatus.BLOCKED },
'07': { description: 'BANK_CANCELLED', status: CardStatus.BLOCKED },
'08': { description: 'FRAUD', status: CardStatus.BLOCKED },
'09': { description: 'DAMAGED', status: CardStatus.BLOCKED },
'50': { description: 'SAFE_BLOCK', status: CardStatus.BLOCKED },
'51': { description: 'TEMPORARY_BLOCK', status: CardStatus.BLOCKED },
'52': { description: 'RISK_BLOCK', status: CardStatus.BLOCKED },
'53': { description: 'OVERDRAFT', status: CardStatus.BLOCKED },
'54': { description: 'BLOCKED_FOR_FEES', status: CardStatus.BLOCKED },
'67': { description: 'CLOSED_CUSTOMER_DEAD', status: CardStatus.BLOCKED },
'75': { description: 'RETURN_CARD', status: CardStatus.BLOCKED },
//Fallback
'99': { description: 'UNKNOWN', status: CardStatus.PENDING },
};
*/
export enum CardStatusDescription {
NORMAL = 'NORMAL',
NOT_YET_ISSUED = 'NOT_YET_ISSUED',
PENDING_ISSUANCE = 'PENDING_ISSUANCE',
CARD_EXTRACTED = 'CARD_EXTRACTED',
EXTRACTION_FAILED = 'EXTRACTION_FAILED',
FAILED_PRINTING_BULK = 'FAILED_PRINTING_BULK',
FAILED_PRINTING_INST = 'FAILED_PRINTING_INST',
PENDING_ACTIVATION = 'PENDING_ACTIVATION',
PENDING_PIN = 'PENDING_PIN',
PREPARE_TO_CLOSE = 'PREPARE_TO_CLOSE',
PIN_TRIES_EXCEEDED = 'PIN_TRIES_EXCEEDED',
CARD_EXPIRED = 'CARD_EXPIRED',
LOST = 'LOST',
STOLEN = 'STOLEN',
CUSTOMER_CLOSE = 'CUSTOMER_CLOSE',
BANK_CANCELLED = 'BANK_CANCELLED',
FRAUD = 'FRAUD',
DAMAGED = 'DAMAGED',
SAFE_BLOCK = 'SAFE_BLOCK',
TEMPORARY_BLOCK = 'TEMPORARY_BLOCK',
RISK_BLOCK = 'RISK_BLOCK',
OVERDRAFT = 'OVERDRAFT',
BLOCKED_FOR_FEES = 'BLOCKED_FOR_FEES',
CLOSED_CUSTOMER_DEAD = 'CLOSED_CUSTOMER_DEAD',
RETURN_CARD = 'RETURN_CARD',
UNKNOWN = 'UNKNOWN',
}

View File

@ -0,0 +1,6 @@
export enum CardStatus {
ACTIVE = 'ACTIVE',
CANCELED = 'CANCELED',
BLOCKED = 'BLOCKED',
PENDING = 'PENDING',
}

View File

@ -0,0 +1,4 @@
export enum CustomerType {
PARENT = 'PARENT',
CHILD = 'CHILD',
}

8
src/card/enums/index.ts Normal file
View File

@ -0,0 +1,8 @@
export * from './card-colors.enum';
export * from './card-issuers.enum';
export * from './card-scheme.enum';
export * from './card-status-description.enum';
export * from './card-status.enum';
export * from './customer-type.enum';
export * from './transaction-scope.enum';
export * from './transaction-type.enum';

View File

@ -0,0 +1,4 @@
export enum TransactionScope {
CARD = 'CARD',
ACCOUNT = 'ACCOUNT',
}

View File

@ -0,0 +1,4 @@
export enum TransactionType {
INTERNAL = 'INTERNAL',
EXTERNAL = 'EXTERNAL',
}

View File

@ -0,0 +1,109 @@
import { UserLocale } from '~/core/enums';
import { CardStatusDescription } from '../enums';
export const CardStatusMapper: Record<CardStatusDescription, { [key in UserLocale]: { description: string } }> = {
[CardStatusDescription.NORMAL]: {
[UserLocale.ENGLISH]: { description: 'The card is active' },
[UserLocale.ARABIC]: { description: 'البطاقة نشطة' },
},
[CardStatusDescription.NOT_YET_ISSUED]: {
[UserLocale.ENGLISH]: { description: 'The card is not yet issued' },
[UserLocale.ARABIC]: { description: 'البطاقة لم تصدر بعد' },
},
[CardStatusDescription.PENDING_ISSUANCE]: {
[UserLocale.ENGLISH]: { description: 'The card is pending issuance' },
[UserLocale.ARABIC]: { description: 'البطاقة قيد الإصدار' },
},
[CardStatusDescription.CARD_EXTRACTED]: {
[UserLocale.ENGLISH]: { description: 'The card has been extracted' },
[UserLocale.ARABIC]: { description: 'تم استخراج البطاقة' },
},
[CardStatusDescription.EXTRACTION_FAILED]: {
[UserLocale.ENGLISH]: { description: 'The card extraction has failed' },
[UserLocale.ARABIC]: { description: 'فشل استخراج البطاقة' },
},
[CardStatusDescription.FAILED_PRINTING_BULK]: {
[UserLocale.ENGLISH]: { description: 'The card printing in bulk has failed' },
[UserLocale.ARABIC]: { description: 'فشل الطباعة بالجملة للبطاقة' },
},
[CardStatusDescription.FAILED_PRINTING_INST]: {
[UserLocale.ENGLISH]: { description: 'The card printing in institution has failed' },
[UserLocale.ARABIC]: { description: 'فشل الطباعة في المؤسسة للبطاقة' },
},
[CardStatusDescription.PENDING_ACTIVATION]: {
[UserLocale.ENGLISH]: { description: 'The card is pending activation' },
[UserLocale.ARABIC]: { description: 'البطاقة قيد التفعيل' },
},
[CardStatusDescription.PENDING_PIN]: {
[UserLocale.ENGLISH]: { description: 'The card is pending PIN' },
[UserLocale.ARABIC]: { description: 'البطاقة قيد الانتظار لرقم التعريف الشخصي' },
},
[CardStatusDescription.PREPARE_TO_CLOSE]: {
[UserLocale.ENGLISH]: { description: 'The card is being prepared for closure' },
[UserLocale.ARABIC]: { description: 'البطاقة قيد التحضير للإغلاق' },
},
[CardStatusDescription.PIN_TRIES_EXCEEDED]: {
[UserLocale.ENGLISH]: { description: 'The card PIN tries have been exceeded' },
[UserLocale.ARABIC]: { description: 'تم تجاوز محاولات رقم التعريف الشخصي للبطاقة' },
},
[CardStatusDescription.CARD_EXPIRED]: {
[UserLocale.ENGLISH]: { description: 'The card has expired' },
[UserLocale.ARABIC]: { description: 'انتهت صلاحية البطاقة' },
},
[CardStatusDescription.LOST]: {
[UserLocale.ENGLISH]: { description: 'The card is lost' },
[UserLocale.ARABIC]: { description: 'البطاقة ضائعة' },
},
[CardStatusDescription.STOLEN]: {
[UserLocale.ENGLISH]: { description: 'The card is stolen' },
[UserLocale.ARABIC]: { description: 'البطاقة مسروقة' },
},
[CardStatusDescription.CUSTOMER_CLOSE]: {
[UserLocale.ENGLISH]: { description: 'The card is being closed by the customer' },
[UserLocale.ARABIC]: { description: 'البطاقة قيد الإغلاق من قبل العميل' },
},
[CardStatusDescription.BANK_CANCELLED]: {
[UserLocale.ENGLISH]: { description: 'The card has been cancelled by the bank' },
[UserLocale.ARABIC]: { description: 'البطاقة ألغيت من قبل البنك' },
},
[CardStatusDescription.FRAUD]: {
[UserLocale.ENGLISH]: { description: 'Fraud' },
[UserLocale.ARABIC]: { description: 'احتيال' },
},
[CardStatusDescription.DAMAGED]: {
[UserLocale.ENGLISH]: { description: 'The card is damaged' },
[UserLocale.ARABIC]: { description: 'البطاقة تالفة' },
},
[CardStatusDescription.SAFE_BLOCK]: {
[UserLocale.ENGLISH]: { description: 'The card is in a safe block' },
[UserLocale.ARABIC]: { description: 'البطاقة في حظر آمن' },
},
[CardStatusDescription.TEMPORARY_BLOCK]: {
[UserLocale.ENGLISH]: { description: 'The card is in a temporary block' },
[UserLocale.ARABIC]: { description: 'البطاقة في حظر مؤقت' },
},
[CardStatusDescription.RISK_BLOCK]: {
[UserLocale.ENGLISH]: { description: 'The card is in a risk block' },
[UserLocale.ARABIC]: { description: 'البطاقة في حظر المخاطر' },
},
[CardStatusDescription.OVERDRAFT]: {
[UserLocale.ENGLISH]: { description: 'The card is in overdraft' },
[UserLocale.ARABIC]: { description: 'البطاقة في السحب على المكشوف' },
},
[CardStatusDescription.BLOCKED_FOR_FEES]: {
[UserLocale.ENGLISH]: { description: 'The card is blocked for fees' },
[UserLocale.ARABIC]: { description: 'البطاقة محظورة للرسوم' },
},
[CardStatusDescription.CLOSED_CUSTOMER_DEAD]: {
[UserLocale.ENGLISH]: { description: 'The card is closed because the customer is dead' },
[UserLocale.ARABIC]: { description: 'البطاقة مغلقة لأن العميل متوفى' },
},
[CardStatusDescription.RETURN_CARD]: {
[UserLocale.ENGLISH]: { description: 'The card is being returned' },
[UserLocale.ARABIC]: { description: 'البطاقة قيد الإرجاع' },
},
[CardStatusDescription.UNKNOWN]: {
[UserLocale.ENGLISH]: { description: 'The card status is unknown' },
[UserLocale.ARABIC]: { description: 'حالة البطاقة غير معروفة' },
},
};

View File

@ -0,0 +1,37 @@
import { CardStatus, CardStatusDescription } from '../enums';
export const CardStatusMapper: Record<string, { description: CardStatusDescription; status: CardStatus }> = {
//ACTIVE
'00': { description: CardStatusDescription.NORMAL, status: CardStatus.ACTIVE },
//PENDING
'02': { description: CardStatusDescription.NOT_YET_ISSUED, status: CardStatus.PENDING },
'20': { description: CardStatusDescription.PENDING_ISSUANCE, status: CardStatus.PENDING },
'21': { description: CardStatusDescription.CARD_EXTRACTED, status: CardStatus.PENDING },
'22': { description: CardStatusDescription.EXTRACTION_FAILED, status: CardStatus.PENDING },
'23': { description: CardStatusDescription.FAILED_PRINTING_BULK, status: CardStatus.PENDING },
'24': { description: CardStatusDescription.FAILED_PRINTING_INST, status: CardStatus.PENDING },
'30': { description: CardStatusDescription.PENDING_ACTIVATION, status: CardStatus.PENDING },
'27': { description: CardStatusDescription.PENDING_PIN, status: CardStatus.PENDING },
'16': { description: CardStatusDescription.PREPARE_TO_CLOSE, status: CardStatus.PENDING },
//BLOCKED
'01': { description: CardStatusDescription.PIN_TRIES_EXCEEDED, status: CardStatus.BLOCKED },
'03': { description: CardStatusDescription.CARD_EXPIRED, status: CardStatus.BLOCKED },
'04': { description: CardStatusDescription.LOST, status: CardStatus.BLOCKED },
'05': { description: CardStatusDescription.STOLEN, status: CardStatus.BLOCKED },
'06': { description: CardStatusDescription.CUSTOMER_CLOSE, status: CardStatus.BLOCKED },
'07': { description: CardStatusDescription.BANK_CANCELLED, status: CardStatus.BLOCKED },
'08': { description: CardStatusDescription.FRAUD, status: CardStatus.BLOCKED },
'09': { description: CardStatusDescription.DAMAGED, status: CardStatus.BLOCKED },
'50': { description: CardStatusDescription.SAFE_BLOCK, status: CardStatus.BLOCKED },
'51': { description: CardStatusDescription.TEMPORARY_BLOCK, status: CardStatus.BLOCKED },
'52': { description: CardStatusDescription.RISK_BLOCK, status: CardStatus.BLOCKED },
'53': { description: CardStatusDescription.OVERDRAFT, status: CardStatus.BLOCKED },
'54': { description: CardStatusDescription.BLOCKED_FOR_FEES, status: CardStatus.BLOCKED },
'67': { description: CardStatusDescription.CLOSED_CUSTOMER_DEAD, status: CardStatus.BLOCKED },
'75': { description: CardStatusDescription.RETURN_CARD, status: CardStatus.BLOCKED },
//Fallback
'99': { description: CardStatusDescription.UNKNOWN, status: CardStatus.PENDING },
};

View File

@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Account } from '../entities/account.entity';
@Injectable()
export class AccountRepository {
constructor(@InjectRepository(Account) private readonly accountRepository: Repository<Account>) {}
createAccount(accountId: string): Promise<Account> {
return this.accountRepository.save(
this.accountRepository.create({
accountReference: accountId,
balance: 0,
currency: '682',
}),
);
}
getAccountByReferenceNumber(accountReference: string): Promise<Account | null> {
return this.accountRepository.findOne({
where: { accountReference },
relations: ['cards'],
});
}
topUpAccountBalance(accountReference: string, amount: number) {
return this.accountRepository.increment({ accountReference }, 'balance', amount);
}
decreaseAccountBalance(accountReference: string, amount: number) {
return this.accountRepository.decrement({ accountReference }, 'balance', amount);
}
}

View File

@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateApplicationResponse } from '~/common/modules/neoleap/dtos/response';
import { Card } from '../entities';
import { CardColors, CardIssuers, CardScheme, CardStatus, CardStatusDescription, CustomerType } from '../enums';
@Injectable()
export class CardRepository {
constructor(@InjectRepository(Card) private readonly cardRepository: Repository<Card>) {}
createCard(customerId: string, accountId: string, card: CreateApplicationResponse): Promise<Card> {
return this.cardRepository.save(
this.cardRepository.create({
customerId: customerId,
expiry: card.expiryDate,
cardReference: card.cardId,
customerType: CustomerType.PARENT,
firstSixDigits: card.firstSixDigits,
lastFourDigits: card.lastFourDigits,
color: CardColors.BLUE,
scheme: CardScheme.VISA,
issuer: CardIssuers.NEOLEAP,
accountId: accountId,
}),
);
}
getCardById(id: string): Promise<Card | null> {
return this.cardRepository.findOne({ where: { id } });
}
getCardByReferenceNumber(referenceNumber: string): Promise<Card | null> {
return this.cardRepository.findOne({ where: { cardReference: referenceNumber }, relations: ['account'] });
}
getActiveCardForCustomer(customerId: string): Promise<Card | null> {
return this.cardRepository.findOne({
where: { customerId, status: CardStatus.ACTIVE },
});
}
updateCardStatus(id: string, status: CardStatus, statusDescription: CardStatusDescription) {
return this.cardRepository.update(id, {
status: status,
statusDescription: statusDescription,
});
}
}

View File

@ -0,0 +1 @@
export * from './card.repository';

View File

@ -0,0 +1,65 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import moment from 'moment';
import { Repository } from 'typeorm';
import {
AccountTransactionWebhookRequest,
CardTransactionWebhookRequest,
} from '~/common/modules/neoleap/dtos/requests';
import { Card } from '../entities';
import { Account } from '../entities/account.entity';
import { Transaction } from '../entities/transaction.entity';
import { TransactionScope, TransactionType } from '../enums';
@Injectable()
export class TransactionRepository {
constructor(@InjectRepository(Transaction) private transactionRepository: Repository<Transaction>) {}
createCardTransaction(card: Card, transactionData: CardTransactionWebhookRequest): Promise<Transaction> {
return this.transactionRepository.save(
this.transactionRepository.create({
transactionId: transactionData.transactionId,
cardReference: transactionData.cardId,
transactionAmount: transactionData.transactionAmount,
transactionCurrency: transactionData.transactionCurrency,
billingAmount: transactionData.billingAmount,
settlementAmount: transactionData.settlementAmount,
transactionDate: moment(transactionData.date + transactionData.time, 'YYYYMMDDHHmmss').toDate(),
rrn: transactionData.rrn,
cardMaskedNumber: transactionData.cardMaskedNumber,
fees: transactionData.fees,
cardId: card.id,
accountId: card.account!.id,
transactionType: TransactionType.EXTERNAL,
accountReference: card.account!.accountReference,
transactionScope: TransactionScope.CARD,
vatOnFees: transactionData.vatOnFees,
}),
);
}
createAccountTransaction(account: Account, transactionData: AccountTransactionWebhookRequest): Promise<Transaction> {
return this.transactionRepository.save(
this.transactionRepository.create({
transactionId: transactionData.transactionId,
transactionAmount: transactionData.amount,
transactionCurrency: transactionData.currency,
billingAmount: 0,
settlementAmount: 0,
transactionDate: moment(transactionData.date + transactionData.time, 'YYYYMMDDHHmmss').toDate(),
fees: 0,
accountReference: account.accountReference,
accountId: account.id,
transactionType: TransactionType.EXTERNAL,
transactionScope: TransactionScope.ACCOUNT,
vatOnFees: 0,
}),
);
}
findTransactionByReference(transactionId: string, accountReference: string): Promise<Transaction | null> {
return this.transactionRepository.findOne({
where: { transactionId, accountReference },
});
}
}

View File

@ -0,0 +1,38 @@
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import { Account } from '../entities/account.entity';
import { AccountRepository } from '../repositories/account.repository';
@Injectable()
export class AccountService {
constructor(private readonly accountRepository: AccountRepository) {}
createAccount(accountId: string): Promise<Account> {
return this.accountRepository.createAccount(accountId);
}
async getAccountByReferenceNumber(accountReference: string): Promise<Account> {
const account = await this.accountRepository.getAccountByReferenceNumber(accountReference);
if (!account) {
throw new UnprocessableEntityException('ACCOUNT.NOT_FOUND');
}
return account;
}
async creditAccountBalance(accountReference: string, amount: number) {
return this.accountRepository.topUpAccountBalance(accountReference, amount);
}
async decreaseAccountBalance(accountReference: string, amount: number) {
const account = await this.getAccountByReferenceNumber(accountReference);
/**
* While there is no need to check for insufficient balance because this is a webhook handler,
* I just added this check to ensure we don't have corruption in our data especially if this service is used elsewhere.
*/
if (account.balance < amount) {
throw new UnprocessableEntityException('ACCOUNT.INSUFFICIENT_BALANCE');
}
return this.accountRepository.decreaseAccountBalance(accountReference, amount);
}
}

View File

@ -0,0 +1,54 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Transactional } from 'typeorm-transactional';
import { AccountCardStatusChangedWebhookRequest } from '~/common/modules/neoleap/dtos/requests';
import { CreateApplicationResponse } from '~/common/modules/neoleap/dtos/response';
import { Card } from '../entities';
import { CardStatusMapper } from '../mappers/card-status.mapper';
import { CardRepository } from '../repositories';
import { AccountService } from './account.service';
@Injectable()
export class CardService {
constructor(private readonly cardRepository: CardRepository, private readonly accountService: AccountService) {}
@Transactional()
async createCard(customerId: string, cardData: CreateApplicationResponse): Promise<Card> {
const account = await this.accountService.createAccount(cardData.accountId);
return this.cardRepository.createCard(customerId, account.id, cardData);
}
async getCardById(id: string): Promise<Card> {
const card = await this.cardRepository.getCardById(id);
if (!card) {
throw new BadRequestException('CARD.NOT_FOUND');
}
return card;
}
async getCardByReferenceNumber(referenceNumber: string): Promise<Card> {
const card = await this.cardRepository.getCardByReferenceNumber(referenceNumber);
if (!card) {
throw new BadRequestException('CARD.NOT_FOUND');
}
return card;
}
async getActiveCardForCustomer(customerId: string): Promise<Card> {
const card = await this.cardRepository.getActiveCardForCustomer(customerId);
if (!card) {
throw new BadRequestException('CARD.NOT_FOUND');
}
return card;
}
async updateCardStatus(body: AccountCardStatusChangedWebhookRequest) {
const card = await this.getCardByReferenceNumber(body.cardId);
const { description, status } = CardStatusMapper[body.newStatus] || CardStatusMapper['99'];
return this.cardRepository.updateCardStatus(card.id, status, description);
}
}

View File

@ -0,0 +1 @@
export * from './card.service';

View File

@ -0,0 +1,62 @@
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import Decimal from 'decimal.js';
import { Transactional } from 'typeorm-transactional';
import {
AccountTransactionWebhookRequest,
CardTransactionWebhookRequest,
} from '~/common/modules/neoleap/dtos/requests';
import { Transaction } from '../entities/transaction.entity';
import { TransactionRepository } from '../repositories/transaction.repository';
import { AccountService } from './account.service';
import { CardService } from './card.service';
@Injectable()
export class TransactionService {
constructor(
private readonly transactionRepository: TransactionRepository,
private readonly cardService: CardService,
private readonly accountService: AccountService,
) {}
@Transactional()
async createCardTransaction(body: CardTransactionWebhookRequest) {
const card = await this.cardService.getCardByReferenceNumber(body.cardId);
const existingTransaction = await this.findExistingTransaction(body.transactionId, card.account.accountReference);
if (existingTransaction) {
throw new UnprocessableEntityException('TRANSACTION.ALREADY_EXISTS');
}
const transaction = await this.transactionRepository.createCardTransaction(card, body);
const total = new Decimal(body.transactionAmount).plus(body.billingAmount).plus(body.fees).plus(body.vatOnFees);
await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber());
return transaction;
}
@Transactional()
async createAccountTransaction(body: AccountTransactionWebhookRequest) {
const account = await this.accountService.getAccountByReferenceNumber(body.accountId);
const existingTransaction = await this.findExistingTransaction(body.transactionId, account.accountReference);
if (existingTransaction) {
throw new UnprocessableEntityException('TRANSACTION.ALREADY_EXISTS');
}
const transaction = await this.transactionRepository.createAccountTransaction(account, body);
await this.accountService.creditAccountBalance(account.accountReference, body.amount);
return transaction;
}
private async findExistingTransaction(transactionId: string, accountReference: string): Promise<Transaction | null> {
const existingTransaction = await this.transactionRepository.findTransactionByReference(
transactionId,
accountReference,
);
return existingTransaction;
}
}

View File

@ -0,0 +1,253 @@
import { CountryIso } from '../enums';
export const CountriesNumericISO: Record<CountryIso, string> = {
[CountryIso.ARUBA]: '533',
[CountryIso.AFGHANISTAN]: '004',
[CountryIso.ANGOLA]: '024',
[CountryIso.ANGUILLA]: '660',
[CountryIso.ALAND_ISLANDS]: '248',
[CountryIso.ALBANIA]: '008',
[CountryIso.ANDORRA]: '020',
[CountryIso.UNITED_ARAB_EMIRATES]: '784',
[CountryIso.ARGENTINA]: '032',
[CountryIso.ARMENIA]: '051',
[CountryIso.AMERICAN_SAMOA]: '016',
[CountryIso.ANTARCTICA]: '010',
[CountryIso.FRENCH_SOUTHERN_TERRITORIES]: '260',
[CountryIso.ANTIGUA_AND_BARBUDA]: '028',
[CountryIso.AUSTRALIA]: '036',
[CountryIso.AUSTRIA]: '040',
[CountryIso.AZERBAIJAN]: '031',
[CountryIso.BURUNDI]: '108',
[CountryIso.BELGIUM]: '056',
[CountryIso.BENIN]: '204',
[CountryIso.BONAIRE_SINT_EUSTATIUS_AND_SABA]: '535',
[CountryIso.BURKINA_FASO]: '854',
[CountryIso.BANGLADESH]: '050',
[CountryIso.BULGARIA]: '100',
[CountryIso.BAHRAIN]: '048',
[CountryIso.BAHAMAS]: '044',
[CountryIso.BOSNIA_AND_HERZEGOVINA]: '070',
[CountryIso.SAINT_BARTHÉLEMY]: '652',
[CountryIso.BELARUS]: '112',
[CountryIso.BELIZE]: '084',
[CountryIso.BERMUDA]: '060',
[CountryIso.BOLIVIA_PLURINATIONAL_STATE_OF]: '068',
[CountryIso.BRAZIL]: '076',
[CountryIso.BARBADOS]: '052',
[CountryIso.BRUNEI_DARUSSALAM]: '096',
[CountryIso.BHUTAN]: '064',
[CountryIso.BOUVET_ISLAND]: '074',
[CountryIso.BOTSWANA]: '072',
[CountryIso.CENTRAL_AFRICAN_REPUBLIC]: '140',
[CountryIso.CANADA]: '124',
[CountryIso.COCOS_KEELING_ISLANDS]: '166',
[CountryIso.SWITZERLAND]: '756',
[CountryIso.CHILE]: '152',
[CountryIso.CHINA]: '156',
[CountryIso.COTE_DIVOIRE]: '384',
[CountryIso.CAMEROON]: '120',
[CountryIso.CONGO_THE_DEMOCRATIC_REPUBLIC_OF_THE]: '180',
[CountryIso.CONGO]: '178',
[CountryIso.COOK_ISLANDS]: '184',
[CountryIso.COLOMBIA]: '170',
[CountryIso.COMOROS]: '174',
[CountryIso.CABO_VERDE]: '132',
[CountryIso.COSTA_RICA]: '188',
[CountryIso.CUBA]: '192',
[CountryIso.CURAÇAO]: '531',
[CountryIso.CHRISTMAS_ISLAND]: '162',
[CountryIso.CAYMAN_ISLANDS]: '136',
[CountryIso.CYPRUS]: '196',
[CountryIso.CZECHIA]: '203',
[CountryIso.GERMANY]: '276',
[CountryIso.DJIBOUTI]: '262',
[CountryIso.DOMINICA]: '212',
[CountryIso.DENMARK]: '208',
[CountryIso.DOMINICAN_REPUBLIC]: '214',
[CountryIso.ALGERIA]: '012',
[CountryIso.ECUADOR]: '218',
[CountryIso.EGYPT]: '818',
[CountryIso.ERITREA]: '232',
[CountryIso.WESTERN_SAHARA]: '732',
[CountryIso.SPAIN]: '724',
[CountryIso.ESTONIA]: '233',
[CountryIso.ETHIOPIA]: '231',
[CountryIso.FINLAND]: '246',
[CountryIso.FIJI]: '242',
[CountryIso.FALKLAND_ISLANDS_MALVINAS]: '238',
[CountryIso.FRANCE]: '250',
[CountryIso.FAROE_ISLANDS]: '234',
[CountryIso.MICRONESIA_FEDERATED_STATES_OF]: '583',
[CountryIso.GABON]: '266',
[CountryIso.UNITED_KINGDOM]: '826',
[CountryIso.GEORGIA]: '268',
[CountryIso.GUERNSEY]: '831',
[CountryIso.GHANA]: '288',
[CountryIso.GIBRALTAR]: '292',
[CountryIso.GUINEA]: '324',
[CountryIso.GUADELOUPE]: '312',
[CountryIso.GAMBIA]: '270',
[CountryIso.GUINEA_BISSAU]: '624',
[CountryIso.EQUATORIAL_GUINEA]: '226',
[CountryIso.GREECE]: '300',
[CountryIso.GRENADA]: '308',
[CountryIso.GREENLAND]: '304',
[CountryIso.GUATEMALA]: '320',
[CountryIso.FRENCH_GUIANA]: '254',
[CountryIso.GUAM]: '316',
[CountryIso.GUYANA]: '328',
[CountryIso.HONG_KONG]: '344',
[CountryIso.HEARD_ISLAND_AND_MCDONALD_ISLANDS]: '334',
[CountryIso.HONDURAS]: '340',
[CountryIso.CROATIA]: '191',
[CountryIso.HAITI]: '332',
[CountryIso.HUNGARY]: '348',
[CountryIso.INDONESIA]: '360',
[CountryIso.ISLE_OF_MAN]: '833',
[CountryIso.INDIA]: '356',
[CountryIso.BRITISH_INDIAN_OCEAN_TERRITORY]: '086',
[CountryIso.IRELAND]: '372',
[CountryIso.IRAN_ISLAMIC_REPUBLIC_OF]: '364',
[CountryIso.IRAQ]: '368',
[CountryIso.ICELAND]: '352',
[CountryIso.ISRAEL]: '376',
[CountryIso.ITALY]: '380',
[CountryIso.JAMAICA]: '388',
[CountryIso.JERSEY]: '832',
[CountryIso.JORDAN]: '400',
[CountryIso.JAPAN]: '392',
[CountryIso.KAZAKHSTAN]: '398',
[CountryIso.KENYA]: '404',
[CountryIso.KYRGYZSTAN]: '417',
[CountryIso.CAMBODIA]: '116',
[CountryIso.KIRIBATI]: '296',
[CountryIso.SAINT_KITTS_AND_NEVIS]: '659',
[CountryIso.KOREA_REPUBLIC_OF]: '410',
[CountryIso.KUWAIT]: '414',
[CountryIso.LAO_PEOPLES_DEMOCRATIC_REPUBLIC]: '418',
[CountryIso.LEBANON]: '422',
[CountryIso.LIBERIA]: '430',
[CountryIso.LIBYA]: '434',
[CountryIso.SAINT_LUCIA]: '662',
[CountryIso.LIECHTENSTEIN]: '438',
[CountryIso.SRI_LANKA]: '144',
[CountryIso.LESOTHO]: '426',
[CountryIso.LITHUANIA]: '440',
[CountryIso.LUXEMBOURG]: '442',
[CountryIso.LATVIA]: '428',
[CountryIso.MACAO]: '446',
[CountryIso.SAINT_MARTIN_FRENCH_PART]: '663',
[CountryIso.MOROCCO]: '504',
[CountryIso.MONACO]: '492',
[CountryIso.MOLDOVA_REPUBLIC_OF]: '498',
[CountryIso.MADAGASCAR]: '450',
[CountryIso.MALDIVES]: '462',
[CountryIso.MEXICO]: '484',
[CountryIso.MARSHALL_ISLANDS]: '584',
[CountryIso.NORTH_MACEDONIA]: '807',
[CountryIso.MALI]: '466',
[CountryIso.MALTA]: '470',
[CountryIso.MYANMAR]: '104',
[CountryIso.MONTENEGRO]: '499',
[CountryIso.MONGOLIA]: '496',
[CountryIso.NORTHERN_MARIANA_ISLANDS]: '580',
[CountryIso.MOZAMBIQUE]: '508',
[CountryIso.MAURITANIA]: '478',
[CountryIso.MONTSERRAT]: '500',
[CountryIso.MARTINIQUE]: '474',
[CountryIso.MAURITIUS]: '480',
[CountryIso.MALAWI]: '454',
[CountryIso.MALAYSIA]: '458',
[CountryIso.MAYOTTE]: '175',
[CountryIso.NAMIBIA]: '516',
[CountryIso.NEW_CALEDONIA]: '540',
[CountryIso.NIGER]: '562',
[CountryIso.NORFOLK_ISLAND]: '574',
[CountryIso.NIGERIA]: '566',
[CountryIso.NICARAGUA]: '558',
[CountryIso.NIUE]: '570',
[CountryIso.NETHERLANDS]: '528',
[CountryIso.NORWAY]: '578',
[CountryIso.NEPAL]: '524',
[CountryIso.NAURU]: '520',
[CountryIso.NEW_ZEALAND]: '554',
[CountryIso.OMAN]: '512',
[CountryIso.PAKISTAN]: '586',
[CountryIso.PANAMA]: '591',
[CountryIso.PITCAIRN]: '612',
[CountryIso.PERU]: '604',
[CountryIso.PHILIPPINES]: '608',
[CountryIso.PALAU]: '585',
[CountryIso.PAPUA_NEW_GUINEA]: '598',
[CountryIso.POLAND]: '616',
[CountryIso.PUERTO_RICO]: '630',
[CountryIso.KOREA_DEMOCRATIC_PEOPLES_REPUBLIC_OF]: '408',
[CountryIso.PORTUGAL]: '620',
[CountryIso.PARAGUAY]: '600',
[CountryIso.PALESTINE_STATE_OF]: '275',
[CountryIso.FRENCH_POLYNESIA]: '258',
[CountryIso.QATAR]: '634',
[CountryIso.REUNION]: '638',
[CountryIso.ROMANIA]: '642',
[CountryIso.RUSSIAN_FEDERATION]: '643',
[CountryIso.RWANDA]: '646',
[CountryIso.SAUDI_ARABIA]: '682',
[CountryIso.SUDAN]: '729',
[CountryIso.SENEGAL]: '686',
[CountryIso.SINGAPORE]: '702',
[CountryIso.SOUTH_GEORGIA_AND_THE_SOUTH_SANDWICH_ISLANDS]: '239',
[CountryIso.SAINT_HELENA_ASCENSION_AND_TRISTAN_DA_CUNHA]: '654',
[CountryIso.SVALBARD_AND_JAN_MAYEN]: '744',
[CountryIso.SOLOMON_ISLANDS]: '090',
[CountryIso.SIERRA_LEONE]: '694',
[CountryIso.EL_SALVADOR]: '222',
[CountryIso.SAN_MARINO]: '674',
[CountryIso.SOMALIA]: '706',
[CountryIso.SAINT_PIERRE_AND_MIQUELON]: '666',
[CountryIso.SERBIA]: '688',
[CountryIso.SOUTH_SUDAN]: '728',
[CountryIso.SAO_TOME_AND_PRINCIPE]: '678',
[CountryIso.SURINAME]: '740',
[CountryIso.SLOVAKIA]: '703',
[CountryIso.SLOVENIA]: '705',
[CountryIso.SWEDEN]: '752',
[CountryIso.ESWATINI]: '748',
[CountryIso.SINT_MAARTEN_DUTCH_PART]: '534',
[CountryIso.SEYCHELLES]: '690',
[CountryIso.SYRIAN_ARAB_REPUBLIC]: '760',
[CountryIso.TURKS_AND_CAICOS_ISLANDS]: '796',
[CountryIso.CHAD]: '148',
[CountryIso.TOGO]: '768',
[CountryIso.THAILAND]: '764',
[CountryIso.TAJIKISTAN]: '762',
[CountryIso.TOKELAU]: '772',
[CountryIso.TURKMENISTAN]: '795',
[CountryIso.TIMOR_LESTE]: '626',
[CountryIso.TONGA]: '776',
[CountryIso.TRINIDAD_AND_TOBAGO]: '780',
[CountryIso.TUNISIA]: '788',
[CountryIso.TURKEY]: '792',
[CountryIso.TUVALU]: '798',
[CountryIso.TAIWAN_PROVINCE_OF_CHINA]: '158',
[CountryIso.TANZANIA_UNITED_REPUBLIC_OF]: '834',
[CountryIso.UGANDA]: '800',
[CountryIso.UKRAINE]: '804',
[CountryIso.UNITED_STATES_MINOR_OUTLYING_ISLANDS]: '581',
[CountryIso.URUGUAY]: '858',
[CountryIso.UNITED_STATES]: '840',
[CountryIso.UZBEKISTAN]: '860',
[CountryIso.HOLY_SEE_VATICAN_CITY_STATE]: '336',
[CountryIso.SAINT_VINCENT_AND_THE_GRENADINES]: '670',
[CountryIso.VENEZUELA_BOLIVARIAN_REPUBLIC_OF]: '862',
[CountryIso.VIRGIN_ISLANDS_BRITISH]: '092',
[CountryIso.VIRGIN_ISLANDS_US]: '850',
[CountryIso.VIET_NAM]: '704',
[CountryIso.VANUATU]: '548',
[CountryIso.WALLIS_AND_FUTUNA]: '876',
[CountryIso.SAMOA]: '882',
[CountryIso.YEMEN]: '887',
[CountryIso.SOUTH_AFRICA]: '710',
[CountryIso.ZAMBIA]: '894',
[CountryIso.ZIMBABWE]: '716',
};

View File

@ -1 +1,2 @@
export * from './countries-numeric-iso.constant';
export * from './global.constant';

View File

@ -0,0 +1,251 @@
export enum CountryIso {
ARUBA = 'AW',
AFGHANISTAN = 'AF',
ANGOLA = 'AO',
ANGUILLA = 'AI',
ALAND_ISLANDS = 'AX',
ALBANIA = 'AL',
ANDORRA = 'AD',
UNITED_ARAB_EMIRATES = 'AE',
ARGENTINA = 'AR',
ARMENIA = 'AM',
AMERICAN_SAMOA = 'AS',
ANTARCTICA = 'AQ',
FRENCH_SOUTHERN_TERRITORIES = 'TF',
ANTIGUA_AND_BARBUDA = 'AG',
AUSTRALIA = 'AU',
AUSTRIA = 'AT',
AZERBAIJAN = 'AZ',
BURUNDI = 'BI',
BELGIUM = 'BE',
BENIN = 'BJ',
BONAIRE_SINT_EUSTATIUS_AND_SABA = 'BQ',
BURKINA_FASO = 'BF',
BANGLADESH = 'BD',
BULGARIA = 'BG',
BAHRAIN = 'BH',
BAHAMAS = 'BS',
BOSNIA_AND_HERZEGOVINA = 'BA',
SAINT_BARTHÉLEMY = 'BL',
BELARUS = 'BY',
BELIZE = 'BZ',
BERMUDA = 'BM',
BOLIVIA_PLURINATIONAL_STATE_OF = 'BO',
BRAZIL = 'BR',
BARBADOS = 'BB',
BRUNEI_DARUSSALAM = 'BN',
BHUTAN = 'BT',
BOUVET_ISLAND = 'BV',
BOTSWANA = 'BW',
CENTRAL_AFRICAN_REPUBLIC = 'CF',
CANADA = 'CA',
COCOS_KEELING_ISLANDS = 'CC',
SWITZERLAND = 'CH',
CHILE = 'CL',
CHINA = 'CN',
COTE_DIVOIRE = 'CI',
CAMEROON = 'CM',
CONGO_THE_DEMOCRATIC_REPUBLIC_OF_THE = 'CD',
CONGO = 'CG',
COOK_ISLANDS = 'CK',
COLOMBIA = 'CO',
COMOROS = 'KM',
CABO_VERDE = 'CV',
COSTA_RICA = 'CR',
CUBA = 'CU',
CURAÇAO = 'CW',
CHRISTMAS_ISLAND = 'CX',
CAYMAN_ISLANDS = 'KY',
CYPRUS = 'CY',
CZECHIA = 'CZ',
GERMANY = 'DE',
DJIBOUTI = 'DJ',
DOMINICA = 'DM',
DENMARK = 'DK',
DOMINICAN_REPUBLIC = 'DO',
ALGERIA = 'DZ',
ECUADOR = 'EC',
EGYPT = 'EG',
ERITREA = 'ER',
WESTERN_SAHARA = 'EH',
SPAIN = 'ES',
ESTONIA = 'EE',
ETHIOPIA = 'ET',
FINLAND = 'FI',
FIJI = 'FJ',
FALKLAND_ISLANDS_MALVINAS = 'FK',
FRANCE = 'FR',
FAROE_ISLANDS = 'FO',
MICRONESIA_FEDERATED_STATES_OF = 'FM',
GABON = 'GA',
UNITED_KINGDOM = 'GB',
GEORGIA = 'GE',
GUERNSEY = 'GG',
GHANA = 'GH',
GIBRALTAR = 'GI',
GUINEA = 'GN',
GUADELOUPE = 'GP',
GAMBIA = 'GM',
GUINEA_BISSAU = 'GW',
EQUATORIAL_GUINEA = 'GQ',
GREECE = 'GR',
GRENADA = 'GD',
GREENLAND = 'GL',
GUATEMALA = 'GT',
FRENCH_GUIANA = 'GF',
GUAM = 'GU',
GUYANA = 'GY',
HONG_KONG = 'HK',
HEARD_ISLAND_AND_MCDONALD_ISLANDS = 'HM',
HONDURAS = 'HN',
CROATIA = 'HR',
HAITI = 'HT',
HUNGARY = 'HU',
INDONESIA = 'ID',
ISLE_OF_MAN = 'IM',
INDIA = 'IN',
BRITISH_INDIAN_OCEAN_TERRITORY = 'IO',
IRELAND = 'IE',
IRAN_ISLAMIC_REPUBLIC_OF = 'IR',
IRAQ = 'IQ',
ICELAND = 'IS',
ISRAEL = 'IL',
ITALY = 'IT',
JAMAICA = 'JM',
JERSEY = 'JE',
JORDAN = 'JO',
JAPAN = 'JP',
KAZAKHSTAN = 'KZ',
KENYA = 'KE',
KYRGYZSTAN = 'KG',
CAMBODIA = 'KH',
KIRIBATI = 'KI',
SAINT_KITTS_AND_NEVIS = 'KN',
KOREA_REPUBLIC_OF = 'KR',
KUWAIT = 'KW',
LAO_PEOPLES_DEMOCRATIC_REPUBLIC = 'LA',
LEBANON = 'LB',
LIBERIA = 'LR',
LIBYA = 'LY',
SAINT_LUCIA = 'LC',
LIECHTENSTEIN = 'LI',
SRI_LANKA = 'LK',
LESOTHO = 'LS',
LITHUANIA = 'LT',
LUXEMBOURG = 'LU',
LATVIA = 'LV',
MACAO = 'MO',
SAINT_MARTIN_FRENCH_PART = 'MF',
MOROCCO = 'MA',
MONACO = 'MC',
MOLDOVA_REPUBLIC_OF = 'MD',
MADAGASCAR = 'MG',
MALDIVES = 'MV',
MEXICO = 'MX',
MARSHALL_ISLANDS = 'MH',
NORTH_MACEDONIA = 'MK',
MALI = 'ML',
MALTA = 'MT',
MYANMAR = 'MM',
MONTENEGRO = 'ME',
MONGOLIA = 'MN',
NORTHERN_MARIANA_ISLANDS = 'MP',
MOZAMBIQUE = 'MZ',
MAURITANIA = 'MR',
MONTSERRAT = 'MS',
MARTINIQUE = 'MQ',
MAURITIUS = 'MU',
MALAWI = 'MW',
MALAYSIA = 'MY',
MAYOTTE = 'YT',
NAMIBIA = 'NA',
NEW_CALEDONIA = 'NC',
NIGER = 'NE',
NORFOLK_ISLAND = 'NF',
NIGERIA = 'NG',
NICARAGUA = 'NI',
NIUE = 'NU',
NETHERLANDS = 'NL',
NORWAY = 'NO',
NEPAL = 'NP',
NAURU = 'NR',
NEW_ZEALAND = 'NZ',
OMAN = 'OM',
PAKISTAN = 'PK',
PANAMA = 'PA',
PITCAIRN = 'PN',
PERU = 'PE',
PHILIPPINES = 'PH',
PALAU = 'PW',
PAPUA_NEW_GUINEA = 'PG',
POLAND = 'PL',
PUERTO_RICO = 'PR',
KOREA_DEMOCRATIC_PEOPLES_REPUBLIC_OF = 'KP',
PORTUGAL = 'PT',
PARAGUAY = 'PY',
PALESTINE_STATE_OF = 'PS',
FRENCH_POLYNESIA = 'PF',
QATAR = 'QA',
REUNION = 'RE',
ROMANIA = 'RO',
RUSSIAN_FEDERATION = 'RU',
RWANDA = 'RW',
SAUDI_ARABIA = 'SA',
SUDAN = 'SD',
SENEGAL = 'SN',
SINGAPORE = 'SG',
SOUTH_GEORGIA_AND_THE_SOUTH_SANDWICH_ISLANDS = 'GS',
SAINT_HELENA_ASCENSION_AND_TRISTAN_DA_CUNHA = 'SH',
SVALBARD_AND_JAN_MAYEN = 'SJ',
SOLOMON_ISLANDS = 'SB',
SIERRA_LEONE = 'SL',
EL_SALVADOR = 'SV',
SAN_MARINO = 'SM',
SOMALIA = 'SO',
SAINT_PIERRE_AND_MIQUELON = 'PM',
SERBIA = 'RS',
SOUTH_SUDAN = 'SS',
SAO_TOME_AND_PRINCIPE = 'ST',
SURINAME = 'SR',
SLOVAKIA = 'SK',
SLOVENIA = 'SI',
SWEDEN = 'SE',
ESWATINI = 'SZ',
SINT_MAARTEN_DUTCH_PART = 'SX',
SEYCHELLES = 'SC',
SYRIAN_ARAB_REPUBLIC = 'SY',
TURKS_AND_CAICOS_ISLANDS = 'TC',
CHAD = 'TD',
TOGO = 'TG',
THAILAND = 'TH',
TAJIKISTAN = 'TJ',
TOKELAU = 'TK',
TURKMENISTAN = 'TM',
TIMOR_LESTE = 'TL',
TONGA = 'TO',
TRINIDAD_AND_TOBAGO = 'TT',
TUNISIA = 'TN',
TURKEY = 'TR',
TUVALU = 'TV',
TAIWAN_PROVINCE_OF_CHINA = 'TW',
TANZANIA_UNITED_REPUBLIC_OF = 'TZ',
UGANDA = 'UG',
UKRAINE = 'UA',
UNITED_STATES_MINOR_OUTLYING_ISLANDS = 'UM',
URUGUAY = 'UY',
UNITED_STATES = 'US',
UZBEKISTAN = 'UZ',
HOLY_SEE_VATICAN_CITY_STATE = 'VA',
SAINT_VINCENT_AND_THE_GRENADINES = 'VC',
VENEZUELA_BOLIVARIAN_REPUBLIC_OF = 'VE',
VIRGIN_ISLANDS_BRITISH = 'VG',
VIRGIN_ISLANDS_US = 'VI',
VIET_NAM = 'VN',
VANUATU = 'VU',
WALLIS_AND_FUTUNA = 'WF',
SAMOA = 'WS',
YEMEN = 'YE',
SOUTH_AFRICA = 'ZA',
ZAMBIA = 'ZM',
ZIMBABWE = 'ZW',
}

View File

@ -0,0 +1 @@
export * from './countries-iso.enum';

View File

@ -0,0 +1,750 @@
export const CREATE_APPLICATION_MOCK = {
ResponseHeader: {
Version: '1.0.0',
MsgUid: 'adaa1893-9f95-48a8-b7a1-0422bcf629b5',
Source: 'ZOD',
ServiceId: 'CreateNewApplication',
ReqDateTime: '2025-06-03T07:32:16.304Z',
RspDateTime: '2025-06-03T08:21:15.662',
ResponseCode: '000',
ResponseType: 'Success',
ProcessingTime: 1665,
EncryptionKey: null,
ResponseDescription: 'Operation Successful',
LocalizedResponseDescription: null,
CustomerSpecificResponseDescriptionList: null,
HeaderUserDataList: null,
},
CreateNewApplicationResponseDetails: {
InstitutionCode: '1100',
ApplicationTypeDetails: {
TypeCode: '01',
Description: 'Normal Primary',
Additional: false,
Corporate: false,
UserData: null,
},
ApplicationDetails: {
cif: null,
ApplicationNumber: '3300000000073',
ExternalApplicationNumber: '3',
ApplicationStatus: '04',
Organization: 0,
Product: '1101',
ApplicatonDate: '2025-05-29',
ApplicationSource: 'O',
SalesSource: null,
DeliveryMethod: 'V',
ProgramCode: null,
Campaign: null,
Plastic: null,
Design: null,
ProcessStage: '99',
ProcessStageStatus: 'S',
Score: null,
ExternalScore: null,
RequestedLimit: 0,
SuggestedLimit: null,
AssignedLimit: 0,
AllowedLimitList: null,
EligibilityCheckResult: '00',
EligibilityCheckDescription: null,
Title: 'Mr.',
FirstName: 'Abdalhamid',
SecondName: null,
ThirdName: null,
LastName: ' Ahmad',
FullName: 'Abdalhamid Ahmad',
EmbossName: 'ABDALHAMID AHMAD',
PlaceOfBirth: null,
DateOfBirth: '1999-01-07',
LocalizedDateOfBirth: '1999-01-07',
Age: 26,
Gender: 'M',
Married: 'U',
Nationality: '682',
IdType: '01',
IdNumber: '1089055972',
IdExpiryDate: '2031-09-17',
EducationLevel: null,
ProfessionCode: 0,
NumberOfDependents: 0,
EmployerName: 'N/A',
EmploymentYears: 0,
EmploymentMonths: 0,
EmployerPhoneArea: null,
EmployerPhoneNumber: null,
EmployerPhoneExtension: null,
EmployerMobile: null,
EmployerFaxArea: null,
EmployerFax: null,
EmployerCity: null,
EmployerAddress: null,
EmploymentActivity: null,
EmploymentStatus: null,
CIF: null,
BankAccountNumber: ' ',
Currency: {
CurrCode: '682',
AlphaCode: 'SAR',
},
RequestedCurrencyList: null,
CreditAccountNumber: '6000000000000000',
AccountType: '30',
OpenDate: null,
Income: 0,
AdditionalIncome: 0,
TotalIncome: 0,
CurrentBalance: 0,
AverageBalance: 0,
AssetsBalance: 0,
InsuranceBalance: 0,
DepositAmount: 0,
GuarenteeAccountNumber: null,
GuarenteeAmount: 0,
InstalmentAmount: 0,
AutoDebit: 'N',
PaymentMethod: '2',
BillingCycle: 'C1',
OldIssueDate: null,
OtherPaymentsDate: null,
MaximumDelinquency: null,
CreditBureauDecision: null,
CreditBureauUserData: null,
ECommerce: 'N',
NumberOfCards: 0,
OtherBank: null,
OtherBankDescription: null,
InsuranceProduct: null,
SocialCode: '000',
JobGrade: 0,
Flags: [
{
Position: 1,
Value: '0',
},
{
Position: 2,
Value: '0',
},
{
Position: 3,
Value: '0',
},
{
Position: 4,
Value: '0',
},
{
Position: 5,
Value: '0',
},
{
Position: 6,
Value: '0',
},
{
Position: 7,
Value: '0',
},
{
Position: 8,
Value: '0',
},
{
Position: 9,
Value: '0',
},
{
Position: 10,
Value: '0',
},
{
Position: 11,
Value: '0',
},
{
Position: 12,
Value: '0',
},
{
Position: 13,
Value: '0',
},
{
Position: 14,
Value: '0',
},
{
Position: 15,
Value: '0',
},
{
Position: 16,
Value: '0',
},
{
Position: 17,
Value: '0',
},
{
Position: 18,
Value: '0',
},
{
Position: 19,
Value: '0',
},
{
Position: 20,
Value: '0',
},
{
Position: 21,
Value: '0',
},
{
Position: 22,
Value: '0',
},
{
Position: 23,
Value: '0',
},
{
Position: 24,
Value: '0',
},
{
Position: 25,
Value: '0',
},
{
Position: 26,
Value: '0',
},
{
Position: 27,
Value: '0',
},
{
Position: 28,
Value: '0',
},
{
Position: 29,
Value: '0',
},
{
Position: 30,
Value: '0',
},
{
Position: 31,
Value: '0',
},
{
Position: 32,
Value: '0',
},
{
Position: 33,
Value: '0',
},
{
Position: 34,
Value: '0',
},
{
Position: 35,
Value: '0',
},
{
Position: 36,
Value: '0',
},
{
Position: 37,
Value: '0',
},
{
Position: 38,
Value: '0',
},
{
Position: 39,
Value: '0',
},
{
Position: 40,
Value: '0',
},
{
Position: 41,
Value: '0',
},
{
Position: 42,
Value: '0',
},
{
Position: 43,
Value: '0',
},
{
Position: 44,
Value: '0',
},
{
Position: 45,
Value: '0',
},
{
Position: 46,
Value: '0',
},
{
Position: 47,
Value: '0',
},
{
Position: 48,
Value: '0',
},
{
Position: 49,
Value: '0',
},
{
Position: 50,
Value: '0',
},
{
Position: 51,
Value: '0',
},
{
Position: 52,
Value: '0',
},
{
Position: 53,
Value: '0',
},
{
Position: 54,
Value: '0',
},
{
Position: 55,
Value: '0',
},
{
Position: 56,
Value: '0',
},
{
Position: 57,
Value: '0',
},
{
Position: 58,
Value: '0',
},
{
Position: 59,
Value: '0',
},
{
Position: 60,
Value: '0',
},
{
Position: 61,
Value: '0',
},
{
Position: 62,
Value: '0',
},
{
Position: 63,
Value: '0',
},
{
Position: 64,
Value: '0',
},
],
CheckFlags: [
{
Position: 1,
Value: '0',
},
{
Position: 2,
Value: '0',
},
{
Position: 3,
Value: '0',
},
{
Position: 4,
Value: '0',
},
{
Position: 5,
Value: '0',
},
{
Position: 6,
Value: '0',
},
{
Position: 7,
Value: '0',
},
{
Position: 8,
Value: '0',
},
{
Position: 9,
Value: '0',
},
{
Position: 10,
Value: '0',
},
{
Position: 11,
Value: '0',
},
{
Position: 12,
Value: '0',
},
{
Position: 13,
Value: '0',
},
{
Position: 14,
Value: '0',
},
{
Position: 15,
Value: '0',
},
{
Position: 16,
Value: '0',
},
{
Position: 17,
Value: '0',
},
{
Position: 18,
Value: '0',
},
{
Position: 19,
Value: '0',
},
{
Position: 20,
Value: '0',
},
{
Position: 21,
Value: '0',
},
{
Position: 22,
Value: '0',
},
{
Position: 23,
Value: '0',
},
{
Position: 24,
Value: '0',
},
{
Position: 25,
Value: '0',
},
{
Position: 26,
Value: '0',
},
{
Position: 27,
Value: '0',
},
{
Position: 28,
Value: '0',
},
{
Position: 29,
Value: '0',
},
{
Position: 30,
Value: '0',
},
{
Position: 31,
Value: '0',
},
{
Position: 32,
Value: '0',
},
{
Position: 33,
Value: '0',
},
{
Position: 34,
Value: '0',
},
{
Position: 35,
Value: '0',
},
{
Position: 36,
Value: '0',
},
{
Position: 37,
Value: '0',
},
{
Position: 38,
Value: '0',
},
{
Position: 39,
Value: '0',
},
{
Position: 40,
Value: '0',
},
{
Position: 41,
Value: '0',
},
{
Position: 42,
Value: '0',
},
{
Position: 43,
Value: '0',
},
{
Position: 44,
Value: '0',
},
{
Position: 45,
Value: '0',
},
{
Position: 46,
Value: '0',
},
{
Position: 47,
Value: '0',
},
{
Position: 48,
Value: '0',
},
{
Position: 49,
Value: '0',
},
{
Position: 50,
Value: '0',
},
{
Position: 51,
Value: '0',
},
{
Position: 52,
Value: '0',
},
{
Position: 53,
Value: '0',
},
{
Position: 54,
Value: '0',
},
{
Position: 55,
Value: '0',
},
{
Position: 56,
Value: '0',
},
{
Position: 57,
Value: '0',
},
{
Position: 58,
Value: '0',
},
{
Position: 59,
Value: '0',
},
{
Position: 60,
Value: '0',
},
{
Position: 61,
Value: '0',
},
{
Position: 62,
Value: '0',
},
{
Position: 63,
Value: '0',
},
{
Position: 64,
Value: '0',
},
],
Maker: null,
Checker: null,
ReferredTo: null,
ReferralReason: null,
UserData1: null,
UserData2: null,
UserData3: null,
UserData4: null,
UserData5: null,
AdditionalFields: [],
},
ApplicationStatusDetails: {
StatusCode: '04',
Description: 'Approved',
Canceled: false,
},
CorporateDetails: null,
CustomerDetails: {
Id: 115158,
CustomerCode: '100000024619',
IdNumber: ' ',
TypeId: 0,
PreferredLanguage: 'EN',
ExternalCustomerCode: null,
Title: ' ',
FirstName: ' ',
LastName: ' ',
DateOfBirth: null,
UserData1: '2031-09-17',
UserData2: '01',
UserData3: null,
UserData4: '682',
CustomerSegment: null,
Gender: 'U',
Married: 'U',
},
AccountDetailsList: [
{
Id: 21017,
InstitutionCode: '1100',
AccountNumber: '6899999999999999',
Currency: {
CurrCode: '682',
AlphaCode: 'SAR',
},
AccountTypeCode: '30',
ClassId: '2',
AccountStatus: '00',
VipFlag: '0',
BlockedAmount: 0,
EquivalentBlockedAmount: null,
UnclearCredit: 0,
EquivalentUnclearCredit: null,
AvailableBalance: 0,
EquivalentAvailableBalance: null,
AvailableBalanceToSpend: 0,
CreditLimit: 0,
RemainingCashLimit: null,
UserData1: 'D36407C9AE4C28D2185',
UserData2: null,
UserData3: 'D36407C9AE4C28D2185',
UserData4: null,
UserData5: 'SA2380900000752991120011',
},
],
CardDetailsList: [
{
pvv: null,
ResponseCardIdentifier: {
Id: 28595,
Pan: 'DDDDDDDDDDDDDDDDDDD',
MaskedPan: '999999_9999',
VPan: '1100000000000000',
Seqno: 0,
},
ExpiryDate: '2031-09-30',
EffectiveDate: '2025-06-02',
CardStatus: '30',
OldPlasticExpiryDate: null,
OldPlasticCardStatus: null,
EmbossingName: 'ABDALHAMID AHMAD',
Title: 'Mr.',
FirstName: 'Abdalhamid',
LastName: ' Ahmad',
Additional: false,
BatchNumber: 8849,
ServiceCode: '226',
Kinship: null,
DateOfBirth: '1999-01-07',
LastActivity: null,
LastStatusChangeDate: '2025-06-03',
ActivationDate: null,
DateLastIssued: null,
PVV: null,
UserData: '4',
UserData1: '3',
UserData2: null,
UserData3: null,
UserData4: null,
UserData5: null,
Memo: null,
CardAuthorizationParameters: null,
L10NTitle: null,
L10NFirstName: null,
L10NLastName: null,
PinStatus: '40',
OldPinStatus: '0',
CustomerIdNumber: '1089055972',
Language: 0,
},
],
},
};

View File

@ -0,0 +1,2 @@
export * from './create-application.mock';
export * from './inquire-application.mock';

View File

@ -0,0 +1,728 @@
export const INQUIRE_APPLICATION_MOCK = {
ResponseHeader: {
Version: '1.0.0',
MsgUid: 'adaa1893-9f95-48a8-b7a1-0422bcf629b4',
Source: 'ZOD',
ServiceId: 'InquireApplication',
ReqDateTime: '2023-07-18T10:34:12.553Z',
RspDateTime: '2025-06-03T11:14:54.748',
ResponseCode: '000',
ResponseType: 'Success',
ProcessingTime: 476,
EncryptionKey: null,
ResponseDescription: 'Operation Successful',
LocalizedResponseDescription: null,
CustomerSpecificResponseDescriptionList: null,
HeaderUserDataList: null,
},
InquireApplicationResponseDetails: {
InstitutionCode: '1100',
ApplicationTypeDetails: {
TypeCode: '01',
Description: 'Normal Primary',
Additional: false,
Corporate: false,
UserData: null,
},
ApplicationDetails: {
cif: null,
ApplicationNumber: '3300000000070',
ExternalApplicationNumber: '10000002',
ApplicationStatus: '04',
Organization: 0,
Product: '1101',
ApplicatonDate: '2025-05-29',
ApplicationSource: 'O',
SalesSource: null,
DeliveryMethod: 'V',
ProgramCode: null,
Campaign: null,
Plastic: null,
Design: null,
ProcessStage: '99',
ProcessStageStatus: 'S',
Score: null,
ExternalScore: null,
RequestedLimit: 0,
SuggestedLimit: null,
AssignedLimit: 0,
AllowedLimitList: null,
EligibilityCheckResult: '00',
EligibilityCheckDescription: null,
Title: 'Mr.',
FirstName: 'Abdalhamid',
SecondName: null,
ThirdName: null,
LastName: ' Ahmad',
FullName: 'Abdalhamid Ahmad',
EmbossName: 'ABDALHAMID AHMAD',
PlaceOfBirth: null,
DateOfBirth: '1999-01-07',
LocalizedDateOfBirth: '1999-01-07',
Age: 26,
Gender: 'M',
Married: 'U',
Nationality: '682',
IdType: '01',
IdNumber: '1089055972',
IdExpiryDate: '2031-09-17',
EducationLevel: null,
ProfessionCode: 0,
NumberOfDependents: 0,
EmployerName: 'N/A',
EmploymentYears: 0,
EmploymentMonths: 0,
EmployerPhoneArea: null,
EmployerPhoneNumber: null,
EmployerPhoneExtension: null,
EmployerMobile: null,
EmployerFaxArea: null,
EmployerFax: null,
EmployerCity: null,
EmployerAddress: null,
EmploymentActivity: null,
EmploymentStatus: null,
CIF: null,
BankAccountNumber: ' ',
Currency: {
CurrCode: '682',
AlphaCode: 'SAR',
},
RequestedCurrencyList: null,
CreditAccountNumber: '6823000000000019',
AccountType: '30',
OpenDate: null,
Income: 0,
AdditionalIncome: 0,
TotalIncome: 0,
CurrentBalance: 0,
AverageBalance: 0,
AssetsBalance: 0,
InsuranceBalance: 0,
DepositAmount: 0,
GuarenteeAccountNumber: null,
GuarenteeAmount: 0,
InstalmentAmount: 0,
AutoDebit: 'N',
PaymentMethod: '2',
BillingCycle: 'C1',
OldIssueDate: null,
OtherPaymentsDate: null,
MaximumDelinquency: null,
CreditBureauDecision: null,
CreditBureauUserData: null,
ECommerce: 'N',
NumberOfCards: 0,
OtherBank: null,
OtherBankDescription: null,
InsuranceProduct: null,
SocialCode: '000',
JobGrade: 0,
Flags: [
{
Position: 1,
Value: '0',
},
{
Position: 2,
Value: '0',
},
{
Position: 3,
Value: '0',
},
{
Position: 4,
Value: '0',
},
{
Position: 5,
Value: '0',
},
{
Position: 6,
Value: '0',
},
{
Position: 7,
Value: '0',
},
{
Position: 8,
Value: '0',
},
{
Position: 9,
Value: '0',
},
{
Position: 10,
Value: '0',
},
{
Position: 11,
Value: '0',
},
{
Position: 12,
Value: '0',
},
{
Position: 13,
Value: '0',
},
{
Position: 14,
Value: '0',
},
{
Position: 15,
Value: '0',
},
{
Position: 16,
Value: '0',
},
{
Position: 17,
Value: '0',
},
{
Position: 18,
Value: '0',
},
{
Position: 19,
Value: '0',
},
{
Position: 20,
Value: '0',
},
{
Position: 21,
Value: '0',
},
{
Position: 22,
Value: '0',
},
{
Position: 23,
Value: '0',
},
{
Position: 24,
Value: '0',
},
{
Position: 25,
Value: '0',
},
{
Position: 26,
Value: '0',
},
{
Position: 27,
Value: '0',
},
{
Position: 28,
Value: '0',
},
{
Position: 29,
Value: '0',
},
{
Position: 30,
Value: '0',
},
{
Position: 31,
Value: '0',
},
{
Position: 32,
Value: '0',
},
{
Position: 33,
Value: '0',
},
{
Position: 34,
Value: '0',
},
{
Position: 35,
Value: '0',
},
{
Position: 36,
Value: '0',
},
{
Position: 37,
Value: '0',
},
{
Position: 38,
Value: '0',
},
{
Position: 39,
Value: '0',
},
{
Position: 40,
Value: '0',
},
{
Position: 41,
Value: '0',
},
{
Position: 42,
Value: '0',
},
{
Position: 43,
Value: '0',
},
{
Position: 44,
Value: '0',
},
{
Position: 45,
Value: '0',
},
{
Position: 46,
Value: '0',
},
{
Position: 47,
Value: '0',
},
{
Position: 48,
Value: '0',
},
{
Position: 49,
Value: '0',
},
{
Position: 50,
Value: '0',
},
{
Position: 51,
Value: '0',
},
{
Position: 52,
Value: '0',
},
{
Position: 53,
Value: '0',
},
{
Position: 54,
Value: '0',
},
{
Position: 55,
Value: '0',
},
{
Position: 56,
Value: '0',
},
{
Position: 57,
Value: '0',
},
{
Position: 58,
Value: '0',
},
{
Position: 59,
Value: '0',
},
{
Position: 60,
Value: '0',
},
{
Position: 61,
Value: '0',
},
{
Position: 62,
Value: '0',
},
{
Position: 63,
Value: '0',
},
{
Position: 64,
Value: '0',
},
],
CheckFlags: [
{
Position: 1,
Value: '0',
},
{
Position: 2,
Value: '0',
},
{
Position: 3,
Value: '0',
},
{
Position: 4,
Value: '0',
},
{
Position: 5,
Value: '0',
},
{
Position: 6,
Value: '0',
},
{
Position: 7,
Value: '0',
},
{
Position: 8,
Value: '0',
},
{
Position: 9,
Value: '0',
},
{
Position: 10,
Value: '0',
},
{
Position: 11,
Value: '0',
},
{
Position: 12,
Value: '0',
},
{
Position: 13,
Value: '0',
},
{
Position: 14,
Value: '0',
},
{
Position: 15,
Value: '0',
},
{
Position: 16,
Value: '0',
},
{
Position: 17,
Value: '0',
},
{
Position: 18,
Value: '0',
},
{
Position: 19,
Value: '0',
},
{
Position: 20,
Value: '0',
},
{
Position: 21,
Value: '0',
},
{
Position: 22,
Value: '0',
},
{
Position: 23,
Value: '0',
},
{
Position: 24,
Value: '0',
},
{
Position: 25,
Value: '0',
},
{
Position: 26,
Value: '0',
},
{
Position: 27,
Value: '0',
},
{
Position: 28,
Value: '0',
},
{
Position: 29,
Value: '0',
},
{
Position: 30,
Value: '0',
},
{
Position: 31,
Value: '0',
},
{
Position: 32,
Value: '0',
},
{
Position: 33,
Value: '0',
},
{
Position: 34,
Value: '0',
},
{
Position: 35,
Value: '0',
},
{
Position: 36,
Value: '0',
},
{
Position: 37,
Value: '0',
},
{
Position: 38,
Value: '0',
},
{
Position: 39,
Value: '0',
},
{
Position: 40,
Value: '0',
},
{
Position: 41,
Value: '0',
},
{
Position: 42,
Value: '0',
},
{
Position: 43,
Value: '0',
},
{
Position: 44,
Value: '0',
},
{
Position: 45,
Value: '0',
},
{
Position: 46,
Value: '0',
},
{
Position: 47,
Value: '0',
},
{
Position: 48,
Value: '0',
},
{
Position: 49,
Value: '0',
},
{
Position: 50,
Value: '0',
},
{
Position: 51,
Value: '0',
},
{
Position: 52,
Value: '0',
},
{
Position: 53,
Value: '0',
},
{
Position: 54,
Value: '0',
},
{
Position: 55,
Value: '0',
},
{
Position: 56,
Value: '0',
},
{
Position: 57,
Value: '0',
},
{
Position: 58,
Value: '0',
},
{
Position: 59,
Value: '0',
},
{
Position: 60,
Value: '0',
},
{
Position: 61,
Value: '0',
},
{
Position: 62,
Value: '0',
},
{
Position: 63,
Value: '0',
},
{
Position: 64,
Value: '0',
},
],
Maker: null,
Checker: null,
ReferredTo: null,
ReferralReason: null,
UserData1: null,
UserData2: null,
UserData3: null,
UserData4: null,
UserData5: null,
AdditionalFields: [],
},
ApplicationStatusDetails: {
StatusCode: '04',
Description: 'Approved',
Canceled: false,
},
ApplicationHistoryList: null,
ApplicationAddressList: [
{
Id: 43859,
AddressLine1: '5536 abdullah Ibn al zubair ',
AddressLine2: ' Umm Alarad Dist.',
AddressLine3: null,
AddressLine4: null,
AddressLine5: null,
Directions: null,
City: 'AT TAIF',
PostalCode: null,
Province: null,
Territory: null,
State: null,
Region: null,
County: null,
Country: '682',
CountryDetails: {
IsoCode: '682',
Alpha3: 'SAU',
Alpha2: 'SA',
DefaultCurrency: {
CurrCode: '682',
AlphaCode: 'SAR',
},
Description: [
{
Language: 'EN',
Description: 'SAUDI ARABIA',
},
{
Language: 'GB',
Description: 'SAUDI ARABIA',
},
],
},
Phone1: '+966541884784',
Phone2: null,
Extension: null,
Email: 'a.ahmad@zod-alkhair.com',
Fax: null,
District: null,
PoBox: null,
OwnershipType: 'O',
UserData1: null,
UserData2: null,
AddressRole: 0,
AddressCustomValues: null,
},
],
CorporateDetails: null,
CustomerDetails: {
Id: 115129,
CustomerCode: '100000024552',
IdNumber: ' ',
TypeId: 0,
PreferredLanguage: 'EN',
ExternalCustomerCode: null,
Title: ' ',
FirstName: ' ',
LastName: ' ',
DateOfBirth: null,
UserData1: '2031-09-17',
UserData2: '01',
UserData3: null,
UserData4: '682',
CustomerSegment: null,
Gender: 'U',
Married: 'U',
},
BranchDetails: null,
CardAccountLinkageList: null,
},
};

View File

@ -0,0 +1,29 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
AccountCardStatusChangedWebhookRequest,
AccountTransactionWebhookRequest,
CardTransactionWebhookRequest,
} from '../dtos/requests';
import { NeoLeapWebhookService } from '../services';
@Controller('neoleap-webhooks')
@ApiTags('Neoleap Webhooks')
export class NeoLeapWebhooksController {
constructor(private readonly neoleapWebhookService: NeoLeapWebhookService) {}
@Post('card-transaction')
async handleCardTransactionWebhook(@Body() body: CardTransactionWebhookRequest) {
return this.neoleapWebhookService.handleCardTransactionWebhook(body);
}
@Post('account-transaction')
async handleAccountTransactionWebhook(@Body() body: AccountTransactionWebhookRequest) {
return this.neoleapWebhookService.handleAccountTransactionWebhook(body);
}
@Post('account-card-status-changed')
async handleAccountCardStatusChangedWebhook(@Body() body: AccountCardStatusChangedWebhookRequest) {
return this.neoleapWebhookService.handleAccountCardStatusChangedWebhook(body);
}
}

View File

@ -0,0 +1,62 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { IJwtPayload } from '~/auth/interfaces';
import { CardService } from '~/card/services';
import { AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards';
import { ApiDataResponse } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import { CustomerResponseDto } from '~/customer/dtos/response';
import { CustomerService } from '~/customer/services';
import { UpdateCardControlsRequestDto } from '../dtos/requests';
import { CreateApplicationResponse, InquireApplicationResponse } from '../dtos/response';
import { NeoLeapService } from '../services/neoleap.service';
@Controller('neotest')
@ApiTags('Neoleap Test API , for testing purposes only, will be removed in production')
@UseGuards(AccessTokenGuard)
@ApiBearerAuth()
export class NeoTestController {
constructor(
private readonly neoleapService: NeoLeapService,
private readonly customerService: CustomerService,
private readonly cardService: CardService,
private readonly configService: ConfigService,
) {}
@Post('update-kys')
@ApiDataResponse(CustomerResponseDto)
async updateKys(@AuthenticatedUser() user: IJwtPayload) {
const customer = await this.customerService.updateKyc(user.sub);
return ResponseFactory.data(new CustomerResponseDto(customer));
}
@Post('inquire-application')
@ApiDataResponse(InquireApplicationResponse)
async inquireApplication(@AuthenticatedUser() user: IJwtPayload) {
const customer = await this.customerService.findCustomerById(user.sub);
const data = await this.neoleapService.inquireApplication(customer.applicationNumber.toString());
return ResponseFactory.data(data);
}
@Post('create-application')
@ApiDataResponse(CreateApplicationResponse)
async createApplication(@AuthenticatedUser() user: IJwtPayload) {
const customer = await this.customerService.findCustomerById(user.sub);
const data = await this.neoleapService.createApplication(customer);
await this.cardService.createCard(customer.id, data);
return ResponseFactory.data(data);
}
@Post('update-card-controls')
async updateCardControls(
@AuthenticatedUser() user: IJwtPayload,
@Body() { amount, count }: UpdateCardControlsRequestDto,
) {
const card = await this.cardService.getActiveCardForCustomer(user.sub);
await this.neoleapService.updateCardControl(card.cardReference, amount, count);
return ResponseFactory.data({ message: 'Card controls updated successfully' });
}
}

View File

@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose } from 'class-transformer';
import { IsString } from 'class-validator';
export class AccountCardStatusChangedWebhookRequest {
@ApiProperty()
@Expose({ name: 'InstId' })
@IsString()
instId!: string;
@ApiProperty()
@Expose({ name: 'cardId' })
@IsString()
cardId!: string;
@ApiProperty()
@Expose({ name: 'newStatus' })
@IsString()
newStatus!: string;
}

View File

@ -0,0 +1,68 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose, Type } from 'class-transformer';
import { IsNumber, IsPositive, IsString } from 'class-validator';
export class AccountTransactionWebhookRequest {
@Expose({ name: 'InstId' })
@IsString()
@ApiProperty({ name: 'InstId', example: '9000' })
instId!: string;
@Expose()
@IsString()
@ApiProperty({ example: '143761' })
transactionId!: string;
@Expose()
@IsString()
@ApiProperty({ example: '0037' })
transactionType!: string;
@Expose()
@IsString()
@ApiProperty({ example: '26' })
transactionCode!: string;
@Expose()
@IsString()
@ApiProperty({ example: '6823000000000018' })
accountNumber!: string;
@Expose()
@IsString()
@ApiProperty({ example: '6823000000000018' })
accountId!: string;
@Expose()
@Type(() => Number)
@IsNumber()
@ApiProperty({ example: '7080.15' })
otb!: number;
@Expose()
@Type(() => Number)
@IsNumber()
@IsPositive()
@ApiProperty({ example: '3050.95' })
amount!: number;
@Expose()
@IsString()
@ApiProperty({ example: 'C' })
sign!: string;
@Expose()
@IsString()
@ApiProperty({ example: '682' })
currency!: string;
@Expose()
@IsString()
@ApiProperty({ name: 'Date', example: '20241112' })
date!: string;
@Expose()
@IsString()
@ApiProperty({ name: 'Time', example: '125340' })
time!: string;
}

View File

@ -0,0 +1,158 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose, Type } from 'class-transformer';
import { IsNumber, IsString, Min, ValidateNested } from 'class-validator';
export class CardAcceptorLocationDto {
@Expose()
@IsString()
@ApiProperty()
merchantId!: string;
@Expose()
@IsString()
@ApiProperty()
merchantName!: string;
@Expose()
@IsString()
@ApiProperty()
merchantCountry!: string;
@Expose()
@IsString()
@ApiProperty()
merchantCity!: string;
@Expose()
@IsString()
@ApiProperty()
mcc!: string;
}
export class CardTransactionWebhookRequest {
@Expose({ name: 'InstId' })
@IsString()
@ApiProperty({ name: 'InstId', example: '1100' })
instId!: string;
@Expose()
@IsString()
@ApiProperty({ example: '30829' })
cardId!: string;
@Expose()
@IsString()
@ApiProperty({ example: '1234567890123456' })
transactionId!: string;
@Expose()
@IsString()
@ApiProperty({ example: '277012*****3456' })
cardMaskedNumber!: string;
@Expose()
@IsString()
@ApiProperty({ example: '1234567890123456' })
accountNumber!: string;
@Expose({ name: 'Date' })
@IsString()
@ApiProperty({ name: 'Date', example: '20241112' })
date!: string;
@Expose({ name: 'Time' })
@IsString()
@ApiProperty({ name: 'Time', example: '125250' })
time!: string;
@Expose()
@Type(() => Number)
@IsNumber()
@ApiProperty({ example: '132' })
otb!: number;
@Expose()
@IsString()
@ApiProperty({ example: '0' })
transactionCode!: string;
@Expose()
@IsString()
@ApiProperty({ example: '1' })
messageClass!: string;
@Expose({ name: 'RRN' })
@IsString()
@ApiProperty({ name: 'RRN', example: '431712003306' })
rrn!: string;
@Expose()
@IsString()
@ApiProperty({ example: '3306' })
stan!: string;
@Expose()
@ValidateNested()
@Type(() => CardAcceptorLocationDto)
@ApiProperty({ type: CardAcceptorLocationDto })
cardAcceptorLocation!: CardAcceptorLocationDto;
@Type(() => Number)
@IsNumber()
@Min(0, { message: 'amount must be zero or a positive number' })
@ApiProperty({ example: '100.5' })
transactionAmount!: number;
@IsString()
@ApiProperty({ example: '682' })
transactionCurrency!: string;
@Type(() => Number)
@IsNumber()
@Min(0, { message: 'amount must be zero or a positive number' })
@ApiProperty({ example: '100.5' })
billingAmount!: number;
@IsString()
@ApiProperty({ example: '682' })
billingCurrency!: string;
@Type(() => Number)
@IsNumber()
@Min(0, { message: 'amount must be zero or a positive number' })
@ApiProperty({ example: '100.5' })
settlementAmount!: number;
@IsString()
@ApiProperty({ example: '682' })
settlementCurrency!: string;
@Expose()
@Type(() => Number)
@IsNumber()
@Min(0, { message: 'amount must be zero or a positive number' })
@ApiProperty({ example: '20' })
fees!: number;
@Expose()
@Type(() => Number)
@IsNumber()
@Min(0, { message: 'amount must be zero or a positive number' })
@ApiProperty({ example: '4.5' })
vatOnFees!: number;
@Expose()
@IsString()
@ApiProperty({ example: '9' })
posEntryMode!: string;
@Expose()
@IsString()
@ApiProperty({ example: '036657' })
authIdResponse!: string;
@Expose({ name: 'POSCDIM' })
@IsString()
@ApiProperty({ name: 'POSCDIM', example: '9' })
posCdim!: string;
}

View File

@ -0,0 +1,4 @@
export * from './account-card-status-changed-webhook.request.dto';
export * from './account-transaction-webhook.request.dto';
export * from './card-transaction-webhook.request.dto';
export * from './update-card-controls.request.dto';

View File

@ -0,0 +1,15 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsPositive } from 'class-validator';
export class UpdateCardControlsRequestDto {
@ApiProperty()
@IsNumber()
@IsPositive()
amount!: number;
@IsNumber()
@IsPositive()
@IsOptional()
@ApiPropertyOptional()
count?: number;
}

View File

@ -0,0 +1,40 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose, Transform } from 'class-transformer';
import { InquireApplicationResponse } from './inquire-application.response';
export class CreateApplicationResponse extends InquireApplicationResponse {
@Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier.Id.toString())
@Expose()
@ApiProperty()
cardId!: string;
@Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier.VPan)
@Expose()
@ApiProperty()
vpan!: string;
@Transform(({ obj }) => obj.CardDetailsList?.[0]?.ExpiryDate)
@Expose()
@ApiProperty()
expiryDate!: string;
@Transform(({ obj }) => obj.CardDetailsList?.[0]?.CardStatus)
@Expose()
@ApiProperty()
cardStatus!: string;
@Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier?.MaskedPan?.split('_')[0])
@Expose()
@ApiProperty()
firstSixDigits!: string;
@Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier?.MaskedPan?.split('_')[1])
@Expose()
@ApiProperty()
lastFourDigits!: string;
@Transform(({ obj }) => obj.AccountDetailsList?.[0]?.Id.toString())
@Expose()
@ApiProperty()
accountId!: string;
}

View File

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

View File

@ -0,0 +1,179 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose, Transform } from 'class-transformer';
export class InquireApplicationResponse {
@Transform(({ obj }) => obj.ApplicationDetails?.ApplicationNumber)
@Expose()
@ApiProperty()
applicationNumber!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ExternalApplicationNumber)
@Expose()
@ApiProperty()
externalApplicationNumber!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ApplicationStatus)
@Expose()
@ApiProperty()
applicationStatus!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.Organization)
@Expose()
@ApiProperty()
organization!: number;
@Transform(({ obj }) => obj.ApplicationDetails?.Product)
@Expose()
@ApiProperty()
product!: string;
// this typo is from neoleap, so we keep it as is
@Transform(({ obj }) => obj.ApplicationDetails?.ApplicatonDate)
@Expose()
@ApiProperty()
applicationDate!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ApplicationSource)
@Expose()
@ApiProperty()
applicationSource!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.SalesSource)
@Expose()
@ApiProperty()
salesSource!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.DeliveryMethod)
@Expose()
@ApiProperty()
deliveryMethod!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ProgramCode)
@Expose()
@ApiProperty()
ProgramCode!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.Plastic)
@Expose()
@ApiProperty()
plastic!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.Design)
@Expose()
@ApiProperty()
design!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ProcessStage)
@Expose()
@ApiProperty()
processStage!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ProcessStageStatus)
@Expose()
@ApiProperty()
processStageStatus!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.EligibilityCheckResult)
@Expose()
@ApiProperty()
eligibilityCheckResult!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.EligibilityCheckDescription)
@Expose()
@ApiProperty()
eligibilityCheckDescription!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.Title)
@Expose()
@ApiProperty()
title!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.FirstName)
@Expose()
@ApiProperty()
firstName!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.SecondName)
@Expose()
@ApiProperty()
secondName!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ThirdName)
@Expose()
@ApiProperty()
thirdName!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.LastName)
@Expose()
@ApiProperty()
lastName!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.FullName)
@Expose()
@ApiProperty()
fullName!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.EmbossName)
@Expose()
@ApiProperty()
embossName!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.PlaceOfBirth)
@Expose()
@ApiProperty()
placeOfBirth!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.DateOfBirth)
@Expose()
@ApiProperty()
dateOfBirth!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.LocalizedDateOfBirth)
@Expose()
@ApiProperty()
localizedDateOfBirth!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.Age)
@Expose()
@ApiProperty()
age!: number;
@Transform(({ obj }) => obj.ApplicationDetails?.Gender)
@Expose()
@ApiProperty()
gender!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.Married)
@Expose()
@ApiProperty()
married!: string;
@Transform(({ obj }) => obj.ApplicationDetails.Nationality)
@Expose()
@ApiProperty()
nationality!: string;
@Transform(({ obj }) => obj.ApplicationDetails.IdType)
@Expose()
@ApiProperty()
idType!: string;
@Transform(({ obj }) => obj.ApplicationDetails.IdNumber)
@Expose()
@ApiProperty()
idNumber!: string;
@Transform(({ obj }) => obj.ApplicationDetails.IdExpiryDate)
@Expose()
@ApiProperty()
idExpiryDate!: string;
@Transform(({ obj }) => obj.ApplicationStatusDetails?.Description)
@Expose()
@ApiProperty()
applicationStatusDescription!: string;
@Transform(({ obj }) => obj.ApplicationStatusDetails?.Canceled)
@Expose()
@ApiProperty()
canceled!: boolean;
}

View File

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

View File

@ -0,0 +1,61 @@
import { INeoleapHeaderRequest } from './neoleap-header.request.interface';
export interface ICreateApplicationRequest extends INeoleapHeaderRequest {
CreateNewApplicationRequestDetails: {
ApplicationRequestDetails: {
InstitutionCode: string;
ExternalApplicationNumber: string;
ApplicationType: string;
Product: string;
ApplicationDate: string;
BranchCode: '000';
ApplicationSource: 'O';
DeliveryMethod: 'V';
};
ApplicationProcessingDetails: {
ProcessControl: string;
RequestedLimit: number;
SuggestedLimit: number;
AssignedLimit: number;
};
ApplicationFinancialInformation: {
Currency: {
AlphaCode: 'SAR';
};
BillingCycle: 'C1';
};
ApplicationOtherInfo: object;
ApplicationCustomerDetails: {
Title: string;
FirstName: string;
LastName: string;
FullName: string;
EmbossName: string;
DateOfBirth: string;
LocalizedDateOfBirth: string;
Gender: string;
Nationality: string;
IdType: string;
IdNumber: string;
IdExpiryDate: string;
};
ApplicationAddress: {
AddressLine1: string;
AddressLine2: string;
City: string;
Region: string;
Country: string;
CountryDetails: {
DefaultCurrency: {};
Description: [];
};
Phone1: string;
Email: string;
AddressRole: number;
};
};
}

View File

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

View File

@ -0,0 +1,22 @@
import { INeoleapHeaderRequest } from './neoleap-header.request.interface';
export interface IInquireApplicationRequest extends INeoleapHeaderRequest {
InquireApplicationRequestDetails: {
ApplicationIdentifier: {
InstitutionCode: string;
ExternalApplicationNumber: string;
};
AdditionalData?: {
ReturnApplicationType?: boolean;
ReturnApplicationStatus?: boolean;
ReturnAddresses?: boolean;
ReturnBranch?: boolean;
ReturnHistory?: boolean;
ReturnCard?: boolean;
ReturnCustomer?: boolean;
ReturnAccount?: boolean;
ReturnDirectDebitDetails?: boolean;
};
HistoryTypeFilterList?: number[];
};
}

View File

@ -0,0 +1,9 @@
export interface INeoleapHeaderRequest {
RequestHeader: {
Version: string;
MsgUid: string;
Source: 'ZOD';
ServiceId: string;
ReqDateTime: Date;
};
}

View File

@ -0,0 +1,77 @@
import { INeoleapHeaderRequest } from './neoleap-header.request.interface';
export interface IUpdateCardControlRequest extends INeoleapHeaderRequest {
UpdateCardControlsRequestDetails: {
InstitutionCode: string;
CardIdentifier: {
Id: string;
InstitutionCode: string;
};
UsageTransactionLimit?: {
AmountLimit: number;
CountLimit: number;
};
DomesticCashDailyLimit?: {
AmountLimit: number;
CountLimit: number;
};
InternationalCashDailyLimit?: {
AmountLimit: number;
CountLimit: number;
};
DomesticPosDailyLimit?: {
AmountLimit: number;
CountLimit: number;
lenient?: boolean;
};
InternationalPosDailyLimit?: {
AmountLimit: number;
CountLimit: number;
};
EcommerceDailyLimit?: {
AmountLimit: number;
CountLimit: number;
};
DomesticCashMonthlyLimit?: {
AmountLimit: number;
CountLimit: number;
};
InternationalCashMonthlyLimit?: {
AmountLimit: number;
CountLimit: number;
};
DomesticPosMonthlyLimit?: {
AmountLimit: number;
CountLimit: number;
};
InternationalPosMonthlyLimit?: {
AmountLimit: number;
CountLimit: number;
};
EcommerceMonthlyLimit?: {
AmountLimit: number;
CountLimit: number;
};
DomesticCashTransactionLimit?: {
AmountLimit: number;
CountLimit: number;
};
InternationalCashTransactionLimit?: {
AmountLimit: number;
CountLimit: number;
};
DomesticPosTransactionLimit?: {
AmountLimit: number;
CountLimit: number;
};
InternationalPosTransactionLimit?: {
AmountLimit: number;
CountLimit: number;
};
EcommerceTransactionLimit?: {
AmountLimit: number;
CountLimit: number;
};
};
}

View File

@ -0,0 +1,15 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { CardModule } from '~/card/card.module';
import { CustomerModule } from '~/customer/customer.module';
import { NeoLeapWebhooksController } from './controllers/neoleap-webhooks.controller';
import { NeoTestController } from './controllers/neotest.controller';
import { NeoLeapWebhookService } from './services';
import { NeoLeapService } from './services/neoleap.service';
@Module({
imports: [HttpModule, CustomerModule, CardModule],
controllers: [NeoTestController, NeoLeapWebhooksController],
providers: [NeoLeapService, NeoLeapWebhookService],
})
export class NeoLeapModule {}

View File

@ -0,0 +1,2 @@
export * from './neoleap-webook.service';
export * from './neoleap.service';

View File

@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { CardService } from '~/card/services';
import { TransactionService } from '~/card/services/transaction.service';
import {
AccountCardStatusChangedWebhookRequest,
AccountTransactionWebhookRequest,
CardTransactionWebhookRequest,
} from '../dtos/requests';
@Injectable()
export class NeoLeapWebhookService {
constructor(private readonly transactionService: TransactionService, private readonly cardService: CardService) {}
handleCardTransactionWebhook(body: CardTransactionWebhookRequest) {
return this.transactionService.createCardTransaction(body);
}
handleAccountTransactionWebhook(body: AccountTransactionWebhookRequest) {
return this.transactionService.createAccountTransaction(body);
}
handleAccountCardStatusChangedWebhook(body: AccountCardStatusChangedWebhookRequest) {
return this.cardService.updateCardStatus(body);
}
}

View File

@ -0,0 +1,217 @@
import { HttpService } from '@nestjs/axios';
import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ClassConstructor, plainToInstance } from 'class-transformer';
import moment from 'moment';
import { v4 as uuid } from 'uuid';
import { CountriesNumericISO } from '~/common/constants';
import { Customer } from '~/customer/entities';
import { Gender, KycStatus } from '~/customer/enums';
import { CREATE_APPLICATION_MOCK, INQUIRE_APPLICATION_MOCK } from '../__mocks__/';
import { CreateApplicationResponse, InquireApplicationResponse, UpdateCardControlsResponseDto } from '../dtos/response';
import {
ICreateApplicationRequest,
IInquireApplicationRequest,
INeoleapHeaderRequest,
IUpdateCardControlRequest,
} from '../interfaces';
@Injectable()
export class NeoLeapService {
private readonly baseUrl: string;
private readonly apiKey: string;
private readonly useGateway: boolean;
private readonly institutionCode = '1100';
useLocalCert: boolean;
constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) {
this.baseUrl = this.configService.getOrThrow<string>('GATEWAY_URL');
this.apiKey = this.configService.getOrThrow<string>('GATEWAY_API_KEY');
this.useGateway = [true, 'true'].includes(this.configService.get<boolean>('USE_GATEWAY', false));
this.useLocalCert = this.configService.get<boolean>('USE_LOCAL_CERT', false);
}
async createApplication(customer: Customer) {
const responseKey = 'CreateNewApplicationResponseDetails';
if (customer.kycStatus !== KycStatus.APPROVED) {
throw new BadRequestException('CUSTOMER.KYC_NOT_APPROVED');
}
if (customer.cards.length > 0) {
throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD');
}
if (!this.useGateway) {
return plainToInstance(CreateApplicationResponse, CREATE_APPLICATION_MOCK[responseKey], {
excludeExtraneousValues: true,
});
}
const payload: ICreateApplicationRequest = {
CreateNewApplicationRequestDetails: {
ApplicationRequestDetails: {
InstitutionCode: this.institutionCode,
ExternalApplicationNumber: customer.applicationNumber.toString(),
ApplicationType: '01',
Product: '1101',
ApplicationDate: moment().format('YYYY-MM-DD'),
BranchCode: '000',
ApplicationSource: 'O',
DeliveryMethod: 'V',
},
ApplicationProcessingDetails: {
SuggestedLimit: 0,
RequestedLimit: 0,
AssignedLimit: 0,
ProcessControl: 'STND',
},
ApplicationFinancialInformation: {
Currency: {
AlphaCode: 'SAR',
},
BillingCycle: 'C1',
},
ApplicationOtherInfo: {},
ApplicationCustomerDetails: {
FirstName: customer.firstName,
LastName: customer.lastName,
FullName: customer.fullName,
DateOfBirth: moment(customer.dateOfBirth).format('YYYY-MM-DD'),
EmbossName: customer.fullName.toUpperCase(), // TODO Enter Emboss Name
IdType: '01',
IdNumber: customer.nationalId,
IdExpiryDate: moment(customer.nationalIdExpiry).format('YYYY-MM-DD'),
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],
},
ApplicationAddress: {
City: customer.city,
Country: CountriesNumericISO[customer.country],
Region: customer.region,
AddressLine1: `${customer.street} ${customer.building}`,
AddressLine2: customer.neighborhood,
AddressRole: 0,
Email: customer.user.email,
Phone1: customer.user.phoneNumber,
CountryDetails: {
DefaultCurrency: {},
Description: [],
},
},
},
RequestHeader: this.prepareHeaders('CreateNewApplication'),
};
return this.sendRequestToNeoLeap<ICreateApplicationRequest, CreateApplicationResponse>(
'application/CreateNewApplication',
payload,
responseKey,
CreateApplicationResponse,
);
}
async inquireApplication(externalApplicationNumber: string) {
const responseKey = 'InquireApplicationResponseDetails';
if (!this.useGateway) {
return plainToInstance(InquireApplicationResponse, INQUIRE_APPLICATION_MOCK[responseKey], {
excludeExtraneousValues: true,
});
}
const payload = {
InquireApplicationRequestDetails: {
ApplicationIdentifier: {
InstitutionCode: this.institutionCode,
ExternalApplicationNumber: externalApplicationNumber,
},
AdditionalData: {
ReturnApplicationType: true,
ReturnApplicationStatus: true,
ReturnCustomer: true,
ReturnAddresses: true,
},
},
RequestHeader: this.prepareHeaders('InquireApplication'),
};
return this.sendRequestToNeoLeap<IInquireApplicationRequest, InquireApplicationResponse>(
'application/InquireApplication',
payload,
responseKey,
InquireApplicationResponse,
);
}
async updateCardControl(cardId: string, amount: number, count?: number) {
const responseKey = 'UpdateCardControlResponseDetails';
if (!this.useGateway) {
return;
}
const payload: IUpdateCardControlRequest = {
UpdateCardControlsRequestDetails: {
InstitutionCode: this.institutionCode,
CardIdentifier: {
InstitutionCode: this.institutionCode,
Id: cardId,
},
UsageTransactionLimit: {
AmountLimit: amount,
CountLimit: count || 10,
},
},
RequestHeader: this.prepareHeaders('UpdateCardControl'),
};
return this.sendRequestToNeoLeap<IUpdateCardControlRequest, UpdateCardControlsResponseDto>(
'cardcontrol/UpdateCardControl',
payload,
responseKey,
UpdateCardControlsResponseDto,
);
}
private prepareHeaders(serviceName: string): INeoleapHeaderRequest['RequestHeader'] {
return {
Version: '1.0.0',
MsgUid: uuid(),
Source: 'ZOD',
ServiceId: serviceName,
ReqDateTime: new Date(),
};
}
private async sendRequestToNeoLeap<T, R>(
endpoint: string,
payload: T,
responseKey: string,
responseClass: ClassConstructor<R>,
): Promise<R> {
try {
const { data } = await this.httpService.axiosRef.post(`${this.baseUrl}/${endpoint}`, payload, {
headers: {
'Content-Type': 'application/json',
Authorization: `${this.apiKey}`,
},
});
if (data.data?.ResponseHeader?.ResponseCode !== '000') {
throw new BadRequestException(
data.data.ResponseHeader.ResponseDescription || 'Unexpected Error from Service Provider',
);
}
return plainToInstance(responseClass, data.data[responseKey], {
excludeExtraneousValues: true,
});
} catch (error: any) {
if (error.status === 400) {
console.error('Error sending request to NeoLeap:', error);
throw new BadRequestException(error.response?.data?.ResponseHeader?.ResponseDescription || error.message);
}
console.error('Error sending request to NeoLeap:', error);
throw new InternalServerErrorException('Error communicating with NeoLeap service');
}
}
}

View File

@ -1,3 +0,0 @@
export class NotificationEvent {
constructor(public readonly notification: Notification) {}
}

View File

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

View File

@ -0,0 +1,88 @@
// notification-created.handler.ts
import { MailerService } from '@nestjs-modules/mailer';
import { Injectable, Logger } from '@nestjs/common';
import { SendEmailRequestDto } from '~/common/modules/notification/dtos/request';
import { EventType, NotificationChannel, NotificationScope } from '~/common/modules/notification/enums';
import { FirebaseService, TwilioService } from '~/common/modules/notification/services';
import { IEventInterface } from '~/common/redis/interface';
import { DeviceService } from '~/user/services';
@Injectable()
export class NotificationCreatedListener {
private readonly logger = new Logger(NotificationCreatedListener.name);
constructor(
private readonly twilioService: TwilioService,
private readonly deviceService: DeviceService,
private readonly mailerService: MailerService,
private readonly firebaseService: FirebaseService,
) {}
/**
* Handles the NOTIFICATION_CREATED event by calling the appropriate channel logic.
*/
async handle(event: IEventInterface) {
this.logger.log(
`Handling ${EventType.NOTIFICATION_CREATED} event for notification ${event.id} (channel: ${event.channel})`,
);
switch (event.channel) {
case NotificationChannel.SMS:
return this.sendSMS(event.recipient!, event.message);
case NotificationChannel.PUSH:
return this.sendPushNotification(event.userId, event.title, event.message);
case NotificationChannel.EMAIL:
return this.sendEmail({
to: event.recipient!,
subject: event.title,
template: this.getTemplateFromNotification(event),
data: event.data,
});
}
}
private getTemplateFromNotification(notification: IEventInterface) {
switch (notification.scope) {
case NotificationScope.OTP:
return 'otp';
case NotificationScope.USER_INVITED:
return 'user-invite';
default:
return 'otp';
}
}
private async sendPushNotification(userId: string, title: string, body: string) {
this.logger.log(`Sending push notification to user ${userId}`);
const tokens = await this.deviceService.getTokens(userId);
if (!tokens.length) {
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);
}
private async sendSMS(to: string, body: string) {
this.logger.log(`Sending SMS to ${to}`);
await this.twilioService.sendSMS(to, body);
}
private async sendEmail({ to, subject, data, template }: SendEmailRequestDto) {
this.logger.log(`Sending email to ${to}`);
try {
await this.mailerService.sendMail({
to,
subject,
template,
context: { ...data, currentYear: new Date().getFullYear() },
});
this.logger.log(`Email sent to ${to}`);
} catch (error) {
this.logger.error(`Failed to send email to ${to} error: ${JSON.stringify(error)}`);
throw error;
}
}
}

View File

@ -3,15 +3,19 @@ import { forwardRef, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TwilioModule } from 'nestjs-twilio';
import { RedisModule } from '~/common/redis/redis.module';
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 { NotificationsRepository } from './repositories';
import { FirebaseService, NotificationsService, TwilioService } from './services';
@Module({
imports: [
forwardRef(() => RedisModule.register()),
forwardRef(() => UserModule),
TypeOrmModule.forFeature([Notification]),
TwilioModule.forRootAsync({
useFactory: buildTwilioOptions,
@ -21,10 +25,15 @@ import { FirebaseService, NotificationsService, TwilioService } from './services
useFactory: buildMailerOptions,
inject: [ConfigService],
}),
forwardRef(() => UserModule),
],
providers: [NotificationsService, FirebaseService, NotificationsRepository, TwilioService],
exports: [NotificationsService],
providers: [
NotificationsService,
FirebaseService,
NotificationsRepository,
TwilioService,
NotificationCreatedListener,
],
exports: [NotificationsService, NotificationCreatedListener],
controllers: [NotificationsController],
})
export class NotificationModule {}

View File

@ -1,8 +1,6 @@
import { MailerService } from '@nestjs-modules/mailer';
import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { RedisPubSubService } from '~/common/redis/services';
import { PageOptionsRequestDto } from '~/core/dtos';
import { DeviceService } from '~/user/services';
import { OTP_BODY, OTP_TITLE } from '../../otp/constants';
import { OtpType } from '../../otp/enums';
import { ISendOtp } from '../../otp/interfaces';
@ -10,19 +8,15 @@ import { SendEmailRequestDto } from '../dtos/request';
import { Notification } from '../entities';
import { EventType, NotificationChannel, NotificationScope } from '../enums';
import { NotificationsRepository } from '../repositories';
import { FirebaseService } from './firebase.service';
import { TwilioService } from './twilio.service';
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
constructor(
private readonly firebaseService: FirebaseService,
private readonly notificationRepository: NotificationsRepository,
private readonly twilioService: TwilioService,
private readonly eventEmitter: EventEmitter2,
private readonly deviceService: DeviceService,
private readonly mailerService: MailerService,
@Inject(forwardRef(() => RedisPubSubService))
private readonly redisPubSubService: RedisPubSubService,
) {}
async getNotifications(userId: string, pageOptionsDto: PageOptionsRequestDto) {
@ -56,7 +50,11 @@ export class NotificationsService {
scope: NotificationScope.USER_INVITED,
channel: NotificationChannel.EMAIL,
});
return this.eventEmitter.emit(EventType.NOTIFICATION_CREATED, notification, data.data);
// return this.redisPubSubService.emit(EventType.NOTIFICATION_CREATED, notification, data.data);
this.redisPubSubService.publishEvent(EventType.NOTIFICATION_CREATED, {
...notification,
data,
});
}
async sendOtpNotification(sendOtpRequest: ISendOtp, otp: string) {
@ -71,67 +69,9 @@ export class NotificationsService {
this.logger.log(`emitting ${EventType.NOTIFICATION_CREATED} event`);
return this.eventEmitter.emit(EventType.NOTIFICATION_CREATED, notification);
}
private async sendPushNotification(userId: string, title: string, body: string) {
this.logger.log(`Sending push notification to user ${userId}`);
// Get the device tokens for the user
const tokens = await this.deviceService.getTokens(userId);
if (!tokens.length) {
this.logger.log(`No device tokens found for user ${userId} but notification created in the database`);
return;
}
// Send the notification
return this.firebaseService.sendNotification(tokens, title, body);
}
private async sendSMS(to: string, body: string) {
this.logger.log(`Sending SMS to ${to}`);
await this.twilioService.sendSMS(to, body);
}
private async sendEmail({ to, subject, data, template }: SendEmailRequestDto) {
this.logger.log(`Sending email to ${to}`);
await this.mailerService.sendMail({
to,
subject,
template,
context: { ...data },
return this.redisPubSubService.publishEvent(EventType.NOTIFICATION_CREATED, {
...notification,
data: { otp },
});
this.logger.log(`Email sent to ${to}`);
}
private getTemplateFromNotification(notification: Notification) {
switch (notification.scope) {
case NotificationScope.OTP:
return 'otp';
case NotificationScope.USER_INVITED:
return 'user-invite';
default:
return 'otp';
}
}
@OnEvent(EventType.NOTIFICATION_CREATED)
handleNotificationCreatedEvent(notification: Notification, data?: any) {
this.logger.log(
`Handling ${EventType.NOTIFICATION_CREATED} event for notification ${notification.id} and type ${notification.channel}`,
);
switch (notification.channel) {
case NotificationChannel.SMS:
return this.sendSMS(notification.recipient!, notification.message);
case NotificationChannel.PUSH:
return this.sendPushNotification(notification.userId, notification.title, notification.message);
case NotificationChannel.EMAIL:
return this.sendEmail({
to: notification.recipient!,
subject: notification.title,
template: this.getTemplateFromNotification(notification),
data,
});
}
}
}

View File

@ -1,21 +1,22 @@
<body>
<div class="otp">
<h1 class="title">Your OTP Code</h1>
<p class="message">To verify your account, please use the following One-Time Password (OTP):</p>
{{>header}}
<div class="email-container">
<div class="email-body">
<h2>Your One-Time Password</h2>
<p class="instructions">
Hello! Thank you for using Zod. For security purposes, please use the following
One-Time Password (OTP) to proceed with your verification. This code is
valid for 5 minutes.
</p>
<!-- OTP CODE -->
<div class="otp-code">{{otp}}</div>
<p class="instructions">
If you did not request this code, please ignore this email or
contact our support team immediately.
</p>
</div>
</div>
<style>
.otp {
text-align: center;
font-family: sans-serif;
font-size: 16px;
line-height: 1.5;
}
.otp-code {
font-size: 24px;
font-weight: bold;
margin-top: 20px;
}
</style>
</body>
{{>footer}}

View File

@ -0,0 +1,11 @@
<table class="email-footer" role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td>
<p>
&copy; 2025 Zod. All rights reserved. <br />
Need help? Contact us at
<a href="mailto:support@zod-alkhair.com">support@zod-alkhair.com</a>
</p>
</td>
</tr>
</table>

View File

@ -0,0 +1,95 @@
<head>
<meta charset="UTF-8" />
<title>Zod OTP Email</title>
<style>
/* ----- Core Reset ----- */
body, table, td, a {
font-family: Arial, sans-serif;
text-decoration: none;
}
body {
margin: 0;
padding: 0;
background-color: #F8F8F8;
}
table {
border-collapse: collapse;
width: 100%;
}
/* ----- Header ----- */
.email-header {
background-color: #7B2BFF; /* primary color */
padding: 1rem;
text-align: center;
}
.email-header h1 {
color: #FFFFFF;
margin: 0;
font-size: 1.5rem;
text-transform: uppercase;
letter-spacing: 2px;
}
/* ----- Main Container ----- */
.email-container {
max-width: 600px;
margin: 2rem auto;
background-color: #FFFFFF;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
.email-body {
padding: 2rem;
color: #444444;
}
.email-body h2 {
margin-top: 0;
color: #7B2BFF; /* primary color */
}
.otp-code {
margin: 2rem 0;
font-size: 2rem;
font-weight: bold;
color: #1570EF; /* secondary color */
text-align: center;
}
.instructions {
line-height: 1.5;
margin: 1rem 0;
}
.btn-verify {
display: inline-block;
padding: 0.75rem 1.5rem;
background-color: #1570EF; /* secondary color */
color: #FFFFFF;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
margin-top: 1rem;
}
/* ----- Footer ----- */
.email-footer {
background-color: #F8F8F8;
text-align: center;
padding: 1rem;
font-size: 0.875rem;
color: #999999;
}
.email-footer a {
color: #1570EF; /* secondary color */
}
</style>
</head>
<body>
<!-- HEADER -->
<table class="email-header" role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td>
<h1>ZOD</h1>
</td>
</tr>
</table>

View File

@ -24,6 +24,9 @@ export class Otp {
@Column('varchar', { name: 'user_id' })
userId!: string;
@Column('boolean', { default: false, name: 'is_used' })
isUsed!: boolean;
@ManyToOne(() => User, (user) => user.otp, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User;

View File

@ -1,4 +1,6 @@
export enum OtpScope {
VERIFY_PHONE = 'VERIFY_PHONE',
VERIFY_EMAIL = 'VERIFY_EMAIL',
FORGET_PASSWORD = 'FORGET_PASSWORD',
LOGIN = 'LOGIN',
}

View File

@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MoreThan, Repository } from 'typeorm';
import { Otp } from '../entities';
import { IVerifyOtp } from '../interfaces';
import { ISendOtp, IVerifyOtp } from '../interfaces';
const FIVE = 5;
const SIXTY = 60;
const ONE_THOUSAND = 1000;
@ -14,7 +14,7 @@ export class OtpRepository {
createOtp(otp: Partial<Otp>) {
return this.otpRepository.save(
this.otpRepository.create({
userId: otp.userId,
userId: otp.userId ?? undefined,
value: otp.value,
scope: otp.scope,
otpType: otp.otpType,
@ -31,10 +31,29 @@ export class OtpRepository {
value: otp.value,
otpType: otp.otpType,
expiresAt: MoreThan(new Date()),
isUsed: false,
},
order: {
createdAt: 'DESC',
},
});
}
updateOtp(id: string, data: Partial<Otp>) {
return this.otpRepository.update(id, data);
}
invalidateOtp(otp: ISendOtp) {
return this.otpRepository.update(
{
userId: otp.userId,
scope: otp.scope,
otpType: otp.otpType,
isUsed: false,
},
{
isUsed: true,
},
);
}
}

View File

@ -15,8 +15,11 @@ export class OtpService {
private readonly otpRepository: OtpRepository,
private readonly notificationService: NotificationsService,
) {}
private useMock = this.configService.get<boolean>('USE_MOCK', false);
private useMock = [true, 'true'].includes(this.configService.get<boolean>('USE_MOCK', false));
async generateAndSendOtp(sendOtpRequest: ISendOtp): Promise<string> {
this.logger.log(`invalidate OTP for ${sendOtpRequest.recipient} and ${sendOtpRequest.otpType}`);
await this.otpRepository.invalidateOtp(sendOtpRequest);
this.logger.log(`Generating OTP for ${sendOtpRequest.recipient}`);
const otp = this.useMock ? DEFAULT_OTP_DIGIT.repeat(DEFAULT_OTP_LENGTH) : generateRandomOtp(DEFAULT_OTP_LENGTH);
@ -35,11 +38,15 @@ export class OtpService {
if (!otp) {
this.logger.error(
`OTP value ${verifyOtpRequest.value} not found for ${verifyOtpRequest.userId} and ${verifyOtpRequest.otpType}`,
`OTP value ${verifyOtpRequest.value} not found for ${verifyOtpRequest.userId} and ${verifyOtpRequest.otpType} or used`,
);
return false;
}
await this.otpRepository.updateOtp(otp.id, { isUsed: true });
this.logger.log(`OTP verified successfully for ${verifyOtpRequest.userId}`);
return !!otp;
}

View File

@ -0,0 +1,5 @@
import { Notification } from '~/common/modules/notification/entities';
export interface IEventInterface extends Notification {
data?: any;
}

View File

@ -0,0 +1 @@
export * from './event.interface';

View File

@ -0,0 +1,39 @@
// redis.module.ts (NestJS)
import { createClient } from '@keyv/redis';
import { DynamicModule, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NotificationModule } from '../modules/notification/notification.module';
import { RedisPubSubService } from './services';
@Module({})
export class RedisModule {
static register(): DynamicModule {
return {
module: RedisModule,
providers: [
{
provide: 'REDIS_PUBLISHER',
useFactory: async (configService: ConfigService) => {
const publisher = createClient({ url: configService.get<string>('REDIS_URL') });
await publisher.connect();
return publisher;
},
inject: [ConfigService],
},
{
provide: 'REDIS_SUBSCRIBER',
useFactory: async (configService: ConfigService) => {
const subscriber = createClient({ url: configService.get<string>('REDIS_URL') });
await subscriber.connect();
return subscriber;
},
inject: [ConfigService],
},
RedisPubSubService,
],
exports: [RedisPubSubService],
imports: [NotificationModule],
};
}
}

View File

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

View File

@ -0,0 +1,29 @@
// redis.pubsub.service.ts (NestJS)
import { RedisClientType } from '@keyv/redis';
import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { EventType } from '~/common/modules/notification/enums';
import { NotificationCreatedListener } from '~/common/modules/notification/listeners';
import { IEventInterface } from '../interface';
@Injectable()
export class RedisPubSubService implements OnModuleInit {
private readonly logger = new Logger(RedisPubSubService.name);
constructor(
@Inject('REDIS_PUBLISHER') private readonly publisher: RedisClientType,
@Inject('REDIS_SUBSCRIBER') private readonly subscriber: RedisClientType,
private readonly notificationCreatedListener: NotificationCreatedListener,
) {}
onModuleInit() {
this.subscriber.subscribe(EventType.NOTIFICATION_CREATED, async (message) => {
const data = JSON.parse(message);
this.logger.log('Received message on NOTIFICATION_CREATED channel:', data);
await this.notificationCreatedListener.handle(data);
});
}
async publishEvent(channel: string, payload: IEventInterface) {
await this.publisher.publish(channel, JSON.stringify(payload));
}
}

View File

@ -1,4 +1,8 @@
export enum UserLocale {
ARABIC = 'ar',
ENGLISH = 'en',
}
import { ObjectValues } from '../types';
export const UserLocale = {
ARABIC: 'ar',
ENGLISH: 'en',
} as const;
export type UserLocale = ObjectValues<typeof UserLocale>;

View File

@ -1,7 +1,6 @@
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { ConfigService } from '@nestjs/config';
import path from 'path';
export function buildMailerOptions(config: ConfigService) {
return {
transport: {
@ -20,5 +19,13 @@ export function buildMailerOptions(config: ConfigService) {
strict: true,
},
},
options: {
partials: {
dir: path.join(__dirname, '../../common/modules/notification/templates', 'partials'),
options: {
strict: true,
},
},
},
};
}

View File

@ -9,7 +9,7 @@ export function buildValidationPipe(config: ConfigService): ValidationPipe {
transform: true,
validateCustomDecorators: true,
stopAtFirstError: true,
forbidNonWhitelisted: true,
forbidNonWhitelisted: false,
dismissDefaultMessages: true,
enableDebugMessages: config.getOrThrow('NODE_ENV') === Environment.DEV,
exceptionFactory: i18nValidationErrorFactory,

View File

@ -1,6 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsDateString, IsEnum, IsNotEmpty, IsString, IsUUID } from 'class-validator';
import { IsBoolean, IsDateString, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { CountryIso } from '~/common/enums';
import { IsAbove18 } from '~/core/decorators/validations';
import { Gender } from '~/customer/enums';
export class CreateCustomerRequestDto {
@ -16,12 +17,14 @@ export class CreateCustomerRequestDto {
@ApiProperty({ example: 'MALE' })
@IsEnum(Gender, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.gender' }) })
gender!: Gender;
@IsOptional()
gender?: Gender;
@ApiProperty({ example: 'JO' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.countryOfResidence' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.countryOfResidence' }) })
countryOfResidence!: string;
@IsEnum(CountryIso, {
message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }),
})
countryOfResidence!: CountryIso;
@ApiProperty({ example: '2021-01-01' })
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) })
@ -31,39 +34,47 @@ export class CreateCustomerRequestDto {
@ApiProperty({ example: '999300024' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.nationalId' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.nationalId' }) })
nationalId!: string;
@IsOptional()
nationalId?: string;
@ApiProperty({ example: '2021-01-01' })
@IsDateString(
{},
{ message: i18n('validation.IsDateString', { path: 'general', property: 'junior.nationalIdExpiry' }) },
)
nationalIdExpiry!: Date;
@IsOptional()
nationalIdExpiry?: Date;
@ApiProperty({ example: 'Employee' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.sourceOfIncome' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.sourceOfIncome' }) })
sourceOfIncome!: string;
@IsOptional()
sourceOfIncome?: string;
@ApiProperty({ example: 'Accountant' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.profession' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.profession' }) })
profession!: string;
@IsOptional()
profession?: string;
@ApiProperty({ example: 'Finance' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.professionType' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.professionType' }) })
professionType!: string;
@IsOptional()
professionType?: string;
@ApiProperty({ example: false })
@IsBoolean({ message: i18n('validation.IsBoolean', { path: 'general', property: 'junior.isPep' }) })
isPep!: boolean;
@IsOptional()
isPep?: boolean;
@ApiProperty({ example: 'bf342-3f3f-3f3f-3f3f' })
@IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'junior.civilIdFrontId' }) })
civilIdFrontId!: string;
@IsOptional()
civilIdFrontId?: string;
@ApiProperty({ example: 'bf342-3f3f-3f3f-3f3f' })
@IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'junior.civilIdBackId' }) })
civilIdBackId!: string;
@IsOptional()
civilIdBackId?: string;
}

View File

@ -55,6 +55,27 @@ export class CustomerResponseDto {
@ApiProperty()
isGuardian!: boolean;
@ApiProperty()
waitingNumber!: number;
@ApiProperty()
country!: string | null;
@ApiProperty()
region!: string | null;
@ApiProperty()
city!: string | null;
@ApiProperty()
neighborhood!: string | null;
@ApiProperty()
street!: string | null;
@ApiProperty()
building!: string | null;
@ApiPropertyOptional({ type: DocumentMetaResponseDto })
profilePicture!: DocumentMetaResponseDto | null;
@ -76,7 +97,13 @@ export class CustomerResponseDto {
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;
this.profilePicture = customer.profilePicture ? new DocumentMetaResponseDto(customer.profilePicture) : null;
}
}

View File

@ -1,3 +1,3 @@
export * from './customer-response.dto';
export * from './customer.response.dto';
export * from './internal.customer-details.response.dto';
export * from './internal.customer-list.response.dto';

View File

@ -3,11 +3,15 @@ import {
Column,
CreateDateColumn,
Entity,
Generated,
JoinColumn,
OneToMany,
OneToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { Card } from '~/card/entities';
import { CountryIso } from '~/common/enums';
import { Document } from '~/document/entities';
import { Guardian } from '~/guardian/entities/guradian.entity';
import { Junior } from '~/junior/entities';
@ -44,7 +48,7 @@ export class Customer extends BaseEntity {
nationalIdExpiry!: Date;
@Column('varchar', { length: 255, nullable: true, name: 'country_of_residence' })
countryOfResidence!: string;
countryOfResidence!: CountryIso;
@Column('varchar', { length: 255, nullable: true, name: 'source_of_income' })
sourceOfIncome!: string;
@ -67,9 +71,31 @@ export class Customer extends BaseEntity {
@Column('boolean', { default: false, name: 'is_guardian' })
isGuardian!: boolean;
@Column('int', { name: 'application_number' })
@Generated('increment')
applicationNumber!: number;
@Column('varchar', { name: 'user_id' })
userId!: string;
@Column('varchar', { name: 'country', length: 255, nullable: true })
country!: CountryIso;
@Column('varchar', { name: 'region', length: 255, nullable: true })
region!: string;
@Column('varchar', { name: 'city', length: 255, nullable: true })
city!: string;
@Column('varchar', { name: 'neighborhood', length: 255, nullable: true })
neighborhood!: string;
@Column('varchar', { name: 'street', length: 255, nullable: true })
street!: string;
@Column('varchar', { name: 'building', length: 255, nullable: true })
building!: string;
@Column('varchar', { name: 'profile_picture_id', nullable: true })
profilePictureId!: string;
@ -87,23 +113,35 @@ export class Customer extends BaseEntity {
@OneToOne(() => Guardian, (guardian) => guardian.customer, { cascade: true })
guardian!: Guardian;
@Column('uuid', { name: 'civil_id_front_id' })
@Column('uuid', { name: 'civil_id_front_id', nullable: true })
civilIdFrontId!: string;
@Column('uuid', { name: 'civil_id_back_id' })
@Column('uuid', { name: 'civil_id_back_id', nullable: true })
civilIdBackId!: string;
@OneToOne(() => Document, (document) => document.customerCivilIdFront)
@OneToOne(() => Document, (document) => document.customerCivilIdFront, { nullable: true })
@JoinColumn({ name: 'civil_id_front_id' })
civilIdFront!: Document;
@OneToOne(() => Document, (document) => document.customerCivilIdBack)
@OneToOne(() => Document, (document) => document.customerCivilIdBack, { nullable: true })
@JoinColumn({ name: 'civil_id_back_id' })
civilIdBack!: Document;
@CreateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
// relation ship between customer and card
@OneToMany(() => Card, (card) => card.customer)
cards!: Card[];
// relationship between cards and their parent customer
@OneToMany(() => Card, (card) => card.parentCustomer)
childCards!: Card[];
@CreateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
@UpdateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', name: 'updated_at' })
updatedAt!: Date;
get fullName(): string {
return `${this.firstName} ${this.lastName}`;
}
}

View File

@ -15,7 +15,7 @@ export class CustomerRepository {
findOne(where: FindOptionsWhere<Customer>) {
return this.customerRepository.findOne({
where,
relations: ['profilePicture', 'user', 'civilIdFront', 'civilIdBack'],
relations: ['profilePicture', 'user', 'civilIdFront', 'civilIdBack', 'cards'],
});
}
@ -29,17 +29,7 @@ export class CustomerRepository {
firstName: body.firstName,
lastName: body.lastName,
dateOfBirth: body.dateOfBirth,
gender: body.gender,
countryOfResidence: body.countryOfResidence,
nationalId: body.nationalId,
nationalIdExpiry: body.nationalIdExpiry,
sourceOfIncome: body.sourceOfIncome,
profession: body.profession,
professionType: body.professionType,
isPep: body.isPep,
civilIdFrontId: body.civilIdFrontId,
civilIdBackId: body.civilIdBackId,
}),
);
}
@ -51,7 +41,6 @@ export class CustomerRepository {
if (filters.name) {
const nameParts = filters.name.trim().split(/\s+/);
console.log(nameParts);
nameParts.length > 1
? query.andWhere('customer.firstName LIKE :firstName AND customer.lastName LIKE :lastName', {
firstName: `%${nameParts[0]}%`,

View File

@ -1,8 +1,11 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import moment from 'moment';
import { Transactional } from 'typeorm-transactional';
import { CountryIso } from '~/common/enums';
import { DocumentService, OciService } from '~/document/services';
import { GuardianService } from '~/guardian/services';
import { CreateJuniorRequestDto } from '~/junior/dtos/request';
import { User } from '~/user/entities';
import {
CreateCustomerRequestDto,
CustomerFiltersRequestDto,
@ -10,7 +13,7 @@ import {
UpdateCustomerRequestDto,
} from '../dtos/request';
import { Customer } from '../entities';
import { KycStatus } from '../enums';
import { Gender, KycStatus } from '../enums';
import { CustomerRepository } from '../repositories/customer.repository';
@Injectable()
@ -89,16 +92,16 @@ export class CustomerService {
}
@Transactional()
async createGuardianCustomer(userId: string, body: CreateCustomerRequestDto) {
async createGuardianCustomer(userId: string, body: Partial<CreateCustomerRequestDto>) {
this.logger.log(`Creating guardian customer for user ${userId}`);
const existingCustomer = await this.customerRepository.findOne({ id: userId });
if (existingCustomer) {
this.logger.error(`Customer ${userId} already exists`);
throw new BadRequestException('CUSTOMER.ALRADY_EXISTS');
throw new BadRequestException('CUSTOMER.ALREADY_EXISTS');
}
await this.validateCivilIdForCustomer(userId, body.civilIdFrontId, body.civilIdBackId);
// await this.validateCivilIdForCustomer(userId, body.civilIdFrontId, body.civilIdBackId);
const customer = await this.customerRepository.createCustomer(userId, body, true);
this.logger.log(`customer created for user ${userId}`);
@ -125,6 +128,33 @@ export class CustomerService {
this.logger.log(`KYC rejected for customer ${customerId}`);
}
// this function is for testing only and will be removed
@Transactional()
async updateKyc(userId: string) {
this.logger.log(`Updating KYC for customer ${userId}`);
await this.customerRepository.updateCustomer(userId, {
kycStatus: KycStatus.APPROVED,
gender: Gender.MALE,
nationalId: '1089055972',
nationalIdExpiry: moment('2031-09-17').toDate(),
countryOfResidence: CountryIso.SAUDI_ARABIA,
country: CountryIso.SAUDI_ARABIA,
region: 'Mecca',
city: 'AT Taif',
neighborhood: 'Al Faisaliah',
street: 'Al Faisaliah Street',
building: '4',
});
await User.update(userId, {
phoneNumber: this.generateSaudiPhoneNumber(),
countryCode: '+966',
});
this.logger.log(`KYC updated for customer ${userId}`);
return this.findCustomerById(userId);
}
private async validateProfilePictureForCustomer(userId: string, profilePictureId?: string) {
if (!profilePictureId) return;
@ -166,9 +196,12 @@ export class CustomerService {
throw new BadRequestException('CUSTOMER.CIVIL_ID_NOT_CREATED_BY_USER');
}
const customerWithTheSameId = await this.customerRepository.findCustomerByCivilId(civilIdFrontId, civilIdBackId);
const customerWithTheSameCivilId = await this.customerRepository.findCustomerByCivilId(
civilIdFrontId,
civilIdBackId,
);
if (customerWithTheSameId) {
if (customerWithTheSameCivilId) {
this.logger.error(
`Customer with civil id front ${civilIdFrontId} and civil id back ${civilIdBackId} already exists`,
);
@ -195,4 +228,19 @@ export class CustomerService {
customer.civilIdBack.url = civilIdBackUrl;
return customer;
}
async findAnyCustomer() {
return this.customerRepository.findOne({ isGuardian: true });
}
// TO BE REMOVED: This function is for testing only and will be removed
private generateSaudiPhoneNumber(): string {
// Saudi mobile numbers are 9 digits, always starting with '5'
const firstDigit = '5';
let rest = '';
for (let i = 0; i < 8; i++) {
rest += Math.floor(Math.random() * 10);
}
return `${firstDigit}${rest}`;
}
}

View File

@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUsedFlagToOtpAndRemoveConstraintsFromCustomers1741087742821 implements MigrationInterface {
name = 'AddUsedFlagToOtpAndRemoveConstraintsFromCustomers1741087742821';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "otp" ADD "is_used" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_d5f99c497892ce31598ba19a72c"`);
await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_2191662d124c56dd968ba01bf18"`);
await queryRunner.query(`ALTER TABLE "customers" ALTER COLUMN "civil_id_front_id" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "customers" ALTER COLUMN "civil_id_back_id" DROP NOT NULL`);
await queryRunner.query(
`ALTER TABLE "customers" ADD CONSTRAINT "FK_d5f99c497892ce31598ba19a72c" FOREIGN KEY ("civil_id_front_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "customers" ADD CONSTRAINT "FK_2191662d124c56dd968ba01bf18" FOREIGN KEY ("civil_id_back_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_2191662d124c56dd968ba01bf18"`);
await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_d5f99c497892ce31598ba19a72c"`);
await queryRunner.query(`ALTER TABLE "customers" ALTER COLUMN "civil_id_back_id" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "customers" ALTER COLUMN "civil_id_front_id" SET NOT NULL`);
await queryRunner.query(
`ALTER TABLE "customers" ADD CONSTRAINT "FK_2191662d124c56dd968ba01bf18" FOREIGN KEY ("civil_id_back_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "customers" ADD CONSTRAINT "FK_d5f99c497892ce31598ba19a72c" FOREIGN KEY ("civil_id_front_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(`ALTER TABLE "otp" DROP COLUMN "is_used"`);
}
}

View File

@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateCustomerTable1742112997024 implements MigrationInterface {
name = 'UpdateCustomerTable1742112997024';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "customers"
RENAME COLUMN "createdAt"
TO "created_at"
`);
await queryRunner.query(`
ALTER TABLE "customers"
RENAME COLUMN "updatedAt"
TO "updated_at"
`);
await queryRunner.query(`ALTER TABLE "customers" ADD "waiting_number" SERIAL NOT NULL`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "waiting_number"`);
await queryRunner.query(`
ALTER TABLE "customers"
RENAME COLUMN "created_at"
TO "createdAt"
`);
await queryRunner.query(`
ALTER TABLE "customers"
RENAME COLUMN "updated_at"
TO "updatedAt"
`);
}
}

View File

@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAddressFieldsToCustomers1747569536067 implements MigrationInterface {
name = 'AddAddressFieldsToCustomers1747569536067';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "customers" ADD "country" character varying(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "region" character varying(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "city" character varying(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "neighborhood" character varying(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "street" character varying(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "building" character varying(255)`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "building"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "street"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "neighborhood"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "city"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "region"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "country"`);
}
}

View File

@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateCardEntity1749633935436 implements MigrationInterface {
name = 'CreateCardEntity1749633935436';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "cards"
("id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"card_reference" character varying NOT NULL,
"first_six_digits" character varying(6) NOT NULL,
"last_four_digits" character varying(4) NOT NULL,
"expiry" character varying NOT NULL,
"customer_type" character varying NOT NULL,
"color" character varying NOT NULL DEFAULT 'BLUE',
"status" character varying NOT NULL DEFAULT 'PENDING',
"scheme" character varying NOT NULL DEFAULT 'VISA',
"issuer" character varying NOT NULL,
"customer_id" uuid NOT NULL,
"parent_id" uuid,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_9451069b6f1199730791a7f4ae4" PRIMARY KEY ("id"))`,
);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_6fb82820c0b1b7ec32a8dbf911" ON "cards" ("card_reference") `);
await queryRunner.query(
`ALTER TABLE "cards" ADD CONSTRAINT "FK_d46837f6ab27271d8125517d0b6" FOREIGN KEY ("parent_id") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "cards" ADD CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e"`);
await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_d46837f6ab27271d8125517d0b6"`);
await queryRunner.query(`DROP INDEX "public"."IDX_6fb82820c0b1b7ec32a8dbf911"`);
await queryRunner.query(`DROP TABLE "cards"`);
}
}

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