Compare commits

..

135 Commits

Author SHA1 Message Date
a3a61b4923 Implement OTP generation and email verification logic in UserService 2025-10-28 15:52:24 +03:00
39d5fc1869 Merge pull request #52 from HamzaSha1/ZOD-349-weekly-spending-data-not-displaying-in-the-child-profile-parent-view
Enhance weekly summary functionality to accept optional date range pa…
2025-10-28 11:22:52 +03:00
05a6ad2d84 Enhance weekly summary functionality to accept optional date range parameters in CardService, TransactionService, JuniorService, and JuniorController. Update API documentation to reflect new query parameters for start and end dates. 2025-10-28 11:20:49 +03:00
5649d24724 Merge pull request #50 from HamzaSha1/ZOD-349-weekly-spending-data-not-displaying-in-the-child-profile-parent-view
git checkout -b ZOD-349-weekly-spending-data-not-displaying-in-the-child-profile-parent-view
2025-10-26 16:05:00 +03:00
bbeece9e03 git checkout -b ZOD-349-weekly-spending-data-not-displaying-in-the-child-profile-parent-view 2025-10-26 13:14:35 +03:00
596562f6dc Merge pull request #48 from HamzaSha1/feat/neoleap-integration
Feat/neoleap integration
2025-10-21 14:56:38 +03:00
10de8f69c9 Merge pull request #47 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
Remove duplicate email cleanup logic and add unique constraint to use…
2025-10-21 14:15:03 +03:00
8a6b1cc900 Remove duplicate email cleanup logic and add unique constraint to user email 2025-10-21 14:10:14 +03:00
d16ae66252 Merge pull request #46 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
ZOD-341-Add unique constraint to user email and clean up duplicates
2025-10-21 10:51:12 +03:00
e966f95463 ZOD-341-Add unique constraint to user email and clean up duplicates 2025-10-21 10:49:43 +03:00
2714255dd1 Merge pull request #45 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
ZOD-341 Add email uniqueness validation to prevent duplicate emails
2025-10-20 14:31:11 +03:00
39a0b131b8 Merge pull request #44 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
Zod 341 junior a child can edit their email to an existing email causing multiple child accounts to share the same login
2025-10-20 14:27:40 +03:00
4f778f7904 * ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login 2025-10-20 14:25:53 +03:00
7e9bc397a9 Merge pull request #43 from HamzaSha1/ZOD-204-view-spending-from-child-login
git checkout -b ZOD-204-view-spending-from-child-login
2025-10-20 10:30:27 +03:00
7bfc14f0d9 Merge pull request #42 from HamzaSha1/ZOD-204-view-spending-from-child-login
ZOD-204-view-spending-from-child-login
2025-10-19 15:44:16 +03:00
d2e084d3e4 git checkout -b ZOD-204-view-spending-from-child-login 2025-10-19 15:26:47 +03:00
f81714a525 Merge pull request #41 from HamzaSha1/ZOD-339-child-profile-gender-update-is-not-reflected-after-editing
ZOD-339-child-profile-gender-update-is-not-reflected-after-editing
2025-10-19 11:07:39 +03:00
f3282a680b Merge pull request #40 from HamzaSha1/ZOD-339-child-profile-gender-update-is-not-reflected-after-editing
Zod 339 child profile gender update is not reflected after editing
2025-10-19 11:02:40 +03:00
7b57277a7f ZOD-339-child-profile-gender-update-is-not-reflected-after-editing 2025-10-19 11:01:52 +03:00
fdd2e23669 Merge pull request #39 from HamzaSha1/ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instead-of-daughter-or-son-in-child-confirmation-details-after-the-scan-the-qr-code
ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instea
2025-10-19 10:47:51 +03:00
d70ab09960 Merge pull request #38 from HamzaSha1/ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instead-of-daughter-or-son-in-child-confirmation-details-after-the-scan-the-qr-code
Zod 333 junior incorrect relationship label displayed as child instead of daughter or son in child confirmation details after the scan the qr code
2025-10-19 09:58:57 +03:00
297a2fe5ad ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instead-of-daughter-or-son-in-child-confirmation-details-after-the-scan-the-qr-code 2025-10-19 09:57:35 +03:00
33b4f13ec8 Merge pull request #37 from HamzaSha1/feat/neoleap-integration
Feat/neoleap integration
2025-10-16 14:50:23 +03:00
310233c519 Merge pull request #36 from HamzaSha1/ZOD-309-child-transaction-history-parent-→-child-transfers
ZOD-309-child-transaction-history-parent-→-child-transfers
2025-10-16 12:26:50 +03:00
15621124ad ZOD-309-child-transaction-history-parent-→-child-transfers 2025-10-16 12:25:16 +03:00
7fc1918de0 Merge pull request #35 from HamzaSha1/feat/parent-topups-and-child-transfers
feat: add guardian transactions feature with response DTOs and service integration
2025-10-15 14:17:08 +03:00
f6fa74897a feat: add guardian transactions feature with response DTOs and service integration 2025-10-15 14:14:59 +03:00
dd6886ff2b Merge pull request #34 from HamzaSha1/feat/neoleap-integration
match the neoleap-integration branch with dev
2025-10-14 12:20:19 +03:00
649191f3f4 Merge pull request #33 from HamzaSha1/fix/customer-gender-missing-in-get-profile
fix: add gender property to UserResponseDto
2025-10-14 12:14:01 +03:00
183f6b4475 fix: add gender property to UserResponseDto
fix: add gender property to UserResponseDto
2025-10-12 16:06:39 +03:00
8f601b26ae fix: add gender property to UserResponseDto 2025-10-12 16:03:25 +03:00
918b15c315 fix: add swagger 2025-09-23 09:00:41 +03:00
1830d92cbd feat: weekly stats for junior 2025-09-23 08:56:57 +03:00
44124b9964 Merge branch 'dev' of github.com:HamzaSha0/zod-backend into dev 2025-09-18 10:03:47 +03:00
454ded627f fix: fix transfer to child bug 2025-09-16 21:01:32 +03:00
f1484e125b feat: soft delete junior 2025-09-15 09:02:56 +03:00
df4d2e3c1f feat: get card by child id 2025-09-15 08:47:56 +03:00
872d231f72 feat: show embossing information for child cards 2025-09-09 21:45:37 +03:00
cc4c8254f6 feat: view child active cards 2025-09-09 21:37:55 +03:00
039c95aa56 fix: calculating child and parent balance 2025-09-09 20:31:48 +03:00
e1f50decfa feat: money requests 2025-09-08 21:38:11 +03:00
11712bedf3 Merge pull request #31 from HamzaSha1/feat/neoleap-integration
Feat/neoleap integration
2025-09-08 21:34:46 +03:00
e6642b5a15 fix: fix controller name 2025-09-07 21:47:17 +03:00
954aa422a2 fix: fix mock request for funding decorators 2025-09-07 21:40:55 +03:00
15a48e4884 fix: validate card spending limit before transfering to child 2025-09-07 21:18:49 +03:00
d768da70f2 feat: transfer to parent 2025-09-07 20:23:11 +03:00
9b0e1791da feat: transfer money to child 2025-09-07 20:14:28 +03:00
44b5937f7a feat: finalize update junior 2025-09-07 18:13:28 +03:00
edddc2f457 feat: update junior 2025-09-07 09:12:14 +03:00
88730a2b2b fix: fix create application mock 2025-08-26 19:17:00 +03:00
3df34c0017 fix: fix duplicate iban 2025-08-26 12:19:47 +03:00
7dd309e0e3 fix: fix null assertion in creating junior 2025-08-24 20:14:59 +03:00
4552a7fc93 fix: fix issue with customer relationship in creating junior 2025-08-24 20:12:57 +03:00
740135051d feat: create card for children 2025-08-24 20:01:07 +03:00
3222aa4a66 fix: fix card embossing info endpoint 2025-08-24 18:40:49 +03:00
6602414779 feat: finialize creating juniors 2025-08-23 21:52:59 +03:00
7291447c5a Merge branch 'dev' into feat/neoleap-integration 2025-08-23 20:12:48 +03:00
d437b21dc3 fix: make kyc run on mocks 2025-08-23 19:30:29 +03:00
e06642225a feat: working on creating parent card 2025-08-14 14:40:08 +03:00
c06086f899 Merge pull request #30 from HamzaSha1/dev
Dev
2025-08-11 18:22:34 +03:00
e775561a89 Merge pull request #29 from HamzaSha1/main
Merge main into dev
2025-08-11 18:21:52 +03:00
241f1ce427 Merge branch 'dev' into main 2025-08-11 18:21:43 +03:00
d883bd2d9a Update junior.repository.ts 2025-08-11 18:19:57 +03:00
cd800ff8b8 Update customer.repository.ts 2025-08-11 18:18:48 +03:00
05a9f04ac8 fix: fix import in migration index 2025-08-11 16:23:19 +03:00
dcc9077392 fix: rename migration timestamp 2025-08-11 16:22:20 +03:00
681d1e5791 fix: fix seed default avatar migration 2025-08-11 16:16:26 +03:00
bf505a65bf fix: fix invalid imports 2025-08-11 16:13:53 +03:00
6bf32d27c7 Merge branch 'dev' of github.com:HamzaSha1/zod-backend into dev 2025-08-11 15:33:39 +03:00
ac63d4cdc7 refactor: refactor the code 2025-08-11 15:33:32 +03:00
150027fb71 Merge branch 'main' into dev 2025-08-11 15:25:16 +03:00
e8ee74d0d7 refactor: remove unsed code 2025-08-11 15:15:41 +03:00
5f2e06edf9 Merge pull request #28 from HamzaSha1/feat/neoleap-integration
Feat/neoleap integration
2025-08-10 16:16:32 +03:00
99ad17f0f9 feat: add change password api 2025-08-07 15:25:45 +03:00
ee7b365527 feat: kyc process 2025-08-07 14:23:33 +03:00
275984954e feat: working on edit profile ticket 2025-08-05 17:53:38 +03:00
6f7fb2bdcd Merge pull request #27 from HamzaSha1/feat/neoleap-integration
Merge neoleap-integration into dev
2025-08-03 16:24:46 +03:00
1e2b859b92 feat: finish generating signed url for document upload flow 2025-08-03 16:18:06 +03:00
4cc52a1c07 fix: add swagger doc to verify otp api 2025-08-03 14:50:04 +03:00
7461af20dd feat: edit forget password flow 2025-08-03 14:48:14 +03:00
f65a7d2933 feat: generate upload signed url for oci 2025-08-03 14:21:14 +03:00
fce720237f feat: add vpan to card entity 2025-08-03 11:53:16 +03:00
5e0a4e6bd1 feat: fix update card status webhook 2025-07-31 14:42:06 +03:00
f9776e60cf fix: save transaction file 2025-07-31 14:11:53 +03:00
7e63abb2fb feat: add-account-details 2025-07-31 14:07:01 +03:00
a245545811 feat: add login and forget password and refactor code 2025-07-30 15:40:40 +03:00
4cb5814cd3 fix: organize migrations 2025-07-30 14:18:10 +03:00
9e06ea4d71 Merge branch 'dev' into feat/neoleap-integration 2025-07-30 14:09:00 +03:00
cff87c4ecd rollup migrations into one 2025-07-30 13:08:31 +03:00
1541c374ed feat: fix swagger examples 2025-07-27 13:26:21 +03:00
c493bd57e1 feat: onboarding signup journey 2025-07-27 13:15:54 +03:00
bf43e62b17 feat: handle card status changed webhook 2025-07-21 15:30:55 +03:00
5a780eeb17 feat/working on update card control 2025-07-14 11:57:51 +03:00
038b8ef6e3 feat: finish working on account transaction webhook 2025-07-09 13:31:08 +03:00
3b3f8c0104 fix: remove host from request 2025-07-07 16:34:45 +03:00
2770cf8774 fix:fix card migration 2025-07-07 12:06:01 +03:00
bea3ccfbbc Merge branch 'waiting-list' into feat/neoleap-integration 2025-07-06 16:45:37 +03:00
492e538eb8 feat: send request via gateway 2025-07-06 16:44:23 +03:00
d3057beb54 feat: add transaction, card , and account entities 2025-07-02 18:42:38 +03:00
19fa53c981 fix: fix apple client audience 2025-06-17 11:39:51 +03:00
d2cc02fb60 fix: localizze error messages 2025-06-17 09:43:36 +03:00
4cbbfd8136 Merge branch 'waiting-list' into feat/neoleap-integration 2025-06-11 11:15:10 +03:00
6c859a25d2 feat: handle oauth2 login 2025-06-04 14:52:09 +03:00
d1a6d3e715 feat: add test controller for integartion 2025-06-04 10:04:45 +03:00
1ea1f42169 feat: finish create and inquire application api and handle response and errors 2025-06-03 14:51:36 +03:00
d4fe3b3fc3 feat: finish working on mocking inquire application api 2025-05-26 16:34:09 +03:00
b44bc5d5cc fix: localize customer already exist message 2025-05-26 15:24:20 +03:00
9aa6c487ed Merge branch 'waiting-list' into feat/neoleap-integration 2025-05-26 12:11:53 +03:00
42e4d75d70 feat: add country iso enum 2025-05-26 12:10:09 +03:00
a358cd2e7a feat: add neoleap service and mock create application api 2025-05-26 12:04:00 +03:00
641a665beb Merge branch 'waiting-list' into feat/neoleap-integration 2025-05-21 09:59:18 +03:00
49326e983f feat: handle new registration flow 2025-05-19 17:00:32 +03:00
881d88c8d8 feat: add customer details to customer entity 2025-05-19 14:16:18 +03:00
35ab3df7c1 feat: update create junior payload and add new document type 2025-05-14 14:53:22 +03:00
cbade0a87d feat: blacklist refresh tokens 2025-04-09 16:20:29 +03:00
4c6ef17525 refactor: remove login by password and biometric 2025-04-09 15:07:48 +03:00
ffca6996fd feat: styling email template 2025-04-06 13:33:57 +03:00
a3f88c774c feat: handle notification using redis 2025-03-27 12:33:56 +03:00
ec38b82a7b feat: add waiting number and handle resent otp 2025-03-16 11:34:08 +03:00
9b5f863577 add login flow for waiting list demo app 2025-03-04 14:42:02 +03:00
54ce5b022d feat: adding enhancment for zod admin portal kyc and add customer additional fields 2025-03-02 10:49:58 +03:00
dae9cb6323 refactor: handle kyc journey for customers 2025-02-20 16:18:06 +03:00
270753cfd7 fix: fix domain name for apple auth 2025-01-16 13:42:52 +03:00
6b1cb3a84e fix: fix apibase url 2025-01-16 13:38:59 +03:00
ebd4b293e9 feat: client testing api , add ouath2 2025-01-16 13:35:22 +03:00
87bb1a2709 feat: add apple login 2025-01-16 12:17:10 +03:00
663e8972c4 feat: client test app, add apple login 2025-01-15 14:37:53 +03:00
8ff9f921e8 feat: add localization for request dto validations and add accept lanuage header 2025-01-14 14:59:01 +03:00
6d2d2b558a feat: add localization to expection messages 2025-01-14 12:39:19 +03:00
5aa3d3774d refactor: enhance customer creation code 2025-01-13 16:42:27 +03:00
221f3bae4f feat: add find task by id endpoint 2025-01-13 14:15:29 +03:00
62621c1a15 feat: add validation for documents 2025-01-13 11:43:28 +03:00
756e947c8a feat: handle google login 2025-01-09 15:14:09 +03:00
db02a28b4d hotfix: fix completed task filter 2025-01-07 14:18:08 +03:00
afc087ff08 api client for testing 2025-01-07 01:46:58 -05:00
362 changed files with 15392 additions and 7607 deletions

