Compare commits

..

212 Commits

Author SHA1 Message Date
2a62787c3b Merge pull request #63 from HamzaSha1/feature/kyc-onboarding-metadata
refactor: remove unused PoiValidationRule class from KycMetadataRespo
2025-11-18 15:16:33 +03:00
91dea22f45 refactor: remove unused PoiValidationRule class from KycMetadataResponseDto 2025-11-18 15:14:47 +03:00
ef28c75f9b Merge pull request #62 from HamzaSha1/feature/kyc-onboarding-metadata
feat: add KYC onboarding metadata endpoint with POI validation
2025-11-18 15:06:50 +03:00
c007ac584f feat: add KYC onboarding metadata endpoint with POI validation 2025-11-18 15:03:42 +03:00
d2d83549b2 Merge pull request #61 from HamzaSha1/fix/junior-profile-picture-refresh-on-update
Enhance profile picture handling in JuniorService to ensure foreign
2025-11-09 12:43:54 +03:00
506974afc8 Enhance profile picture handling in JuniorService to ensure foreign key consistency and validate document ownership before assignment. 2025-11-09 12:42:48 +03:00
95f8cfbfdf Merge pull request #60 from HamzaSha1/fix/junior-profile-picture-refresh-on-update
Update return value in updateJunior method to fetch updated junior dtails by ID instead of returning the junior object directly.
2025-11-09 12:26:44 +03:00
8b00cda23d Update return value in updateJunior method to fetch updated junior details by ID instead of returning the junior object directly. 2025-11-09 12:25:37 +03:00
12cc88a50e Merge pull request #59 from HamzaSha1/money-request-to-use-the-parint-account
Refactor balance check in increaseReservedBalance method to delegate …
2025-11-02 12:41:51 +03:00
2172051093 Refactor balance check in increaseReservedBalance method to delegate validation to the caller, improving clarity and responsibility separation. 2025-11-02 12:41:16 +03:00
a6a573957c Merge pull request #58 from HamzaSha1/money-request-to-use-the-parint-account
add more loggs
2025-11-02 12:35:31 +03:00
d6fb5f48d9 add more loggs 2025-11-02 12:34:41 +03:00
b0011eb7cc Merge pull request #57 from HamzaSha1/money-request-to-use-the-parint-account
Money request to use the parint account
2025-11-02 12:07:13 +03:00
99af65a300 money-request to use the parent card 2025-11-02 11:57:41 +03:00
0c9b40132a Merge pull request #56 from HamzaSha1/ZOD-344-after-a-child-completes-registration-using-the-qr-code-the-same-qr-code-remains-valid-and-allows-the-child-to-register-again-instead-of-expiring
ZOD-344-Add QR code validation error handling and localization support
2025-11-02 11:02:25 +03:00
3b295ea79f ZOD-344-Add QR code validation error handling and localization support
- Introduced new error handling for already used or expired QR codes in JuniorService.
- Added corresponding localization entries in Arabic and English app.json files for QR code validation messages.
2025-11-02 10:52:43 +03:00
5ffe18ede3 Merge pull request #54 from HamzaSha1/fix/verfy-email
Implement OTP generation and email verification logic in UserService
2025-10-28 16:17:51 +03:00
a3a61b4923 Implement OTP generation and email verification logic in UserService 2025-10-28 15:52:24 +03:00
39d5fc1869 Merge pull request #52 from HamzaSha1/ZOD-349-weekly-spending-data-not-displaying-in-the-child-profile-parent-view
Enhance weekly summary functionality to accept optional date range pa…
2025-10-28 11:22:52 +03:00
05a6ad2d84 Enhance weekly summary functionality to accept optional date range parameters in CardService, TransactionService, JuniorService, and JuniorController. Update API documentation to reflect new query parameters for start and end dates. 2025-10-28 11:20:49 +03:00
5649d24724 Merge pull request #50 from HamzaSha1/ZOD-349-weekly-spending-data-not-displaying-in-the-child-profile-parent-view
git checkout -b ZOD-349-weekly-spending-data-not-displaying-in-the-child-profile-parent-view
2025-10-26 16:05:00 +03:00
bbeece9e03 git checkout -b ZOD-349-weekly-spending-data-not-displaying-in-the-child-profile-parent-view 2025-10-26 13:14:35 +03:00
596562f6dc Merge pull request #48 from HamzaSha1/feat/neoleap-integration
Feat/neoleap integration
2025-10-21 14:56:38 +03:00
10de8f69c9 Merge pull request #47 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
Remove duplicate email cleanup logic and add unique constraint to use…
2025-10-21 14:15:03 +03:00
8a6b1cc900 Remove duplicate email cleanup logic and add unique constraint to user email 2025-10-21 14:10:14 +03:00
d16ae66252 Merge pull request #46 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
ZOD-341-Add unique constraint to user email and clean up duplicates
2025-10-21 10:51:12 +03:00
e966f95463 ZOD-341-Add unique constraint to user email and clean up duplicates 2025-10-21 10:49:43 +03:00
2714255dd1 Merge pull request #45 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
ZOD-341 Add email uniqueness validation to prevent duplicate emails
2025-10-20 14:31:11 +03:00
39a0b131b8 Merge pull request #44 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
Zod 341 junior a child can edit their email to an existing email causing multiple child accounts to share the same login
2025-10-20 14:27:40 +03:00
4f778f7904 * ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login 2025-10-20 14:25:53 +03:00
7e9bc397a9 Merge pull request #43 from HamzaSha1/ZOD-204-view-spending-from-child-login
git checkout -b ZOD-204-view-spending-from-child-login
2025-10-20 10:30:27 +03:00
7bfc14f0d9 Merge pull request #42 from HamzaSha1/ZOD-204-view-spending-from-child-login
ZOD-204-view-spending-from-child-login
2025-10-19 15:44:16 +03:00
d2e084d3e4 git checkout -b ZOD-204-view-spending-from-child-login 2025-10-19 15:26:47 +03:00
f81714a525 Merge pull request #41 from HamzaSha1/ZOD-339-child-profile-gender-update-is-not-reflected-after-editing
ZOD-339-child-profile-gender-update-is-not-reflected-after-editing
2025-10-19 11:07:39 +03:00
f3282a680b Merge pull request #40 from HamzaSha1/ZOD-339-child-profile-gender-update-is-not-reflected-after-editing
Zod 339 child profile gender update is not reflected after editing
2025-10-19 11:02:40 +03:00
7b57277a7f ZOD-339-child-profile-gender-update-is-not-reflected-after-editing 2025-10-19 11:01:52 +03:00
fdd2e23669 Merge pull request #39 from HamzaSha1/ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instead-of-daughter-or-son-in-child-confirmation-details-after-the-scan-the-qr-code
ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instea
2025-10-19 10:47:51 +03:00
d70ab09960 Merge pull request #38 from HamzaSha1/ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instead-of-daughter-or-son-in-child-confirmation-details-after-the-scan-the-qr-code
Zod 333 junior incorrect relationship label displayed as child instead of daughter or son in child confirmation details after the scan the qr code
2025-10-19 09:58:57 +03:00
297a2fe5ad ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instead-of-daughter-or-son-in-child-confirmation-details-after-the-scan-the-qr-code 2025-10-19 09:57:35 +03:00
33b4f13ec8 Merge pull request #37 from HamzaSha1/feat/neoleap-integration
Feat/neoleap integration
2025-10-16 14:50:23 +03:00
310233c519 Merge pull request #36 from HamzaSha1/ZOD-309-child-transaction-history-parent-→-child-transfers
ZOD-309-child-transaction-history-parent-→-child-transfers
2025-10-16 12:26:50 +03:00
15621124ad ZOD-309-child-transaction-history-parent-→-child-transfers 2025-10-16 12:25:16 +03:00
7fc1918de0 Merge pull request #35 from HamzaSha1/feat/parent-topups-and-child-transfers
feat: add guardian transactions feature with response DTOs and service integration
2025-10-15 14:17:08 +03:00
f6fa74897a feat: add guardian transactions feature with response DTOs and service integration 2025-10-15 14:14:59 +03:00
dd6886ff2b Merge pull request #34 from HamzaSha1/feat/neoleap-integration
match the neoleap-integration branch with dev
2025-10-14 12:20:19 +03:00
649191f3f4 Merge pull request #33 from HamzaSha1/fix/customer-gender-missing-in-get-profile
fix: add gender property to UserResponseDto
2025-10-14 12:14:01 +03:00
183f6b4475 fix: add gender property to UserResponseDto
fix: add gender property to UserResponseDto
2025-10-12 16:06:39 +03:00
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
ee433a5c8c fix: remove send email endpoint 2025-01-06 16:47:43 +03:00
3ab0f179d8 feat: add smtp and fix dynamic link 2025-01-06 16:46:35 +03:00
25ef549417 feat: prefix all apis 2025-01-05 14:46:49 +03:00
084d39096c feat:integration brancjio dynamic links to junior qr code registration 2025-01-05 13:15:57 +03:00
eca84b4e75 fix: general fixes on date types and some typos 2025-01-05 10:38:19 +03:00
aefa866ae7 feat: add money request cronjob, and edit money request entity 2025-01-02 11:22:33 +03:00
557ef4cd33 Merge pull request #26 from HamzaSha1/feat/cron-lock
Feat/cron lock
2024-12-30 16:18:06 +03:00
eea6302dda Merge branch 'mvp-1' of github.com:HamzaSha1/zod-backend into feat/cron-lock 2024-12-30 16:10:28 +03:00
c0fafd3f7c feat: add cron job for allowance and implement cron lock 2024-12-30 16:10:19 +03:00
f7290419d2 Merge pull request #25 from HamzaSha1/feat/add-loggers
feat: add loggers to all services
2024-12-30 10:44:07 +03:00
0fd2066c4a feat: add loggers to all services 2024-12-30 10:35:36 +03:00
cb54311a7b Merge pull request #24 from HamzaSha1/refactor/seperate-auth-and-user
refactor: seperate user and auth modules
2024-12-29 14:30:15 +03:00
ca71632755 refactor: sepeare user and auth modules 2024-12-29 14:17:39 +03:00
ebf335eabd Merge pull request #23 from HamzaSha1/feat/use-event-emitter
feat: handle notification async using event emitter
2024-12-29 11:53:23 +03:00
f383f6d14d feat: handle notification async using event emitter 2024-12-29 11:44:51 +03:00
5663a287f9 Merge pull request #22 from HamzaSha1/feat/mobile-notification
feat: sms using twillio
2024-12-29 10:00:24 +03:00
a7028fa64c feat: sms using twillio 2024-12-29 09:46:02 +03:00
0750509a85 Merge pull request #21 from HamzaSha1/feat/notifications
Feat/notifications
2024-12-24 13:08:29 +03:00
4d9ebe729e Merge branch 'mvp-1' of github.com:HamzaSha1/zod-backend into feat/notifications 2024-12-24 13:00:58 +03:00
bb8cc33d53 fix: register fcm token on enabling push notification 2024-12-24 13:00:54 +03:00
e933cacdcf Merge pull request #20 from HamzaSha1/feat/notifications
feat: working on push notifications journey
2024-12-24 12:18:32 +03:00
3719498c2f feat: working on push notifications journey 2024-12-24 12:10:49 +03:00
c7470302bd Merge pull request #19 from HamzaSha1/feat/gifts-journey
feat: gift journeys
2024-12-22 16:47:01 +03:00
5e9e83cb74 feat: gift journeys 2024-12-22 16:34:02 +03:00
4cef58580e Merge pull request #18 from HamzaSha1/feat/logout
Feat/logout
2024-12-22 10:55:24 +03:00
0ba09cbf8b Merge branch 'mvp-1' of github.com:HamzaSha1/zod-backend into feat/logout 2024-12-22 10:48:03 +03:00
28a2cb5d75 fix: fix exception in case of logout 2024-12-22 10:47:59 +03:00
4961a192ea Merge pull request #17 from HamzaSha1/feat/logout
Feat/logout
2024-12-19 16:59:16 +03:00
8ab47f3835 Merge branch 'mvp-1' of github.com:HamzaSha1/zod-backend into feat/logout 2024-12-19 16:50:44 +03:00
8112fb81a2 feat: logout 2024-12-19 16:50:39 +03:00
2c3c862c4a Merge pull request #16 from HamzaSha1/feat/refresh-token
feat: refresh token
2024-12-19 16:49:25 +03:00
93f5d83825 feat: refresh token 2024-12-19 16:25:39 +03:00
ea60ac3d7b Merge pull request #15 from HamzaSha1/feat/allowance-journey
feat: allowance journey
2024-12-19 15:27:00 +03:00
0748695f23 fix: remove comments from allowance entity 2024-12-19 15:21:44 +03:00
a201692c0c feat: allowance journey 2024-12-19 15:18:35 +03:00
fd6c1d1442 Merge pull request #14 from HamzaSha1/feat/money-requests
feat: working on money requests journrey
2024-12-18 13:17:29 +03:00
ed57ce6e91 feat: working on money requests jounrey 2024-12-18 12:57:23 +03:00
33453b193f Merge pull request #13 from HamzaSha1/feat/register-junior-via-qrcode
feat: onboard junior by qrcode
2024-12-15 17:00:42 +03:00
b0972f1a0a feat: onbard junior by qrcode 2024-12-15 16:46:49 +03:00
7437403756 Merge pull request #12 from HamzaSha1/feat/saving-goals
feat: working on saving goals journey for juniors
2024-12-15 12:53:27 +03:00
4d2f6f57f4 feat: working on saving goals jounrey for juniors 2024-12-15 12:44:59 +03:00
24d990592d fix: fix tasks submission journey 2024-12-12 15:22:04 +03:00
5b7b7ff689 fix: fix oci bucket name in generating signed url 2024-12-12 13:30:11 +03:00
6fccacd085 Merge pull request #11 from HamzaSha1/feat/customer-settings
feat: update customer profile picture and notifications settings
2024-12-12 13:23:28 +03:00
51fa61dbc6 feat: update customer profile picture and notifications settings 2024-12-12 13:15:47 +03:00
4867a5f858 Merge pull request #10 from HamzaSha1/feat/tasks-default-logo
feat: seed default task logos
2024-12-12 11:22:20 +03:00
687b6a5c6d feat: seed default task logos 2024-12-12 11:14:38 +03:00
e6ed1772f7 Merge pull request #9 from HamzaSha1/feat/signed-urls
Feat/signed urls
2024-12-12 10:01:52 +03:00
1f0a14fee4 fix: fix magic number lint issue 2024-12-12 09:47:49 +03:00
eb70828ae0 Merge branch 'mvp-1' of github.com:HamzaSha1/zod-backend into feat/signed-urls 2024-12-12 09:46:48 +03:00
220a03cc46 feat: working on signed url for private files 2024-12-12 09:46:38 +03:00
39b1e76bb5 fix: import tasks migration 2024-12-11 17:49:15 +03:00
83fc634d25 Merge pull request #8 from HamzaSha1/feat/tasks
feat: tasks journey
2024-12-11 11:14:21 +03:00
35b434bc3d fix: fix multiple submissions 2024-12-11 11:09:55 +03:00
749ee5457f feat: tasks jounrey 2024-12-11 10:27:51 +03:00
d539073f29 Merge pull request #7 from HamzaSha1/feat/roles-guard
feat: protecting endpoints by roles
2024-12-10 10:19:32 +03:00
66e1bb0f28 feat: protecting endpoint by roles 2024-12-10 10:11:47 +03:00
577f91b796 Merge pull request #6 from HamzaSha1/feat/junior-theme
feat: set theme for junior users
2024-12-10 09:30:42 +03:00
7ed37c30e1 feat: set theme for junior users 2024-12-10 09:23:30 +03:00
c2f63ccc72 Merge pull request #5 from HamzaSha1/feat/create-juniors
feat: create junior
2024-12-09 13:18:03 +03:00
333 changed files with 19139 additions and 3906 deletions

