diff --git a/.gitignore b/.gitignore index ff85744..4cb3dae 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,5 @@ pids # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json -zod-certs \ No newline at end of file + +zod-certs diff --git a/nest-cli.json b/nest-cli.json index 60e4148..87dfbbf 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -11,6 +11,7 @@ "exclude": "**/*.md" }, { "include": "common/modules/**/templates/**/*", "watchAssets": true }, + { "include": "common/modules/neoleap/zod-certs" }, "i18n", "files" ] diff --git a/package-lock.json b/package-lock.json index 2a54f0c..3cd82fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "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", @@ -5167,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, diff --git a/package.json b/package.json index a991cb2..f486a74 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "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", diff --git a/src/app.module.ts b/src/app.module.ts index 4c2b94b..a2cae02 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index d402ad6..61a1985 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -1,29 +1,22 @@ import { Body, Controller, HttpCode, HttpStatus, Post, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Request } from 'express'; -import { AuthenticatedUser, Public } from '~/common/decorators'; +import { Public } from '~/common/decorators'; import { AccessTokenGuard } from '~/common/guards'; import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; import { - AppleLoginRequestDto, CreateUnverifiedUserRequestDto, - DisableBiometricRequestDto, - EnableBiometricRequestDto, ForgetPasswordRequestDto, - GoogleLoginRequestDto, + LoginRequestDto, RefreshTokenRequestDto, SendForgetPasswordOtpRequestDto, - SendLoginOtpRequestDto, - SetEmailRequestDto, - setJuniorPasswordRequestDto, - SetPasscodeRequestDto, - VerifyLoginOtpRequestDto, + VerifyForgetPasswordOtpRequestDto, VerifyUserRequestDto, } from '../dtos/request'; import { SendForgetPasswordOtpResponseDto, SendRegisterOtpResponseDto } from '../dtos/response'; import { LoginResponseDto } from '../dtos/response/login.response.dto'; -import { IJwtPayload } from '../interfaces'; +import { VerifyForgetPasswordOtpResponseDto } from '../dtos/response/verify-forget-password-otp.response.dto'; import { AuthService } from '../services'; @Controller('auth') @@ -44,98 +37,31 @@ 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) - async setEmail(@AuthenticatedUser() { sub }: IJwtPayload, @Body() setEmailDto: SetEmailRequestDto) { - await this.authService.setEmail(sub, setEmailDto); - } - - @Post('register/set-passcode') - @HttpCode(HttpStatus.NO_CONTENT) - @UseGuards(AccessTokenGuard) - async setPasscode(@AuthenticatedUser() { sub }: IJwtPayload, @Body() { passcode }: SetPasscodeRequestDto) { - 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/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) - @UseGuards(AccessTokenGuard) - enableBiometric(@AuthenticatedUser() { sub }: IJwtPayload, @Body() enableBiometricDto: EnableBiometricRequestDto) { - return this.authService.enableBiometric(sub, enableBiometricDto); - } - - @Post('biometric/disable') - @HttpCode(HttpStatus.NO_CONTENT) - @UseGuards(AccessTokenGuard) - disableBiometric(@AuthenticatedUser() { sub }: IJwtPayload, @Body() disableBiometricDto: DisableBiometricRequestDto) { - return this.authService.disableBiometric(sub, disableBiometricDto); + @Post('login') + async login(@Body() verifyUserDto: LoginRequestDto) { + const [res, user] = await this.authService.loginWithPassword(verifyUserDto); + return ResponseFactory.data(new LoginResponseDto(res, user)); } @Post('forget-password/otp') async forgetPassword(@Body() sendForgetPasswordOtpDto: SendForgetPasswordOtpRequestDto) { - const email = await this.authService.sendForgetPasswordOtp(sendForgetPasswordOtpDto); - return ResponseFactory.data(new SendForgetPasswordOtpResponseDto(email)); + const maskedNumber = await this.authService.sendForgetPasswordOtp(sendForgetPasswordOtpDto); + return ResponseFactory.data(new SendForgetPasswordOtpResponseDto(maskedNumber)); + } + + @Post('forget-password/verify') + @HttpCode(HttpStatus.OK) + @ApiDataResponse(VerifyForgetPasswordOtpResponseDto) + async verifyForgetPasswordOtp(@Body() forgetPasswordDto: VerifyForgetPasswordOtpRequestDto) { + const { token, user } = await this.authService.verifyForgetPasswordOtp(forgetPasswordDto); + + return ResponseFactory.data(new VerifyForgetPasswordOtpResponseDto(token, user)); } @Post('forget-password/reset') @HttpCode(HttpStatus.NO_CONTENT) resetPassword(@Body() forgetPasswordDto: ForgetPasswordRequestDto) { - return this.authService.verifyForgetPasswordOtp(forgetPasswordDto); - } - - @Post('junior/set-passcode') - @HttpCode(HttpStatus.NO_CONTENT) - @Public() - setJuniorPasscode(@Body() setPasscodeDto: setJuniorPasswordRequestDto) { - return this.authService.setJuniorPasscode(setPasscodeDto); + return this.authService.resetPassword(forgetPasswordDto); } @Post('refresh-token') @@ -151,4 +77,25 @@ export class AuthController { async logout(@Req() request: Request) { await this.authService.logout(request); } + + // @Post('biometric/enable') + // @HttpCode(HttpStatus.NO_CONTENT) + // @UseGuards(AccessTokenGuard) + // enableBiometric(@AuthenticatedUser() { sub }: IJwtPayload, @Body() enableBiometricDto: EnableBiometricRequestDto) { + // return this.authService.enableBiometric(sub, enableBiometricDto); + // } + + // @Post('biometric/disable') + // @HttpCode(HttpStatus.NO_CONTENT) + // @UseGuards(AccessTokenGuard) + // disableBiometric(@AuthenticatedUser() { sub }: IJwtPayload, @Body() disableBiometricDto: DisableBiometricRequestDto) { + // return this.authService.disableBiometric(sub, disableBiometricDto); + // } + + // @Post('junior/set-passcode') + // @HttpCode(HttpStatus.NO_CONTENT) + // @Public() + // setJuniorPasscode(@Body() setPasscodeDto: setJuniorPasswordRequestDto) { + // return this.authService.setJuniorPasscode(setPasscodeDto); + // } } diff --git a/src/auth/dtos/request/create-unverified-user.request.dto.ts b/src/auth/dtos/request/create-unverified-user.request.dto.ts index 9fdc3bd..520f99e 100644 --- a/src/auth/dtos/request/create-unverified-user.request.dto.ts +++ b/src/auth/dtos/request/create-unverified-user.request.dto.ts @@ -1,14 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { OmitType } from '@nestjs/swagger'; +import { VerifyUserRequestDto } from './verify-user.request.dto'; -export class CreateUnverifiedUserRequestDto { - @ApiProperty({ example: 'test@test.com' }) - @IsEmail( - {}, - { - message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }), - }, - ) - email!: string; -} +export class CreateUnverifiedUserRequestDto extends OmitType(VerifyUserRequestDto, ['otp']) {} diff --git a/src/auth/dtos/request/forget-password.request.dto.ts b/src/auth/dtos/request/forget-password.request.dto.ts index 97f7236..4680c71 100644 --- a/src/auth/dtos/request/forget-password.request.dto.ts +++ b/src/auth/dtos/request/forget-password.request.dto.ts @@ -1,32 +1,34 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsNotEmpty, IsNumberString, IsString, MaxLength, MinLength } from 'class-validator'; +import { IsString, Matches } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; +import { COUNTRY_CODE_REGEX, PASSWORD_REGEX } from '~/auth/constants'; +import { IsValidPhoneNumber } from '~/core/decorators/validations'; export class ForgetPasswordRequestDto { - @ApiProperty({ example: 'test@test.com' }) - @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) - email!: string; + @ApiProperty({ example: '+962' }) + @Matches(COUNTRY_CODE_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }), + }) + countryCode!: string; - @ApiProperty({ example: 'password' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.password' }) }) + @ApiProperty({ example: '787259134' }) + @IsValidPhoneNumber({ + message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }), + }) + phoneNumber!: string; + + @ApiProperty({ example: 'Abcd1234@' }) + @Matches(PASSWORD_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.password' }), + }) password!: string; - @ApiProperty({ example: 'password' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.confirmPassword' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.confirmPassword' }) }) + @ApiProperty({ example: 'Abcd1234@' }) + @Matches(PASSWORD_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.confirmPassword' }), + }) confirmPassword!: string; - @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; + @ApiProperty({ example: 'reset-token-32423123' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.resetPasswordToken' }) }) + resetPasswordToken!: string; } diff --git a/src/auth/dtos/request/index.ts b/src/auth/dtos/request/index.ts index d5c81cf..09274d4 100644 --- a/src/auth/dtos/request/index.ts +++ b/src/auth/dtos/request/index.ts @@ -7,10 +7,9 @@ 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-forget-password-otp.request.dto'; export * from './verify-otp.request.dto'; export * from './verify-user.request.dto'; diff --git a/src/auth/dtos/request/login.request.dto.ts b/src/auth/dtos/request/login.request.dto.ts index 476961d..ed48ae3 100644 --- a/src/auth/dtos/request/login.request.dto.ts +++ b/src/auth/dtos/request/login.request.dto.ts @@ -1,43 +1,24 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, ValidateIf } from 'class-validator'; +import { IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, Matches, ValidateIf } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { COUNTRY_CODE_REGEX } from '~/auth/constants'; import { GrantType } from '~/auth/enums'; +import { IsValidPhoneNumber } from '~/core/decorators/validations'; export class LoginRequestDto { - @ApiProperty({ example: GrantType.APPLE }) - @IsEnum(GrantType, { message: i18n('validation.IsEnum', { path: 'general', property: 'auth.grantType' }) }) - grantType!: GrantType; + @ApiProperty({ example: '+962' }) + @Matches(COUNTRY_CODE_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }), + }) + countryCode!: string; - @ApiProperty({ example: 'test@test.com' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.email' }) }) - @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) - @ValidateIf((o) => o.grantType !== GrantType.APPLE && o.grantType !== GrantType.GOOGLE) - email!: string; + @ApiProperty({ example: '787259134' }) + @IsValidPhoneNumber({ + message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }), + }) + phoneNumber!: string; - @ApiProperty({ example: '123456' }) + @ApiProperty({ example: 'Abcd1234@' }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) }) @ValidateIf((o) => o.grantType === GrantType.PASSWORD) password!: string; - - @ApiProperty({ example: 'Login signature' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.signature' }) }) - @ValidateIf((o) => o.grantType === GrantType.BIOMETRIC) - signature!: string; - - @ApiProperty({ example: 'google_token' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.googleToken' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.googleToken' }) }) - @ValidateIf((o) => o.grantType === GrantType.GOOGLE) - googleToken!: string; - - @ApiProperty({ example: 'apple_token' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.appleToken' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.appleToken' }) }) - @ValidateIf((o) => o.grantType === GrantType.APPLE) - appleToken!: string; - - @ApiProperty({ example: 'fcm-device-token' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.fcmToken' }) }) - @IsOptional() - fcmToken?: string; } diff --git a/src/auth/dtos/request/send-forget-password-otp.request.dto.ts b/src/auth/dtos/request/send-forget-password-otp.request.dto.ts index 077b14a..ebb7941 100644 --- a/src/auth/dtos/request/send-forget-password-otp.request.dto.ts +++ b/src/auth/dtos/request/send-forget-password-otp.request.dto.ts @@ -1,4 +1,4 @@ import { PickType } from '@nestjs/swagger'; import { LoginRequestDto } from './login.request.dto'; -export class SendForgetPasswordOtpRequestDto extends PickType(LoginRequestDto, ['email']) {} +export class SendForgetPasswordOtpRequestDto extends PickType(LoginRequestDto, ['countryCode', 'phoneNumber']) {} diff --git a/src/auth/dtos/request/send-login-otp.request.dto.ts b/src/auth/dtos/request/send-login-otp.request.dto.ts deleted file mode 100644 index 45388f3..0000000 --- a/src/auth/dtos/request/send-login-otp.request.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -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; -} diff --git a/src/auth/dtos/request/verify-login-otp.request.dto.ts b/src/auth/dtos/request/verify-forget-password-otp.request.dto.ts similarity index 73% rename from src/auth/dtos/request/verify-login-otp.request.dto.ts rename to src/auth/dtos/request/verify-forget-password-otp.request.dto.ts index 819ead5..03f2100 100644 --- a/src/auth/dtos/request/verify-login-otp.request.dto.ts +++ b/src/auth/dtos/request/verify-forget-password-otp.request.dto.ts @@ -1,10 +1,13 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, PickType } 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'; +import { ForgetPasswordRequestDto } from './forget-password.request.dto'; -export class VerifyLoginOtpRequestDto extends SendLoginOtpRequestDto { +export class VerifyForgetPasswordOtpRequestDto extends PickType(ForgetPasswordRequestDto, [ + 'countryCode', + 'phoneNumber', +]) { @ApiProperty({ example: '111111' }) @IsNumberString( { no_symbols: true }, diff --git a/src/auth/dtos/request/verify-user.request.dto.ts b/src/auth/dtos/request/verify-user.request.dto.ts index c1adc87..f0d7289 100644 --- a/src/auth/dtos/request/verify-user.request.dto.ts +++ b/src/auth/dtos/request/verify-user.request.dto.ts @@ -1,21 +1,34 @@ -import { ApiProperty, PickType } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; import { IsDateString, + IsEmail, IsEnum, IsNotEmpty, IsNumberString, IsOptional, IsString, + Matches, MaxLength, MinLength, } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { COUNTRY_CODE_REGEX, PASSWORD_REGEX } from '~/auth/constants'; import { CountryIso } from '~/common/enums'; import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; -import { IsAbove18 } from '~/core/decorators/validations'; -import { CreateUnverifiedUserRequestDto } from './create-unverified-user.request.dto'; +import { IsAbove18, IsValidPhoneNumber } from '~/core/decorators/validations'; -export class VerifyUserRequestDto extends PickType(CreateUnverifiedUserRequestDto, ['email']) { +export class VerifyUserRequestDto { + @ApiProperty({ example: '+962' }) + @Matches(COUNTRY_CODE_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }), + }) + countryCode!: string; + + @ApiProperty({ example: '787259134' }) + @IsValidPhoneNumber({ + message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }), + }) + phoneNumber!: string; @ApiProperty({ example: 'John' }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) }) @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) }) @@ -38,6 +51,23 @@ export class VerifyUserRequestDto extends PickType(CreateUnverifiedUserRequestDt @IsOptional() countryOfResidence: CountryIso = CountryIso.SAUDI_ARABIA; + @ApiProperty({ example: 'test@test.com' }) + @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) + @IsOptional() + email!: string; + + @ApiProperty({ example: 'Abcd1234@' }) + @Matches(PASSWORD_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.password' }), + }) + password!: string; + + @ApiProperty({ example: 'Abcd1234@' }) + @Matches(PASSWORD_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.confirmPassword' }), + }) + confirmPassword!: string; + @ApiProperty({ example: '111111' }) @IsNumberString( { no_symbols: true }, diff --git a/src/auth/dtos/response/login.response.dto.ts b/src/auth/dtos/response/login.response.dto.ts index 6e9ee15..6a64ef4 100644 --- a/src/auth/dtos/response/login.response.dto.ts +++ b/src/auth/dtos/response/login.response.dto.ts @@ -17,7 +17,7 @@ export class LoginResponseDto { @ApiProperty({ example: UserResponseDto }) user!: UserResponseDto; - @ApiProperty({ example: CustomerResponseDto }) + @ApiProperty({ type: CustomerResponseDto }) customer!: CustomerResponseDto | null; constructor(IVerifyUserResponse: ILoginResponse, user: User) { diff --git a/src/auth/dtos/response/send-forget-password.response.dto.ts b/src/auth/dtos/response/send-forget-password.response.dto.ts index 1bbf6b8..40e338f 100644 --- a/src/auth/dtos/response/send-forget-password.response.dto.ts +++ b/src/auth/dtos/response/send-forget-password.response.dto.ts @@ -1,7 +1,7 @@ export class SendForgetPasswordOtpResponseDto { - email!: string; + maskedNumber!: string; - constructor(email: string) { - this.email = email; + constructor(maskedNumber: string) { + this.maskedNumber = maskedNumber; } } diff --git a/src/auth/dtos/response/send-register-otp.response.dto.ts b/src/auth/dtos/response/send-register-otp.response.dto.ts index 0597b90..ee27ad5 100644 --- a/src/auth/dtos/response/send-register-otp.response.dto.ts +++ b/src/auth/dtos/response/send-register-otp.response.dto.ts @@ -2,9 +2,9 @@ import { ApiProperty } from '@nestjs/swagger'; export class SendRegisterOtpResponseDto { @ApiProperty() - email!: string; + maskedNumber!: string; - constructor(email: string) { - this.email = email; + constructor(maskedNumber: string) { + this.maskedNumber = maskedNumber; } } diff --git a/src/auth/dtos/response/send-register-otp.v2.response.dto.ts b/src/auth/dtos/response/send-register-otp.v2.response.dto.ts new file mode 100644 index 0000000..27c72ec --- /dev/null +++ b/src/auth/dtos/response/send-register-otp.v2.response.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SendRegisterOtpV2ResponseDto { + @ApiProperty() + maskedNumber!: string; + + constructor(maskedNumber: string) { + this.maskedNumber = maskedNumber; + } +} diff --git a/src/auth/dtos/response/verify-forget-password-otp.response.dto.ts b/src/auth/dtos/response/verify-forget-password-otp.response.dto.ts new file mode 100644 index 0000000..8fc6010 --- /dev/null +++ b/src/auth/dtos/response/verify-forget-password-otp.response.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { User } from '~/user/entities'; + +export class VerifyForgetPasswordOtpResponseDto { + @ApiProperty() + phoneNumber!: string; + + @ApiProperty() + countryCode!: string; + + @ApiProperty() + resetPasswordToken!: string; + + constructor(token: string, user: User) { + this.phoneNumber = user.phoneNumber; + this.countryCode = user.countryCode; + this.resetPasswordToken = token; + } +} diff --git a/src/auth/enums/grant-type.enum.ts b/src/auth/enums/grant-type.enum.ts index 952f851..4a16d92 100644 --- a/src/auth/enums/grant-type.enum.ts +++ b/src/auth/enums/grant-type.enum.ts @@ -1,6 +1,4 @@ export enum GrantType { PASSWORD = 'PASSWORD', BIOMETRIC = 'BIOMETRIC', - GOOGLE = 'GOOGLE', - APPLE = 'APPLE', } diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 0d192b2..e096196 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -3,32 +3,26 @@ import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { Request } from 'express'; -import { ArrayContains } from 'typeorm'; +import moment from 'moment'; import { CacheService } from '~/common/modules/cache/services'; import { OtpScope, OtpType } from '~/common/modules/otp/enums'; import { OtpService } from '~/common/modules/otp/services'; import { UserType } from '~/user/enums'; 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, + VerifyForgetPasswordOtpRequestDto, VerifyUserRequestDto, } from '../dtos/request'; import { Roles } from '../enums'; import { IJwtPayload, ILoginResponse } from '../interfaces'; -import { removePadding, verifySignature } from '../utils'; import { Oauth2Service } from './oauth2.service'; const ONE_THOUSAND = 1000; @@ -47,36 +41,52 @@ export class AuthService { private readonly cacheService: CacheService, private readonly oauth2Service: Oauth2Service, ) {} - async sendRegisterOtp(body: CreateUnverifiedUserRequestDto) { - this.logger.log(`Sending OTP to ${body.email}`); - const user = await this.userService.findOrCreateUser(body); + async sendRegisterOtp(body: CreateUnverifiedUserRequestDto) { + if (body.email) { + const isEmailUsed = await this.userService.findUser({ email: body.email, isEmailVerified: true }); + if (isEmailUsed) { + this.logger.error(`Email ${body.email} is already used`); + throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN'); + } + } + + if (body.password !== body.confirmPassword) { + this.logger.error('Password and confirm password do not match'); + throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); + } + + this.logger.log(`Sending OTP to ${body.countryCode + body.phoneNumber}`); + const user = await this.userService.findOrCreateUser(body); return this.otpService.generateAndSendOtp({ userId: user.id, - recipient: user.email, - scope: OtpScope.VERIFY_EMAIL, - otpType: OtpType.EMAIL, + recipient: user.fullPhoneNumber, + scope: OtpScope.VERIFY_PHONE, + otpType: OtpType.SMS, }); } async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> { - this.logger.log(`Verifying user with email ${verifyUserDto.email}`); - const user = await this.userService.findUserOrThrow({ email: verifyUserDto.email }); + this.logger.log(`Verifying user with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber}`); + const user = await this.userService.findUserOrThrow({ + phoneNumber: verifyUserDto.phoneNumber, + countryCode: verifyUserDto.countryCode, + }); - if (user.isEmailVerified) { - this.logger.error(`User with email ${verifyUserDto.email} already verified`); - throw new BadRequestException('USER.EMAIL_ALREADY_VERIFIED'); + if (user.isPhoneVerified) { + this.logger.error(`User with phone number ${user.fullPhoneNumber} already verified`); + throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_VERIFIED'); } const isOtpValid = await this.otpService.verifyOtp({ userId: user.id, - scope: OtpScope.VERIFY_EMAIL, - otpType: OtpType.EMAIL, + scope: OtpScope.VERIFY_PHONE, + otpType: OtpType.SMS, value: verifyUserDto.otp, }); if (!isOtpValid) { - this.logger.error(`Invalid OTP for user with email ${verifyUserDto.email}`); + this.logger.error(`Invalid OTP for user with phone number ${user.fullPhoneNumber}`); throw new BadRequestException('OTP.INVALID_OTP'); } @@ -85,85 +95,10 @@ export class AuthService { await user.reload(); const tokens = await this.generateAuthToken(user); - this.logger.log(`User with email ${verifyUserDto.email} verified successfully`); + this.logger.log(`User with phone number ${user.fullPhoneNumber} verified successfully`); return [tokens, user]; } - async setEmail(userId: string, { email }: SetEmailRequestDto) { - this.logger.log(`Setting email for user with id ${userId}`); - const user = await this.userService.findUserOrThrow({ id: userId }); - - if (user.email) { - this.logger.error(`Email already set for user with id ${userId}`); - throw new BadRequestException('USER.EMAIL_ALREADY_SET'); - } - - const existingUser = await this.userService.findUser({ email }); - - if (existingUser) { - this.logger.error(`Email ${email} already taken`); - throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN'); - } - - return this.userService.setEmail(userId, email); - } - - async setPasscode(userId: string, passcode: string) { - this.logger.log(`Setting passcode for user with id ${userId}`); - const user = await this.userService.findUserOrThrow({ id: userId }); - - if (user.password) { - this.logger.error(`Passcode already set for user with id ${userId}`); - throw new BadRequestException('AUTH.PASSCODE_ALREADY_SET'); - } - const salt = bcrypt.genSaltSync(SALT_ROUNDS); - const hashedPasscode = bcrypt.hashSync(passcode, salt); - - await this.userService.setPasscode(userId, hashedPasscode, salt); - 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 }); - - // 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 }); - - // 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); - - // 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, - // }); - - // if (!isOtpValid) { - // this.logger.error(`Invalid OTP for user with id ${userId}`); - // throw new BadRequestException('OTP.INVALID_OTP'); - // } - - // return this.userService.verifyPhoneNumber(userId); - // } - async enableBiometric(userId: string, { deviceId, publicKey }: EnableBiometricRequestDto) { this.logger.log(`Enabling biometric for user with id ${userId}`); const device = await this.deviceService.findUserDeviceById(deviceId, userId); @@ -201,48 +136,66 @@ export class AuthService { return this.deviceService.updateDevice(deviceId, { publicKey: null }); } - async sendForgetPasswordOtp({ email }: SendForgetPasswordOtpRequestDto) { - this.logger.log(`Sending forget password OTP to ${email}`); - const user = await this.userService.findUserOrThrow({ email }); - - if (!user.isProfileCompleted) { - this.logger.error(`Profile not completed for user with email ${email}`); - throw new BadRequestException('USER.PROFILE_NOT_COMPLETED'); - } + async sendForgetPasswordOtp({ countryCode, phoneNumber }: SendForgetPasswordOtpRequestDto) { + this.logger.log(`Sending forget password OTP to ${countryCode + phoneNumber}`); + const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber }); return this.otpService.generateAndSendOtp({ userId: user.id, - recipient: user.email, + recipient: user.fullPhoneNumber, scope: OtpScope.FORGET_PASSWORD, - otpType: OtpType.EMAIL, + otpType: OtpType.SMS, }); } - async verifyForgetPasswordOtp({ email, otp, password, confirmPassword }: ForgetPasswordRequestDto) { - this.logger.log(`Verifying forget password OTP for ${email}`); - const user = await this.userService.findUserOrThrow({ email }); - if (!user.isProfileCompleted) { - this.logger.error(`Profile not completed for user with email ${email}`); - throw new BadRequestException('USER.PROFILE_NOT_COMPLETED'); - } + async verifyForgetPasswordOtp({ countryCode, phoneNumber, otp }: VerifyForgetPasswordOtpRequestDto) { + const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber }); + const isOtpValid = await this.otpService.verifyOtp({ userId: user.id, scope: OtpScope.FORGET_PASSWORD, - otpType: OtpType.EMAIL, + otpType: OtpType.SMS, value: otp, }); if (!isOtpValid) { - this.logger.error(`Invalid OTP for user with email ${email}`); + this.logger.error(`Invalid OTP for user with phone number ${user.fullPhoneNumber}`); throw new BadRequestException('OTP.INVALID_OTP'); } - this.validatePassword(password, confirmPassword, user); + // generate a token for the user to reset password + const token = await this.userTokenService.generateToken( + user.id, + user.roles.includes(Roles.GUARDIAN) ? UserType.GUARDIAN : UserType.JUNIOR, + moment().add(5, 'minutes').toDate(), + ); + + return { token, user }; + } + async resetPassword({ + countryCode, + phoneNumber, + resetPasswordToken, + password, + confirmPassword, + }: ForgetPasswordRequestDto) { + this.logger.log(`Verifying forget password OTP for ${countryCode + phoneNumber}`); + const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber }); + await this.userTokenService.validateToken( + resetPasswordToken, + user.roles.includes(Roles.GUARDIAN) ? UserType.GUARDIAN : UserType.JUNIOR, + ); + + if (password !== confirmPassword) { + this.logger.error('Password and confirm password do not match'); + throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); + } const hashedPassword = bcrypt.hashSync(password, user.salt); - await this.userService.setPasscode(user.id, hashedPassword, user.salt); - this.logger.log(`Passcode updated successfully for user with email ${email}`); + await this.userService.setPassword(user.id, hashedPassword, user.salt); + await this.userTokenService.invalidateToken(resetPasswordToken); + this.logger.log(`Passcode updated successfully for user with phone number ${user.fullPhoneNumber}`); } async setJuniorPasscode(body: setJuniorPasswordRequestDto) { @@ -250,7 +203,7 @@ export class AuthService { const juniorId = await this.userTokenService.validateToken(body.qrToken, UserType.JUNIOR); const salt = bcrypt.genSaltSync(SALT_ROUNDS); const hashedPasscode = bcrypt.hashSync(body.passcode, salt); - await this.userService.setPasscode(juniorId!, hashedPasscode, salt); + await this.userService.setPassword(juniorId!, hashedPasscode, salt); await this.userTokenService.invalidateToken(body.qrToken); this.logger.log(`Passcode set successfully for junior with id ${juniorId}`); } @@ -291,40 +244,6 @@ 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; @@ -332,147 +251,68 @@ export class AuthService { return this.cacheService.set(accessToken, 'BLACKLISTED', expiryInTtl); } - private async loginWithPassword(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> { - const user = await this.userService.findUserOrThrow({ email: loginDto.email }); + async loginWithPassword(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> { + const user = await this.userService.findUser({ + countryCode: loginDto.countryCode, + phoneNumber: loginDto.phoneNumber, + }); - this.logger.log(`validating password for user with email ${loginDto.email}`); + if (!user) { + this.logger.error(`User not found with phone number ${loginDto.countryCode + loginDto.phoneNumber}`); + throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS'); + } + + if (!user.password) { + this.logger.error(`Password not set for user with phone number ${loginDto.countryCode + loginDto.phoneNumber}`); + throw new UnauthorizedException('AUTH.PHONE_NUMBER_NOT_VERIFIED'); + } + + this.logger.log(`validating password for user with phone ${loginDto.countryCode + loginDto.phoneNumber}`); const isPasswordValid = bcrypt.compareSync(loginDto.password, user.password); if (!isPasswordValid) { - this.logger.error(`Invalid password for user with email ${loginDto.email}`); + this.logger.error(`Invalid password for user with phone ${loginDto.countryCode + loginDto.phoneNumber}`); throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS'); } const tokens = await this.generateAuthToken(user); - this.logger.log(`Password validated successfully for user with email ${loginDto.email}`); + this.logger.log(`Password validated successfully for user`); return [tokens, user]; } - private async loginWithBiometric(loginDto: LoginRequestDto, deviceId: string): Promise<[ILoginResponse, User]> { - const user = await this.userService.findUserOrThrow({ email: loginDto.email }); + // private async loginWithBiometric(loginDto: LoginRequestDto, deviceId: string): Promise<[ILoginResponse, User]> { + // const user = await this.userService.findUserOrThrow({ email: loginDto.email }); - this.logger.log(`validating biometric for user with email ${loginDto.email}`); - const device = await this.deviceService.findUserDeviceById(deviceId, user.id); + // this.logger.log(`validating biometric for user with email ${loginDto.email}`); + // const device = await this.deviceService.findUserDeviceById(deviceId, user.id); - if (!device) { - this.logger.error(`Device not found for user with email ${loginDto.email} and device id ${deviceId}`); - throw new UnauthorizedException('AUTH.DEVICE_NOT_FOUND'); - } + // if (!device) { + // this.logger.error(`Device not found for user with email ${loginDto.email} and device id ${deviceId}`); + // throw new UnauthorizedException('AUTH.DEVICE_NOT_FOUND'); + // } - if (!device.publicKey) { - this.logger.error(`Biometric not enabled for user with email ${loginDto.email}`); - throw new UnauthorizedException('AUTH.BIOMETRIC_NOT_ENABLED'); - } + // if (!device.publicKey) { + // this.logger.error(`Biometric not enabled for user with email ${loginDto.email}`); + // throw new UnauthorizedException('AUTH.BIOMETRIC_NOT_ENABLED'); + // } - const cleanToken = removePadding(loginDto.signature); - const isValidToken = await verifySignature( - device.publicKey, - cleanToken, - `${user.email} - ${device.deviceId}`, - 'SHA1', - ); + // const cleanToken = removePadding(loginDto.signature); + // const isValidToken = await verifySignature( + // device.publicKey, + // cleanToken, + // `${user.email} - ${device.deviceId}`, + // 'SHA1', + // ); - if (!isValidToken) { - this.logger.error(`Invalid biometric for user with email ${loginDto.email}`); - throw new UnauthorizedException('AUTH.INVALID_BIOMETRIC'); - } + // if (!isValidToken) { + // this.logger.error(`Invalid biometric for user with email ${loginDto.email}`); + // throw new UnauthorizedException('AUTH.INVALID_BIOMETRIC'); + // } - const tokens = await this.generateAuthToken(user); - this.logger.log(`Biometric validated successfully for user with email ${loginDto.email}`); - return [tokens, user]; - } - - 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) { - this.logger.error(`User with email ${email} is an already registered junior`); - throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET'); - } - - 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!); - - return [tokens, existingUser!]; - } - - async loginWithApple(loginDto: AppleLoginRequestDto): Promise<[ILoginResponse, User]> { - const { sub, email } = await this.oauth2Service.verifyAppleToken(loginDto.appleToken); - - const [existingUserWithSub, isJunior] = await Promise.all([ - this.userService.findUser({ appleId: sub }), - this.userService.findUser({ email, roles: ArrayContains([Roles.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 (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 || !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, - loginDto.additionalData.firstName, - loginDto.additionalData.lastName, - ); - - const tokens = await this.generateAuthToken(user); - - return [tokens, user]; - } - - const tokens = await this.generateAuthToken(existingUserWithSub); - - this.logger.log(`User with apple id ${sub} logged in successfully`); - - return [tokens, existingUserWithSub]; - } + // const tokens = await this.generateAuthToken(user); + // this.logger.log(`Biometric validated successfully for user with email ${loginDto.email}`); + // return [tokens, user]; + // } private async generateAuthToken(user: User) { this.logger.log(`Generating auth token for user with id ${user.id}`); @@ -496,19 +336,4 @@ export class AuthService { this.logger.log(`Auth token generated successfully for user with id ${user.id}`); return { accessToken, refreshToken, expiresAt: new Date(this.jwtService.decode(accessToken).exp * ONE_THOUSAND) }; } - - private validatePassword(password: string, confirmPassword: string, user: User) { - this.logger.log(`Validating password for user with id ${user.id}`); - if (password !== confirmPassword) { - this.logger.error(`Password mismatch for user with id ${user.id}`); - throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); - } - - if (!PASSCODE_REGEX.test(password)) { - this.logger.error(`Invalid password for user with id ${user.id}`); - throw new BadRequestException('AUTH.INVALID_PASSCODE'); - } - } - - private validateGoogleToken(googleToken: string) {} } diff --git a/src/card/card.module.ts b/src/card/card.module.ts new file mode 100644 index 0000000..92500ad --- /dev/null +++ b/src/card/card.module.ts @@ -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 {} diff --git a/src/card/entities/account.entity.ts b/src/card/entities/account.entity.ts new file mode 100644 index 0000000..4a1ffcb --- /dev/null +++ b/src/card/entities/account.entity.ts @@ -0,0 +1,39 @@ +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; + + @Index({ unique: true }) + @Column('varchar', { length: 255, nullable: false, name: 'account_number' }) + accountNumber!: string; + + @Index({ unique: true }) + @Column('varchar', { length: 255, nullable: false, name: 'iban' }) + iban!: 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; +} diff --git a/src/card/entities/card.entity.ts b/src/card/entities/card.entity.ts new file mode 100644 index 0000000..72ce7a0 --- /dev/null +++ b/src/card/entities/card.entity.ts @@ -0,0 +1,89 @@ +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; + + @Index({ unique: true }) + @Column({ name: 'vpan', nullable: false, type: 'varchar' }) + vpan!: 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; +} diff --git a/src/card/entities/index.ts b/src/card/entities/index.ts new file mode 100644 index 0000000..1c77083 --- /dev/null +++ b/src/card/entities/index.ts @@ -0,0 +1 @@ +export * from './card.entity'; diff --git a/src/card/entities/transaction.entity.ts b/src/card/entities/transaction.entity.ts new file mode 100644 index 0000000..029ef29 --- /dev/null +++ b/src/card/entities/transaction.entity.ts @@ -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; +} diff --git a/src/card/enums/card-colors.enum.ts b/src/card/enums/card-colors.enum.ts new file mode 100644 index 0000000..9fe8e47 --- /dev/null +++ b/src/card/enums/card-colors.enum.ts @@ -0,0 +1,4 @@ +export enum CardColors { + RED = 'RED', + BLUE = 'BLUE', +} diff --git a/src/card/enums/card-issuers.enum.ts b/src/card/enums/card-issuers.enum.ts new file mode 100644 index 0000000..8c31985 --- /dev/null +++ b/src/card/enums/card-issuers.enum.ts @@ -0,0 +1,3 @@ +export enum CardIssuers { + NEOLEAP = 'NEOLEAP', +} diff --git a/src/card/enums/card-scheme.enum.ts b/src/card/enums/card-scheme.enum.ts new file mode 100644 index 0000000..f4c6dcf --- /dev/null +++ b/src/card/enums/card-scheme.enum.ts @@ -0,0 +1,4 @@ +export enum CardScheme { + VISA = 'VISA', + MASTERCARD = 'MASTERCARD', +} diff --git a/src/card/enums/card-status-description.enum.ts b/src/card/enums/card-status-description.enum.ts new file mode 100644 index 0000000..1c277a8 --- /dev/null +++ b/src/card/enums/card-status-description.enum.ts @@ -0,0 +1,68 @@ +/** + * import { CardStatus, CardStatusDescription } from '../enums'; + + export const CardStatusMapper: Record = { + //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', +} diff --git a/src/card/enums/card-status.enum.ts b/src/card/enums/card-status.enum.ts new file mode 100644 index 0000000..e48ad71 --- /dev/null +++ b/src/card/enums/card-status.enum.ts @@ -0,0 +1,6 @@ +export enum CardStatus { + ACTIVE = 'ACTIVE', + CANCELED = 'CANCELED', + BLOCKED = 'BLOCKED', + PENDING = 'PENDING', +} diff --git a/src/card/enums/customer-type.enum.ts b/src/card/enums/customer-type.enum.ts new file mode 100644 index 0000000..4a236e3 --- /dev/null +++ b/src/card/enums/customer-type.enum.ts @@ -0,0 +1,4 @@ +export enum CustomerType { + PARENT = 'PARENT', + CHILD = 'CHILD', +} diff --git a/src/card/enums/index.ts b/src/card/enums/index.ts new file mode 100644 index 0000000..16b52c2 --- /dev/null +++ b/src/card/enums/index.ts @@ -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'; diff --git a/src/card/enums/transaction-scope.enum.ts b/src/card/enums/transaction-scope.enum.ts new file mode 100644 index 0000000..d1afd17 --- /dev/null +++ b/src/card/enums/transaction-scope.enum.ts @@ -0,0 +1,4 @@ +export enum TransactionScope { + CARD = 'CARD', + ACCOUNT = 'ACCOUNT', +} diff --git a/src/card/enums/transaction-type.enum.ts b/src/card/enums/transaction-type.enum.ts new file mode 100644 index 0000000..a65e819 --- /dev/null +++ b/src/card/enums/transaction-type.enum.ts @@ -0,0 +1,4 @@ +export enum TransactionType { + INTERNAL = 'INTERNAL', + EXTERNAL = 'EXTERNAL', +} diff --git a/src/card/mappers/card-status-description.mapper.ts b/src/card/mappers/card-status-description.mapper.ts new file mode 100644 index 0000000..85fe6d9 --- /dev/null +++ b/src/card/mappers/card-status-description.mapper.ts @@ -0,0 +1,109 @@ +import { UserLocale } from '~/core/enums'; +import { CardStatusDescription } from '../enums'; + +export const CardStatusMapper: Record = { + [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: 'حالة البطاقة غير معروفة' }, + }, +}; diff --git a/src/card/mappers/card-status.mapper.ts b/src/card/mappers/card-status.mapper.ts new file mode 100644 index 0000000..3c6da90 --- /dev/null +++ b/src/card/mappers/card-status.mapper.ts @@ -0,0 +1,37 @@ +import { CardStatus, CardStatusDescription } from '../enums'; + +export const CardStatusMapper: Record = { + //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 }, +}; diff --git a/src/card/repositories/account.repository.ts b/src/card/repositories/account.repository.ts new file mode 100644 index 0000000..428b109 --- /dev/null +++ b/src/card/repositories/account.repository.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreateApplicationResponse } from '~/common/modules/neoleap/dtos/response'; +import { Account } from '../entities/account.entity'; + +@Injectable() +export class AccountRepository { + constructor(@InjectRepository(Account) private readonly accountRepository: Repository) {} + + createAccount(data: CreateApplicationResponse): Promise { + return this.accountRepository.save( + this.accountRepository.create({ + accountReference: data.accountId, + accountNumber: data.accountNumber, + iban: data.iBan, + balance: 0, + currency: '682', + }), + ); + } + + getAccountByReferenceNumber(accountReference: string): Promise { + return this.accountRepository.findOne({ + where: { accountReference }, + relations: ['cards'], + }); + } + + getAccountByAccountNumber(accountNumber: string): Promise { + return this.accountRepository.findOne({ + where: { accountNumber }, + 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); + } +} diff --git a/src/card/repositories/card.repository.ts b/src/card/repositories/card.repository.ts new file mode 100644 index 0000000..a6e8cb6 --- /dev/null +++ b/src/card/repositories/card.repository.ts @@ -0,0 +1,57 @@ +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) {} + + createCard(customerId: string, accountId: string, card: CreateApplicationResponse): Promise { + 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, + vpan: card.vpan, + }), + ); + } + + getCardById(id: string): Promise { + return this.cardRepository.findOne({ where: { id } }); + } + + getCardByReferenceNumber(referenceNumber: string): Promise { + return this.cardRepository.findOne({ where: { cardReference: referenceNumber }, relations: ['account'] }); + } + + getCardByVpan(vpan: string): Promise { + return this.cardRepository.findOne({ + where: { vpan }, + relations: ['account'], + }); + } + + getActiveCardForCustomer(customerId: string): Promise { + 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, + }); + } +} diff --git a/src/card/repositories/index.ts b/src/card/repositories/index.ts new file mode 100644 index 0000000..8458740 --- /dev/null +++ b/src/card/repositories/index.ts @@ -0,0 +1 @@ +export * from './card.repository'; diff --git a/src/card/repositories/transaction.repository.ts b/src/card/repositories/transaction.repository.ts new file mode 100644 index 0000000..39d8665 --- /dev/null +++ b/src/card/repositories/transaction.repository.ts @@ -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) {} + + createCardTransaction(card: Card, transactionData: CardTransactionWebhookRequest): Promise { + 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 { + 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 { + return this.transactionRepository.findOne({ + where: { transactionId, accountReference }, + }); + } +} diff --git a/src/card/services/account.service.ts b/src/card/services/account.service.ts new file mode 100644 index 0000000..ac9e0dd --- /dev/null +++ b/src/card/services/account.service.ts @@ -0,0 +1,47 @@ +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; +import { CreateApplicationResponse } from '~/common/modules/neoleap/dtos/response'; +import { Account } from '../entities/account.entity'; +import { AccountRepository } from '../repositories/account.repository'; + +@Injectable() +export class AccountService { + constructor(private readonly accountRepository: AccountRepository) {} + + createAccount(data: CreateApplicationResponse): Promise { + return this.accountRepository.createAccount(data); + } + + async getAccountByReferenceNumber(accountReference: string): Promise { + const account = await this.accountRepository.getAccountByReferenceNumber(accountReference); + if (!account) { + throw new UnprocessableEntityException('ACCOUNT.NOT_FOUND'); + } + return account; + } + + async getAccountByAccountNumber(accountNumber: string): Promise { + const account = await this.accountRepository.getAccountByAccountNumber(accountNumber); + 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); + } +} diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts new file mode 100644 index 0000000..6d48d0f --- /dev/null +++ b/src/card/services/card.service.ts @@ -0,0 +1,63 @@ +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 { + const account = await this.accountService.createAccount(cardData); + return this.cardRepository.createCard(customerId, account.id, cardData); + } + + async getCardById(id: string): Promise { + const card = await this.cardRepository.getCardById(id); + + if (!card) { + throw new BadRequestException('CARD.NOT_FOUND'); + } + + return card; + } + + async getCardByReferenceNumber(referenceNumber: string): Promise { + const card = await this.cardRepository.getCardByReferenceNumber(referenceNumber); + + if (!card) { + throw new BadRequestException('CARD.NOT_FOUND'); + } + + return card; + } + + async getCardByVpan(vpan: string): Promise { + const card = await this.cardRepository.getCardByVpan(vpan); + + if (!card) { + throw new BadRequestException('CARD.NOT_FOUND'); + } + return card; + } + + async getActiveCardForCustomer(customerId: string): Promise { + 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.getCardByVpan(body.cardId); + const { description, status } = CardStatusMapper[body.newStatus] || CardStatusMapper['99']; + + return this.cardRepository.updateCardStatus(card.id, status, description); + } +} diff --git a/src/card/services/index.ts b/src/card/services/index.ts new file mode 100644 index 0000000..ea35f0f --- /dev/null +++ b/src/card/services/index.ts @@ -0,0 +1 @@ +export * from './card.service'; diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts new file mode 100644 index 0000000..ff22aef --- /dev/null +++ b/src/card/services/transaction.service.ts @@ -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.getCardByVpan(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.getAccountByAccountNumber(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 { + const existingTransaction = await this.transactionRepository.findTransactionByReference( + transactionId, + accountReference, + ); + + return existingTransaction; + } +} diff --git a/src/common/modules/neoleap/__mocks__/create-application.mock.ts b/src/common/modules/neoleap/__mocks__/create-application.mock.ts new file mode 100644 index 0000000..760d2c4 --- /dev/null +++ b/src/common/modules/neoleap/__mocks__/create-application.mock.ts @@ -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, + }, + ], + }, +}; diff --git a/src/common/modules/neoleap/__mocks__/index.ts b/src/common/modules/neoleap/__mocks__/index.ts new file mode 100644 index 0000000..db8964a --- /dev/null +++ b/src/common/modules/neoleap/__mocks__/index.ts @@ -0,0 +1,2 @@ +export * from './create-application.mock'; +export * from './inquire-application.mock'; diff --git a/src/common/modules/neoleap/__mocks__/inquire-application.mock.ts b/src/common/modules/neoleap/__mocks__/inquire-application.mock.ts new file mode 100644 index 0000000..6f2b7d2 --- /dev/null +++ b/src/common/modules/neoleap/__mocks__/inquire-application.mock.ts @@ -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, + }, +}; diff --git a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts new file mode 100644 index 0000000..03aaf8a --- /dev/null +++ b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts @@ -0,0 +1,33 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ResponseFactory } from '~/core/utils'; +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) { + await this.neoleapWebhookService.handleCardTransactionWebhook(body); + return ResponseFactory.data({ message: 'Card transaction processed successfully', status: 'success' }); + } + + @Post('account-transaction') + async handleAccountTransactionWebhook(@Body() body: AccountTransactionWebhookRequest) { + await this.neoleapWebhookService.handleAccountTransactionWebhook(body); + return ResponseFactory.data({ message: 'Account transaction processed successfully', status: 'success' }); + } + + @Post('account-card-status-changed') + async handleAccountCardStatusChangedWebhook(@Body() body: AccountCardStatusChangedWebhookRequest) { + await this.neoleapWebhookService.handleAccountCardStatusChangedWebhook(body); + return ResponseFactory.data({ message: 'Card status updated successfully', status: 'success' }); + } +} diff --git a/src/common/modules/neoleap/controllers/neotest.controller.ts b/src/common/modules/neoleap/controllers/neotest.controller.ts new file mode 100644 index 0000000..5ea191d --- /dev/null +++ b/src/common/modules/neoleap/controllers/neotest.controller.ts @@ -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' }); + } +} diff --git a/src/common/modules/neoleap/dtos/requests/account-card-status-changed-webhook.request.dto.ts b/src/common/modules/neoleap/dtos/requests/account-card-status-changed-webhook.request.dto.ts new file mode 100644 index 0000000..412effc --- /dev/null +++ b/src/common/modules/neoleap/dtos/requests/account-card-status-changed-webhook.request.dto.ts @@ -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; +} diff --git a/src/common/modules/neoleap/dtos/requests/account-transaction-webhook.request.dto.ts b/src/common/modules/neoleap/dtos/requests/account-transaction-webhook.request.dto.ts new file mode 100644 index 0000000..06e1437 --- /dev/null +++ b/src/common/modules/neoleap/dtos/requests/account-transaction-webhook.request.dto.ts @@ -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; +} diff --git a/src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts b/src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts new file mode 100644 index 0000000..db8afe5 --- /dev/null +++ b/src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts @@ -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; +} diff --git a/src/common/modules/neoleap/dtos/requests/index.ts b/src/common/modules/neoleap/dtos/requests/index.ts new file mode 100644 index 0000000..9360a77 --- /dev/null +++ b/src/common/modules/neoleap/dtos/requests/index.ts @@ -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'; diff --git a/src/common/modules/neoleap/dtos/requests/update-card-controls.request.dto.ts b/src/common/modules/neoleap/dtos/requests/update-card-controls.request.dto.ts new file mode 100644 index 0000000..280c05e --- /dev/null +++ b/src/common/modules/neoleap/dtos/requests/update-card-controls.request.dto.ts @@ -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; +} diff --git a/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts b/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts new file mode 100644 index 0000000..8da0c0a --- /dev/null +++ b/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts @@ -0,0 +1,50 @@ +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; + + @Transform(({ obj }) => obj.AccountDetailsList?.[0]?.AccountNumber) + @Expose() + @ApiProperty() + accountNumber!: string; + + @Transform(({ obj }) => obj.AccountDetailsList?.[0]?.UserData5) + @Expose() + @ApiProperty() + iBan!: string; +} diff --git a/src/common/modules/neoleap/dtos/response/index.ts b/src/common/modules/neoleap/dtos/response/index.ts new file mode 100644 index 0000000..457e4bc --- /dev/null +++ b/src/common/modules/neoleap/dtos/response/index.ts @@ -0,0 +1,3 @@ +export * from './create-application.response.dto'; +export * from './inquire-application.response'; +export * from './update-card-controls.response.dto'; diff --git a/src/common/modules/neoleap/dtos/response/inquire-application.response.ts b/src/common/modules/neoleap/dtos/response/inquire-application.response.ts new file mode 100644 index 0000000..aeb7165 --- /dev/null +++ b/src/common/modules/neoleap/dtos/response/inquire-application.response.ts @@ -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; +} diff --git a/src/common/modules/neoleap/dtos/response/update-card-controls.response.dto.ts b/src/common/modules/neoleap/dtos/response/update-card-controls.response.dto.ts new file mode 100644 index 0000000..f39eaa3 --- /dev/null +++ b/src/common/modules/neoleap/dtos/response/update-card-controls.response.dto.ts @@ -0,0 +1 @@ +export class UpdateCardControlsResponseDto {} diff --git a/src/common/modules/neoleap/interfaces/create-application.request.interface.ts b/src/common/modules/neoleap/interfaces/create-application.request.interface.ts new file mode 100644 index 0000000..9b313c6 --- /dev/null +++ b/src/common/modules/neoleap/interfaces/create-application.request.interface.ts @@ -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; + }; + }; +} diff --git a/src/common/modules/neoleap/interfaces/index.ts b/src/common/modules/neoleap/interfaces/index.ts new file mode 100644 index 0000000..911935c --- /dev/null +++ b/src/common/modules/neoleap/interfaces/index.ts @@ -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'; diff --git a/src/common/modules/neoleap/interfaces/inquire-application.request.interface.ts b/src/common/modules/neoleap/interfaces/inquire-application.request.interface.ts new file mode 100644 index 0000000..866c61b --- /dev/null +++ b/src/common/modules/neoleap/interfaces/inquire-application.request.interface.ts @@ -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[]; + }; +} diff --git a/src/common/modules/neoleap/interfaces/neoleap-header.request.interface.ts b/src/common/modules/neoleap/interfaces/neoleap-header.request.interface.ts new file mode 100644 index 0000000..81f4592 --- /dev/null +++ b/src/common/modules/neoleap/interfaces/neoleap-header.request.interface.ts @@ -0,0 +1,9 @@ +export interface INeoleapHeaderRequest { + RequestHeader: { + Version: string; + MsgUid: string; + Source: 'ZOD'; + ServiceId: string; + ReqDateTime: Date; + }; +} diff --git a/src/common/modules/neoleap/interfaces/update-card-control.request.interface.ts b/src/common/modules/neoleap/interfaces/update-card-control.request.interface.ts new file mode 100644 index 0000000..3764887 --- /dev/null +++ b/src/common/modules/neoleap/interfaces/update-card-control.request.interface.ts @@ -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; + }; + }; +} diff --git a/src/common/modules/neoleap/neoleap.module.ts b/src/common/modules/neoleap/neoleap.module.ts new file mode 100644 index 0000000..39951f0 --- /dev/null +++ b/src/common/modules/neoleap/neoleap.module.ts @@ -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 {} diff --git a/src/common/modules/neoleap/services/index.ts b/src/common/modules/neoleap/services/index.ts new file mode 100644 index 0000000..4fac8d7 --- /dev/null +++ b/src/common/modules/neoleap/services/index.ts @@ -0,0 +1,2 @@ +export * from './neoleap-webook.service'; +export * from './neoleap.service'; diff --git a/src/common/modules/neoleap/services/neoleap-webook.service.ts b/src/common/modules/neoleap/services/neoleap-webook.service.ts new file mode 100644 index 0000000..83b68c4 --- /dev/null +++ b/src/common/modules/neoleap/services/neoleap-webook.service.ts @@ -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); + } +} diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts new file mode 100644 index 0000000..3abd065 --- /dev/null +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -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('GATEWAY_URL'); + this.apiKey = this.configService.getOrThrow('GATEWAY_API_KEY'); + this.useGateway = [true, 'true'].includes(this.configService.get('USE_GATEWAY', false)); + this.useLocalCert = this.configService.get('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( + '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( + '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( + '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( + endpoint: string, + payload: T, + responseKey: string, + responseClass: ClassConstructor, + ): Promise { + 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'); + } + } +} diff --git a/src/core/enums/user-locale.enum.ts b/src/core/enums/user-locale.enum.ts index b9aa1f4..66339fe 100644 --- a/src/core/enums/user-locale.enum.ts +++ b/src/core/enums/user-locale.enum.ts @@ -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; diff --git a/src/core/pipes/validation.pipe.ts b/src/core/pipes/validation.pipe.ts index 505fb03..5153b10 100644 --- a/src/core/pipes/validation.pipe.ts +++ b/src/core/pipes/validation.pipe.ts @@ -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, diff --git a/src/customer/dtos/response/customer.response.dto.ts b/src/customer/dtos/response/customer.response.dto.ts index 8b6a0ba..f0b46f6 100644 --- a/src/customer/dtos/response/customer.response.dto.ts +++ b/src/customer/dtos/response/customer.response.dto.ts @@ -1,63 +1,81 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Customer } from '~/customer/entities'; -import { CustomerStatus, KycStatus } from '~/customer/enums'; +import { CustomerStatus, Gender, KycStatus } from '~/customer/enums'; import { DocumentMetaResponseDto } from '~/document/dtos/response'; export class CustomerResponseDto { - @ApiProperty() + @ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' }) id!: string; - @ApiProperty() + @ApiProperty({ example: CustomerStatus.PENDING }) customerStatus!: CustomerStatus; - @ApiProperty() + @ApiProperty({ example: KycStatus.PENDING }) kycStatus!: KycStatus; - @ApiProperty() + @ApiProperty({ example: 'Rejection reason if any' }) rejectionReason!: string | null; - @ApiProperty() + @ApiProperty({ example: 'John' }) firstName!: string; - @ApiProperty() + @ApiProperty({ example: 'Doe' }) lastName!: string; - @ApiProperty() + @ApiProperty({ example: '1990-01-01' }) dateOfBirth!: Date; - @ApiProperty() + @ApiProperty({ example: '123456789' }) nationalId!: string; - @ApiProperty() + @ApiProperty({ example: '2025-01-01' }) nationalIdExpiry!: Date; - @ApiProperty() + @ApiProperty({ example: 'JO' }) countryOfResidence!: string; - @ApiProperty() + @ApiProperty({ example: 'Employee' }) sourceOfIncome!: string; - @ApiProperty() + @ApiProperty({ example: 'Software Development' }) profession!: string; - @ApiProperty() + @ApiProperty({ example: 'Full-time' }) professionType!: string; - @ApiProperty() + @ApiProperty({ example: false }) isPep!: boolean; - @ApiProperty() + @ApiProperty({ example: Gender.MALE }) gender!: string; - @ApiProperty() + @ApiProperty({ example: false }) isJunior!: boolean; - @ApiProperty() + @ApiProperty({ example: true }) isGuardian!: boolean; - @ApiProperty() + @ApiProperty({ example: 12345 }) waitingNumber!: number; + @ApiProperty({ example: 'SA' }) + country!: string | null; + + @ApiProperty({ example: 'Riyadh' }) + region!: string | null; + + @ApiProperty({ example: 'Riyadh City' }) + city!: string | null; + + @ApiProperty({ example: 'Al-Masif' }) + neighborhood!: string | null; + + @ApiProperty({ example: 'King Fahd Road' }) + street!: string | null; + + @ApiProperty({ example: '123' }) + building!: string | null; + @ApiPropertyOptional({ type: DocumentMetaResponseDto }) profilePicture!: DocumentMetaResponseDto | null; @@ -80,6 +98,12 @@ export class CustomerResponseDto { 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; } diff --git a/src/customer/entities/customer.entity.ts b/src/customer/entities/customer.entity.ts index 6b977cc..aa029ef 100644 --- a/src/customer/entities/customer.entity.ts +++ b/src/customer/entities/customer.entity.ts @@ -5,10 +5,13 @@ import { 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'; @@ -45,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; @@ -75,6 +78,24 @@ export class Customer extends BaseEntity { @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; @@ -106,9 +127,21 @@ export class Customer extends BaseEntity { @JoinColumn({ name: 'civil_id_back_id' }) civilIdBack!: Document; + // 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', name: 'updated_at' }) updatedAt!: Date; + + get fullName(): string { + return `${this.firstName} ${this.lastName}`; + } } diff --git a/src/customer/repositories/customer.repository.ts b/src/customer/repositories/customer.repository.ts index a17b383..85565a6 100644 --- a/src/customer/repositories/customer.repository.ts +++ b/src/customer/repositories/customer.repository.ts @@ -15,7 +15,7 @@ export class CustomerRepository { findOne(where: FindOptionsWhere) { return this.customerRepository.findOne({ where, - relations: ['profilePicture', 'user', 'civilIdFront', 'civilIdBack'], + relations: ['profilePicture', 'user', 'civilIdFront', 'civilIdBack', 'cards'], }); } diff --git a/src/customer/services/customer.service.ts b/src/customer/services/customer.service.ts index 85c4d0a..5690858 100644 --- a/src/customer/services/customer.service.ts +++ b/src/customer/services/customer.service.ts @@ -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() @@ -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; @@ -198,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}`; + } } diff --git a/src/db/migrations/1733990253208-seeds-default-tasks-logo.ts b/src/db/migrations/1733990253208-seeds-default-tasks-logo.ts index ea02a1c..94661aa 100644 --- a/src/db/migrations/1733990253208-seeds-default-tasks-logo.ts +++ b/src/db/migrations/1733990253208-seeds-default-tasks-logo.ts @@ -1,31 +1,35 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import { v4 as uuid } from 'uuid'; import { Document } from '~/document/entities'; -import { DocumentType } from '~/document/enums'; +import { DocumentType, UploadStatus } from '~/document/enums'; const DEFAULT_TASK_LOGOS = [ { id: uuid(), name: 'bed-furniture', extension: '.jpg', documentType: DocumentType.DEFAULT_TASKS_LOGO, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'dog', extension: '.jpg', documentType: DocumentType.DEFAULT_TASKS_LOGO, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'dish-washing', extension: '.jpg', documentType: DocumentType.DEFAULT_TASKS_LOGO, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'walking-the-dog', extension: '.jpg', documentType: DocumentType.DEFAULT_TASKS_LOGO, + uploadStatus: UploadStatus.UPLOADED, }, ]; export class SeedsDefaultTasksLogo1733990253208 implements MigrationInterface { diff --git a/src/db/migrations/1753869637732-seed-default-avatar.ts b/src/db/migrations/1753869637732-seed-default-avatar.ts index 7b1a5e3..a6c5935 100644 --- a/src/db/migrations/1753869637732-seed-default-avatar.ts +++ b/src/db/migrations/1753869637732-seed-default-avatar.ts @@ -1,61 +1,70 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import { v4 as uuid } from 'uuid'; import { Document } from '../../document/entities'; -import { DocumentType } from '../../document/enums'; +import { DocumentType, UploadStatus } from '../../document/enums'; const DEFAULT_AVATARS = [ { id: uuid(), name: 'vacation', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'colors', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'astronaut', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'pet', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'disney', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'clothes', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'playstation', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'football', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'cars', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, ]; export class SeedDefaultAvatar1753869637732 implements MigrationInterface { diff --git a/src/db/migrations/1753874205042-add-neoleap-related-entities.ts b/src/db/migrations/1753874205042-add-neoleap-related-entities.ts new file mode 100644 index 0000000..ab80885 --- /dev/null +++ b/src/db/migrations/1753874205042-add-neoleap-related-entities.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddNeoleapRelatedEntities1753874205042 implements MigrationInterface { + name = 'AddNeoleapRelatedEntities1753874205042' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "transactions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "transaction_scope" character varying NOT NULL, "transaction_type" character varying NOT NULL DEFAULT 'EXTERNAL', "card_reference" character varying, "account_reference" character varying, "transaction_id" character varying, "card_masked_number" character varying, "transaction_date" TIMESTAMP WITH TIME ZONE, "rrn" character varying, "transaction_amount" numeric(12,2) NOT NULL, "transaction_currency" character varying NOT NULL, "billing_amount" numeric(12,2) NOT NULL, "settlement_amount" numeric(12,2) NOT NULL, "fees" numeric(12,2) NOT NULL, "vat_on_fees" numeric(12,2) NOT NULL DEFAULT '0', "card_id" uuid, "account_id" uuid, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_9162bf9ab4e31961a8f7932974c" UNIQUE ("transaction_id"), CONSTRAINT "PK_a219afd8dd77ed80f5a862f1db9" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "accounts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "account_reference" character varying(255) NOT NULL, "currency" character varying(255) NOT NULL, "balance" numeric(10,2) NOT NULL DEFAULT '0', "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_829183fe026a0ce5fc8026b2417" UNIQUE ("account_reference"), CONSTRAINT "PK_5a7a02c20412299d198e097a8fe" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_829183fe026a0ce5fc8026b241" ON "accounts" ("account_reference") `); + 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', "statusDescription" character varying NOT NULL DEFAULT 'PENDING_ACTIVATION', "limit" numeric(10,2) NOT NULL DEFAULT '0', "scheme" character varying NOT NULL DEFAULT 'VISA', "issuer" character varying NOT NULL, "customer_id" uuid NOT NULL, "parent_id" uuid, "account_id" uuid NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_5f3269634705fdff4a9935860fc" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_4f7df5c5dc950295dc417a11d8" ON "cards" ("card_reference") `); + 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)`); + await queryRunner.query(`ALTER TABLE "transactions" ADD CONSTRAINT "FK_80ad48141be648db2d84ff32f79" FOREIGN KEY ("card_id") REFERENCES "cards"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "transactions" ADD CONSTRAINT "FK_49c0d6e8ba4bfb5582000d851f0" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "cards" ADD CONSTRAINT "FK_8ba18e7060c38ddae12e5bdf907" FOREIGN KEY ("parent_id") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "cards" ADD CONSTRAINT "FK_2fd0ee722ec57594d2e448c73d7" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "cards" ADD CONSTRAINT "FK_b2874ef49ff7da2dee49e4bc6d3" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_b2874ef49ff7da2dee49e4bc6d3"`); + await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_2fd0ee722ec57594d2e448c73d7"`); + await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_8ba18e7060c38ddae12e5bdf907"`); + await queryRunner.query(`ALTER TABLE "transactions" DROP CONSTRAINT "FK_49c0d6e8ba4bfb5582000d851f0"`); + await queryRunner.query(`ALTER TABLE "transactions" DROP CONSTRAINT "FK_80ad48141be648db2d84ff32f79"`); + 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"`); + await queryRunner.query(`DROP INDEX "public"."IDX_4f7df5c5dc950295dc417a11d8"`); + await queryRunner.query(`DROP TABLE "cards"`); + await queryRunner.query(`DROP INDEX "public"."IDX_829183fe026a0ce5fc8026b241"`); + await queryRunner.query(`DROP TABLE "accounts"`); + await queryRunner.query(`DROP TABLE "transactions"`); + } + +} diff --git a/src/db/migrations/1753948642040-add-account-number-and-iban-to-account-entity.ts b/src/db/migrations/1753948642040-add-account-number-and-iban-to-account-entity.ts new file mode 100644 index 0000000..165be69 --- /dev/null +++ b/src/db/migrations/1753948642040-add-account-number-and-iban-to-account-entity.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +export class AddAccountNumberAndIbanToAccountEntity1753948642040 implements MigrationInterface { + name = 'AddAccountNumberAndIbanToAccountEntity1753948642040'; + + public async up(queryRunner: QueryRunner): Promise { + // Step 1: Add columns as nullable + await queryRunner.query(`ALTER TABLE "accounts" ADD "account_number" character varying(255)`); + await queryRunner.query(`ALTER TABLE "accounts" ADD "iban" character varying(255)`); + + // Step 2: Populate dummy values or correct ones + await queryRunner.query(` + UPDATE "accounts" + SET "account_number" = 'TEMP_ACC_' || id, + "iban" = 'TEMP_IBAN_' || id + `); + + // Step 3: Alter columns to be NOT NULL + await queryRunner.query(`ALTER TABLE "accounts" ALTER COLUMN "account_number" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "accounts" ALTER COLUMN "iban" SET NOT NULL`); + + // Step 4: Add unique indexes + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_ffd1ae96513bfb2c6eada0f7d3" ON "accounts" ("account_number")`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_9a4b004902294416b096e7556e" ON "accounts" ("iban")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_9a4b004902294416b096e7556e"`); + await queryRunner.query(`DROP INDEX "public"."IDX_ffd1ae96513bfb2c6eada0f7d3"`); + await queryRunner.query(`ALTER TABLE "accounts" DROP COLUMN "iban"`); + await queryRunner.query(`ALTER TABLE "accounts" DROP COLUMN "account_number"`); + } +} diff --git a/src/db/migrations/1754210729273-add-vpan-to-card.ts b/src/db/migrations/1754210729273-add-vpan-to-card.ts new file mode 100644 index 0000000..03d7beb --- /dev/null +++ b/src/db/migrations/1754210729273-add-vpan-to-card.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddVpanToCard1754210729273 implements MigrationInterface { + name = 'AddVpanToCard1754210729273'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "cards" ADD "vpan" character varying`); + await queryRunner.query(` + UPDATE "cards" + SET "vpan" = 'TEMP_VPAN_' || id + `); + + await queryRunner.query(`ALTER TABLE "cards" ALTER COLUMN "vpan" SET NOT NULL`); + + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_1ec2ef68b0370f26639261e87b" ON "cards" ("vpan") `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_1ec2ef68b0370f26639261e87b"`); + await queryRunner.query(`ALTER TABLE "cards" DROP COLUMN "vpan"`); + } +} diff --git a/src/db/migrations/1754226754947-add-upload-status-to-document-entity.ts b/src/db/migrations/1754226754947-add-upload-status-to-document-entity.ts new file mode 100644 index 0000000..10a6b67 --- /dev/null +++ b/src/db/migrations/1754226754947-add-upload-status-to-document-entity.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUploadStatusToDocumentEntity1754226754947 implements MigrationInterface { + name = 'AddUploadStatusToDocumentEntity1754226754947'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "documents" ADD "upload_status" character varying(255) NOT NULL DEFAULT 'NOT_UPLOADED'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "documents" DROP COLUMN "upload_status"`); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index e06880b..85c5217 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -1,4 +1,8 @@ -export * from './1753869637732-seed-default-avatar'; +export * from './1733750228289-initial-migration'; export * from './1733990253208-seeds-default-tasks-logo'; export * from './1734247702310-seeds-goals-categories'; -export * from './1733750228289-initial-migration'; +export * from './1753869637732-seed-default-avatar'; +export * from './1753874205042-add-neoleap-related-entities'; +export * from './1753948642040-add-account-number-and-iban-to-account-entity'; +export * from './1754210729273-add-vpan-to-card'; +export * from './1754226754947-add-upload-status-to-document-entity'; diff --git a/src/document/controllers/document.controller.ts b/src/document/controllers/document.controller.ts index 7b3eed1..4393413 100644 --- a/src/document/controllers/document.controller.ts +++ b/src/document/controllers/document.controller.ts @@ -1,15 +1,12 @@ -import { Body, Controller, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; -import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; -import { memoryStorage } from 'multer'; +import { Body, Controller, HttpCode, HttpStatus, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiNoContentResponse, ApiTags } from '@nestjs/swagger'; import { IJwtPayload } from '~/auth/interfaces'; import { AuthenticatedUser } 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 { UploadDocumentRequestDto } from '../dtos/request'; -import { DocumentMetaResponseDto } from '../dtos/response'; -import { DocumentType } from '../enums'; +import { CreateDocumentRequestDto } from '../dtos/request'; +import { GenerateUploadSignedUrlResponseDto } from '../dtos/response'; import { DocumentService } from '../services'; @Controller('document') @ApiTags('Document') @@ -19,34 +16,17 @@ import { DocumentService } from '../services'; export class DocumentController { constructor(private readonly documentService: DocumentService) {} - @Post() - @ApiConsumes('multipart/form-data') - @ApiBody({ - schema: { - type: 'object', - properties: { - document: { - type: 'string', - format: 'binary', - }, - documentType: { - type: 'string', - enum: Object.values(DocumentType).filter( - (value) => ![DocumentType.DEFAULT_AVATAR, DocumentType.DEFAULT_TASKS_LOGO].includes(value), - ), - }, - }, - required: ['document', 'documentType'], - }, - }) - @UseInterceptors(FileInterceptor('document', { storage: memoryStorage() })) - async createDocument( - @UploadedFile() file: Express.Multer.File, - @Body() uploadedDocumentRequest: UploadDocumentRequestDto, - @AuthenticatedUser() user: IJwtPayload, - ) { - const document = await this.documentService.createDocument(file, uploadedDocumentRequest, user.sub); + @Post('signed-url') + @ApiDataResponse(GenerateUploadSignedUrlResponseDto) + async generateSignedUrl(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateDocumentRequestDto) { + const result = await this.documentService.generateUploadSignedUrl(sub, body); + return ResponseFactory.data(new GenerateUploadSignedUrlResponseDto(result.document, result.uploadUrl)); + } - return ResponseFactory.data(new DocumentMetaResponseDto(document)); + @Post(':documentId/confirm-update') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiNoContentResponse({ description: 'Document update confirmed successfully' }) + async confirmDocumentUpdate(@Param('documentId') documentId: string) { + return this.documentService.confirmDocumentUpdate(documentId); } } diff --git a/src/document/dtos/request/create-document.request.dto.ts b/src/document/dtos/request/create-document.request.dto.ts new file mode 100644 index 0000000..2b0bd63 --- /dev/null +++ b/src/document/dtos/request/create-document.request.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsString } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { DocumentType } from '~/document/enums'; + +export class CreateDocumentRequestDto { + @ApiProperty({ enum: DocumentType }) + @IsEnum(DocumentType, { message: i18n('validation.IsEnum', { path: 'general', property: 'document.documentType' }) }) + documentType!: DocumentType; + + @ApiProperty({ type: String, example: '.jpg' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'document.document.extension' }) }) + extension!: string; + + @ApiProperty({ type: String }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'document.originalFileName' }) }) + originalFileName!: string; +} diff --git a/src/document/dtos/request/index.ts b/src/document/dtos/request/index.ts index 4071ca7..7d9b39c 100644 --- a/src/document/dtos/request/index.ts +++ b/src/document/dtos/request/index.ts @@ -1 +1 @@ -export * from './upload-document.request.dto'; +export * from './create-document.request.dto'; diff --git a/src/document/dtos/request/upload-document.request.dto.ts b/src/document/dtos/request/upload-document.request.dto.ts deleted file mode 100644 index 89210fe..0000000 --- a/src/document/dtos/request/upload-document.request.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { IsEnum } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -import { DocumentType } from '~/document/enums'; - -export class UploadDocumentRequestDto { - @IsEnum(DocumentType, { message: i18n('validation.IsEnum', { path: 'general', property: 'document.documentType' }) }) - documentType!: DocumentType; -} diff --git a/src/document/dtos/response/generate-upload-signed-url.response.dto.ts b/src/document/dtos/response/generate-upload-signed-url.response.dto.ts new file mode 100644 index 0000000..4e2055e --- /dev/null +++ b/src/document/dtos/response/generate-upload-signed-url.response.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Document } from '~/document/entities'; +import { DocumentMetaResponseDto } from './document-meta.response.dto'; + +export class GenerateUploadSignedUrlResponseDto { + @ApiProperty({ type: DocumentMetaResponseDto }) + document!: DocumentMetaResponseDto; + + @ApiProperty() + uploadUrl!: string; + + constructor(document: Document, uploadUrl: string) { + this.document = new DocumentMetaResponseDto(document); + this.uploadUrl = uploadUrl; + } +} diff --git a/src/document/dtos/response/index.ts b/src/document/dtos/response/index.ts index 18a07a3..6422d12 100644 --- a/src/document/dtos/response/index.ts +++ b/src/document/dtos/response/index.ts @@ -1,2 +1,3 @@ export * from './document-meta.response.dto'; +export * from './generate-upload-signed-url.response.dto'; export * from './upload.response.dto'; diff --git a/src/document/entities/document.entity.ts b/src/document/entities/document.entity.ts index 7c327f1..959f63d 100644 --- a/src/document/entities/document.entity.ts +++ b/src/document/entities/document.entity.ts @@ -15,13 +15,16 @@ import { SavingGoal } from '~/saving-goals/entities'; import { Task } from '~/task/entities'; import { TaskSubmission } from '~/task/entities/task-submissions.entity'; import { User } from '~/user/entities'; -import { DocumentType } from '../enums'; +import { DocumentType, UploadStatus } from '../enums'; @Entity('documents') export class Document { @PrimaryGeneratedColumn('uuid') id!: string; + @Column({ type: 'varchar', length: 255, default: UploadStatus.NOT_UPLOADED, name: 'upload_status' }) + uploadStatus!: UploadStatus; + @Column({ type: 'varchar', length: 255 }) name!: string; diff --git a/src/document/enums/index.ts b/src/document/enums/index.ts index 173c116..e725bf4 100644 --- a/src/document/enums/index.ts +++ b/src/document/enums/index.ts @@ -1 +1,2 @@ export * from './document-type.enum'; +export * from './upload-status.enum'; diff --git a/src/document/enums/upload-status.enum.ts b/src/document/enums/upload-status.enum.ts new file mode 100644 index 0000000..c5f049e --- /dev/null +++ b/src/document/enums/upload-status.enum.ts @@ -0,0 +1,4 @@ +export enum UploadStatus { + NOT_UPLOADED = 'NOT_UPLOADED', + UPLOADED = 'UPLOADED', +} diff --git a/src/document/repositories/document.repository.ts b/src/document/repositories/document.repository.ts index 42e383a..bf9f124 100644 --- a/src/document/repositories/document.repository.ts +++ b/src/document/repositories/document.repository.ts @@ -26,4 +26,8 @@ export class DocumentRepository { findDocumentById(id: string) { return this.documentRepository.findOne({ where: { id } }); } + + updateDocument(id: string, updateData: Partial) { + return this.documentRepository.update(id, updateData); + } } diff --git a/src/document/services/document.service.ts b/src/document/services/document.service.ts index 5378942..027dd3c 100644 --- a/src/document/services/document.service.ts +++ b/src/document/services/document.service.ts @@ -1,7 +1,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; -import { UploadDocumentRequestDto } from '../dtos/request'; +import { CreateDocumentRequestDto } from '../dtos/request'; import { Document } from '../entities'; +import { UploadStatus } from '../enums'; import { DocumentRepository } from '../repositories'; import { OciService } from './oci.service'; @@ -9,11 +10,6 @@ import { OciService } from './oci.service'; export class DocumentService { private readonly logger = new Logger(DocumentService.name); constructor(private readonly ociService: OciService, private readonly documentRepository: DocumentRepository) {} - async createDocument(file: Express.Multer.File, uploadedDocumentRequest: UploadDocumentRequestDto, userId: string) { - this.logger.log(`creating document for with type ${uploadedDocumentRequest.documentType}`); - const uploadedFile = await this.ociService.uploadFile(file, uploadedDocumentRequest); - return this.documentRepository.createDocument(userId, uploadedFile); - } findDocuments(where: FindOptionsWhere) { this.logger.log(`finding documents with where clause ${JSON.stringify(where)}`); @@ -24,4 +20,28 @@ export class DocumentService { this.logger.log(`finding document with id ${id}`); return this.documentRepository.findDocumentById(id); } + + async confirmDocumentUpdate(documentId: string) { + const document = await this.documentRepository.findDocumentById(documentId); + if (!document) { + this.logger.error(`Document with id ${documentId} not found`); + throw new Error(`Document with id ${documentId} not found.`); + } + this.logger.log(`Confirming document update for document id ${documentId}`); + + return this.documentRepository.updateDocument(documentId, { + uploadStatus: UploadStatus.UPLOADED, + }); + } + + async generateUploadSignedUrl(userId: string, body: CreateDocumentRequestDto) { + this.logger.log( + `generating signed URL for document type ${body.documentType} with original file name ${body.originalFileName}`, + ); + const uploadResult = await this.ociService.generateUploadPreSignedUrl(body); + + const document = await this.documentRepository.createDocument(userId, uploadResult); + + return { document, uploadUrl: uploadResult.url }; + } } diff --git a/src/document/services/oci.service.ts b/src/document/services/oci.service.ts index 3e65a89..ad2f319 100644 --- a/src/document/services/oci.service.ts +++ b/src/document/services/oci.service.ts @@ -5,10 +5,9 @@ import moment from 'moment'; import { Region, SimpleAuthenticationDetailsProvider } from 'oci-common'; import { ObjectStorageClient } from 'oci-objectstorage'; import { CreatePreauthenticatedRequestDetails } from 'oci-objectstorage/lib/model'; -import path from 'path'; import { CacheService } from '~/common/modules/cache/services'; import { BUCKETS } from '../constants'; -import { UploadDocumentRequestDto } from '../dtos/request'; +import { CreateDocumentRequestDto } from '../dtos/request'; import { UploadResponseDto } from '../dtos/response'; import { Document } from '../entities'; import { generateNewFileName } from '../utils'; @@ -37,35 +36,6 @@ export class OciService { }); } - async uploadFile(file: Express.Multer.File, { documentType }: UploadDocumentRequestDto): Promise { - this.logger.log(`Uploading file with type ${documentType}`); - const bucketName = BUCKETS[documentType]; - const objectName = generateNewFileName(file.originalname); - - if (!bucketName) { - this.logger.error('Could not find bucket name for document type', documentType); - throw new BadRequestException('DOCUMENT.TYPE_NOT_SUPPORTED'); - } - - this.logger.debug(`Uploading file to bucket ${bucketName} with object name ${objectName}`); - await this.ociClient.putObject({ - namespaceName: this.namespace, - bucketName, - putObjectBody: file.buffer, - contentLength: file.buffer.length, - objectName, - }); - - this.logger.log(`File uploaded successfully to bucket ${bucketName} with object name ${objectName}`); - - return plainToInstance(UploadResponseDto, { - name: objectName, - extension: path.extname(file.originalname), - url: `https://objectstorage.${this.region}.oraclecloud.com/n/${this.namespace}/b/${bucketName}/o/${objectName}`, - documentType, - }); - } - async generatePreSignedUrl(document?: Document): Promise { this.logger.log(`Generating pre-signed url for document ${document?.id}`); if (!document) { @@ -106,4 +76,47 @@ export class OciService { return document.name; } } + + async generateUploadPreSignedUrl({ + documentType, + originalFileName, + extension, + }: CreateDocumentRequestDto): Promise { + const bucketName = BUCKETS[documentType]; + + if (!bucketName) { + this.logger.error('Could not find bucket name for document type', documentType); + throw new BadRequestException('DOCUMENT.TYPE_NOT_SUPPORTED'); + } + + const objectName = generateNewFileName(originalFileName); + const expiration = moment().add('1', 'hours').toDate(); + + try { + this.logger.debug(`Generating pre-signed upload URL for object ${objectName} in bucket ${bucketName}`); + + const response = await this.ociClient.createPreauthenticatedRequest({ + namespaceName: this.namespace, + bucketName, + createPreauthenticatedRequestDetails: { + name: `upload-${objectName}`, + accessType: CreatePreauthenticatedRequestDetails.AccessType.ObjectWrite, // 🔑 for upload + timeExpires: expiration, + objectName: `${objectName}${extension}`, // Ensure the object name includes the extension + }, + }); + + this.logger.log(`Generated upload URL for ${objectName}`); + + return plainToInstance(UploadResponseDto, { + name: objectName, + extension, + url: response.preauthenticatedRequest.fullPath, + documentType, + }); + } catch (error) { + this.logger.error('Error generating pre-signed upload URL', error); + throw new BadRequestException('UPLOAD.URL_GENERATION_FAILED'); + } + } } diff --git a/src/i18n/ar/app.json b/src/i18n/ar/app.json index 1ce9b9e..797784d 100644 --- a/src/i18n/ar/app.json +++ b/src/i18n/ar/app.json @@ -11,7 +11,9 @@ "PASSWORD_MISMATCH": "كلمات المرور التي أدخلتها غير متطابقة. يرجى إدخال كلمات المرور مرة أخرى.", "INVALID_PASSCODE": "رمز المرور الذي أدخلته غير صحيح. يرجى المحاولة مرة أخرى.", "PASSCODE_ALREADY_SET": "تم تعيين رمز المرور بالفعل.", - "APPLE_RE-CONSENT_REQUIRED": "إعادة الموافقة على آبل مطلوبة. يرجى إلغاء تطبيقك من إعدادات معرف آبل الخاصة بك والمحاولة مرة أخرى." + "APPLE_RE-CONSENT_REQUIRED": "إعادة الموافقة على آبل مطلوبة. يرجى إلغاء تطبيقك من إعدادات معرف آبل الخاصة بك والمحاولة مرة أخرى.", + "TOKEN_INVALID": "رمز المستخدم غير صالح.", + "TOKEN_EXPIRED": "رمز المستخدم منتهي الصلاحية." }, "USER": { @@ -25,7 +27,8 @@ "ALREADY_EXISTS": "المستخدم موجود بالفعل.", "NOT_FOUND": "لم يتم العثور على المستخدم.", "PHONE_NUMBER_ALREADY_EXISTS": "رقم الهاتف موجود بالفعل.", - "JUNIOR_UPGRADE_NOT_SUPPORTED_YET": "ترقية الحساب من حساب طفل إلى حساب ولي أمر غير مدعومة حاليًا." + "JUNIOR_UPGRADE_NOT_SUPPORTED_YET": "ترقية الحساب من حساب طفل إلى حساب ولي أمر غير مدعومة حاليًا.", + "PHONE_NUMBER_ALREADY_TAKEN": "رقم الهاتف الذي أدخلته مستخدم بالفعل. يرجى استخدام رقم هاتف آخر." }, "ALLOWANCE": { @@ -43,7 +46,8 @@ }, "CUSTOMER": { "NOT_FOUND": "لم يتم العثور على العميل.", - "ALREADY_EXISTS": "العميل موجود بالفعل." + "ALREADY_EXISTS": "العميل موجود بالفعل.", + "KYC_NOT_APPROVED": "لم يتم الموافقة على هوية العميل بعد." }, "GIFT": { diff --git a/src/i18n/en/app.json b/src/i18n/en/app.json index 64b6c26..668b1e8 100644 --- a/src/i18n/en/app.json +++ b/src/i18n/en/app.json @@ -11,7 +11,9 @@ "PASSWORD_MISMATCH": "The passwords you entered do not match. Please re-enter the passwords.", "INVALID_PASSCODE": "The passcode you entered is incorrect. Please try again.", "PASSCODE_ALREADY_SET": "The pass code has already been set.", - "APPLE_RE-CONSENT_REQUIRED": "Apple re-consent is required. Please revoke the app from your Apple ID settings and try again." + "APPLE_RE-CONSENT_REQUIRED": "Apple re-consent is required. Please revoke the app from your Apple ID settings and try again.", + "TOKEN_INVALID": "The user token is invalid.", + "TOKEN_EXPIRED": "The user token has expired." }, "USER": { @@ -25,7 +27,8 @@ "ALREADY_EXISTS": "The user already exists.", "NOT_FOUND": "The user was not found.", "PHONE_NUMBER_ALREADY_EXISTS": "The phone number already exists.", - "JUNIOR_UPGRADE_NOT_SUPPORTED_YET": "Upgrading account from junior to guardian is not yet supported." + "JUNIOR_UPGRADE_NOT_SUPPORTED_YET": "Upgrading account from junior to guardian is not yet supported.", + "PHONE_NUMBER_ALREADY_TAKEN": "The phone number you entered is already in use. Please use a different phone number." }, "ALLOWANCE": { "START_DATE_BEFORE_TODAY": "The start date cannot be before today.", @@ -42,7 +45,8 @@ }, "CUSTOMER": { "NOT_FOUND": "The customer was not found.", - "ALREADY_EXISTS": "The customer already exists." + "ALREADY_EXISTS": "The customer already exists.", + "KYC_NOT_APPROVED": "The customer's KYC has not been approved yet." }, "GIFT": { diff --git a/src/user/enums/user-type.enum.ts b/src/user/enums/user-type.enum.ts index e00c99c..b3a705f 100644 --- a/src/user/enums/user-type.enum.ts +++ b/src/user/enums/user-type.enum.ts @@ -1,4 +1,5 @@ export enum UserType { CHECKER = 'CHECKER', JUNIOR = 'JUNIOR', + GUARDIAN = 'GUARDIAN', } diff --git a/src/user/repositories/user-token-repository.ts b/src/user/repositories/user-token-repository.ts index 3e130db..9eda1bf 100644 --- a/src/user/repositories/user-token-repository.ts +++ b/src/user/repositories/user-token-repository.ts @@ -17,7 +17,7 @@ export class UserTokenRepository { generateToken(userId: string, userType: UserType, expiryDate?: Date) { return this.userTokenRepository.save( this.userTokenRepository.create({ - userId: userType === UserType.CHECKER ? userId : null, + userId, juniorId: userType === UserType.JUNIOR ? userId : null, expiryDate: expiryDate ?? moment().add('15', 'minutes').toDate(), userType, diff --git a/src/user/repositories/user.repository.ts b/src/user/repositories/user.repository.ts index 79b8489..d740cd8 100644 --- a/src/user/repositories/user.repository.ts +++ b/src/user/repositories/user.repository.ts @@ -11,8 +11,7 @@ export class UserRepository { createUnverifiedUser(data: Partial) { return this.userRepository.save( this.userRepository.create({ - email: data.email, - roles: data.roles, + ...data, }), ); } diff --git a/src/user/services/user-token.service.ts b/src/user/services/user-token.service.ts index 3a3ee1b..efcbe00 100644 --- a/src/user/services/user-token.service.ts +++ b/src/user/services/user-token.service.ts @@ -20,12 +20,12 @@ export class UserTokenService { if (!tokenEntity) { this.logger.error(`Token ${token} not found`); - throw new BadRequestException('TOKEN.INVALID'); + throw new BadRequestException('AUTH.TOKEN_INVALID'); } if (tokenEntity.expiryDate < new Date()) { this.logger.error(`Token ${token} expired`); - throw new BadRequestException('TOKEN.EXPIRED'); + throw new BadRequestException('AUTH.TOKEN_EXPIRED'); } this.logger.log(`Token validated successfully`); diff --git a/src/user/services/user.service.ts b/src/user/services/user.service.ts index ec32953..2640cbe 100644 --- a/src/user/services/user.service.ts +++ b/src/user/services/user.service.ts @@ -44,9 +44,9 @@ export class UserService { return this.userRepository.update(userId, { email }); } - setPasscode(userId: string, passcode: string, salt: string) { + setPassword(userId: string, password: string, salt: string) { this.logger.log(`Setting passcode for user ${userId}`); - return this.userRepository.update(userId, { password: passcode, salt, isProfileCompleted: true }); + return this.userRepository.update(userId, { password, salt }); } setPhoneNumber(userId: string, phoneNumber: string, countryCode: string) { @@ -56,7 +56,9 @@ export class UserService { @Transactional() async verifyUser(userId: string, body: VerifyUserRequestDto) { - this.logger.log(`Verifying user email with id ${userId}`); + const salt = bcrypt.genSaltSync(SALT_ROUNDS); + const hashedPassword = bcrypt.hashSync(body.password, salt); + this.logger.log(`Verifying user with id ${userId}`); await Promise.all([ this.customerService.createGuardianCustomer(userId, { firstName: body.firstName, @@ -64,7 +66,12 @@ export class UserService { dateOfBirth: body.dateOfBirth, countryOfResidence: body.countryOfResidence, }), - this.userRepository.update(userId, { isEmailVerified: true }), + this.userRepository.update(userId, { + isPhoneVerified: true, + password: hashedPassword, + salt, + ...(body.email && { email: body.email }), + }), ]); } @@ -88,44 +95,34 @@ export class UserService { @Transactional() async findOrCreateUser(body: CreateUnverifiedUserRequestDto) { - this.logger.log(`Finding or creating user with email ${body.email}`); - const user = await this.userRepository.findOne({ email: body.email }); + this.logger.log(`Finding or creating user with phone number ${body.countryCode + body.phoneNumber}`); + const user = await this.userRepository.findOne({ + phoneNumber: body.phoneNumber, + countryCode: body.countryCode, + }); if (!user) { - this.logger.log(`User with email ${body.email} not found, creating new user`); - return this.userRepository.createUnverifiedUser({ email: body.email, roles: [Roles.GUARDIAN] }); + this.logger.log(`User with phone number ${body.phoneNumber} not found, creating new user`); + return this.userRepository.createUnverifiedUser({ + phoneNumber: body.phoneNumber, + countryCode: body.countryCode, + email: body.email, + roles: [Roles.GUARDIAN], + }); } - if (user && user.roles.includes(Roles.GUARDIAN) && user.isEmailVerified) { - this.logger.error(`User with email ${body.email} already exists`); - throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN'); + + if (user && user.roles.includes(Roles.GUARDIAN) && user.isPhoneVerified) { + this.logger.error(`User with phone number ${body.phoneNumber} already exists`); + throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_TAKEN'); } if (user && user.roles.includes(Roles.JUNIOR)) { - this.logger.error(`User with email ${body.email} is an already registered junior`); + this.logger.error(`User with phone number ${body.phoneNumber} is an already registered junior`); throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET'); //TODO add role Guardian to the existing user and send OTP } - this.logger.log(`User with email ${body.email}`); - return user; - } - - async findOrCreateByEmail(email: string) { - this.logger.log(`Finding or creating user with email ${email} `); - const user = await this.userRepository.findOne({ email }); - - if (!user) { - this.logger.log(`User with email ${email} not found, creating new user`); - return this.userRepository.createUser({ email, roles: [Roles.GUARDIAN] }); - } - - if (user && user.roles.includes(Roles.JUNIOR)) { - this.logger.error(`User with email ${email} is an already registered junior`); - throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET'); - //TODO add role Guardian to the existing user and send OTP - } - - this.logger.log(`User with email ${email} found successfully`); + this.logger.log(`User with phone number ${body.phoneNumber} found successfully`); return user; }