2
.gitignore vendored
View File

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

View File

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

9081
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -23,7 +23,8 @@
"migration:generate": "npm run typeorm:cli-d migration:generate",
"migration:create": "npm run typeorm:cli migration:create",
"migration:up": "npm run typeorm:cli-d migration:run",
"migration:down": "npm run typeorm:cli-d migration:revert"
"migration:down": "npm run typeorm:cli-d migration:revert",
"seed": "TS_NODE_PROJECT=tsconfig.json ts-node -r tsconfig-paths/register src/scripts/seed.ts"
},
"dependencies": {
"@abdalhamid/hello": "^2.0.0",
@ -50,10 +51,12 @@
"cacheable": "^1.8.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"decimal.js": "^10.6.0",
"firebase-admin": "^13.0.2",
"google-libphonenumber": "^3.2.39",
"handlebars": "^4.7.8",
"ioredis": "^5.4.1",
"handlebars-layouts": "^3.1.4",
"jwk-to-pem": "^2.0.7",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"nestjs-i18n": "^10.4.9",
@ -82,6 +85,7 @@
"@types/express": "^5.0.0",
"@types/google-libphonenumber": "^7.4.30",
"@types/jest": "^29.5.2",
"@types/jwk-to-pem": "^2.0.3",
"@types/lodash": "^4.17.13",
"@types/multer": "^1.4.12",
"@types/node": "^20.3.1",

View File

@ -1,20 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JuniorModule } from '~/junior/junior.module';
import { AllowanceChangeRequestController, AllowancesController } from './controllers';
import { Allowance, AllowanceChangeRequest } from './entities';
import { AllowanceChangeRequestsRepository, AllowancesRepository } from './repositories';
import { AllowanceChangeRequestsService, AllowancesService } from './services';
@Module({
controllers: [AllowancesController, AllowanceChangeRequestController],
imports: [TypeOrmModule.forFeature([Allowance, AllowanceChangeRequest]), JuniorModule],
providers: [
AllowancesService,
AllowancesRepository,
AllowanceChangeRequestsService,
AllowanceChangeRequestsRepository,
],
exports: [AllowancesService],
})
export class AllowanceModule {}

View File

@ -1,80 +0,0 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces';
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
import { RolesGuard } from '~/common/guards';
import { ApiDataPageResponse, ApiDataResponse } from '~/core/decorators';
import { PageOptionsRequestDto } from '~/core/dtos';
import { CustomParseUUIDPipe } from '~/core/pipes';
import { ResponseFactory } from '~/core/utils';
import { CreateAllowanceChangeRequestDto } from '../dtos/request';
import { AllowanceChangeRequestResponseDto } from '../dtos/response';
import { AllowanceChangeRequestsService } from '../services';
@Controller('allowance-change-requests')
@ApiTags('Allowance Change Requests')
@ApiBearerAuth()
export class AllowanceChangeRequestController {
constructor(private readonly allowanceChangeRequestsService: AllowanceChangeRequestsService) {}
@Post()
@UseGuards(RolesGuard)
@AllowedRoles(Roles.JUNIOR)
@HttpCode(HttpStatus.NO_CONTENT)
requestAllowanceChange(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateAllowanceChangeRequestDto) {
return this.allowanceChangeRequestsService.createAllowanceChangeRequest(sub, body);
}
@Get()
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataPageResponse(AllowanceChangeRequestResponseDto)
async findAllowanceChangeRequests(@AuthenticatedUser() { sub }: IJwtPayload, @Query() query: PageOptionsRequestDto) {
const [requests, itemCount] = await this.allowanceChangeRequestsService.findAllowanceChangeRequests(sub, query);
return ResponseFactory.dataPage(
requests.map((request) => new AllowanceChangeRequestResponseDto(request)),
{
itemCount,
page: query.page,
size: query.size,
},
);
}
@Get('/:changeRequestId')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(AllowanceChangeRequestResponseDto)
async findAllowanceChangeRequestById(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('changeRequestId', CustomParseUUIDPipe) changeRequestId: string,
) {
const request = await this.allowanceChangeRequestsService.findAllowanceChangeRequestById(sub, changeRequestId);
return ResponseFactory.data(new AllowanceChangeRequestResponseDto(request));
}
@Patch(':changeRequestId/approve')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@HttpCode(HttpStatus.NO_CONTENT)
approveAllowanceChangeRequest(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('changeRequestId', CustomParseUUIDPipe) changeRequestId: string,
) {
return this.allowanceChangeRequestsService.approveAllowanceChangeRequest(sub, changeRequestId);
}
@Patch(':changeRequestId/reject')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@HttpCode(HttpStatus.NO_CONTENT)
rejectAllowanceChangeRequest(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('changeRequestId', CustomParseUUIDPipe) changeRequestId: string,
) {
return this.allowanceChangeRequestsService.rejectAllowanceChangeRequest(sub, changeRequestId);
}
}

View File

@ -1,72 +0,0 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces';
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
import { RolesGuard } from '~/common/guards';
import { ApiDataPageResponse, ApiDataResponse } from '~/core/decorators';
import { PageOptionsRequestDto } from '~/core/dtos';
import { CustomParseUUIDPipe } from '~/core/pipes';
import { ResponseFactory } from '~/core/utils';
import { CreateAllowanceRequestDto } from '../dtos/request';
import { AllowanceResponseDto } from '../dtos/response';
import { AllowancesService } from '../services';
@Controller('allowances')
@ApiTags('Allowances')
@ApiBearerAuth()
export class AllowancesController {
constructor(private readonly allowancesService: AllowancesService) {}
@Post()
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(AllowanceResponseDto)
async createAllowance(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateAllowanceRequestDto) {
const allowance = await this.allowancesService.createAllowance(sub, body);
return ResponseFactory.data(new AllowanceResponseDto(allowance));
}
@Get()
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataPageResponse(AllowanceResponseDto)
async findAllowances(@AuthenticatedUser() { sub }: IJwtPayload, @Query() query: PageOptionsRequestDto) {
const [allowances, itemCount] = await this.allowancesService.findAllowances(sub, query);
return ResponseFactory.dataPage(
allowances.map((allowance) => new AllowanceResponseDto(allowance)),
{
itemCount,
page: query.page,
size: query.size,
},
);
}
@Get(':allowanceId')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(AllowanceResponseDto)
async findAllowanceById(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('allowanceId', CustomParseUUIDPipe) allowanceId: string,
) {
const allowance = await this.allowancesService.findAllowanceById(allowanceId, sub);
return ResponseFactory.data(new AllowanceResponseDto(allowance));
}
@Delete(':allowanceId')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(AllowanceResponseDto)
@HttpCode(HttpStatus.NO_CONTENT)
deleteAllowance(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('allowanceId', CustomParseUUIDPipe) allowanceId: string,
) {
return this.allowancesService.deleteAllowance(sub, allowanceId);
}
}

View File

@ -1,2 +0,0 @@
export * from './allowance-change-request.controller';
export * from './allowances.controller';

View File

@ -1,28 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class CreateAllowanceChangeRequestDto {
@ApiProperty({ example: 'I want to change the amount of the allowance' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'allowanceChangeRequest.reason' }) })
@IsNotEmpty({
message: i18n('validation.IsNotEmpty', { path: 'general', property: 'allowanceChangeRequest.reason' }),
})
reason!: string;
@ApiProperty({ example: 100 })
@IsNumber(
{},
{ message: i18n('validation.IsNumber', { path: 'general', property: 'allowanceChangeRequest.amount' }) },
)
@IsPositive({
message: i18n('validation.IsPositive', { path: 'general', property: 'allowanceChangeRequest.amount' }),
})
amount!: number;
@ApiProperty({ example: 'd641bb71-2e7c-4e62-96fa-2785f0a651c6' })
@IsUUID('4', {
message: i18n('validation.IsUUID', { path: 'general', property: 'allowanceChangeRequest.allowanceId' }),
})
allowanceId!: string;
}

View File

@ -1,52 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsDate, IsEnum, IsInt, IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID, ValidateIf } from 'class-validator';
import moment from 'moment';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { AllowanceFrequency, AllowanceType } from '~/allowance/enums';
export class CreateAllowanceRequestDto {
@ApiProperty({ example: 'Allowance name' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'allowance.name' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'allowance.name' }) })
name!: string;
@ApiProperty({ example: 100 })
@IsNumber({}, { message: i18n('validation.IsNumber', { path: 'general', property: 'allowance.amount' }) })
@IsPositive({ message: i18n('validation.IsPositive', { path: 'general', property: 'allowance.amount' }) })
amount!: number;
@ApiProperty({ example: AllowanceFrequency.WEEKLY })
@IsEnum(AllowanceFrequency, {
message: i18n('validation.IsEnum', { path: 'general', property: 'allowance.frequency' }),
})
frequency!: AllowanceFrequency;
@ApiProperty({ example: AllowanceType.BY_END_DATE })
@IsEnum(AllowanceType, { message: i18n('validation.IsEnum', { path: 'general', property: 'allowance.type' }) })
type!: AllowanceType;
@ApiProperty({ example: new Date() })
@IsDate({ message: i18n('validation.IsDate', { path: 'general', property: 'allowance.startDate' }) })
@Transform(({ value }) => moment(value).startOf('day').toDate())
startDate!: Date;
@ApiProperty({ example: new Date() })
@IsDate({ message: i18n('validation.IsDate', { path: 'general', property: 'allowance.endDate' }) })
@Transform(({ value }) => moment(value).endOf('day').toDate())
@ValidateIf((o) => o.type === AllowanceType.BY_END_DATE)
endDate?: Date;
@ApiProperty({ example: 10 })
@IsNumber(
{},
{ message: i18n('validation.IsNumber', { path: 'general', property: 'allowance.numberOfTransactions' }) },
)
@IsInt({ message: i18n('validation.IsInt', { path: 'general', property: 'allowance.amount' }) })
@IsPositive({ message: i18n('validation.IsPositive', { path: 'general', property: 'allowance.amount' }) })
@ValidateIf((o) => o.type === AllowanceType.BY_COUNT)
numberOfTransactions?: number;
@ApiProperty({ example: 'e7b1b3b4-4b3b-4b3b-4b3b-4b3b4b3b4b3b' })
@IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'allowance.juniorId' }) })
juniorId!: string;
}

View File

@ -1,2 +0,0 @@
export * from './create-allowance-change.request.dto';
export * from './create-allowance.request.dto';

View File

@ -1,45 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { AllowanceChangeRequest } from '~/allowance/entities';
import { AllowanceChangeRequestStatus } from '~/allowance/enums';
import { JuniorResponseDto } from '~/junior/dtos/response';
export class AllowanceChangeRequestResponseDto {
@ApiProperty({ example: 'd641bb71-2e7c-4e62-96fa-2785f0a651c6' })
id!: string;
@ApiProperty({ example: AllowanceChangeRequestStatus.APPROVED })
status!: AllowanceChangeRequestStatus;
@ApiProperty({ example: 'Allowance name' })
name!: string;
@ApiProperty({ example: '100' })
oldAmount!: number;
@ApiProperty({ example: '200' })
newAmount!: number;
@ApiProperty({ example: 'Some reason' })
reason!: string;
@ApiProperty({ example: 'd641bb71-2e7c-4e62-96fa-2785f0a651c6' })
allowanceId!: string;
@ApiProperty({ type: JuniorResponseDto })
junior!: JuniorResponseDto;
@ApiProperty({ example: new Date() })
createdAt!: Date;
constructor(allowanceChangeRequest: AllowanceChangeRequest) {
this.id = allowanceChangeRequest.id;
this.status = allowanceChangeRequest.status;
this.name = allowanceChangeRequest.allowance.name;
this.oldAmount = allowanceChangeRequest.allowance.amount;
this.newAmount = allowanceChangeRequest.amount;
this.reason = allowanceChangeRequest.reason;
this.allowanceId = allowanceChangeRequest.allowanceId;
this.junior = new JuniorResponseDto(allowanceChangeRequest.allowance.junior);
this.createdAt = allowanceChangeRequest.createdAt;
}
}

View File

@ -1,53 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { Allowance } from '~/allowance/entities';
import { AllowanceFrequency, AllowanceType } from '~/allowance/enums';
import { JuniorResponseDto } from '~/junior/dtos/response';
export class AllowanceResponseDto {
@ApiProperty({ example: 'd641bb71-2e7c-4e62-96fa-2785f0a651c6' })
id!: string;
@ApiProperty({ example: 'Allowance name' })
name!: string;
@ApiProperty({ example: 100 })
amount!: number;
@ApiProperty({ example: AllowanceFrequency.WEEKLY })
frequency!: AllowanceFrequency;
@ApiProperty({ example: AllowanceType.BY_END_DATE })
type!: AllowanceType;
@ApiProperty({ example: new Date() })
startDate!: Date;
@ApiProperty({ example: new Date() })
endDate?: Date;
@ApiProperty({ example: 10 })
numberOfTransactions?: number;
@ApiProperty({ type: JuniorResponseDto })
junior!: JuniorResponseDto;
@ApiProperty({ example: new Date() })
createdAt!: Date;
@ApiProperty({ example: new Date() })
updatedAt!: Date;
constructor(allowance: Allowance) {
this.id = allowance.id;
this.name = allowance.name;
this.amount = allowance.amount;
this.frequency = allowance.frequency;
this.type = allowance.type;
this.startDate = allowance.startDate;
this.endDate = allowance.endDate;
this.numberOfTransactions = allowance.numberOfTransactions;
this.junior = new JuniorResponseDto(allowance.junior);
this.createdAt = allowance.createdAt;
this.updatedAt = allowance.updatedAt;
}
}

View File

@ -1,2 +0,0 @@
export * from './allowance-change-request.response.dto';
export * from './allowance.response.dto';

View File