View File

@ -29,3 +29,12 @@ MAIL_USER=aahalhmad@gmail.com
MAIL_PASSWORD= MAIL_PASSWORD=
MAIL_PORT=587 MAIL_PORT=587
MAIL_FROM=UBA MAIL_FROM=UBA
BRANCH_IO_URL=https://api2.branch.io/v1/url
BRANCH_IO_KEY=
ZOD_BASE_URL=http://localhost:5001
ANDROID_PACKAGE_NAME=com.zod
IOS_PACKAGE_NAME=com.zod
ANDRIOD_JUNIOR_DEEPLINK_PATH=zodbank://juniors/qr-code/validate
IOS_JUNIOR_DEEPLINK_PATH=zodbank://juniors/qr-code/validate

2
.gitignore vendored
View File

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

View File

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

11175
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -23,11 +23,13 @@
"migration:generate": "npm run typeorm:cli-d migration:generate", "migration:generate": "npm run typeorm:cli-d migration:generate",
"migration:create": "npm run typeorm:cli migration:create", "migration:create": "npm run typeorm:cli migration:create",
"migration:up": "npm run typeorm:cli-d migration:run", "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": { "dependencies": {
"@abdalhamid/hello": "^2.0.0", "@abdalhamid/hello": "^2.0.0",
"@hamid/hello": "file:../libraries/test-package", "@hamid/hello": "file:../libraries/test-package",
"@keyv/redis": "^4.0.2",
"@nestjs-modules/mailer": "^2.0.2", "@nestjs-modules/mailer": "^2.0.2",
"@nestjs/axios": "^3.1.2", "@nestjs/axios": "^3.1.2",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
@ -38,6 +40,7 @@
"@nestjs/microservices": "^10.4.7", "@nestjs/microservices": "^10.4.7",
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.8", "@nestjs/platform-express": "^10.4.8",
"@nestjs/schedule": "^4.1.2",
"@nestjs/swagger": "^8.0.5", "@nestjs/swagger": "^8.0.5",
"@nestjs/terminus": "^10.2.3", "@nestjs/terminus": "^10.2.3",
"@nestjs/throttler": "^6.2.1", "@nestjs/throttler": "^6.2.1",
@ -45,15 +48,20 @@
"amqp-connection-manager": "^4.1.14", "amqp-connection-manager": "^4.1.14",
"amqplib": "^0.10.4", "amqplib": "^0.10.4",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cacheable": "^1.8.5",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"decimal.js": "^10.6.0",
"firebase-admin": "^13.0.2",
"google-libphonenumber": "^3.2.39", "google-libphonenumber": "^3.2.39",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"ioredis": "^5.4.1", "handlebars-layouts": "^3.1.4",
"jwk-to-pem": "^2.0.7",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.30.1", "moment": "^2.30.1",
"nestjs-i18n": "^10.4.9", "nestjs-i18n": "^10.4.9",
"nestjs-pino": "^4.1.0", "nestjs-pino": "^4.1.0",
"nestjs-twilio": "^4.4.0",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"oci-common": "^2.99.0", "oci-common": "^2.99.0",
"oci-sdk": "^2.99.0", "oci-sdk": "^2.99.0",
@ -62,6 +70,7 @@
"pg": "^8.13.1", "pg": "^8.13.1",
"pino-http": "^10.3.0", "pino-http": "^10.3.0",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.20", "typeorm": "^0.3.20",
@ -76,20 +85,25 @@
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/google-libphonenumber": "^7.4.30", "@types/google-libphonenumber": "^7.4.30",
"@types/jest": "^29.5.2", "@types/jest": "^29.5.2",
"@types/jwk-to-pem": "^2.0.3",
"@types/lodash": "^4.17.13", "@types/lodash": "^4.17.13",
"@types/multer": "^1.4.12", "@types/multer": "^1.4.12",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
"@types/nodemailer": "^6.4.16", "@types/nodemailer": "^6.4.16",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.5",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.0",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^5.59.2", "@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2", "@typescript-eslint/parser": "^5.59.2",
"eslint": "^8.39.0", "eslint": "^8.39.0",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-security": "^1.7.1", "eslint-plugin-security": "^1.7.1",
"i": "^0.3.7",
"jest": "^29.5.0", "jest": "^29.5.0",
"lint-staged": "^13.2.2", "lint-staged": "^13.2.2",
"npm": "^10.9.2",
"prettier": "^2.8.8", "prettier": "^2.8.8",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",

View File

@ -1,23 +1,35 @@
import { MiddlewareConsumer, Module } from '@nestjs/common'; import { MiddlewareConsumer, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_FILTER, APP_PIPE } from '@nestjs/core'; import { APP_FILTER, APP_PIPE } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { I18nMiddleware, I18nModule } from 'nestjs-i18n'; import { I18nMiddleware, I18nModule } from 'nestjs-i18n';
import { LoggerModule } from 'nestjs-pino'; import { LoggerModule } from 'nestjs-pino';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { addTransactionalDataSource } from 'typeorm-transactional'; import { addTransactionalDataSource } from 'typeorm-transactional';
import { AuthModule } from './auth/auth.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 { OtpModule } from './common/modules/otp/otp.module';
import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters'; import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters';
import { buildConfigOptions, buildLoggerOptions, buildTypeormOptions } from './core/module-options'; import { buildConfigOptions, buildLoggerOptions, buildTypeormOptions } from './core/module-options';
import { buildI18nOptions } from './core/module-options/i18n-options'; import { buildI18nOptions } from './core/module-options/i18n-options';
import { buildValidationPipe } from './core/pipes'; import { buildValidationPipe } from './core/pipes';
import { CronModule } from './cron/cron.module';
import { CustomerModule } from './customer/customer.module'; import { CustomerModule } from './customer/customer.module';
import { migrations } from './db'; import { migrations } from './db';
import { DocumentModule } from './document/document.module'; import { DocumentModule } from './document/document.module';
import { GuardianModule } from './guardian/guardian.module'; import { GuardianModule } from './guardian/guardian.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { JuniorModule } from './junior/junior.module'; import { JuniorModule } from './junior/junior.module';
import { UserModule } from './user/user.module';
import { WebhookModule } from './webhook/webhook.module';
import { MoneyRequestModule } from './money-request/money-request.module';
@Module({ @Module({
controllers: [], controllers: [],
imports: [ imports: [
@ -28,7 +40,6 @@ import { JuniorModule } from './junior/junior.module';
useFactory: (config: ConfigService) => { useFactory: (config: ConfigService) => {
return buildTypeormOptions(config, migrations); return buildTypeormOptions(config, migrations);
}, },
/* eslint-disable require-await */
async dataSourceFactory(options) { async dataSourceFactory(options) {
if (!options) { if (!options) {
throw new Error('Invalid options passed'); throw new Error('Invalid options passed');
@ -36,21 +47,35 @@ import { JuniorModule } from './junior/junior.module';
return addTransactionalDataSource(new DataSource(options)); return addTransactionalDataSource(new DataSource(options));
}, },
/* eslint-enable require-await */
}), }),
LoggerModule.forRootAsync({ LoggerModule.forRootAsync({
useFactory: (config: ConfigService) => buildLoggerOptions(config), useFactory: (config: ConfigService) => buildLoggerOptions(config),
inject: [ConfigService], inject: [ConfigService],
}), }),
I18nModule.forRoot(buildI18nOptions()), I18nModule.forRoot(buildI18nOptions()),
CacheModule,
EventEmitterModule.forRoot(),
ScheduleModule.forRoot(),
// App modules // App modules
AuthModule, AuthModule,
UserModule,
CustomerModule, CustomerModule,
JuniorModule, JuniorModule,
GuardianModule, GuardianModule,
CardModule,
NotificationModule,
OtpModule, OtpModule,
DocumentModule, DocumentModule,
LookupModule,
HealthModule, HealthModule,
CronModule,
NeoLeapModule,
WebhookModule,
MoneyRequestModule,
], ],
providers: [ providers: [
// Global Pipes // Global Pipes

View File

@ -1,22 +1,16 @@
import { forwardRef, Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm'; import { JuniorModule } from '~/junior/junior.module';
import { CustomerModule } from '~/customer/customer.module'; import { UserModule } from '~/user/user.module';
import { AuthController } from './controllers'; import { AuthController } from './controllers';
import { Device, User, UserNotificationSettings } from './entities'; import { AuthService } from './services';
import { DeviceRepository, UserRepository } from './repositories';
import { AuthService, DeviceService } from './services';
import { UserService } from './services/user.service';
import { AccessTokenStrategy } from './strategies'; import { AccessTokenStrategy } from './strategies';
@Module({ @Module({
imports: [ imports: [JwtModule.register({}), UserModule, JuniorModule, HttpModule],
TypeOrmModule.forFeature([User, UserNotificationSettings, Device]), providers: [AuthService, AccessTokenStrategy],
JwtModule.register({}),
forwardRef(() => CustomerModule),
],
providers: [AuthService, UserRepository, UserService, DeviceService, DeviceRepository, AccessTokenStrategy],
controllers: [AuthController], controllers: [AuthController],
exports: [UserService], exports: [],
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -1,28 +1,32 @@
import { Body, Controller, Headers, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, HttpCode, HttpStatus, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { DEVICE_ID_HEADER } from '~/common/constants'; import { Request } from 'express';
import { AuthenticatedUser } from '~/common/decorators'; import { AuthenticatedUser, Public } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards'; import { AccessTokenGuard } from '~/common/guards';
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils'; import { ResponseFactory } from '~/core/utils';
import { import {
ChangePasswordRequestDto,
CreateUnverifiedUserRequestDto, CreateUnverifiedUserRequestDto,
DisableBiometricRequestDto,
EnableBiometricRequestDto,
ForgetPasswordRequestDto, ForgetPasswordRequestDto,
JuniorLoginRequestDto,
LoginRequestDto, LoginRequestDto,
RefreshTokenRequestDto,
SendForgetPasswordOtpRequestDto, SendForgetPasswordOtpRequestDto,
SetEmailRequestDto, setJuniorPasswordRequestDto,
SetPasscodeRequestDto, VerifyForgetPasswordOtpRequestDto,
VerifyUserRequestDto, VerifyUserRequestDto,
} from '../dtos/request'; } from '../dtos/request';
import { SendForgetPasswordOtpResponseDto, SendRegisterOtpResponseDto } from '../dtos/response'; import { SendForgetPasswordOtpResponseDto, SendRegisterOtpResponseDto } from '../dtos/response';
import { LoginResponseDto } from '../dtos/response/login.response.dto'; import { LoginResponseDto } from '../dtos/response/login.response.dto';
import { VerifyForgetPasswordOtpResponseDto } from '../dtos/response/verify-forget-password-otp.response.dto';
import { IJwtPayload } from '../interfaces'; import { IJwtPayload } from '../interfaces';
import { AuthService } from '../services'; import { AuthService } from '../services';
@Controller('auth') @Controller('auth')
@ApiTags('Auth') @ApiTags('Auth')
@ApiBearerAuth() @ApiBearerAuth()
@ApiLangRequestHeader()
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
@Post('register/otp') @Post('register/otp')
@ -37,49 +41,67 @@ export class AuthController {
return ResponseFactory.data(new LoginResponseDto(res, user)); return ResponseFactory.data(new LoginResponseDto(res, user));
} }
@Post('register/set-email') @Post('login')
@HttpCode(HttpStatus.NO_CONTENT) async login(@Body() verifyUserDto: LoginRequestDto) {
@UseGuards(AccessTokenGuard) const [res, user] = await this.authService.loginWithPassword(verifyUserDto);
async setEmail(@AuthenticatedUser() { sub }: IJwtPayload, @Body() setEmailDto: SetEmailRequestDto) { return ResponseFactory.data(new LoginResponseDto(res, user));
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('forget-password/otp') @Post('forget-password/otp')
async forgetPassword(@Body() sendForgetPasswordOtpDto: SendForgetPasswordOtpRequestDto) { async forgetPassword(@Body() sendForgetPasswordOtpDto: SendForgetPasswordOtpRequestDto) {
const email = await this.authService.sendForgetPasswordOtp(sendForgetPasswordOtpDto); const maskedNumber = await this.authService.sendForgetPasswordOtp(sendForgetPasswordOtpDto);
return ResponseFactory.data(new SendForgetPasswordOtpResponseDto(email)); 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') @Post('forget-password/reset')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
resetPassword(@Body() forgetPasswordDto: ForgetPasswordRequestDto) { resetPassword(@Body() forgetPasswordDto: ForgetPasswordRequestDto) {
return this.authService.verifyForgetPasswordOtp(forgetPasswordDto); return this.authService.resetPassword(forgetPasswordDto);
} }
@Post('login') @Post('change-password')
async login(@Body() loginDto: LoginRequestDto, @Headers(DEVICE_ID_HEADER) deviceId: string) { @HttpCode(HttpStatus.NO_CONTENT)
const [res, user] = await this.authService.login(loginDto, deviceId); @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() 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)); return ResponseFactory.data(new LoginResponseDto(res, user));
} }
@Post('refresh-token')
@Public()
async refreshToken(@Body() { refreshToken }: RefreshTokenRequestDto) {
const [res, user] = await this.authService.refreshToken(refreshToken);
return ResponseFactory.data(new LoginResponseDto(res, user));
}
@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard)
async logout(@Req() request: Request) {
await this.authService.logout(request);
}
} }

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 { OmitType } from '@nestjs/swagger';
import { Matches } from 'class-validator'; import { VerifyUserRequestDto } from './verify-user.request.dto';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { COUNTRY_CODE_REGEX } from '~/auth/constants';
import { IsValidPhoneNumber } from '~/core/decorators/validations';
export class CreateUnverifiedUserRequestDto { export class CreateUnverifiedUserRequestDto extends OmitType(VerifyUserRequestDto, ['otp']) {}
@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;
}

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 { 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 { 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 { export class ForgetPasswordRequestDto {
@ApiProperty({ example: 'test@test.com' }) @ApiProperty({ example: '+962' })
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) @Matches(COUNTRY_CODE_REGEX, {
email!: string; message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }),
})
countryCode!: string;
@ApiProperty({ example: 'password' }) @ApiProperty({ example: '787259134' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) }) @IsValidPhoneNumber({
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.password' }) }) 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; password!: string;
@ApiProperty({ example: 'password' }) @ApiProperty({ example: 'Abcd1234@' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.confirmPassword' }) }) @Matches(PASSWORD_REGEX, {
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.confirmPassword' }) }) message: i18n('validation.Matches', { path: 'general', property: 'auth.confirmPassword' }),
})
confirmPassword!: string; confirmPassword!: string;
@ApiProperty({ example: '111111' }) @ApiProperty({ example: 'reset-token-32423123' })
@IsNumberString( @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.resetPasswordToken' }) })
{ no_symbols: true }, resetPasswordToken!: string;
{ 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,9 +1,11 @@
export * from './change-password.request.dto';
export * from './create-unverified-user.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 './forget-password.request.dto';
export * from './junior-login.request.dto';
export * from './login.request.dto'; export * from './login.request.dto';
export * from './refresh-token.request.dto';
export * from './send-forget-password-otp.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'; 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,24 +1,24 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsEnum, IsString, ValidateIf } from 'class-validator'; import { IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, Matches, ValidateIf } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n'; import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { COUNTRY_CODE_REGEX } from '~/auth/constants';
import { GrantType } from '~/auth/enums'; import { GrantType } from '~/auth/enums';
import { IsValidPhoneNumber } from '~/core/decorators/validations';
export class LoginRequestDto { export class LoginRequestDto {
@ApiProperty({ example: GrantType.PASSWORD }) @ApiProperty({ example: '+962' })
@IsEnum(GrantType, { message: i18n('validation.IsEnum', { path: 'general', property: 'auth.grantType' }) }) @Matches(COUNTRY_CODE_REGEX, {
grantType!: GrantType; message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }),
})
countryCode!: string;
@ApiProperty({ example: 'test@test.com' }) @ApiProperty({ example: '787259134' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.email' }) }) @IsValidPhoneNumber({
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }),
email!: string; })
phoneNumber!: string;
@ApiProperty({ example: '123456' }) @ApiProperty({ example: 'Abcd1234@' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) })
@ValidateIf((o) => o.grantType === GrantType.PASSWORD) @ValidateIf((o) => o.grantType === GrantType.PASSWORD)
password!: string; password!: string;
@ApiProperty({ example: 'device-token' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceToken' }) })
@ValidateIf((o) => o.grantType === GrantType.BIOMETRIC)
deviceToken!: string;
} }

View File

@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class RefreshTokenRequestDto {
@ApiProperty()
@IsString({ message: i18n('validation.isString', { path: 'general', property: 'auth.refreshToken' }) })
@IsNotEmpty({ message: i18n('validation.required', { path: 'general', property: 'auth.refreshToken' }) })
refreshToken!: string;
}

View File

@ -1,4 +1,4 @@
import { PickType } from '@nestjs/swagger'; import { PickType } from '@nestjs/swagger';
import { LoginRequestDto } from './login.request.dto'; import { LoginRequestDto } from './login.request.dto';
export class SendForgetPasswordOtpRequestDto extends PickType(LoginRequestDto, ['email']) {} export class SendForgetPasswordOtpRequestDto extends PickType(LoginRequestDto, ['countryCode', 'phoneNumber']) {}

View File

@ -0,0 +1,13 @@
import { ApiProperty, PickType } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
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' }) })
qrToken!: string;
}

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 { 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 { 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 { 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' }) @ApiProperty({ example: '111111' })
@IsNumberString( @IsNumberString(
{ no_symbols: true }, { no_symbols: true },

View File

@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { User } from '~/auth/entities';
import { ILoginResponse } from '~/auth/interfaces'; import { ILoginResponse } from '~/auth/interfaces';
import { CustomerResponseDto } from '~/customer/dtos/response'; import { CustomerResponseDto } from '~/customer/dtos/response';
import { User } from '~/user/entities';
import { UserResponseDto } from './user.response.dto'; import { UserResponseDto } from './user.response.dto';
export class LoginResponseDto { export class LoginResponseDto {
@ -17,12 +17,12 @@ export class LoginResponseDto {
@ApiProperty({ example: UserResponseDto }) @ApiProperty({ example: UserResponseDto })
user!: UserResponseDto; user!: UserResponseDto;
@ApiProperty({ example: CustomerResponseDto }) @ApiProperty({ type: CustomerResponseDto })
customer!: CustomerResponseDto; customer!: CustomerResponseDto | null;
constructor(IVerifyUserResponse: ILoginResponse, user: User) { constructor(IVerifyUserResponse: ILoginResponse, user: User) {
this.user = new UserResponseDto(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.accessToken = IVerifyUserResponse.accessToken;
this.refreshToken = IVerifyUserResponse.refreshToken; this.refreshToken = IVerifyUserResponse.refreshToken;
this.expiresAt = IVerifyUserResponse.expiresAt; this.expiresAt = IVerifyUserResponse.expiresAt;

View File

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

View File

@ -2,9 +2,9 @@ import { ApiProperty } from '@nestjs/swagger';
export class SendRegisterOtpResponseDto { export class SendRegisterOtpResponseDto {
@ApiProperty() @ApiProperty()
phoneNumber!: string; maskedNumber!: string;
constructor(phoneNumber: string) { constructor(maskedNumber: string) {
this.phoneNumber = phoneNumber; 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,36 +1,54 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { User } from '~/auth/entities'; import { Gender } from '~/customer/enums';
import { Roles } from '~/auth/enums'; import { DocumentMetaResponseDto } from '~/document/dtos/response';
import { User } from '~/user/entities';
export class UserResponseDto { export class UserResponseDto {
@ApiProperty() @ApiProperty()
id!: string; id!: string;
@ApiProperty() @ApiProperty()
email!: string; countryCode!: string;
@ApiProperty() @ApiProperty()
phoneNumber!: string; phoneNumber!: string;
@ApiProperty() @ApiProperty()
countryCode!: string; email!: string;
@ApiProperty() @ApiProperty()
isPasswordSet!: boolean; firstName!: string;
@ApiProperty() @ApiProperty()
isProfileCompleted!: boolean; lastName!: string;
@ApiProperty() @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) { constructor(user: User) {
this.id = user.id; this.id = user.id;
this.email = user.email;
this.phoneNumber = user.phoneNumber;
this.countryCode = user.countryCode; this.countryCode = user.countryCode;
this.isPasswordSet = user.isPasswordSet; this.phoneNumber = user.phoneNumber;
this.isProfileCompleted = user.isProfileCompleted; this.dateOfBirth = user.customer?.dateOfBirth;
this.roles = user.roles; 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,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { User } from '~/auth/entities';
import { ILoginResponse } from '~/auth/interfaces'; import { ILoginResponse } from '~/auth/interfaces';
import { User } from '~/user/entities';
export class VerifyUserResponseDto { export class VerifyUserResponseDto {
@ApiProperty() @ApiProperty()

View File

@ -1,36 +0,0 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from './user.entity';
@Entity('user_notification_settings')
export class UserNotificationSettings extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'is_email_enabled', default: false })
isEmailEnabled!: boolean;
@Column({ name: 'is_push_enabled', default: false })
isPushEnabled!: boolean;
@Column({ name: 'is_sms_enabled', default: false })
isSmsEnabled!: boolean;
@OneToOne(() => User, (user) => user.notificationSettings, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone' })
updatedAt!: Date;
}

View File

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

View File

@ -1,4 +1,6 @@
import { Roles } from '../enums';
export interface IJwtPayload { export interface IJwtPayload {
sub: string; sub: string;
roles: string[]; roles: Roles[];
} }

View File

@ -1,54 +1,79 @@
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt'; 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 { OtpScope, OtpType } from '~/common/modules/otp/enums';
import { OtpService } from '~/common/modules/otp/services'; import { OtpService } from '~/common/modules/otp/services';
import { PASSCODE_REGEX, PASSWORD_REGEX } from '../constants'; import { UserType } from '~/user/enums';
import { DeviceService, UserService, UserTokenService } from '~/user/services';
import { User } from '../../user/entities';
import { import {
ChangePasswordRequestDto,
CreateUnverifiedUserRequestDto, CreateUnverifiedUserRequestDto,
DisableBiometricRequestDto,
EnableBiometricRequestDto,
ForgetPasswordRequestDto, ForgetPasswordRequestDto,
JuniorLoginRequestDto,
LoginRequestDto, LoginRequestDto,
SendForgetPasswordOtpRequestDto, SendForgetPasswordOtpRequestDto,
SetEmailRequestDto, setJuniorPasswordRequestDto,
VerifyForgetPasswordOtpRequestDto,
VerifyUserRequestDto,
} from '../dtos/request'; } from '../dtos/request';
import { VerifyUserRequestDto } from '../dtos/request/verify-user.request.dto'; import { Roles } from '../enums';
import { User } from '../entities'; import { IJwtPayload, ILoginResponse } from '../interfaces';
import { GrantType, Roles } from '../enums';
import { ILoginResponse } from '../interfaces';
import { removePadding, verifySignature } from '../utils';
import { DeviceService } from './device.service';
import { UserService } from './user.service';
const ONE_THOUSAND = 1000; const ONE_THOUSAND = 1000;
const SALT_ROUNDS = 10; const SALT_ROUNDS = 10;
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor( constructor(
private readonly otpService: OtpService, private readonly otpService: OtpService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly userService: UserService, private readonly userService: UserService,
private readonly deviceService: DeviceService, private readonly deviceService: DeviceService,
private readonly userTokenService: UserTokenService,
private readonly cacheService: CacheService,
) {} ) {}
async sendRegisterOtp({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) {
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({ return this.otpService.generateAndSendOtp({
userId: user.id, userId: user.id,
recipient: user.phoneNumber, recipient: user.fullPhoneNumber,
scope: OtpScope.VERIFY_PHONE, scope: OtpScope.VERIFY_PHONE,
otpType: OtpType.SMS, otpType: OtpType.SMS,
}); });
} }
async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> { async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> {
const user = await this.userService.findUserOrThrow({ phoneNumber: verifyUserDto.phoneNumber }); this.logger.log(`Verifying user with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber}`);
const user = await this.userService.findUserOrThrow({
phoneNumber: verifyUserDto.phoneNumber,
countryCode: verifyUserDto.countryCode,
});
if (user.isPasswordSet) { if (user.isPhoneVerified) {
throw new BadRequestException('USERS.PHONE_ALREADY_VERIFIED'); 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({ const isOtpValid = await this.otpService.verifyOtp({
@ -59,174 +84,226 @@ export class AuthService {
}); });
if (!isOtpValid) { if (!isOtpValid) {
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); await user.reload();
return [tokens, updatedUser]; 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) { async sendForgetPasswordOtp({ countryCode, phoneNumber }: SendForgetPasswordOtpRequestDto) {
const user = await this.userService.findUserOrThrow({ id: userId }); this.logger.log(`Sending forget password OTP to ${countryCode + phoneNumber}`);
const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber });
if (user.email) {
throw new BadRequestException('USERS.EMAIL_ALREADY_SET');
}
const existingUser = await this.userService.findUser({ email });
if (existingUser) {
throw new BadRequestException('USERS.EMAIL_ALREADY_TAKEN');
}
return this.userService.setEmail(userId, email);
}
async setPasscode(userId: string, passcode: string) {
const user = await this.userService.findUserOrThrow({ id: userId });
if (user.password) {
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);
}
async enableBiometric(userId: string, { deviceId, publicKey }: EnableBiometricRequestDto) {
const device = await this.deviceService.findUserDeviceById(deviceId, userId);
if (!device) {
return this.deviceService.createDevice({
deviceId,
userId,
publicKey,
});
}
if (device.publicKey) {
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) {
throw new BadRequestException('AUTH.DEVICE_NOT_FOUND');
}
if (!device.publicKey) {
throw new BadRequestException('AUTH.BIOMETRIC_ALREADY_DISABLED');
}
return this.deviceService.updateDevice(deviceId, { publicKey: null });
}
async sendForgetPasswordOtp({ email }: SendForgetPasswordOtpRequestDto) {
const user = await this.userService.findUserOrThrow({ email });
if (!user.isProfileCompleted) {
throw new BadRequestException('USERS.PROFILE_NOT_COMPLETED');
}
return this.otpService.generateAndSendOtp({ return this.otpService.generateAndSendOtp({
userId: user.id, userId: user.id,
recipient: user.email, recipient: user.fullPhoneNumber,
scope: OtpScope.FORGET_PASSWORD, scope: OtpScope.FORGET_PASSWORD,
otpType: OtpType.EMAIL, otpType: OtpType.SMS,
}); });
} }
async verifyForgetPasswordOtp({ email, otp, password, confirmPassword }: ForgetPasswordRequestDto) { async verifyForgetPasswordOtp({ countryCode, phoneNumber, otp }: VerifyForgetPasswordOtpRequestDto) {
const user = await this.userService.findUserOrThrow({ email }); const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber });
if (!user.isProfileCompleted) {
throw new BadRequestException('USERS.PROFILE_NOT_COMPLETED');
}
const isOtpValid = await this.otpService.verifyOtp({ const isOtpValid = await this.otpService.verifyOtp({
userId: user.id, userId: user.id,
scope: OtpScope.FORGET_PASSWORD, scope: OtpScope.FORGET_PASSWORD,
otpType: OtpType.EMAIL, otpType: OtpType.SMS,
value: otp, value: otp,
}); });
if (!isOtpValid) { if (!isOtpValid) {
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); const hashedPassword = bcrypt.hashSync(password, user.salt);
await this.userService.setPasscode(user.id, hashedPassword, user.salt); 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]> { async changePassword(userId: string, { currentPassword, newPassword, confirmNewPassword }: ChangePasswordRequestDto) {
const user = await this.userService.findUser({ email: loginDto.email }); const user = await this.userService.findUserOrThrow({ id: userId });
let tokens;
if (!user.isPasswordSet) {
this.logger.error(`Password not set for user with id ${userId}`);
throw new BadRequestException('AUTH.PASSWORD_NOT_SET');
}
if (currentPassword === newPassword) {
this.logger.error('New password cannot be the same as current password');
throw new BadRequestException('AUTH.PASSWORD_SAME_AS_CURRENT');
}
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');
}
const salt = bcrypt.genSaltSync(SALT_ROUNDS);
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'),
});
this.logger.log(`Refreshing token for user with id ${isValid.sub}`);
const user = await this.userService.findUserOrThrow({ id: isValid.sub });
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];
} catch (error) {
this.logger.error('Invalid refresh token');
throw new BadRequestException('AUTH.INVALID_REFRESH_TOKEN');
}
}
logout(req: Request) {
this.logger.log('Logging out');
const accessToken = req.headers.authorization?.split(' ')[1] as string;
const expiryInTtl = this.jwtService.decode(accessToken).exp - Date.now() / ONE_THOUSAND;
return this.cacheService.set(accessToken, 'BLACKLISTED', expiryInTtl);
}
async loginWithPassword(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> {
const user = await this.userService.findUser({
countryCode: loginDto.countryCode,
phoneNumber: loginDto.phoneNumber,
});
if (!user) { if (!user) {
this.logger.error(`User not found with phone number ${loginDto.countryCode + loginDto.phoneNumber}`);
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS'); throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
} }
if (loginDto.grantType === GrantType.PASSWORD) { if (!user.password) {
tokens = await this.loginWithPassword(loginDto, user); this.logger.error(`Password not set for user with phone number ${loginDto.countryCode + loginDto.phoneNumber}`);
} else { throw new UnauthorizedException('AUTH.PHONE_NUMBER_NOT_VERIFIED');
tokens = await this.loginWithBiometric(loginDto, user, deviceId);
} }
this.deviceService.updateDevice(deviceId, { lastAccessOn: new Date() }); this.logger.log(`validating password for user with phone ${loginDto.countryCode + loginDto.phoneNumber}`);
return [tokens, user];
}
private async loginWithPassword(loginDto: LoginRequestDto, user: User): Promise<ILoginResponse> {
const isPasswordValid = bcrypt.compareSync(loginDto.password, user.password); const isPasswordValid = bcrypt.compareSync(loginDto.password, user.password);
if (!isPasswordValid) { if (!isPasswordValid) {
this.logger.error(`Invalid password for user with phone ${loginDto.countryCode + loginDto.phoneNumber}`);
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS'); throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
} }
const tokens = await this.generateAuthToken(user); const tokens = await this.generateAuthToken(user);
this.logger.log(`Password validated successfully for user`);
return tokens; return [tokens, user];
} }
private async loginWithBiometric(loginDto: LoginRequestDto, user: User, deviceId: string): Promise<ILoginResponse> { async juniorLogin(juniorLoginDto: JuniorLoginRequestDto): Promise<[ILoginResponse, User]> {
const device = await this.deviceService.findUserDeviceById(deviceId, user.id); const user = await this.userService.findUser({ email: juniorLoginDto.email });
if (!device) { if (!user || !user.roles.includes(Roles.JUNIOR)) {
throw new UnauthorizedException('AUTH.DEVICE_NOT_FOUND'); throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
} }
if (!device.publicKey) { this.logger.log(`validating password for user with email ${juniorLoginDto.email}`);
throw new UnauthorizedException('AUTH.BIOMETRIC_NOT_ENABLED'); const isPasswordValid = bcrypt.compareSync(juniorLoginDto.password, user.password);
}
const cleanToken = removePadding(loginDto.deviceToken); if (!isPasswordValid) {
const isValidToken = await verifySignature( this.logger.error(`Invalid password for user with email ${juniorLoginDto.email}`);
device.publicKey, throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
cleanToken,
`${user.email} - ${device.deviceId}`,
'SHA1',
);
if (!isValidToken) {
throw new UnauthorizedException('AUTH.INVALID_BIOMETRIC');
} }
const tokens = await this.generateAuthToken(user); const tokens = await this.generateAuthToken(user);
this.logger.log(`Password validated successfully for user`);
return tokens; return [tokens, user];
} }
private async generateAuthToken(user: User) { private async generateAuthToken(user: User) {
this.logger.log(`Generating auth token for user with id ${user.id}`);
const [accessToken, refreshToken] = await Promise.all([ const [accessToken, refreshToken] = await Promise.all([
this.jwtService.sign( this.jwtService.sign(
{ sub: user.id, roles: user.roles }, { sub: user.id, roles: user.roles },
@ -244,22 +321,7 @@ 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) }; return { accessToken, refreshToken, expiresAt: new Date(this.jwtService.decode(accessToken).exp * ONE_THOUSAND) };
} }
private validatePassword(password: string, confirmPassword: string, user: User) {
if (password !== confirmPassword) {
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
}
const roles = user.roles;
if (roles.includes(Roles.GUARDIAN) && !PASSCODE_REGEX.test(password)) {
throw new BadRequestException('AUTH.INVALID_PASSCODE');
}
if (roles.includes(Roles.JUNIOR) && !PASSWORD_REGEX.test(password)) {
throw new BadRequestException('AUTH.INVALID_PASSWORD');
}
}
} }

View File

@ -1,19 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Device } from '../entities';
import { DeviceRepository } from '../repositories';
@Injectable()
export class DeviceService {
constructor(private readonly deviceRepository: DeviceRepository) {}
findUserDeviceById(deviceId: string, userId: string) {
return this.deviceRepository.findUserDeviceById(deviceId, userId);
}
createDevice(data: Partial<Device>) {
return this.deviceRepository.createDevice(data);
}
updateDevice(deviceId: string, data: Partial<Device>) {
return this.deviceRepository.updateDevice(deviceId, data);
}
}

View File

@ -1,3 +1 @@
export * from './auth.service'; export * from './auth.service';
export * from './device.service';
export * from './user.service';

View File

@ -1,83 +0,0 @@
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { UpdateNotificationsSettingsRequestDto } from '~/customer/dtos/request';
import { CustomerService } from '~/customer/services';
import { Guardian } from '~/guardian/entities/guradian.entity';
import { CreateUnverifiedUserRequestDto } from '../dtos/request';
import { User } from '../entities';
import { Roles } from '../enums';
import { UserRepository } from '../repositories';
@Injectable()
export class UserService {
constructor(
private readonly userRepository: UserRepository,
@Inject(forwardRef(() => CustomerService)) private readonly customerService: CustomerService,
) {}
async updateNotificationSettings(userId: string, body: UpdateNotificationsSettingsRequestDto) {
const user = await this.findUserOrThrow({ id: userId });
const notificationSettings = (await this.userRepository.updateNotificationSettings(user, body))
.notificationSettings;
return notificationSettings;
}
findUser(where: FindOptionsWhere<User> | FindOptionsWhere<User>[]) {
return this.userRepository.findOne(where);
}
async findUserOrThrow(where: FindOptionsWhere<User>) {
const user = await this.findUser(where);
if (!user) {
throw new BadRequestException('USERS.NOT_FOUND');
}
return user;
}
async findOrCreateUser({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) {
const user = await this.userRepository.findOne({ phoneNumber });
if (!user) {
return this.userRepository.createUnverifiedUser({ phoneNumber, countryCode, roles: [Roles.GUARDIAN] });
}
if (user && user.roles.includes(Roles.GUARDIAN) && user.isPasswordSet) {
throw new BadRequestException('USERS.PHONE_NUMBER_ALREADY_EXISTS');
}
if (user && user.roles.includes(Roles.JUNIOR)) {
throw new BadRequestException('USERS.JUNIOR_UPGRADE_NOT_SUPPORTED_YET');
//TODO add role Guardian to the existing user and send OTP
}
return user;
}
async createUser(data: Partial<User>) {
const user = await this.userRepository.createUser(data);
return user;
}
setEmail(userId: string, email: string) {
return this.userRepository.update(userId, { email });
}
setPasscode(userId: string, passcode: string, salt: string) {
return this.userRepository.update(userId, { password: passcode, salt, isProfileCompleted: true });
}
async verifyUserAndCreateCustomer(user: User) {
await this.customerService.createCustomer(
{
guardian: Guardian.create({ id: user.id }),
},
user,
);
return this.findUserOrThrow({ id: user.id });
}
}

View File

@ -1,12 +1,13 @@
import { Injectable } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { UserService } from '~/user/services';
import { IJwtPayload } from '../interfaces'; import { IJwtPayload } from '../interfaces';
@Injectable() @Injectable()
export class AccessTokenStrategy extends PassportStrategy(Strategy, 'access-token') { export class AccessTokenStrategy extends PassportStrategy(Strategy, 'access-token') {
constructor(configService: ConfigService) { constructor(configService: ConfigService, private userService: UserService) {
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, 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; 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,81 @@
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) {
// Balance check is performed by the caller (e.g., transferToChild)
// to ensure correct account (guardian vs child) is validated
return this.accountRepository.increaseReservedBalance(account.id, amount);
}
decrementReservedBalance(account: Account, amount: number) {
return this.accountRepository.decreaseReservedBalance(account.id, amount);
}
//THIS IS A MOCK FUNCTION FOR TESTING PURPOSES ONLY
async fundIban(iban: string, amount: number) {
const account = await this.getAccountByIban(iban);
return this.accountRepository.topUpAccountBalance(account.accountReference, amount);
}
}

View File

@ -0,0 +1,197 @@
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);
this.logger.debug(`Transfer to child - juniorId: ${juniorId}, parentId: ${card.parentId}, cardId: ${card.id}`);
this.logger.debug(`Card account - balance: ${card.account.balance}, reserved: ${card.account.reservedBalance}`);
const fundingAccount = card.parentId
? await this.accountService.getAccountByCustomerId(card.parentId)
: card.account;
this.logger.debug(`Funding account - balance: ${fundingAccount.balance}, reserved: ${fundingAccount.reservedBalance}, available: ${fundingAccount.balance - fundingAccount.reservedBalance}`);
this.logger.debug(`Amount requested: ${amount}`);
if (amount > fundingAccount.balance - fundingAccount.reservedBalance) {
this.logger.error(`Insufficient balance - requested: ${amount}, available: ${fundingAccount.balance - fundingAccount.reservedBalance}`);
throw new BadRequestException('CARD.INSUFFICIENT_BALANCE');
}
const finalAmount = Decimal(amount).plus(card.limit);
await Promise.all([
this.neoleapService.updateCardControl(card.cardReference, finalAmount.toNumber()),
this.updateCardLimit(card.id, finalAmount.toNumber()),
this.accountService.increaseReservedBalance(fundingAccount, amount),
this.transactionService.createInternalChildTransaction(card.id, amount),
]);
return finalAmount.toNumber();
}
getWeeklySummary(juniorId: string, startDate?: Date, endDate?: Date) {
return this.transactionService.getWeeklySummary(juniorId, startDate, endDate);
}
fundIban(iban: string, amount: number) {
return this.accountService.fundIban(iban, amount);
}
private async prepareJuniorImages(cards: Card[]) {
this.logger.log(`Preparing junior images`);
await Promise.all(
cards.map(async (card) => {
const profilePicture = card.customer?.user?.profilePicture;
if (profilePicture) {
profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture);
}
}),
);
}
}

View File

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

View File

@ -0,0 +1,323 @@
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) {
if (card.parentId) {
const parentAccount = await this.accountService.getAccountByCustomerId(card.parentId);
await Promise.all([
this.accountService.decreaseAccountBalance(parentAccount.accountReference, total.toNumber()),
this.accountService.decrementReservedBalance(parentAccount, total.toNumber()),
]);
} else {
await Promise.all([
this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()),
this.accountService.decrementReservedBalance(card.account, total.toNumber()),
]);
}
} else {
await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber());
}
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'; export * from './global.constant';

View File

@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { Roles } from '~/auth/enums';
export const ROLE_METADATA_KEY = 'roles';
export const AllowedRoles = (...roles: Roles[]) => SetMetadata(ROLE_METADATA_KEY, roles);

View File

@ -1,2 +1,3 @@
export * from './allowed-roles.decorator';
export * from './public.decorator'; export * from './public.decorator';
export * from './user.decorator'; export * from './user.decorator';

View File

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

View File

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

View File

@ -1,15 +1,15 @@
import { ExecutionContext, Injectable } from '@nestjs/common'; import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';
import { IS_PUBLIC_KEY } from '../decorators'; import { IS_PUBLIC_KEY } from '../decorators';
import { CacheService } from '../modules/cache/services';
@Injectable() @Injectable()
export class AccessTokenGuard extends AuthGuard('access-token') { export class AccessTokenGuard extends AuthGuard('access-token') {
constructor(private reflector: Reflector) { constructor(protected reflector: Reflector, private readonly cacheService: CacheService) {
super(); super();
} }
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> { async canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(), context.getHandler(),
context.getClass(), context.getClass(),
@ -18,6 +18,17 @@ export class AccessTokenGuard extends AuthGuard('access-token') {
if (isPublic) { if (isPublic) {
return true; return true;
} }
return super.canActivate(context);
await super.canActivate(context);
const token = context.switchToHttp().getRequest().headers['authorization']?.split(' ')[1];
const isRevoked = await this.cacheService.get(token);
if (isRevoked) {
throw new UnauthorizedException();
}
return true;
} }
} }

View File

@ -1 +1,2 @@
export * from './access-token.guard'; export * from './access-token.guard';
export * from './roles-guard';

View File

@ -0,0 +1,28 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Roles } from '~/auth/enums';
import { ROLE_METADATA_KEY } from '../decorators';
import { AccessTokenGuard } from './access-token.guard';
@Injectable()
export class RolesGuard extends AccessTokenGuard {
async canActivate(context: ExecutionContext): Promise<boolean> {
await super.canActivate(context);
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
return false;
}
const allowedRoles = this.reflector.getAllAndOverride<Roles[]>(ROLE_METADATA_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!allowedRoles) {
return true;
}
return allowedRoles.some((role) => user.roles.includes(role));
}
}

View File

@ -0,0 +1 @@
export * from './numeric-to-iso.mapper';

View File

@ -0,0 +1,11 @@
import { CountriesNumericISO } from '../constants';
import { CountryIso } from '../enums';
// At module top-level
export const NumericToCountryIso: Record<string, CountryIso> = Object.entries(CountriesNumericISO).reduce(
(acc, [isoKey, numeric]) => {
acc[numeric] = isoKey as CountryIso;
return acc;
},
{} as Record<string, CountryIso>,
);

View File

@ -0,0 +1,18 @@
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { buildKeyvOptions } from '~/core/module-options';
import { CacheService } from './services';
@Module({
providers: [
{
provide: 'CACHE_INSTANCE',
useFactory: (config: ConfigService) => buildKeyvOptions(config),
inject: [ConfigService],
},
CacheService,
],
exports: ['CACHE_INSTANCE', CacheService],
})
@Global()
export class CacheModule {}

View File

@ -0,0 +1,23 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Cacheable } from 'cacheable';
@Injectable()
export class CacheService {
private readonly logger = new Logger(CacheService.name);
constructor(@Inject('CACHE_INSTANCE') private readonly cache: Cacheable) {}
get<T>(key: string): Promise<T | undefined> {
this.logger.log(`Getting value for key ${key}`);
return this.cache.get(key);
}
async set<T>(key: string, value: T, ttl?: number | string): Promise<void> {
this.logger.log(`Setting value for key ${key}`);
await this.cache.set(key, value, ttl);
}
async delete(key: string): Promise<void> {
this.logger.log(`Deleting value for key ${key}`);
await this.cache.delete(key);
}
}

View File

@ -0,0 +1 @@
export * from './cache.services';

View File

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

View File

@ -0,0 +1,32 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AccessTokenGuard } from '~/common/guards';
import { ApiDataArrayResponse } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import { DocumentMetaResponseDto } from '~/document/dtos/response';
import { LookupService } from '../services';
@Controller('lookup')
@ApiTags('Lookups')
@ApiBearerAuth()
export class LookupController {
constructor(private readonly lookupService: LookupService) {}
@UseGuards(AccessTokenGuard)
@Get('default-avatars')
@ApiDataArrayResponse(DocumentMetaResponseDto)
async findDefaultAvatars() {
const avatars = await this.lookupService.findDefaultAvatar();
return ResponseFactory.dataArray(avatars.map((avatar) => new DocumentMetaResponseDto(avatar)));
}
@UseGuards(AccessTokenGuard)
@Get('default-task-logos')
@ApiDataArrayResponse(DocumentMetaResponseDto)
async findDefaultTaskLogos() {
const avatars = await this.lookupService.findDefaultTasksLogo();
return ResponseFactory.dataArray(avatars.map((avatar) => new DocumentMetaResponseDto(avatar)));
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { DocumentModule } from '~/document/document.module';
import { LookupController } from './controllers';
import { LookupService } from './services';
@Module({
controllers: [LookupController],
providers: [LookupService],
imports: [DocumentModule],
})
export class LookupModule {}

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