@ -1,45 +0,0 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { AllowanceChangeRequestStatus } from '../enums';
import { Allowance } from './allowance.entity';
@Entity('allowance_change_requests')
export class AllowanceChangeRequest {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'text', name: 'reason' })
reason!: string;
@Column({
type: 'decimal',
precision: 10,
scale: 2,
name: 'amount',
transformer: { to: (value: number) => value, from: (value: string) => parseFloat(value) },
})
amount!: number;
@Column({ type: 'varchar', length: 255, name: 'status', default: AllowanceChangeRequestStatus.PENDING })
status!: AllowanceChangeRequestStatus;
@Column({ type: 'uuid', name: 'allowance_id' })
allowanceId!: string;
@ManyToOne(() => Allowance, (allowance) => allowance.changeRequests)
@JoinColumn({ name: 'allowance_id' })
allowance!: Allowance;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date;
}

View File

@ -1,107 +0,0 @@
import moment from 'moment';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Guardian } from '~/guardian/entities/guradian.entity';
import { Junior } from '~/junior/entities';
import { AllowanceFrequency, AllowanceType } from '../enums';
import { AllowanceChangeRequest } from './allowance-change-request.entity';
@Entity('allowances')
export class Allowance {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 255, name: 'name' })
name!: string;
@Column({
type: 'decimal',
precision: 10,
scale: 2,
name: 'amount',
transformer: { to: (value: number) => value, from: (value: string) => parseFloat(value) },
})
amount!: number;
@Column({ type: 'varchar', length: 255, name: 'frequency' })
frequency!: AllowanceFrequency;
@Column({ type: 'varchar', length: 255, name: 'type' })
type!: AllowanceType;
@Column({ type: 'timestamp with time zone', name: 'start_date' })
startDate!: Date;
@Column({ type: 'timestamp with time zone', name: 'end_date', nullable: true })
endDate?: Date;
@Column({ type: 'int', name: 'number_of_transactions', nullable: true })
numberOfTransactions?: number;
@Column({ type: 'uuid', name: 'guardian_id' })
guardianId!: string;
@Column({ type: 'uuid', name: 'junior_id' })
juniorId!: string;
@ManyToOne(() => Guardian, (guardian) => guardian.allowances)
@JoinColumn({ name: 'guardian_id' })
guardian!: Guardian;
@ManyToOne(() => Junior, (junior) => junior.allowances)
@JoinColumn({ name: 'junior_id' })
junior!: Junior;
@OneToMany(() => AllowanceChangeRequest, (changeRequest) => changeRequest.allowance)
changeRequests!: AllowanceChangeRequest[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamp with time zone', nullable: true })
deletedAt?: Date;
get nextPaymentDate(): Date | null {
const startDate = moment(this.startDate).clone().startOf('day');
const endDate = this.endDate ? moment(this.endDate).endOf('day') : null;
const now = moment().startOf('day');
if (endDate && moment().isAfter(endDate)) {
return null;
}
const calculateNextDate = (unit: moment.unitOfTime.Diff) => {
const diff = now.diff(startDate, unit);
const nextDate = startDate.clone().add(diff, unit);
const adjustedDate = nextDate.isSameOrAfter(now) ? nextDate : nextDate.add('1', unit);
if (endDate && adjustedDate.isAfter(endDate)) {
return null;
}
return adjustedDate.toDate();
};
switch (this.frequency) {
case AllowanceFrequency.DAILY:
return calculateNextDate('days');
case AllowanceFrequency.WEEKLY:
return calculateNextDate('weeks');
case AllowanceFrequency.MONTHLY:
return calculateNextDate('months');
default:
return null;
}
}
}

View File

@ -1,2 +0,0 @@
export * from './allowance-change-request.entity';
export * from './allowance.entity';

View File

@ -1,5 +0,0 @@
export enum AllowanceFrequency {
DAILY = 'DAILY',
WEEKLY = 'WEEKLY',
MONTHLY = 'MONTHLY',
}

View File

@ -1,4 +0,0 @@
export enum AllowanceType {
BY_END_DATE = 'BY_END_DATE',
BY_COUNT = 'BY_COUNT',
}

View File

@ -1,3 +0,0 @@
export * from './allowance-change-request-status.enum';
export * from './allowance-frequency.enum';
export * from './allowance-type.enum';

View File

@ -1,50 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, Repository } from 'typeorm';
import { PageOptionsRequestDto } from '~/core/dtos';
import { CreateAllowanceChangeRequestDto } from '../dtos/request';
import { AllowanceChangeRequest } from '../entities';
import { AllowanceChangeRequestStatus } from '../enums';
const ONE = 1;
@Injectable()
export class AllowanceChangeRequestsRepository {
constructor(
@InjectRepository(AllowanceChangeRequest)
private readonly allowanceChangeRequestsRepository: Repository<AllowanceChangeRequest>,
) {}
createAllowanceChangeRequest(allowanceId: string, body: CreateAllowanceChangeRequestDto) {
return this.allowanceChangeRequestsRepository.save(
this.allowanceChangeRequestsRepository.create({
allowanceId,
amount: body.amount,
reason: body.reason,
}),
);
}
findAllowanceChangeRequestBy(where: FindOptionsWhere<AllowanceChangeRequest>, withRelations = false) {
const relations = withRelations
? ['allowance', 'allowance.junior', 'allowance.junior.customer', 'allowance.junior.customer.profilePicture']
: [];
return this.allowanceChangeRequestsRepository.findOne({ where, relations });
}
updateAllowanceChangeRequestStatus(requestId: string, status: AllowanceChangeRequestStatus) {
return this.allowanceChangeRequestsRepository.update({ id: requestId }, { status });
}
findAllowanceChangeRequests(guardianId: string, query: PageOptionsRequestDto) {
return this.allowanceChangeRequestsRepository.findAndCount({
where: { allowance: { guardianId } },
take: query.size,
skip: query.size * (query.page - ONE),
relations: [
'allowance',
'allowance.junior',
'allowance.junior.customer',
'allowance.junior.customer.profilePicture',
],
});
}
}

View File

@ -1,64 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PageOptionsRequestDto } from '~/core/dtos';
import { CreateAllowanceRequestDto } from '../dtos/request';
import { Allowance } from '../entities';
const ONE = 1;
@Injectable()
export class AllowancesRepository {
constructor(@InjectRepository(Allowance) private readonly allowancesRepository: Repository<Allowance>) {}
createAllowance(guardianId: string, body: CreateAllowanceRequestDto) {
return this.allowancesRepository.save(
this.allowancesRepository.create({
guardianId,
name: body.name,
amount: body.amount,
frequency: body.frequency,
type: body.type,
startDate: body.startDate,
endDate: body.endDate,
numberOfTransactions: body.numberOfTransactions,
juniorId: body.juniorId,
}),
);
}
findAllowanceById(allowanceId: string, guardianId?: string) {
return this.allowancesRepository.findOne({
where: { id: allowanceId, guardianId },
relations: ['junior', 'junior.customer', 'junior.customer.profilePicture'],
});
}
findAllowances(guardianId: string, query: PageOptionsRequestDto) {
return this.allowancesRepository.findAndCount({
where: { guardianId },
relations: ['junior', 'junior.customer', 'junior.customer.profilePicture'],
take: query.size,
skip: query.size * (query.page - ONE),
});
}
deleteAllowance(guardianId: string, allowanceId: string) {
return this.allowancesRepository.softDelete({ id: allowanceId, guardianId });
}
async *findAllowancesChunks(chunkSize: number) {
let offset = 0;
while (true) {
const allowances = await this.allowancesRepository.find({
take: chunkSize,
skip: offset,
});
if (!allowances.length) {
break;
}
yield allowances;
offset += chunkSize;
}
}
}

View File

@ -1,2 +0,0 @@
export * from './allowance-change-request.repository';
export * from './allowances.repository';

View File

@ -1,132 +0,0 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { PageOptionsRequestDto } from '~/core/dtos';
import { OciService } from '~/document/services';
import { CreateAllowanceChangeRequestDto } from '../dtos/request';
import { AllowanceChangeRequest } from '../entities';
import { AllowanceChangeRequestStatus } from '../enums';
import { AllowanceChangeRequestsRepository } from '../repositories';
import { AllowancesService } from './allowances.service';
@Injectable()
export class AllowanceChangeRequestsService {
private readonly logger = new Logger(AllowanceChangeRequestsService.name);
constructor(
private readonly allowanceChangeRequestsRepository: AllowanceChangeRequestsRepository,
private readonly ociService: OciService,
private readonly allowanceService: AllowancesService,
) {}
async createAllowanceChangeRequest(juniorId: string, body: CreateAllowanceChangeRequestDto) {
this.logger.log(`Creating allowance change request for junior ${juniorId}`);
const allowance = await this.allowanceService.validateAllowanceForJunior(juniorId, body.allowanceId);
if (allowance.amount === body.amount) {
this.logger.error(`Amount is the same as the current allowance amount`);
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.SAME_AMOUNT');
}
const requestWithTheSameAmount = await this.findAllowanceChangeRequestBy({
allowanceId: body.allowanceId,
amount: body.amount,
status: AllowanceChangeRequestStatus.PENDING,
});
if (requestWithTheSameAmount) {
this.logger.error(`There is a pending request with the same amount`);
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.SAME_AMOUNT_PENDING');
}
return this.allowanceChangeRequestsRepository.createAllowanceChangeRequest(body.allowanceId, body);
}
findAllowanceChangeRequestBy(where: FindOptionsWhere<AllowanceChangeRequest>) {
this.logger.log(`Finding allowance change request by ${JSON.stringify(where)}`);
return this.allowanceChangeRequestsRepository.findAllowanceChangeRequestBy(where);
}
async approveAllowanceChangeRequest(guardianId: string, requestId: string) {
this.logger.log(`Approving allowance change request ${requestId} by guardian ${guardianId}`);
const request = await this.findAllowanceChangeRequestBy({ id: requestId, allowance: { guardianId } });
if (!request) {
this.logger.error(`Allowance change request ${requestId} not found for guardian ${guardianId}`);
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.NOT_FOUND');
}
if (request.status === AllowanceChangeRequestStatus.APPROVED) {
this.logger.error(`Allowance change request ${requestId} already approved`);
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.ALREADY_APPROVED');
}
return this.allowanceChangeRequestsRepository.updateAllowanceChangeRequestStatus(
requestId,
AllowanceChangeRequestStatus.APPROVED,
);
}
async rejectAllowanceChangeRequest(guardianId: string, requestId: string) {
this.logger.log(`Rejecting allowance change request ${requestId} by guardian ${guardianId}`);
const request = await this.findAllowanceChangeRequestBy({ id: requestId, allowance: { guardianId } });
if (!request) {
this.logger.error(`Allowance change request ${requestId} not found for guardian ${guardianId}`);
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.NOT_FOUND');
}
if (request.status === AllowanceChangeRequestStatus.REJECTED) {
this.logger.error(`Allowance change request ${requestId} already rejected`);
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.ALREADY_REJECTED');
}
return this.allowanceChangeRequestsRepository.updateAllowanceChangeRequestStatus(
requestId,
AllowanceChangeRequestStatus.REJECTED,
);
}
async findAllowanceChangeRequests(
guardianId: string,
query: PageOptionsRequestDto,
): Promise<[AllowanceChangeRequest[], number]> {
this.logger.log(`Finding allowance change requests for guardian ${guardianId}`);
const [requests, itemCount] = await this.allowanceChangeRequestsRepository.findAllowanceChangeRequests(
guardianId,
query,
);
await this.prepareAllowanceChangeRequestsImages(requests);
this.logger.log(`Returning allowance change requests for guardian ${guardianId}`);
return [requests, itemCount];
}
async findAllowanceChangeRequestById(guardianId: string, requestId: string) {
this.logger.log(`Finding allowance change request ${requestId} for guardian ${guardianId}`);
const request = await this.allowanceChangeRequestsRepository.findAllowanceChangeRequestBy(
{
id: requestId,
allowance: { guardianId },
},
true,
);
if (!request) {
this.logger.error(`Allowance change request ${requestId} not found for guardian ${guardianId}`);
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.NOT_FOUND');
}
await this.prepareAllowanceChangeRequestsImages([request]);
this.logger.log(`Allowance change request ${requestId} found successfully`);
return request;
}
private prepareAllowanceChangeRequestsImages(requests: AllowanceChangeRequest[]) {
this.logger.log(`Preparing allowance change requests images`);
return Promise.all(
requests.map(async (request) => {
const profilePicture = request.allowance.junior.customer.profilePicture;
if (profilePicture) {
profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture);
}
}),
);
}
}

View File

@ -1,110 +0,0 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import moment from 'moment';
import { PageOptionsRequestDto } from '~/core/dtos';
import { OciService } from '~/document/services';
import { JuniorService } from '~/junior/services';
import { CreateAllowanceRequestDto } from '../dtos/request';
import { Allowance } from '../entities';
import { AllowancesRepository } from '../repositories';
@Injectable()
export class AllowancesService {
private readonly logger = new Logger(AllowancesService.name);
constructor(
private readonly allowancesRepository: AllowancesRepository,
private readonly juniorService: JuniorService,
private readonly ociService: OciService,
) {}
async createAllowance(guardianId: string, body: CreateAllowanceRequestDto) {
this.logger.log(`Creating allowance for junior ${body.juniorId} by guardian ${guardianId}`);
if (moment(body.startDate).isBefore(moment().startOf('day'))) {
this.logger.error(`Start date ${body.startDate} is before today`);
throw new BadRequestException('ALLOWANCE.START_DATE_BEFORE_TODAY');
}
if (moment(body.startDate).isAfter(body.endDate)) {
this.logger.error(`Start date ${body.startDate} is after end date ${body.endDate}`);
throw new BadRequestException('ALLOWANCE.START_DATE_AFTER_END_DATE');
}
const doesJuniorBelongToGuardian = await this.juniorService.doesJuniorBelongToGuardian(guardianId, body.juniorId);
if (!doesJuniorBelongToGuardian) {
this.logger.error(`Junior ${body.juniorId} does not belong to guardian ${guardianId}`);
throw new BadRequestException('JUNIOR.DOES_NOT_BELONG_TO_GUARDIAN');
}
const allowance = await this.allowancesRepository.createAllowance(guardianId, body);
this.logger.log(`Allowance ${allowance.id} created successfully`);
return this.findAllowanceById(allowance.id);
}
async findAllowanceById(allowanceId: string, guardianId?: string) {
this.logger.log(`Finding allowance ${allowanceId} ${guardianId ? `by guardian ${guardianId}` : ''}`);
const allowance = await this.allowancesRepository.findAllowanceById(allowanceId, guardianId);
if (!allowance) {
this.logger.error(`Allowance ${allowanceId} not found ${guardianId ? `for guardian ${guardianId}` : ''}`);
throw new BadRequestException('ALLOWANCE.NOT_FOUND');
}
await this.prepareAllowanceDocuments([allowance]);
this.logger.log(`Allowance ${allowanceId} found successfully`);
return allowance;
}
async findAllowances(guardianId: string, query: PageOptionsRequestDto): Promise<[Allowance[], number]> {
this.logger.log(`Finding allowances for guardian ${guardianId}`);
const [allowances, itemCount] = await this.allowancesRepository.findAllowances(guardianId, query);
await this.prepareAllowanceDocuments(allowances);
this.logger.log(`Returning allowances for guardian ${guardianId}`);
return [allowances, itemCount];
}
async deleteAllowance(guardianId: string, allowanceId: string) {
this.logger.log(`Deleting allowance ${allowanceId} for guardian ${guardianId}`);
const { affected } = await this.allowancesRepository.deleteAllowance(guardianId, allowanceId);
if (!affected) {
this.logger.error(`Allowance ${allowanceId} not found`);
throw new BadRequestException('ALLOWANCE.NOT_FOUND');
}
this.logger.log(`Allowance ${allowanceId} deleted successfully`);
}
async validateAllowanceForJunior(juniorId: string, allowanceId: string) {
this.logger.log(`Validating allowance ${allowanceId} for junior ${juniorId}`);
const allowance = await this.allowancesRepository.findAllowanceById(allowanceId);
if (!allowance) {
this.logger.error(`Allowance ${allowanceId} not found`);
throw new BadRequestException('ALLOWANCE.NOT_FOUND');
}
if (allowance.juniorId !== juniorId) {
this.logger.error(`Allowance ${allowanceId} does not belong to junior ${juniorId}`);
throw new BadRequestException('ALLOWANCE.DOES_NOT_BELONG_TO_JUNIOR');
}
return allowance;
}
async findAllowancesChunks(chunkSize: number) {
this.logger.log(`Finding allowances chunks`);
const allowances = await this.allowancesRepository.findAllowancesChunks(chunkSize);
this.logger.log(`Returning allowances chunks`);
return allowances;
}
private async prepareAllowanceDocuments(allowance: Allowance[]) {
this.logger.log(`Preparing document for allowances`);
await Promise.all(
allowance.map(async (allowance) => {
const profilePicture = allowance.junior.customer.profilePicture;
if (profilePicture) {
profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture);
}
}),
);
}
}

View File

@ -1,2 +0,0 @@
export * from './allowance-change-requests.service';
export * from './allowances.service';

View File

@ -8,10 +8,11 @@ import { I18nMiddleware, I18nModule } from 'nestjs-i18n';
import { LoggerModule } from 'nestjs-pino';
import { DataSource } from 'typeorm';
import { addTransactionalDataSource } from 'typeorm-transactional';
import { AllowanceModule } from './allowance/allowance.module';
import { AuthModule } from './auth/auth.module';
import { CardModule } from './card/card.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';
@ -22,14 +23,12 @@ import { CronModule } from './cron/cron.module';
import { CustomerModule } from './customer/customer.module';
import { migrations } from './db';
import { DocumentModule } from './document/document.module';
import { GiftModule } from './gift/gift.module';
import { GuardianModule } from './guardian/guardian.module';
import { HealthModule } from './health/health.module';
import { JuniorModule } from './junior/junior.module';
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 { WebhookModule } from './webhook/webhook.module';
import { MoneyRequestModule } from './money-request/money-request.module';
@Module({
controllers: [],
@ -41,7 +40,6 @@ import { UserModule } from './user/user.module';
useFactory: (config: ConfigService) => {
return buildTypeormOptions(config, migrations);
},
/* eslint-disable require-await */
async dataSourceFactory(options) {
if (!options) {
throw new Error('Invalid options passed');
@ -49,7 +47,6 @@ import { UserModule } from './user/user.module';
return addTransactionalDataSource(new DataSource(options));
},
/* eslint-enable require-await */
}),
LoggerModule.forRootAsync({
useFactory: (config: ConfigService) => buildLoggerOptions(config),
@ -61,15 +58,13 @@ import { UserModule } from './user/user.module';
ScheduleModule.forRoot(),
// App modules
AuthModule,
UserModule,
CustomerModule,
JuniorModule,
TaskModule,
GuardianModule,
SavingGoalsModule,
AllowanceModule,
MoneyRequestModule,
GiftModule,
CardModule,
NotificationModule,
OtpModule,
DocumentModule,
@ -77,9 +72,10 @@ import { UserModule } from './user/user.module';
HealthModule,
UserModule,
CronModule,
NeoLeapModule,
WebhookModule,
MoneyRequestModule,
],
providers: [
// Global Pipes

View File

@ -1,3 +1,4 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { JuniorModule } from '~/junior/junior.module';
@ -7,7 +8,7 @@ import { AuthService } from './services';
import { AccessTokenStrategy } from './strategies';
@Module({
imports: [JwtModule.register({}), JuniorModule, UserModule],
imports: [JwtModule.register({}), UserModule, JuniorModule, HttpModule],
providers: [AuthService, AccessTokenStrategy],
controllers: [AuthController],
exports: [],

View File

@ -1,31 +1,32 @@
import { Body, Controller, Headers, HttpCode, HttpStatus, Post, Req, UseGuards } from '@nestjs/common';
import { Body, Controller, HttpCode, HttpStatus, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { DEVICE_ID_HEADER } from '~/common/constants';
import { AuthenticatedUser, Public } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards';
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import {
ChangePasswordRequestDto,
CreateUnverifiedUserRequestDto,
DisableBiometricRequestDto,
EnableBiometricRequestDto,
ForgetPasswordRequestDto,
JuniorLoginRequestDto,
LoginRequestDto,
RefreshTokenRequestDto,
SendForgetPasswordOtpRequestDto,
SetEmailRequestDto,
setJuniorPasswordRequestDto,
SetPasscodeRequestDto,
VerifyForgetPasswordOtpRequestDto,
VerifyUserRequestDto,
} from '../dtos/request';
import { SendForgetPasswordOtpResponseDto, SendRegisterOtpResponseDto } from '../dtos/response';
import { LoginResponseDto } from '../dtos/response/login.response.dto';
import { VerifyForgetPasswordOtpResponseDto } from '../dtos/response/verify-forget-password-otp.response.dto';
import { IJwtPayload } from '../interfaces';
import { AuthService } from '../services';
@Controller('auth')
@ApiTags('Auth')
@ApiBearerAuth()
@ApiLangRequestHeader()
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register/otp')
@ -40,51 +41,54 @@ export class AuthController {
return ResponseFactory.data(new LoginResponseDto(res, 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('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);
return this.authService.resetPassword(forgetPasswordDto);
}
@Post('junior/set-passcode')
@Post('change-password')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard)
changePassword(@AuthenticatedUser() { sub }: IJwtPayload, @Body() forgetPasswordDto: ChangePasswordRequestDto) {
return this.authService.changePassword(sub, forgetPasswordDto);
}
@Post('junior/set-password')
@HttpCode(HttpStatus.NO_CONTENT)
@Public()
setJuniorPasscode(@Body() setPasscodeDto: setJuniorPasswordRequestDto) {
return this.authService.setJuniorPasscode(setPasscodeDto);
setJuniorPasscode(@Body() setPassworddto: setJuniorPasswordRequestDto) {
return this.authService.setJuniorPassword(setPassworddto);
}
@Post('junior/login')
@HttpCode(HttpStatus.OK)
@ApiDataResponse(LoginResponseDto)
async juniorLogin(@Body() juniorLoginDto: JuniorLoginRequestDto) {
const [res, user] = await this.authService.juniorLogin(juniorLoginDto);
return ResponseFactory.data(new LoginResponseDto(res, user));
}
@Post('refresh-token')
@ -94,12 +98,6 @@ export class AuthController {
return ResponseFactory.data(new LoginResponseDto(res, user));
}
@Post('login')
async login(@Body() loginDto: LoginRequestDto, @Headers(DEVICE_ID_HEADER) deviceId: string) {
const [res, user] = await this.authService.login(loginDto, deviceId);
return ResponseFactory.data(new LoginResponseDto(res, user));
}
@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard)

View File

@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { PASSWORD_REGEX } from '~/auth/constants';
export class ChangePasswordRequestDto {
@ApiProperty({ example: 'currentPassword@123' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.currentPassword' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.currentPassword' }) })
currentPassword!: string;
@ApiProperty({ example: 'Abcd1234@' })
@Matches(PASSWORD_REGEX, {
message: i18n('validation.Matches', { path: 'general', property: 'auth.newPassword' }),
})
newPassword!: string;
@ApiProperty({ example: 'Abcd1234@' })
@Matches(PASSWORD_REGEX, {
message: i18n('validation.Matches', { path: 'general', property: 'auth.confirmNewPassword' }),
})
confirmNewPassword!: string;
}

View File

@ -1,19 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { Matches } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { COUNTRY_CODE_REGEX } from '~/auth/constants';
import { IsValidPhoneNumber } from '~/core/decorators/validations';
import { OmitType } from '@nestjs/swagger';
import { VerifyUserRequestDto } from './verify-user.request.dto';
export class CreateUnverifiedUserRequestDto {
@ApiProperty({ example: '+962' })
@Matches(COUNTRY_CODE_REGEX, {
message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }),
})
countryCode: string = '+966';
@ApiProperty({ example: '787259134' })
@IsValidPhoneNumber({
message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }),
})
phoneNumber!: string;
}
export class CreateUnverifiedUserRequestDto extends OmitType(VerifyUserRequestDto, ['otp']) {}

View File

@ -1,4 +0,0 @@
import { PickType } from '@nestjs/swagger';
import { EnableBiometricRequestDto } from './enable-biometric.request.dto';
export class DisableBiometricRequestDto extends PickType(EnableBiometricRequestDto, ['deviceId']) {}

View File

@ -1,14 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class EnableBiometricRequestDto {
@ApiProperty({ example: 'device-id' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.deviceId' }) })
deviceId!: string;
@ApiProperty({ example: 'publicKey' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.publicKey' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.publicKey' }) })
publicKey!: string;
}

View File

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

View File

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

View File

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

View File

@ -1,30 +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.PASSWORD })
@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' }) })
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: '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;
@ApiProperty({ example: 'Login signature' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.signature' }) })
@ValidateIf((o) => o.grantType === GrantType.BIOMETRIC)
signature!: string;
}

View File

@ -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']) {}

View File

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

View File

@ -1,15 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumberString, MaxLength, MinLength } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
const PASSCODE_LENGTH = 6;
export class SetPasscodeRequestDto {
@ApiProperty({ example: '123456' })
@IsNumberString(
{ no_symbols: true },
{ message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.passcode' }) },
)
@MinLength(PASSCODE_LENGTH, { message: i18n('validation.MinLength', { path: 'general', property: 'auth.passcode' }) })
@MaxLength(PASSCODE_LENGTH, { message: i18n('validation.MaxLength', { path: 'general', property: 'auth.passcode' }) })
passcode!: string;
}

View File

@ -0,0 +1,23 @@
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 { ForgetPasswordRequestDto } from './forget-password.request.dto';
export class VerifyForgetPasswordOtpRequestDto extends PickType(ForgetPasswordRequestDto, [
'countryCode',
'phoneNumber',
]) {
@ApiProperty({ example: '111111' })
@IsNumberString(
{ no_symbols: true },
{ message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.otp' }) },
)
@MaxLength(DEFAULT_OTP_LENGTH, {
message: i18n('validation.MaxLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }),
})
@MinLength(DEFAULT_OTP_LENGTH, {
message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }),
})
otp!: string;
}

View File

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

View File

@ -1,10 +1,73 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumberString, MaxLength, MinLength } from 'class-validator';
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 { CreateUnverifiedUserRequestDto } from './create-unverified-user.request.dto';
import { IsAbove18, IsValidPhoneNumber } from '~/core/decorators/validations';
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' }) })
firstName!: string;
@ApiProperty({ example: 'Doe' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) })
lastName!: string;
@ApiProperty({ example: '2001-01-01' })
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) })
@IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) })
dateOfBirth!: Date;
@ApiProperty({ example: 'JO' })
@IsEnum(CountryIso, {
message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }),
})
@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;
export class VerifyUserRequestDto extends CreateUnverifiedUserRequestDto {
@ApiProperty({ example: '111111' })
@IsNumberString(
{ no_symbols: true },

View File

@ -17,12 +17,12 @@ export class LoginResponseDto {
@ApiProperty({ example: UserResponseDto })
user!: UserResponseDto;
@ApiProperty({ example: CustomerResponseDto })
customer!: CustomerResponseDto;
@ApiProperty({ type: CustomerResponseDto })
customer!: CustomerResponseDto | null;
constructor(IVerifyUserResponse: ILoginResponse, user: User) {
this.user = new UserResponseDto(user);
this.customer = new CustomerResponseDto(user.customer);
this.customer = user.customer ? new CustomerResponseDto(user.customer) : null;
this.accessToken = IVerifyUserResponse.accessToken;
this.refreshToken = IVerifyUserResponse.refreshToken;
this.expiresAt = IVerifyUserResponse.expiresAt;

View File

@ -1,7 +1,7 @@
export class SendForgetPasswordOtpResponseDto {
email!: string;
maskedNumber!: string;
constructor(email: string) {
this.email = email;
constructor(maskedNumber: string) {
this.maskedNumber = maskedNumber;
}
}

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Gender } from '~/customer/enums';
import { DocumentMetaResponseDto } from '~/document/dtos/response';
import { User } from '~/user/entities';
export class UserResponseDto {
@ -7,30 +8,47 @@ export class UserResponseDto {
id!: string;
@ApiProperty()
email!: string;
countryCode!: string;
@ApiProperty()
phoneNumber!: string;
@ApiProperty()
countryCode!: string;
email!: string;
@ApiProperty()
isPasswordSet!: boolean;
firstName!: string;
@ApiProperty()
isProfileCompleted!: boolean;
lastName!: string;
@ApiProperty()
roles!: Roles[];
dateOfBirth!: Date;
@ApiPropertyOptional({ type: DocumentMetaResponseDto, nullable: true })
profilePicture!: DocumentMetaResponseDto | null;
@ApiProperty()
isPhoneVerified!: boolean;
@ApiProperty()
isEmailVerified!: boolean;
@ApiPropertyOptional({ enum: Gender, nullable: true })
gender!: Gender | null;
constructor(user: User) {
this.id = user.id;
this.email = user.email;
this.phoneNumber = user.phoneNumber;
this.countryCode = user.countryCode;
this.isPasswordSet = user.isPasswordSet;
this.isProfileCompleted = user.isProfileCompleted;
this.roles = user.roles;
this.phoneNumber = user.phoneNumber;
this.dateOfBirth = user.customer?.dateOfBirth;
this.email = user.email;
this.firstName = user.firstName;
this.lastName = user.lastName;
this.profilePicture = user.profilePicture ? new DocumentMetaResponseDto(user.profilePicture) : null;
this.isEmailVerified = user.isEmailVerified;
this.isPhoneVerified = user.isPhoneVerified;
this.gender = (user.customer?.gender as Gender) || null;
}
}

View File

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

View File

@ -1,4 +1,6 @@
export enum Roles {
JUNIOR = 'JUNIOR',
GUARDIAN = 'GUARDIAN',
CHECKER = 'CHECKER',
SUPER_ADMIN = 'SUPER_ADMIN',
}

View File

@ -3,49 +3,62 @@ import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { Request } from 'express';
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 { JuniorTokenService } from '~/junior/services';
import { DeviceService, UserService } from '~/user/services';
import { UserType } from '~/user/enums';
import { DeviceService, UserService, UserTokenService } from '~/user/services';
import { User } from '../../user/entities';
import { PASSCODE_REGEX } from '../constants';
import {
ChangePasswordRequestDto,
CreateUnverifiedUserRequestDto,
DisableBiometricRequestDto,
EnableBiometricRequestDto,
ForgetPasswordRequestDto,
JuniorLoginRequestDto,
LoginRequestDto,
SendForgetPasswordOtpRequestDto,
SetEmailRequestDto,
setJuniorPasswordRequestDto,
VerifyForgetPasswordOtpRequestDto,
VerifyUserRequestDto,
} from '../dtos/request';
import { GrantType } from '../enums';
import { Roles } from '../enums';
import { IJwtPayload, ILoginResponse } from '../interfaces';
import { removePadding, verifySignature } from '../utils';
const ONE_THOUSAND = 1000;
const SALT_ROUNDS = 10;
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly otpService: OtpService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly userService: UserService,
private readonly deviceService: DeviceService,
private readonly juniorTokenService: JuniorTokenService,
private readonly userTokenService: UserTokenService,
private readonly cacheService: CacheService,
) {}
async sendRegisterOtp({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) {
this.logger.log(`Sending OTP to ${countryCode + phoneNumber}`);
const user = await this.userService.findOrCreateUser({ phoneNumber, countryCode });
async sendRegisterOtp(body: CreateUnverifiedUserRequestDto) {
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.countryCode + user.phoneNumber,
recipient: user.fullPhoneNumber,
scope: OtpScope.VERIFY_PHONE,
otpType: OtpType.SMS,
});
@ -53,13 +66,14 @@ export class AuthService {
async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> {
this.logger.log(`Verifying user with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber}`);
const user = await this.userService.findUserOrThrow({ phoneNumber: verifyUserDto.phoneNumber });
const user = await this.userService.findUserOrThrow({
phoneNumber: verifyUserDto.phoneNumber,
countryCode: verifyUserDto.countryCode,
});
if (user.isPasswordSet) {
this.logger.error(
`User with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber} already verified`,
);
throw new BadRequestException('USERS.PHONE_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({
@ -70,177 +84,142 @@ export class AuthService {
});
if (!isOtpValid) {
this.logger.error(
`Invalid OTP for user with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber}`,
);
throw new BadRequestException('USERS.INVALID_OTP');
this.logger.error(`Invalid OTP for user with phone number ${user.fullPhoneNumber}`);
throw new BadRequestException('OTP.INVALID_OTP');
}
const updatedUser = await this.userService.verifyUserAndCreateCustomer(user);
await this.userService.verifyUser(user.id, verifyUserDto);
const tokens = await this.generateAuthToken(updatedUser);
this.logger.log(
`User with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber} verified successfully`,
);
return [tokens, updatedUser];
await user.reload();
const tokens = await this.generateAuthToken(user);
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('USERS.EMAIL_ALREADY_SET');
}
const existingUser = await this.userService.findUser({ email });
if (existingUser) {
this.logger.error(`Email ${email} already taken`);
throw new BadRequestException('USERS.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('USERS.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 enableBiometric(userId: string, { deviceId, publicKey }: EnableBiometricRequestDto) {
this.logger.log(`Enabling biometric for user with id ${userId}`);
const device = await this.deviceService.findUserDeviceById(deviceId, userId);
if (!device) {
this.logger.log(`Device not found, creating new device for user with id ${userId}`);
return this.deviceService.createDevice({
deviceId,
userId,
publicKey,
});
}
if (device.publicKey) {
this.logger.error(`Biometric already enabled for user with id ${userId}`);
throw new BadRequestException('AUTH.BIOMETRIC_ALREADY_ENABLED');
}
return this.deviceService.updateDevice(deviceId, { publicKey });
}
async disableBiometric(userId: string, { deviceId }: DisableBiometricRequestDto) {
const device = await this.deviceService.findUserDeviceById(deviceId, userId);
if (!device) {
this.logger.error(`Device not found for user with id ${userId} and device id ${deviceId}`);
throw new BadRequestException('AUTH.DEVICE_NOT_FOUND');
}
if (!device.publicKey) {
this.logger.error(`Biometric already disabled for user with id ${userId}`);
throw new BadRequestException('AUTH.BIOMETRIC_ALREADY_DISABLED');
}
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('USERS.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('USERS.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}`);
throw new BadRequestException('USERS.INVALID_OTP');
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, 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 isOldPassword = bcrypt.compareSync(password, user.password);
if (isOldPassword) {
this.logger.error(
`New password cannot be the same as the current password for user with phone number ${user.fullPhoneNumber}`,
);
throw new BadRequestException('AUTH.PASSWORD_SAME_AS_CURRENT');
}
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 login(loginDto: LoginRequestDto, deviceId: string): Promise<[ILoginResponse, User]> {
this.logger.log(`Logging in user with email ${loginDto.email}`);
const user = await this.userService.findUser({ email: loginDto.email });
let tokens;
async changePassword(userId: string, { currentPassword, newPassword, confirmNewPassword }: ChangePasswordRequestDto) {
const user = await this.userService.findUserOrThrow({ id: userId });
if (!user) {
this.logger.error(`User with email ${loginDto.email} not found`);
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
if (!user.isPasswordSet) {
this.logger.error(`Password not set for user with id ${userId}`);
throw new BadRequestException('AUTH.PASSWORD_NOT_SET');
}
if (loginDto.grantType === GrantType.PASSWORD) {
this.logger.log(`Logging in user with email ${loginDto.email} using password`);
tokens = await this.loginWithPassword(loginDto, user);
} else {
this.logger.log(`Logging in user with email ${loginDto.email} using biometric`);
tokens = await this.loginWithBiometric(loginDto, user, deviceId);
if (currentPassword === newPassword) {
this.logger.error('New password cannot be the same as current password');
throw new BadRequestException('AUTH.PASSWORD_SAME_AS_CURRENT');
}
await this.deviceService.updateDevice(deviceId, {
lastAccessOn: new Date(),
fcmToken: loginDto.fcmToken,
userId: user.id,
});
this.logger.log(`User with email ${loginDto.email} logged in successfully`);
return [tokens, user];
if (newPassword !== confirmNewPassword) {
this.logger.error('New password and confirm new password do not match');
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
}
this.logger.log(`Validating current password for user with id ${userId}`);
const isCurrentPasswordValid = bcrypt.compareSync(currentPassword, user.password);
if (!isCurrentPasswordValid) {
this.logger.error(`Invalid current password for user with id ${userId}`);
throw new UnauthorizedException('AUTH.INVALID_CURRENT_PASSWORD');
}
async setJuniorPasscode(body: setJuniorPasswordRequestDto) {
this.logger.log(`Setting passcode for junior with qrToken ${body.qrToken}`);
const juniorId = await this.juniorTokenService.validateToken(body.qrToken);
const salt = bcrypt.genSaltSync(SALT_ROUNDS);
const hashedPasscode = bcrypt.hashSync(body.passcode, salt);
await this.userService.setPasscode(juniorId, hashedPasscode, salt);
await this.juniorTokenService.invalidateToken(body.qrToken);
const hashedNewPassword = bcrypt.hashSync(newPassword, salt);
await this.userService.setPassword(user.id, hashedNewPassword, salt);
this.logger.log(`Password changed successfully for user with id ${userId}`);
}
async setJuniorPassword(body: setJuniorPasswordRequestDto) {
this.logger.log(`Setting passcode for junior with qrToken ${body.qrToken}`);
if (body.newPassword != body.confirmNewPassword) {
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
}
const juniorId = await this.userTokenService.validateToken(body.qrToken, UserType.JUNIOR);
const salt = bcrypt.genSaltSync(SALT_ROUNDS);
const hashedPasscode = bcrypt.hashSync(body.newPassword, salt);
await this.userService.setPassword(juniorId!, hashedPasscode, salt);
await this.userTokenService.invalidateToken(body.qrToken);
this.logger.log(`Passcode set successfully for junior with id ${juniorId}`);
}
async refreshToken(refreshToken: string): Promise<[ILoginResponse, User]> {
this.logger.log('Refreshing token');
const isBlackListed = await this.cacheService.get(refreshToken);
if (isBlackListed) {
this.logger.error('Refresh token is blacklisted');
throw new BadRequestException('AUTH.INVALID_REFRESH_TOKEN');
}
try {
const isValid = await this.jwtService.verifyAsync<IJwtPayload>(refreshToken, {
secret: this.configService.getOrThrow('JWT_REFRESH_TOKEN_SECRET'),
@ -252,6 +231,12 @@ export class AuthService {
const tokens = await this.generateAuthToken(user);
this.logger.log(`Blacklisting old tokens for user with id ${isValid.sub}`);
const refreshTokenExpiry = this.jwtService.decode(refreshToken).exp - Date.now() / ONE_THOUSAND;
await this.cacheService.set(refreshToken, 'BLACKLISTED', refreshTokenExpiry);
this.logger.log(`Token refreshed successfully for user with id ${isValid.sub}`);
return [tokens, user];
@ -265,53 +250,56 @@ export class AuthService {
this.logger.log('Logging out');
const accessToken = req.headers.authorization?.split(' ')[1] as string;
const expiryInTtl = this.jwtService.decode(accessToken).exp - Date.now() / ONE_THOUSAND;
return this.cacheService.set(accessToken, 'LOGOUT', expiryInTtl);
return this.cacheService.set(accessToken, 'BLACKLISTED', expiryInTtl);
}
private async loginWithPassword(loginDto: LoginRequestDto, user: User): Promise<ILoginResponse> {
this.logger.log(`validating password for user with email ${loginDto.email}`);
async loginWithPassword(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> {
const user = await this.userService.findUser({
countryCode: loginDto.countryCode,
phoneNumber: loginDto.phoneNumber,
});
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}`);
return tokens;
this.logger.log(`Password validated successfully for user`);
return [tokens, user];
}
private async loginWithBiometric(loginDto: LoginRequestDto, user: User, deviceId: string): Promise<ILoginResponse> {
this.logger.log(`validating biometric for user with email ${loginDto.email}`);
const device = await this.deviceService.findUserDeviceById(deviceId, user.id);
async juniorLogin(juniorLoginDto: JuniorLoginRequestDto): Promise<[ILoginResponse, User]> {
const user = await this.userService.findUser({ email: juniorLoginDto.email });
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 (!user || !user.roles.includes(Roles.JUNIOR)) {
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
}
if (!device.publicKey) {
this.logger.error(`Biometric not enabled for user with email ${loginDto.email}`);
throw new UnauthorizedException('AUTH.BIOMETRIC_NOT_ENABLED');
}
this.logger.log(`validating password for user with email ${juniorLoginDto.email}`);
const isPasswordValid = bcrypt.compareSync(juniorLoginDto.password, user.password);
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 (!isPasswordValid) {
this.logger.error(`Invalid password for user with email ${juniorLoginDto.email}`);
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
}
const tokens = await this.generateAuthToken(user);
this.logger.log(`Biometric validated successfully for user with email ${loginDto.email}`);
return tokens;
this.logger.log(`Password validated successfully for user`);
return [tokens, user];
}
private async generateAuthToken(user: User) {
@ -336,17 +324,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');
}
}
}

View File

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

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

@ -0,0 +1,33 @@
import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { NeoLeapModule } from '~/common/modules/neoleap/neoleap.module';
import { CustomerModule } from '~/customer/customer.module';
import { CardsController } from './controllers';
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]),
forwardRef(() => NeoLeapModule),
forwardRef(() => CustomerModule), // <-- add forwardRef here
],
providers: [
CardService,
CardRepository,
TransactionService,
TransactionRepository,
AccountService,
AccountRepository,
],
exports: [CardService, TransactionService],
controllers: [CardsController],
})
export class CardModule {}

View File

@ -0,0 +1,86 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces';
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard, RolesGuard } from '~/common/guards';
import { CardEmbossingDetailsResponseDto } from '~/common/modules/neoleap/dtos/response';
import { ApiDataResponse } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import { FundIbanRequestDto } from '../dtos/requests';
import { AccountIbanResponseDto, CardResponseDto, ChildCardResponseDto } from '../dtos/responses';
import { CardService } from '../services';
@Controller('cards')
@ApiBearerAuth()
@ApiTags('Cards')
@UseGuards(AccessTokenGuard)
export class CardsController {
constructor(private readonly cardService: CardService) {}
@Post()
@ApiDataResponse(CardResponseDto)
async createCard(@AuthenticatedUser() { sub }: IJwtPayload) {
const card = await this.cardService.createCard(sub);
return ResponseFactory.data(new CardResponseDto(card));
}
@Get('child-cards')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(ChildCardResponseDto)
async getChildCards(@AuthenticatedUser() { sub }: IJwtPayload) {
const cards = await this.cardService.getChildCards(sub);
return ResponseFactory.data(cards.map((card) => new ChildCardResponseDto(card)));
}
@Get('child-cards/:childid')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(ChildCardResponseDto)
async getChildCardById(@Param('childid') childId: string, @AuthenticatedUser() { sub }: IJwtPayload) {
const card = await this.cardService.getCardByChildId(sub, childId);
return ResponseFactory.data(new ChildCardResponseDto(card));
}
@Get('child-cards/:cardid/embossing-details')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(CardEmbossingDetailsResponseDto)
async getChildCardEmbossingDetails(@Param('cardid') cardId: string, @AuthenticatedUser() { sub }: IJwtPayload) {
const res = await this.cardService.getChildCardEmbossingInformation(cardId, sub);
return ResponseFactory.data(res);
}
@Get('current')
@ApiDataResponse(CardResponseDto)
async getCurrentCard(@AuthenticatedUser() { sub }: IJwtPayload) {
const card = await this.cardService.getCardByCustomerId(sub);
return ResponseFactory.data(new CardResponseDto(card));
}
@Get('embossing-details')
@ApiDataResponse(CardEmbossingDetailsResponseDto)
async getCardById(@AuthenticatedUser() { sub }: IJwtPayload) {
const res = await this.cardService.getEmbossingInformation(sub);
return ResponseFactory.data(res);
}
@Get('iban')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(AccountIbanResponseDto)
async getCardIban(@AuthenticatedUser() { sub }: IJwtPayload) {
const iban = await this.cardService.getIbanInformation(sub);
return ResponseFactory.data(new AccountIbanResponseDto(iban));
}
@Post('mock/fund-iban')
@ApiOperation({ summary: 'Mock endpoint to fund the IBAN - For testing purposes only' })
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@HttpCode(HttpStatus.NO_CONTENT)
fundIban(@Body() { amount, iban }: FundIbanRequestDto) {
return this.cardService.fundIban(iban, amount);
}
}

View File

@ -0,0 +1 @@
export * from './cards.controller';

View File

@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { TransferToJuniorRequestDto } from '~/junior/dtos/request';
export class FundIbanRequestDto extends TransferToJuniorRequestDto {
@ApiProperty({ example: 'DE89370400440532013000' })
@IsString()
iban!: string;
}

View File

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

View File

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

View File

@ -0,0 +1,66 @@
import { ApiProperty } from '@nestjs/swagger';
import { Card } from '~/card/entities';
import { CardScheme, CardStatus, CustomerType } from '~/card/enums';
import { CardStatusDescriptionMapper } from '~/card/mappers/card-status-description.mapper';
import { UserLocale } from '~/core/enums';
export class CardResponseDto {
@ApiProperty({
example: 'b34df8c2-5d3e-4b1a-9c2f-7e3b1a2d3f4e',
})
id!: string;
@ApiProperty({
example: '123456',
description: 'The first six digits of the card number.',
})
firstSixDigits!: string;
@ApiProperty({ example: '7890', description: 'The last four digits of the card number.' })
lastFourDigits!: string;
@ApiProperty({
enum: CardScheme,
description: 'The card scheme (e.g., VISA, MASTERCARD).',
})
scheme!: CardScheme;
@ApiProperty({
enum: CardStatus,
description: 'The current status of the card (e.g., ACTIVE, PENDING).',
})
status!: CardStatus;
@ApiProperty({
example: 'The card is active',
description: 'A description of the card status.',
})
statusDescription!: string;
@ApiProperty({
example: 2000.0,
description: 'The credit limit of the card.',
})
balance!: number;
@ApiProperty({
example: 100.0,
nullable: true,
description: 'The reserved balance of the card (applicable for child accounts).',
})
reservedBalance!: number | null;
constructor(card: Card) {
this.id = card.id;
this.firstSixDigits = card.firstSixDigits;
this.lastFourDigits = card.lastFourDigits;
this.scheme = card.scheme;
this.status = card.status;
this.statusDescription = CardStatusDescriptionMapper[card.statusDescription][UserLocale.ENGLISH].description;
this.balance =
card.customerType === CustomerType.CHILD
? Math.min(card.limit, card.account.balance)
: card.account.balance - card.account.reservedBalance;
this.reservedBalance = card.customerType === CustomerType.PARENT ? card.account.reservedBalance : null;
}
}

View File

@ -0,0 +1,48 @@
import { ApiProperty } from '@nestjs/swagger';
import { Card } from '~/card/entities';
import { Gender } from '~/customer/enums';
import { DocumentMetaResponseDto } from '~/document/dtos/response';
import { CardResponseDto } from './card.response.dto';
class JuniorInfo {
@ApiProperty({ example: 'id' })
id!: string;
@ApiProperty({ example: 'FirstName' })
firstName!: string;
@ApiProperty({ example: 'LastName' })
lastName!: string;
@ApiProperty({ example: 'test@example.com' })
email!: string;
@ApiProperty({ enum: Gender, example: Gender.MALE })
gender!: Gender;
@ApiProperty({ example: '2000-01-01' })
dateOfBirth!: Date;
@ApiProperty({ example: DocumentMetaResponseDto, nullable: true })
profilePicture!: DocumentMetaResponseDto | null;
constructor(card: Card) {
this.id = card.customer?.junior?.id;
this.firstName = card.customer?.firstName;
this.lastName = card.customer?.lastName;
this.email = card.customer?.user?.email;
this.gender = card.customer.gender;
this.profilePicture = card.customer?.user?.profilePicture
? new DocumentMetaResponseDto(card.customer.user.profilePicture)
: null;
}
}
export class ChildCardResponseDto extends CardResponseDto {
@ApiProperty({ type: JuniorInfo })
junior!: JuniorInfo | null;
constructor(card: Card) {
super(card);
this.junior = card.customer?.junior ? new JuniorInfo(card) : null;
}
}

View File

@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
export class ChildTransferItemDto {
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
date!: Date;
@ApiProperty({ example: 50.0 })
amount!: number;
@ApiProperty({ example: 'SAR' })
currency!: string;
@ApiProperty({ example: 'You received {{amount}} {{currency}} from your parent.' })
message!: string;
}

View File

@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { TransactionItemResponseDto } from './transaction-item.response.dto';
export class GuardianHomeResponseDto {
@ApiProperty({ example: 2000.0 })
availableBalance!: number;
@ApiProperty({ type: [TransactionItemResponseDto] })
recentTransactions!: TransactionItemResponseDto[];
constructor(availableBalance: number, recentTransactions: TransactionItemResponseDto[]) {
this.availableBalance = availableBalance;
this.recentTransactions = recentTransactions;
}
}

View File

@ -0,0 +1,15 @@
export * from './account-iban.response.dto';
export * from './card.response.dto';
export * from './child-card.response.dto';
export * from './transaction-item.response.dto';
export * from './guardian-home.response.dto';
export * from './paged-transactions.response.dto';
export * from './parent-transfer-item.response.dto';
export * from './parent-home.response.dto';
export * from './paged-parent-transfers.response.dto';
export * from './child-transfer-item.response.dto';
export * from './junior-home.response.dto';
export * from './paged-child-transfers.response.dto';
export * from './spending-history-item.response.dto';
export * from './spending-history.response.dto';
export * from './transaction-detail.response.dto';

View File

@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { ChildTransferItemDto } from './child-transfer-item.response.dto';
export class JuniorHomeResponseDto {
@ApiProperty({ example: 500.0 })
availableBalance!: number;
@ApiProperty({ type: [ChildTransferItemDto] })
recentTransfers!: ChildTransferItemDto[];
constructor(availableBalance: number, recentTransfers: ChildTransferItemDto[]) {
this.availableBalance = availableBalance;
this.recentTransfers = recentTransfers;
}
}

View File

@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import { ChildTransferItemDto } from './child-transfer-item.response.dto';
export class PagedChildTransfersResponseDto {
@ApiProperty({ type: [ChildTransferItemDto] })
items!: ChildTransferItemDto[];
@ApiProperty({ example: 1 })
page!: number;
@ApiProperty({ example: 10 })
size!: number;
@ApiProperty({ example: 20 })
total!: number;
@ApiProperty({ example: true })
hasMore!: boolean;
constructor(
items: ChildTransferItemDto[],
page: number,
size: number,
total: number,
) {
this.items = items;
this.page = page;
this.size = size;
this.total = total;
this.hasMore = page * size < total;
}
}

View File

@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import { ParentTransferItemDto } from './parent-transfer-item.response.dto';
export class PagedParentTransfersResponseDto {
@ApiProperty({ type: [ParentTransferItemDto] })
items!: ParentTransferItemDto[];
@ApiProperty({ example: 1 })
page!: number;
@ApiProperty({ example: 10 })
size!: number;
@ApiProperty({ example: 45 })
total!: number;
@ApiProperty({ example: true })
hasMore!: boolean;
constructor(
items: ParentTransferItemDto[],
page: number,
size: number,
total: number,
) {
this.items = items;
this.page = page;
this.size = size;
this.total = total;
this.hasMore = page * size < total;
}
}

View File

@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import { TransactionItemResponseDto } from './transaction-item.response.dto';
export class PagedTransactionsResponseDto {
@ApiProperty({ type: [TransactionItemResponseDto] })
items!: TransactionItemResponseDto[];
@ApiProperty({ example: 1 })
page!: number;
@ApiProperty({ example: 10 })
size!: number;
@ApiProperty({ example: 45 })
total!: number;
@ApiProperty({ example: true })
hasMore!: boolean;
constructor(
items: TransactionItemResponseDto[],
page: number,
size: number,
total: number,
) {
this.items = items;
this.page = page;
this.size = size;
this.total = total;
this.hasMore = page * size < total;
}
}

View File

@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { ParentTransferItemDto } from './parent-transfer-item.response.dto';
export class ParentHomeResponseDto {
@ApiProperty({ example: 2000.0 })
availableBalance!: number;
@ApiProperty({ type: [ParentTransferItemDto] })
recentTransfers!: ParentTransferItemDto[];
constructor(availableBalance: number, recentTransfers: ParentTransferItemDto[]) {
this.availableBalance = availableBalance;
this.recentTransfers = recentTransfers;
}
}

View File

@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
export class ParentTransferItemDto {
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
date!: Date;
@ApiProperty({ example: 50.0 })
amount!: number;
@ApiProperty({ example: 'SAR' })
currency!: string;
@ApiProperty({ example: 'Ahmed Ali' })
childName!: string;
}

View File

@ -0,0 +1,58 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transaction } from '~/card/entities/transaction.entity';
export class SpendingHistoryItemDto {
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
date!: Date;
@ApiProperty({ example: 50.5 })
amount!: number;
@ApiProperty({ example: 'SAR' })
currency!: string;
@ApiPropertyOptional({ example: 'Shopping' })
category!: string | null;
@ApiPropertyOptional({ example: 'Target Store' })
merchantName!: string | null;
@ApiPropertyOptional({ example: 'Riyadh' })
merchantCity!: string | null;
@ApiProperty({ example: '277012*****3456' })
cardMasked!: string;
@ApiProperty()
transactionId!: string;
constructor(transaction: Transaction) {
this.date = transaction.transactionDate;
this.amount = transaction.transactionAmount;
this.currency = transaction.transactionCurrency === '682' ? 'SAR' : transaction.transactionCurrency;
this.category = this.mapMccToCategory(transaction.merchantCategoryCode);
this.merchantName = transaction.merchantName;
this.merchantCity = transaction.merchantCity;
this.cardMasked = transaction.cardMaskedNumber;
this.transactionId = transaction.id;
}
private mapMccToCategory(mcc: string | null): string {
if (!mcc) return 'Other';
const mccCode = mcc;
// Map MCC codes to categories
if (mccCode >= '5200' && mccCode <= '5599') return 'Shopping';
if (mccCode >= '5800' && mccCode <= '5899') return 'Food & Dining';
if (mccCode >= '3000' && mccCode <= '3999') return 'Travel';
if (mccCode >= '4000' && mccCode <= '4799') return 'Transportation';
if (mccCode >= '7200' && mccCode <= '7999') return 'Entertainment';
if (mccCode >= '5900' && mccCode <= '5999') return 'Services';
if (mccCode >= '4800' && mccCode <= '4899') return 'Utilities';
if (mccCode >= '8000' && mccCode <= '8999') return 'Health & Wellness';
return 'Other';
}
}

View File

@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { SpendingHistoryItemDto } from './spending-history-item.response.dto';
export class SpendingHistoryResponseDto {
@ApiProperty({ type: [SpendingHistoryItemDto] })
transactions!: SpendingHistoryItemDto[];
@ApiProperty({ example: 150.75 })
totalSpent!: number;
@ApiProperty({ example: 'SAR' })
currency!: string;
@ApiProperty({ example: 10 })
count!: number;
constructor(transactions: SpendingHistoryItemDto[], currency: string = 'SAR') {
this.transactions = transactions;
this.totalSpent = transactions.reduce((sum, tx) => sum + tx.amount, 0);
this.currency = currency;
this.count = transactions.length;
}
}

View File

@ -0,0 +1,74 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transaction } from '~/card/entities/transaction.entity';
export class TransactionDetailResponseDto {
@ApiProperty()
id!: string;
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
date!: Date;
@ApiProperty({ example: 50.5 })
amount!: number;
@ApiProperty({ example: 'SAR' })
currency!: string;
@ApiProperty({ example: 2.5 })
fees!: number;
@ApiProperty({ example: 0.5 })
vatOnFees!: number;
@ApiPropertyOptional({ example: 'Target Store' })
merchantName!: string | null;
@ApiPropertyOptional({ example: 'Shopping' })
category!: string | null;
@ApiPropertyOptional({ example: 'Riyadh' })
merchantCity!: string | null;
@ApiProperty({ example: '277012*****3456' })
cardMasked!: string;
@ApiProperty()
rrn!: string;
@ApiProperty()
transactionId!: string;
constructor(transaction: Transaction) {
this.id = transaction.id;
this.date = transaction.transactionDate;
this.amount = transaction.transactionAmount;
this.currency = transaction.transactionCurrency === '682' ? 'SAR' : transaction.transactionCurrency;
this.fees = transaction.fees;
this.vatOnFees = transaction.vatOnFees;
this.merchantName = transaction.merchantName;
this.category = this.mapMccToCategory(transaction.merchantCategoryCode);
this.merchantCity = transaction.merchantCity;
this.cardMasked = transaction.cardMaskedNumber;
this.rrn = transaction.rrn;
this.transactionId = transaction.transactionId;
}
private mapMccToCategory(mcc: string | null): string {
if (!mcc) return 'Other';
const mccCode = mcc;
// Map MCC codes to categories
if (mccCode >= '5200' && mccCode <= '5599') return 'Shopping';
if (mccCode >= '5800' && mccCode <= '5899') return 'Food & Dining';
if (mccCode >= '3000' && mccCode <= '3999') return 'Travel';
if (mccCode >= '4000' && mccCode <= '4799') return 'Transportation';
if (mccCode >= '7200' && mccCode <= '7999') return 'Entertainment';
if (mccCode >= '5900' && mccCode <= '5999') return 'Services';
if (mccCode >= '4800' && mccCode <= '4899') return 'Utilities';
if (mccCode >= '8000' && mccCode <= '8999') return 'Health & Wellness';
return 'Other';
}
}

View File

@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { ParentTransactionType } from '~/card/enums';
export class TransactionItemResponseDto {
@ApiProperty()
date!: Date;
@ApiProperty({ example: -50.0 })
amountSigned!: number;
@ApiProperty({ enum: ParentTransactionType })
type!: ParentTransactionType;
@ApiProperty({ description: 'Counterparty display name (child for transfer, source label for top-up)' })
counterpartyName!: string;
@ApiProperty({ nullable: true })
counterpartyAccountMasked!: string | null;
@ApiProperty({ required: false })
childName?: string;
}

View File

@ -0,0 +1,60 @@
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',
transformer: {
to: (value: number) => value,
from: (value: string) => parseFloat(value),
},
})
balance!: number;
@Column('decimal', {
precision: 10,
scale: 2,
default: 0.0,
name: 'reserved_balance',
transformer: {
to: (value: number) => value,
from: (value: string) => parseFloat(value),
},
})
reservedBalance!: number;
@OneToMany(() => Card, (card) => card.account, { cascade: true })
cards!: Card[];
@OneToMany(() => Transaction, (transaction) => transaction.account, { cascade: true })
transactions!: Transaction[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone' })
updatedAt!: Date;
}

View File

@ -0,0 +1,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.DEEP_MAGENTA })
color!: CardColors;
@Column({ type: 'varchar', nullable: false, default: CardStatus.PENDING })
status!: CardStatus;
@Column({ type: 'varchar', nullable: false, default: CardStatusDescription.PENDING_ACTIVATION })
statusDescription!: CardStatusDescription;
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0.0, name: 'limit' })
limit!: number;
@Column({ type: 'varchar', nullable: false, default: CardScheme.VISA })
scheme!: CardScheme;
@Column({ type: 'varchar', nullable: false })
issuer!: CardIssuers;
@Column({ type: 'uuid', name: 'customer_id', nullable: false })
customerId!: string;
@Column({ type: 'uuid', name: 'parent_id', nullable: true })
parentId?: string;
@Column({ type: 'uuid', name: 'account_id', nullable: false })
accountId!: string;
@ManyToOne(() => Customer, (customer) => customer.childCards)
@JoinColumn({ name: 'parent_id' })
parentCustomer?: Customer;
@ManyToOne(() => Customer, (customer) => customer.cards, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'customer_id' })
customer!: Customer;
@ManyToOne(() => Account, (account) => account.cards, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'account_id' })
account!: Account;
@OneToMany(() => Transaction, (transaction) => transaction.card, { cascade: true })
transactions!: Transaction[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamp with time zone', name: 'updated_at' })
updatedAt!: Date;
}

View File

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

View File

@ -0,0 +1,87 @@
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',
transformer: {
to: (value: number) => value,
from: (value: string) => parseFloat(value),
},
})
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: 'merchant_name', type: 'varchar', nullable: true })
merchantName!: string | null;
@Column({ name: 'merchant_category_code', type: 'varchar', nullable: true })
merchantCategoryCode!: string | null;
@Column({ name: 'merchant_city', type: 'varchar', nullable: true })
merchantCity!: string | null;
@Column({ name: 'card_id', type: 'uuid', nullable: true })
cardId!: string;
@Column({ name: 'account_id', type: 'uuid', nullable: true })
accountId!: string;
@ManyToOne(() => Card, (card) => card.transactions, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'card_id' })
card!: Card;
@ManyToOne(() => Account, (account) => account.transactions, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'account_id' })
account!: Account;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt!: Date;
}

View File

@ -0,0 +1,13 @@
export enum CardColors {
RAINBOW_PASTEL = 'RAINBOW_PASTEL',
DEEP_MAGENTA = 'DEEP_MAGENTA',
GREEN_TEAL = 'GREEN_TEAL',
BLUE_GREEN = 'BLUE_GREEN',
TEAL_NAVY = 'TEAL_NAVY',
PURPLE_PINK = 'PURPLE_PINK',
GOLD_BLUE = 'GOLD_BLUE',
OCEAN_BLUE = 'OCEAN_BLUE',
BROWN_RUST = 'BROWN_RUST',
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

@ -0,0 +1,9 @@
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';
export * from './parent-transaction-type.enum';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,66 @@
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<Account>) {}
createAccount(data: CreateApplicationResponse): Promise<Account> {
return this.accountRepository.save(
this.accountRepository.create({
accountReference: data.accountId,
accountNumber: data.accountNumber,
iban: data.iBan,
balance: 0,
currency: '682',
}),
);
}
getAccountByReferenceNumber(accountReference: string): Promise<Account | null> {
return this.accountRepository.findOne({
where: { accountReference },
relations: ['cards'],
});
}
getAccountByIban(iban: string): Promise<Account | null> {
return this.accountRepository.findOne({
where: { iban },
relations: ['cards'],
});
}
getAccountByAccountNumber(accountNumber: string): Promise<Account | null> {
return this.accountRepository.findOne({
where: { accountNumber },
relations: ['cards'],
});
}
getAccountByCustomerId(customerId: string): Promise<Account | null> {
return this.accountRepository.findOne({
where: { cards: { customerId } },
relations: ['cards'],
});
}
topUpAccountBalance(accountReference: string, amount: number) {
return this.accountRepository.increment({ accountReference }, 'balance', amount);
}
decreaseAccountBalance(accountReference: string, amount: number) {
return this.accountRepository.decrement({ accountReference }, 'balance', amount);
}
increaseReservedBalance(accountId: string, amount: number) {
return this.accountRepository.increment({ id: accountId }, 'reservedBalance', amount);
}
decreaseReservedBalance(accountId: string, amount: number) {
return this.accountRepository.decrement({ id: accountId }, 'reservedBalance', amount);
}
}

View File

@ -0,0 +1,85 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateApplicationResponse } from '~/common/modules/neoleap/dtos/response';
import { Card } from '../entities';
import { CardColors, CardIssuers, CardScheme, CardStatus, CardStatusDescription, CustomerType } from '../enums';
@Injectable()
export class CardRepository {
constructor(@InjectRepository(Card) private readonly cardRepository: Repository<Card>) {}
createCard(
customerId: string,
accountId: string,
card: CreateApplicationResponse,
cardColor?: CardColors,
parentId?: string,
): Promise<Card> {
return this.cardRepository.save(
this.cardRepository.create({
customerId: customerId,
expiry: card.expiryDate,
cardReference: card.cardId,
customerType: parentId ? CustomerType.CHILD : CustomerType.PARENT,
firstSixDigits: card.firstSixDigits,
lastFourDigits: card.lastFourDigits,
color: cardColor ? cardColor : CardColors.DEEP_MAGENTA,
scheme: CardScheme.VISA,
issuer: CardIssuers.NEOLEAP,
accountId: accountId,
vpan: card.vpan,
parentId,
}),
);
}
findChildCardsForGuardian(guardianId: string): Promise<Card[]> {
return this.cardRepository.find({
where: { parentId: guardianId, customerType: CustomerType.CHILD },
relations: ['account', 'customer', 'customer.user', 'customer.user.profilePicture', 'customer.junior'],
});
}
getCardById(id: string): Promise<Card | null> {
return this.cardRepository.findOne({ where: { id }, relations: ['account'] });
}
findCardByChildId(guardianId: string, childId: string): Promise<Card | null> {
return this.cardRepository.findOne({
where: { parentId: guardianId, customerId: childId, customerType: CustomerType.CHILD },
relations: ['account', 'customer', 'customer.user', 'customer.user.profilePicture', 'customer.junior'],
});
}
getCardByReferenceNumber(referenceNumber: string): Promise<Card | null> {
return this.cardRepository.findOne({ where: { cardReference: referenceNumber }, relations: ['account'] });
}
getCardByVpan(vpan: string): Promise<Card | null> {
return this.cardRepository.findOne({
where: { vpan },
relations: ['account'],
});
}
getCardByCustomerId(customerId: string): Promise<Card | null> {
return this.cardRepository.findOne({
where: { customerId },
relations: ['account'],
});
}
updateCardStatus(id: string, status: CardStatus, statusDescription: CardStatusDescription) {
return this.cardRepository.update(id, {
status: status,
statusDescription: statusDescription,
});
}
updateCardLimit(cardId: string, newLimit: number) {
return this.cardRepository.update(cardId, {
limit: newLimit,
});
}
}

View File

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

View File

@ -0,0 +1,183 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import moment from 'moment';
import { Repository } from 'typeorm';
import {
AccountTransactionWebhookRequest,
CardTransactionWebhookRequest,
} from '~/common/modules/neoleap/dtos/requests';
import { Card } from '../entities';
import { Account } from '../entities/account.entity';
import { Transaction } from '../entities/transaction.entity';
import { TransactionScope, TransactionType } from '../enums';
@Injectable()
export class TransactionRepository {
constructor(@InjectRepository(Transaction) private transactionRepository: Repository<Transaction>) {}
createCardTransaction(card: Card, transactionData: CardTransactionWebhookRequest): Promise<Transaction> {
return this.transactionRepository.save(
this.transactionRepository.create({
transactionId: transactionData.transactionId,
cardReference: transactionData.cardId,
transactionAmount: transactionData.transactionAmount,
transactionCurrency: transactionData.transactionCurrency,
billingAmount: transactionData.billingAmount,
settlementAmount: transactionData.settlementAmount,
transactionDate: moment(transactionData.date + transactionData.time, 'YYYYMMDDHHmmss').toDate(),
rrn: transactionData.rrn,
cardMaskedNumber: transactionData.cardMaskedNumber,
fees: transactionData.fees,
cardId: card.id,
accountId: card.account!.id,
transactionType: TransactionType.EXTERNAL,
accountReference: card.account!.accountReference,
transactionScope: TransactionScope.CARD,
vatOnFees: transactionData.vatOnFees,
merchantName: transactionData.cardAcceptorLocation?.merchantName || null,
merchantCategoryCode: transactionData.cardAcceptorLocation?.mcc || null,
merchantCity: transactionData.cardAcceptorLocation?.merchantCity || null,
}),
);
}
createAccountTransaction(account: Account, transactionData: AccountTransactionWebhookRequest): Promise<Transaction> {
return this.transactionRepository.save(
this.transactionRepository.create({
transactionId: transactionData.transactionId,
transactionAmount: transactionData.amount,
transactionCurrency: transactionData.currency,
billingAmount: 0,
settlementAmount: 0,
transactionDate: moment(transactionData.date + transactionData.time, 'YYYYMMDDHHmmss').toDate(),
fees: 0,
accountReference: account.accountReference,
accountId: account.id,
transactionType: TransactionType.EXTERNAL,
transactionScope: TransactionScope.ACCOUNT,
vatOnFees: 0,
}),
);
}
createInternalChildTransaction(card: Card, amount: number): Promise<Transaction> {
return this.transactionRepository.save(
this.transactionRepository.create({
transactionId: `CHILD-${card.id}-${Date.now()}`,
transactionAmount: amount,
transactionCurrency: '682',
billingAmount: 0,
settlementAmount: 0,
transactionDate: new Date(),
fees: 0,
cardId: card.id,
cardReference: card.cardReference,
cardMaskedNumber: card.firstSixDigits + '******' + card.lastFourDigits,
accountId: card.account!.id,
transactionType: TransactionType.INTERNAL,
accountReference: card.account!.accountReference,
transactionScope: TransactionScope.CARD,
vatOnFees: 0,
}),
);
}
findTransactionByReference(transactionId: string, accountReference: string): Promise<Transaction | null> {
return this.transactionRepository.findOne({
where: { transactionId, accountReference },
});
}
getTransactionsForCardWithinDateRange(juniorId: string, startDate: Date, endDate: Date): Promise<Transaction[]> {
return this.transactionRepository
.createQueryBuilder('transaction')
.innerJoinAndSelect('transaction.card', 'card')
.where('card.customerId = :juniorId', { juniorId })
.andWhere('transaction.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('transaction.transactionType = :type', { type: TransactionType.EXTERNAL })
.andWhere('transaction.transactionDate BETWEEN :startDate AND :endDate', { startDate, endDate })
.orderBy('transaction.transactionDate', 'DESC')
.getMany();
}
findParentTransfers(guardianCustomerId: string, skip: number, take: number): Promise<Transaction[]> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoinAndSelect('tx.card', 'card')
.innerJoinAndSelect('card.customer', 'childCustomer')
.innerJoinAndSelect('card.account', 'account')
.where('card.parentId = :guardianCustomerId', { guardianCustomerId })
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
.orderBy('tx.transactionDate', 'DESC')
.skip(skip)
.take(take)
.getMany();
}
findParentTopups(guardianCustomerId: string, skip: number, take: number): Promise<Transaction[]> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoinAndSelect('tx.account', 'account')
.leftJoinAndSelect('account.cards', 'parentCards')
.where('tx.transactionScope = :scope', { scope: TransactionScope.ACCOUNT })
.andWhere('parentCards.customerId = :guardianCustomerId', { guardianCustomerId })
.orderBy('tx.transactionDate', 'DESC')
.skip(skip)
.take(take)
.getMany();
}
countParentTransfers(guardianCustomerId: string): Promise<number> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoin('tx.card', 'card')
.where('card.parentId = :guardianCustomerId', { guardianCustomerId })
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
.getCount();
}
countParentTopups(guardianCustomerId: string): Promise<number> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoin('tx.account', 'account')
.leftJoin('account.cards', 'parentCards')
.where('tx.transactionScope = :scope', { scope: TransactionScope.ACCOUNT })
.andWhere('parentCards.customerId = :guardianCustomerId', { guardianCustomerId })
.getCount();
}
findTransfersToJunior(juniorId: string, skip: number, take: number): Promise<Transaction[]> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoinAndSelect('tx.card', 'card')
.innerJoinAndSelect('card.account', 'account')
.where('card.customerId = :juniorId', { juniorId })
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
.orderBy('tx.transactionDate', 'DESC')
.skip(skip)
.take(take)
.getMany();
}
countTransfersToJunior(juniorId: string): Promise<number> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoin('tx.card', 'card')
.where('card.customerId = :juniorId', { juniorId })
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
.getCount();
}
findTransactionById(transactionId: string, juniorId: string): Promise<Transaction | null> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoinAndSelect('tx.card', 'card')
.where('tx.id = :transactionId', { transactionId })
.andWhere('card.customerId = :juniorId', { juniorId })
.getOne();
}
}

View File

@ -0,0 +1,82 @@
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<Account> {
return this.accountRepository.createAccount(data);
}
async getAccountByReferenceNumber(accountReference: string): Promise<Account> {
const account = await this.accountRepository.getAccountByReferenceNumber(accountReference);
if (!account) {
throw new UnprocessableEntityException('ACCOUNT.NOT_FOUND');
}
return account;
}
async getAccountByAccountNumber(accountNumber: string): Promise<Account> {
const account = await this.accountRepository.getAccountByAccountNumber(accountNumber);
if (!account) {
throw new UnprocessableEntityException('ACCOUNT.NOT_FOUND');
}
return account;
}
async getAccountByIban(iban: string): Promise<Account> {
const account = await this.accountRepository.getAccountByIban(iban);
if (!account) {
throw new UnprocessableEntityException('ACCOUNT.NOT_FOUND');
}
return account;
}
creditAccountBalance(accountReference: string, amount: number) {
return this.accountRepository.topUpAccountBalance(accountReference, amount);
}
async getAccountByCustomerId(customerId: string): Promise<Account> {
const account = await this.accountRepository.getAccountByCustomerId(customerId);
if (!account) {
throw new UnprocessableEntityException('ACCOUNT.NOT_FOUND');
}
return account;
}
async decreaseAccountBalance(accountReference: string, amount: number) {
const account = await this.getAccountByReferenceNumber(accountReference);
/**
*
* While there is no need to check for insufficient balance because this is a webhook handler,
* I just added this check to ensure we don't have corruption in our data.
*/
if (account.balance < amount) {
throw new UnprocessableEntityException('ACCOUNT.INSUFFICIENT_BALANCE');
}
return this.accountRepository.decreaseAccountBalance(accountReference, amount);
}
increaseReservedBalance(account: Account, amount: number) {
if (account.balance < account.reservedBalance + amount) {
throw new UnprocessableEntityException('CARD.INSUFFICIENT_BALANCE');
}
return this.accountRepository.increaseReservedBalance(account.id, amount);
}
decrementReservedBalance(account: Account, amount: number) {
return this.accountRepository.decreaseReservedBalance(account.id, amount);
}
//THIS IS A MOCK FUNCTION FOR TESTING PURPOSES ONLY
async fundIban(iban: string, amount: number) {
const account = await this.getAccountByIban(iban);
return this.accountRepository.topUpAccountBalance(account.accountReference, amount);
}
}

View File

@ -0,0 +1,186 @@
import { BadRequestException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import Decimal from 'decimal.js';
import { Transactional } from 'typeorm-transactional';
import { AccountCardStatusChangedWebhookRequest } from '~/common/modules/neoleap/dtos/requests';
import { NeoLeapService } from '~/common/modules/neoleap/services';
import { Customer } from '~/customer/entities';
import { KycStatus } from '~/customer/enums';
import { CustomerService } from '~/customer/services';
import { OciService } from '~/document/services';
import { Card } from '../entities';
import { CardColors } from '../enums';
import { CardStatusMapper } from '../mappers/card-status.mapper';
import { CardRepository } from '../repositories';
import { AccountService } from './account.service';
import { TransactionService } from './transaction.service';
@Injectable()
export class CardService {
private readonly logger = new Logger(CardService.name);
constructor(
private readonly cardRepository: CardRepository,
private readonly accountService: AccountService,
private readonly ociService: OciService,
@Inject(forwardRef(() => TransactionService)) private readonly transactionService: TransactionService,
@Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService,
@Inject(forwardRef(() => CustomerService)) private readonly customerService: CustomerService,
) {}
@Transactional()
async createCard(customerId: string): Promise<Card> {
const customer = await this.customerService.findCustomerById(customerId);
if (customer.kycStatus !== KycStatus.APPROVED) {
throw new BadRequestException('CUSTOMER.KYC_NOT_APPROVED');
}
if (customer.cards.length > 0) {
throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD');
}
const data = await this.neoleapService.createApplication(customer);
const account = await this.accountService.createAccount(data);
const createdCard = await this.cardRepository.createCard(customerId, account.id, data);
return this.getCardById(createdCard.id);
}
async getChildCards(guardianId: string): Promise<Card[]> {
const cards = await this.cardRepository.findChildCardsForGuardian(guardianId);
await this.prepareJuniorImages(cards);
return cards;
}
async createCardForChild(parentCustomer: Customer, childCustomer: Customer, cardColor: CardColors, cardPin: string) {
const data = await this.neoleapService.createChildCard(parentCustomer, childCustomer, cardPin);
const createdCard = await this.cardRepository.createCard(
childCustomer.id,
parentCustomer.cards[0].account.id,
data,
cardColor,
parentCustomer.id,
);
return this.getCardById(createdCard.id);
}
async getCardByChildId(guardianId: string, childId: string): Promise<Card> {
const card = await this.cardRepository.findCardByChildId(guardianId, childId);
if (!card) {
throw new BadRequestException('CARD.NOT_FOUND');
}
await this.prepareJuniorImages([card]);
return card;
}
async getCardById(id: string): Promise<Card> {
const card = await this.cardRepository.getCardById(id);
if (!card) {
throw new BadRequestException('CARD.NOT_FOUND');
}
return card;
}
async getCardByReferenceNumber(referenceNumber: string): Promise<Card> {
const card = await this.cardRepository.getCardByReferenceNumber(referenceNumber);
if (!card) {
throw new BadRequestException('CARD.NOT_FOUND');
}
return card;
}
async getCardByVpan(vpan: string): Promise<Card> {
const card = await this.cardRepository.getCardByVpan(vpan);
if (!card) {
throw new BadRequestException('CARD.NOT_FOUND');
}
return card;
}
async getCardByCustomerId(customerId: string): Promise<Card> {
const card = await this.cardRepository.getCardByCustomerId(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);
}
async getEmbossingInformation(customerId: string) {
const card = await this.getCardByCustomerId(customerId);
return this.neoleapService.getEmbossingInformation(card);
}
async getChildCardEmbossingInformation(cardId: string, guardianId: string) {
const card = await this.getCardById(cardId);
if (card.parentId !== guardianId) {
throw new BadRequestException('CARD.DOES_NOT_BELONG_TO_GUARDIAN');
}
return this.neoleapService.getEmbossingInformation(card);
}
async updateCardLimit(cardId: string, newLimit: number) {
const { affected } = await this.cardRepository.updateCardLimit(cardId, newLimit);
if (affected === 0) {
throw new BadRequestException('CARD.NOT_FOUND');
}
}
async getIbanInformation(customerId: string) {
const account = await this.accountService.getAccountByCustomerId(customerId);
return account.iban;
}
@Transactional()
async transferToChild(juniorId: string, amount: number) {
const card = await this.getCardByCustomerId(juniorId);
if (amount > card.account.balance - card.account.reservedBalance) {
throw new BadRequestException('CARD.INSUFFICIENT_BALANCE');
}
const finalAmount = Decimal(amount).plus(card.limit);
await Promise.all([
this.neoleapService.updateCardControl(card.cardReference, finalAmount.toNumber()),
this.updateCardLimit(card.id, finalAmount.toNumber()),
this.accountService.increaseReservedBalance(card.account, amount),
this.transactionService.createInternalChildTransaction(card.id, amount),
]);
return finalAmount.toNumber();
}
getWeeklySummary(juniorId: string, startDate?: Date, endDate?: Date) {
return this.transactionService.getWeeklySummary(juniorId, startDate, endDate);
}
fundIban(iban: string, amount: number) {
return this.accountService.fundIban(iban, amount);
}
private async prepareJuniorImages(cards: Card[]) {
this.logger.log(`Preparing junior images`);
await Promise.all(
cards.map(async (card) => {
const profilePicture = card.customer?.user?.profilePicture;
if (profilePicture) {
profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture);
}
}),
);
}
}

View File

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

View File

@ -0,0 +1,315 @@
import { forwardRef, Inject, Injectable, UnprocessableEntityException } from '@nestjs/common';
import Decimal from 'decimal.js';
import moment from 'moment';
import { Transactional } from 'typeorm-transactional';
import {
AccountTransactionWebhookRequest,
CardTransactionWebhookRequest,
} from '~/common/modules/neoleap/dtos/requests';
import { Transaction } from '../entities/transaction.entity';
import { CustomerType, TransactionType } from '../enums';
import { TransactionRepository } from '../repositories/transaction.repository';
import { AccountService } from './account.service';
import { CardService } from './card.service';
import {
TransactionItemResponseDto,
PagedTransactionsResponseDto,
ParentTransferItemDto,
PagedParentTransfersResponseDto,
ChildTransferItemDto,
PagedChildTransfersResponseDto,
} from '../dtos/responses';
import { ParentTransactionType } from '../enums';
@Injectable()
export class TransactionService {
constructor(
private readonly transactionRepository: TransactionRepository,
private readonly accountService: AccountService,
@Inject(forwardRef(() => CardService)) private readonly cardService: CardService,
) {}
@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);
if (card.customerType === CustomerType.CHILD) {
await Promise.all([
this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()),
this.accountService.decrementReservedBalance(card.account, total.toNumber()),
]);
} else {
await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber());
}
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;
}
async createInternalChildTransaction(cardId: string, amount: number) {
const card = await this.cardService.getCardById(cardId);
const transaction = await this.transactionRepository.createInternalChildTransaction(card, amount);
return transaction;
}
private async findExistingTransaction(transactionId: string, accountReference: string): Promise<Transaction | null> {
const existingTransaction = await this.transactionRepository.findTransactionByReference(
transactionId,
accountReference,
);
return existingTransaction;
}
async getWeeklySummary(juniorId: string, startDate?: Date, endDate?: Date) {
let startOfWeek: Date;
let endOfWeek: Date;
if (startDate && endDate) {
startOfWeek = startDate;
endOfWeek = endDate;
} else {
const now = moment();
const dayOfWeek = now.day();
startOfWeek = moment().subtract(dayOfWeek, 'days').startOf('day').toDate();
endOfWeek = moment().add(6 - dayOfWeek, 'days').endOf('day').toDate();
}
const transactions = await this.transactionRepository.getTransactionsForCardWithinDateRange(
juniorId,
startOfWeek,
endOfWeek,
);
const summary = {
startOfWeek: startOfWeek,
endOfWeek: endOfWeek,
total: 0,
monday: 0,
tuesday: 0,
wednesday: 0,
thursday: 0,
friday: 0,
saturday: 0,
sunday: 0,
};
transactions.forEach((transaction) => {
const day = moment(transaction.transactionDate).format('dddd').toLowerCase() as
| 'monday'
| 'tuesday'
| 'wednesday'
| 'thursday'
| 'friday'
| 'saturday'
| 'sunday';
summary[day] += transaction.transactionAmount;
});
summary.total = transactions.reduce((acc, curr) => acc + curr.transactionAmount, 0);
return summary;
}
async getParentConsolidated(
guardianCustomerId: string,
page: number,
size: number,
): Promise<TransactionItemResponseDto[]> {
const skip = (page - 1) * size;
const [transfers, topups] = await Promise.all([
this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size),
this.transactionRepository.findParentTopups(guardianCustomerId, skip, size),
]);
const merged = [...transfers, ...topups].sort(
(a, b) => new Date(b.transactionDate).getTime() - new Date(a.transactionDate).getTime(),
);
const trimmed = merged.slice(0, size);
return trimmed.map((t) => this.mapParentItem(t));
}
async getParentTransactionsPaginated(
guardianCustomerId: string,
page: number,
size: number,
type?: ParentTransactionType,
): Promise<PagedTransactionsResponseDto> {
const skip = (page - 1) * size;
let transfers: Transaction[] = [];
let topups: Transaction[] = [];
let transferCount = 0;
let topupCount = 0;
if (!type || type === ParentTransactionType.PARENT_TRANSFER) {
[transfers, transferCount] = await Promise.all([
this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size),
this.transactionRepository.countParentTransfers(guardianCustomerId),
]);
}
if (!type || type === ParentTransactionType.PARENT_TOPUP) {
[topups, topupCount] = await Promise.all([
this.transactionRepository.findParentTopups(guardianCustomerId, skip, size),
this.transactionRepository.countParentTopups(guardianCustomerId),
]);
}
const total = transferCount + topupCount;
if (type) {
const items = type === ParentTransactionType.PARENT_TRANSFER ? transfers : topups;
const mapped = items.map((t) => this.mapParentItem(t));
return new PagedTransactionsResponseDto(mapped, page, size, total);
}
const merged = [...transfers, ...topups].sort(
(a, b) => new Date(b.transactionDate).getTime() - new Date(a.transactionDate).getTime(),
);
const paginated = merged.slice(0, size);
const mapped = paginated.map((t) => this.mapParentItem(t));
return new PagedTransactionsResponseDto(mapped, page, size, total);
}
async getParentTransfersOnly(guardianCustomerId: string, page: number, size: number): Promise<ParentTransferItemDto[]> {
const skip = (page - 1) * size;
const transfers = await this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size);
return transfers.map((t) => this.mapToParentTransferItem(t));
}
async getParentTransfersPaginated(
guardianCustomerId: string,
page: number,
size: number,
): Promise<PagedParentTransfersResponseDto> {
const skip = (page - 1) * size;
const [transfers, total] = await Promise.all([
this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size),
this.transactionRepository.countParentTransfers(guardianCustomerId),
]);
const items = transfers.map((t) => this.mapToParentTransferItem(t));
return new PagedParentTransfersResponseDto(items, page, size, total);
}
async getChildTransfers(juniorId: string, page: number, size: number): Promise<ChildTransferItemDto[]> {
const skip = (page - 1) * size;
const transfers = await this.transactionRepository.findTransfersToJunior(juniorId, skip, size);
return transfers.map((t) => this.mapToChildTransferItem(t));
}
async getChildTransfersPaginated(
juniorId: string,
page: number,
size: number,
): Promise<PagedChildTransfersResponseDto> {
const skip = (page - 1) * size;
const [transfers, total] = await Promise.all([
this.transactionRepository.findTransfersToJunior(juniorId, skip, size),
this.transactionRepository.countTransfersToJunior(juniorId),
]);
const items = transfers.map((t) => this.mapToChildTransferItem(t));
return new PagedChildTransfersResponseDto(items, page, size, total);
}
private mapToParentTransferItem(t: Transaction): ParentTransferItemDto {
const child = t.card?.customer;
const currency = t.transactionCurrency === '682' ? 'SAR' : t.transactionCurrency;
return {
date: t.transactionDate,
amount: Math.abs(t.transactionAmount),
currency,
childName: child ? `${child.firstName} ${child.lastName}` : 'Child',
};
}
private mapToChildTransferItem(t: Transaction): ChildTransferItemDto {
const amount = Math.abs(t.transactionAmount);
const currency = t.transactionCurrency === '682' ? 'SAR' : t.transactionCurrency;
return {
date: t.transactionDate,
amount,
currency,
message: `You received {{amount}} {{currency}} from your parent.`,
};
}
async getChildSpendingHistory(juniorId: string, startUtc: Date, endUtc: Date) {
const transactions = await this.transactionRepository.getTransactionsForCardWithinDateRange(
juniorId,
startUtc,
endUtc,
);
const { SpendingHistoryItemDto, SpendingHistoryResponseDto } = await import('../dtos/responses');
const items = transactions.map((t) => new SpendingHistoryItemDto(t));
return new SpendingHistoryResponseDto(items);
}
async getTransactionDetail(transactionId: string, juniorId: string) {
const transaction = await this.transactionRepository.findTransactionById(transactionId, juniorId);
if (!transaction) {
throw new UnprocessableEntityException('TRANSACTION.NOT_FOUND');
}
const { TransactionDetailResponseDto } = await import('../dtos/responses');
return new TransactionDetailResponseDto(transaction);
}
private mapParentItem(t: Transaction): TransactionItemResponseDto {
const dto = new TransactionItemResponseDto();
dto.date = t.transactionDate;
if (t.transactionType === TransactionType.INTERNAL) {
dto.type = ParentTransactionType.PARENT_TRANSFER;
dto.amountSigned = -Math.abs(t.transactionAmount);
const child = t.card?.customer;
dto.counterpartyName = child ? `${child.firstName} ${child.lastName}` : 'Child';
dto.childName = dto.counterpartyName;
dto.counterpartyAccountMasked = t.card?.account?.accountReference
? `****${t.card.account.accountReference.slice(-4)}`
: null;
return dto;
}
dto.type = ParentTransactionType.PARENT_TOPUP;
const settlement = Number(t.settlementAmount ?? 0);
const txn = Number(t.transactionAmount ?? 0);
const creditAmount = settlement > 0 ? settlement : txn;
dto.amountSigned = Math.abs(Number.isFinite(creditAmount) ? creditAmount : 0);
dto.counterpartyName = 'Top-up';
dto.counterpartyAccountMasked = t.accountReference ? `****${t.accountReference.slice(-4)}` : null;
return dto;
}
}

View File

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

View File

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

View File

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

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