Compare commits

..
Author SHA1 Message Date
dilipshrivastwa-kiwi 5d386322e4 add git ignore file 2023-06-15 15:11:18 +05:30
dilipshrivastwa-kiwi ecb058209d First Commit 2023-06-15 15:01:10 +05:30
503 changed files with 22 additions and 42931 deletions
-40
View File
@@ -1,40 +0,0 @@
NODE_ENV=dev
PORT=5000
DB_HOST=
DB_PASS=
DB_USER=postgres
DB_PORT=5432
DB_NAME=
MIGRATIONS_RUN=true
SWAGGER_API_DOCS_PATH="/api-docs"
JWT_ACCESS_TOKEN_SECRET=your_access_token_secret
JWT_ACCESS_TOKEN_EXPIRY=1d
JWT_REFRESH_TOKEN_SECRET=your_refresh_token_secret
JWT_REFRESH_TOKEN_EXPIRY=1d
USE_MOCK=true
OCI_TENANCY_ID=
OCI_USER_ID=
OCI_FINGERPRINT=
OCI_PRIVATE_KEY=
OCI_PASSPHRASE=""
OCI_NAMESPACE=
OCI_REGION=
MAIL_HOST=smtp.gmail.com
MAIL_USER=[email protected]
MAIL_PASSWORD=
MAIL_PORT=587
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
-47
View File
@@ -1,47 +0,0 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:security/recommended'],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js', 'node_modules', 'dist', 'coverage', 'jest*\\.ts', 'typeorm*\\.ts'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
],
'@typescript-eslint/no-inferrable-types': 'off',
'func-names': ['error', 'as-needed'],
'no-underscore-dangle': ['error'],
'require-await': ['error'],
'no-console': ['error'],
'no-multi-assign': ['error'],
'no-magic-numbers': ['error', { ignoreArrayIndexes: true}],
'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 1, maxBOF: 0 }],
'max-len': [
'error',
{ code: 120, tabWidth: 2, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true },
],
},
overrides: [
{
files: ['*spec.ts'],
rules: {
'no-magic-numbers': ['off'],
},
},
],
};
-85
View File
@@ -1,85 +0,0 @@
name: CI Zod
on:
pull_request:
branches:
- '*'
push:
branches:
- dev
- main
env:
NODE_ENV: development
TAG: dev
jobs:
install_dependencies:
name: Install Dependencies
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 22.4.1
- name: Install Dependencies
run: npm install
- name: Cache Node Modules
uses: actions/cache@v3
with:
path: node_modules
key: ${{ runner.os }}-node_modules-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node_modules-
run_tests:
name: Run Tests
runs-on: ubuntu-latest
needs: install_dependencies
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 22.4.1
- name: Install Dependencies
run: npm install
- name: Run Lint
run: npm run lint
- name: Run Tests with Coverage
run: npm run test:cov
build_app:
name: Build Application
runs-on: ubuntu-latest
needs: run_tests
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 22.4.1
- name: Install Dependencies
run: npm install
- name: Build Application
run: npm run build
- name: Upload Build Artifacts
uses: actions/upload-artifact@v3
with:
name: dist
path: dist/
+21 -54
View File
@@ -1,57 +1,24 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
/static
/media
.idea/
*.pyc
media/
*.name
*.iml
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
*.xml
*.pyo
.DS_Store
.idea
venv/*
static/*
*.pem
*.sqlite3
/migrations/__pycache__/
/__pycache__/
/*.pyc
*/__pycache__/*.pyc
__pycache__/
*.env
ve/*
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
zod-certs
-6
View File
@@ -1,6 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all",
"tabWidth": 2,
"printWidth": 120
}
-6
View File
@@ -1,6 +0,0 @@
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
}
-291
View File
@@ -1,291 +0,0 @@
# Allowance Scheduling System (Backend)
This document captures the complete allowance scheduling feature as implemented in the backend.
It is intended as a long-term reference for how the system works, why it was built this way,
and how to operate/extend it safely.
## Goals and Scope
- Allow parents/guardians to create a recurring allowance schedule for a child.
- Credit the allowance automatically based on the schedule.
- Scale safely with multiple workers and avoid duplicate credits.
- Keep cron lightweight and offload work to RabbitMQ workers.
Non-goals in the current implementation:
- UI changes
- Analytics/reporting views
- Advanced scheduling (e.g., custom weekdays)
## High-Level Flow
1. Parent creates a schedule via API.
2. Cron runs every 5 minutes and enqueues due schedules into RabbitMQ.
3. Workers consume queue messages, credit the allowance, and update the next run.
4. Idempotency is enforced in the database to prevent duplicate credits.
## Data Model
### Table: `allowance_schedules`
Purpose: Stores the schedule definition (amount, frequency, status, next run).
Key fields:
- `guardian_id`, `junior_id`: who funds and who receives.
- `amount`: the allowance amount.
- `frequency`: DAILY / WEEKLY / MONTHLY.
- `status`: ON / OFF.
- `next_run_at`, `last_run_at`: scheduling metadata.
Constraints:
- Unique `(guardian_id, junior_id)` ensures one schedule per child.
Entity: `src/allowance/entities/allowance-schedule.entity.ts`
### Table: `allowance_credits`
Purpose: Audit log and idempotency guard for each credit run.
Key fields:
- `schedule_id`: which schedule executed.
- `transaction_id`: the resulting transaction (nullable).
- `amount`, `run_at`, `credited_at`.
Idempotency:
- Unique `(schedule_id, run_at)` prevents duplicates even with multiple workers.
Entity: `src/allowance/entities/allowance-credit.entity.ts`
### Table: `cron_runs`
Purpose: shared audit table for all cron jobs (not just allowance).
Key fields:
- `job_name`: unique identifier for the cron.
- `status`: SUCCESS / FAILED.
- `processed_count`: number of schedules processed.
- `error_message`: failure reason.
- `started_at`, `finished_at`.
Entity: `src/cron/entities/cron-run.entity.ts`
## API (Schedule Creation)
Endpoint:
- `POST /guardians/me/allowances/:juniorId`
Input DTO:
- `amount` (required, numeric, positive)
- `frequency` (required, enum)
- `status` (required, enum)
DTO: `src/allowance/dtos/request/create-allowance-schedule.request.dto.ts`
Response DTO:
- `AllowanceScheduleResponseDto` with schedule fields.
DTO: `src/allowance/dtos/response/allowance-schedule.response.dto.ts`
Business validation:
- Child must belong to guardian.
- No duplicate schedule for the same guardian+child.
Service: `src/allowance/services/allowance.service.ts`
## Cron Producer (Queue Enqueue)
Cron job:
- Runs every 5 minutes.
- Batches schedules (cursor-based) to avoid large load.
- Enqueues each schedule to RabbitMQ.
Cron: `src/cron/tasks/allowance-schedule.cron.ts`
Batch behavior:
- Batch size = 100
- Uses a cursor (`nextRunAt`, `id`) for stable pagination.
- Prevents re-reading the same rows.
Locking:
- `BaseCronService` uses cache lock to ensure only one instance runs.
- Each cron run is logged to `cron_runs` with status and processed count.
Lock: `src/cron/services/base-cron.service.ts`
## Queue Publisher
Queue publisher:
- Asserts queue + retry/DLQ exchanges.
- Enqueues jobs with message id (for traceability).
Service: `src/allowance/services/allowance-queue.service.ts`
Queue names:
- `allowance.schedule` (main)
- `allowance.schedule.retry` (retry with TTL)
- `allowance.schedule.dlq` (dead-letter queue)
Constants: `src/allowance/constants/allowance-queue.constants.ts`
## Worker Consumer (Transfers)
Worker:
- Consumes `allowance.schedule` queue.
- Validates schedule is due and active.
- Creates `allowance_credits` record for idempotency.
- Transfers money via `cardService.transferToChild`.
- Updates `last_run_at` and `next_run_at`.
Worker: `src/allowance/services/allowance-worker.service.ts`
Transfer:
- Uses existing logic in `card.service.ts` for balance updates and transaction creation.
Service: `src/card/services/card.service.ts`
### Idempotency details
1. Worker inserts `allowance_credits` row first.
2. Unique constraint blocks duplicates.
3. If transfer fails, the credit row is removed so the job can retry.
This makes multiple workers safe.
## Retry + DLQ Strategy
Retry delay:
- Failed jobs are dead-lettered to retry queue.
- Retry queue has `messageTtl = 10 minutes`.
- After TTL, job is routed back to main queue.
DLQ:
- If a job fails `ALLOWANCE_MAX_RETRIES` times (default 5), it is routed to the DLQ.
- This prevents endless loops and allows manual inspection.
Config:
- `ALLOWANCE_MAX_RETRIES` (default 5)
## Redis Usage
Redis is used by `BaseCronService` to enforce a **distributed lock** for the cron job.
This prevents multiple backend instances from enqueuing the same schedules at the same time.
Lock behavior:
- If the lock key exists, cron exits early.
- If the lock key is absent, cron sets it with a TTL and proceeds.
- TTL ensures the lock is released even if a node crashes.
Service: `src/cron/services/base-cron.service.ts`
## RabbitMQ Setup and Behavior
The allowance system uses RabbitMQ for asynchronous processing. Cron publishes due schedules,
and workers consume them.
### Exchanges and Queues
Main queue:
- `allowance.schedule`
Retry setup:
- Exchange: `allowance.schedule.retry.exchange`
- Queue: `allowance.schedule.retry`
- Binding key: `allowance.schedule`
- TTL: 10 minutes
- Dead-letter route: back to `allowance.schedule`
Dead-letter queue (DLQ):
- Exchange: `allowance.schedule.dlq.exchange`
- Queue: `allowance.schedule.dlq`
- Binding key: `allowance.schedule`
### Flow Summary
1. Cron enqueues jobs to `allowance.schedule`.
2. Worker consumes jobs from `allowance.schedule`.
3. On failure, job is **dead-lettered** to `allowance.schedule.retry`.
4. After 10 minutes, it returns to `allowance.schedule`.
5. After max retries, worker publishes the job to `allowance.schedule.dlq`.
### Where it is configured
- Publisher setup: `src/allowance/services/allowance-queue.service.ts`
- Worker consumer: `src/allowance/services/allowance-worker.service.ts`
- Queue constants: `src/allowance/constants/allowance-queue.constants.ts`
## Environment Variables
- `RABBITMQ_URL` (required for queue/worker)
- `ALLOWANCE_QUEUE_NAME` (optional, defaults to `allowance.schedule`)
- `ALLOWANCE_MAX_RETRIES` (optional, defaults to 5)
- `ALLOWANCE_RETRY_DELAY_MS` (optional, defaults to 10 minutes)
### Example .env snippet
```
RABBITMQ_URL=amqp://guest:guest@localhost:5672
ALLOWANCE_QUEUE_NAME=allowance.schedule
ALLOWANCE_MAX_RETRIES=5
ALLOWANCE_RETRY_DELAY_MS=600000
```
## Operational Checklist
- Ensure Redis is running (cron locking).
- Ensure RabbitMQ is running (queue + workers).
- Start at least one worker process.
- Monitor DLQ for failures.
## Manual Test Checklist
1. Create schedule:
- POST `/guardians/me/allowances/:juniorId`
- Valid amount, frequency, status.
2. Duplicate schedule:
- Expect `ALLOWANCE.ALREADY_EXISTS`.
3. Cron enqueue:
- Wait for cron interval or manually trigger.
- Confirm messages appear in RabbitMQ.
4. Worker:
- Ensure worker is running.
- Verify transfers happen and `allowance_credits` is created.
5. Failure paths:
- Simulate transfer failure and verify retry queue behavior.
- Confirm DLQ after max retries.
## Operational Notes
- For large volumes, scale workers horizontally.
- Keep cron lightweight; do not perform transfers in cron.
- Monitor queue depth and DLQ entries.
## Known Limitations (Current)
- Only one schedule per child (guardian+junior unique).
- No custom weekdays or complex schedules.
- Retry delay is fixed at 10 minutes (can be configurable).
## File Map (Quick Reference)
- API:
- `src/allowance/controllers/allowance.controller.ts`
- `src/allowance/services/allowance.service.ts`
- `src/allowance/dtos/request/create-allowance-schedule.request.dto.ts`
- `src/allowance/dtos/response/allowance-schedule.response.dto.ts`
- Cron:
- `src/cron/tasks/allowance-schedule.cron.ts`
- `src/cron/services/base-cron.service.ts`
- Queue/Worker:
- `src/allowance/services/allowance-queue.service.ts`
- `src/allowance/services/allowance-worker.service.ts`
- `src/allowance/constants/allowance-queue.constants.ts`
- Repositories:
- `src/allowance/repositories/allowance-schedule.repository.ts`
- `src/allowance/repositories/allowance-credit.repository.ts`
- Entities:
- `src/allowance/entities/allowance-schedule.entity.ts`
- `src/allowance/entities/allowance-credit.entity.ts`
-33
View File
@@ -1,33 +0,0 @@
# Base image
FROM node:22.4.1-alpine
RUN apk update && \
apk upgrade && \
apk add ca-certificates curl && \
update-ca-certificates
#saudi timezone
ENV TZ=Asia/Riyadh
# Create app directory
RUN mkdir /app && chown node:node /app
WORKDIR /app
# finally switch user to node
USER node
# A wildcard is used to ensure both package.json, package-lock.json and package-lock.json are copied
COPY --chown=node:node package*.json ./
# Install app dependencies
RUN npm install --frozen-lockfile
# Bundle app source
COPY --chown=node:node . .
# Creates a "dist" folder with the production build
RUN npm run build
# Start the server using the production build
CMD [ "node", "dist/main" ]
+1 -99
View File
@@ -1,99 +1 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
# ZODBank-Backend
-35
View File
@@ -1,35 +0,0 @@
import type { Config as JestConfig } from 'jest';
import { pathsToModuleNameMapper } from 'ts-jest';
import { compilerOptions as tsConfigCompilerOptions } from './tsconfig.json';
type ArrayElement<A> = A extends readonly (infer T)[] ? T : never;
type JestProjectConfig = Exclude<ArrayElement<JestConfig['projects']>, string>;
const baseJestConfig: JestConfig = {
// ...
};
const baseJestProjectConfig: JestProjectConfig = {
testEnvironment: 'node',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
moduleNameMapper: pathsToModuleNameMapper(tsConfigCompilerOptions.paths, { prefix: '<rootDir>' }),
globalSetup: '<rootDir>/test/global-setup.ts',
};
const projects: { name?: string; rootDir: string }[] = [{ rootDir: '.' }];
export function buildJestConfig(config?: {
jestConfigs?: JestConfig;
jestProjectConfigs?: JestProjectConfig;
}): JestConfig {
return {
...baseJestConfig,
...config?.jestConfigs,
projects: projects.map(({ name, rootDir }) => ({
...baseJestProjectConfig,
...config?.jestProjectConfigs,
...(name && { displayName: name }),
rootDir,
})),
};
}
-16
View File
@@ -1,16 +0,0 @@
import type { Config } from 'jest';
import { buildJestConfig } from './jest.base.config';
const config: Config = buildJestConfig({
jestProjectConfigs: {
testRegex: '.*\\.spec\\.ts$',
coveragePathIgnorePatterns: ['__testing__', 'entities', '<rootDir>/src/db', 'index.ts'],
},
jestConfigs: {
coverageDirectory: 'coverage',
collectCoverageFrom: ['<rootDir>/src/**/*.(t|j)s'],
coverageReporters: ['clover', 'json', 'lcov', 'text', 'text-summary', 'cobertura'],
},
});
export default config;
-19
View File
@@ -1,19 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"watchAssets": true,
"assets": [
{
"include": "config",
"exclude": "**/*.md"
},
{ "include": "common/modules/**/templates/**/*", "watchAssets": true },
{ "include": "common/modules/neoleap/zod-certs" },
"i18n",
"files"
]
}
}
-22263
View File
File diff suppressed because it is too large Load Diff
-124
View File
@@ -1,124 +0,0 @@
{
"name": "test",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm:cli": "./node_modules/.bin/ts-node -r tsconfig-paths/register ./node_modules/.bin/typeorm",
"typeorm:cli-d": "ts-node -r tsconfig-paths/register ./node_modules/.bin/typeorm -d typeorm.cli.ts",
"migration:generate": "npm run typeorm:cli-d migration:generate",
"migration:create": "npm run typeorm:cli migration:create",
"migration:up": "npm run typeorm:cli-d migration:run",
"migration:down": "npm run typeorm:cli-d migration:revert",
"seed": "TS_NODE_PROJECT=tsconfig.json ts-node -r tsconfig-paths/register src/scripts/seed.ts"
},
"dependencies": {
"@abdalhamid/hello": "^2.0.0",
"@hamid/hello": "file:../libraries/test-package",
"@keyv/redis": "^4.0.2",
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/axios": "^3.1.2",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.0.0",
"@nestjs/event-emitter": "^2.1.1",
"@nestjs/jwt": "^10.2.0",
"@nestjs/microservices": "^10.4.7",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.8",
"@nestjs/schedule": "^4.1.2",
"@nestjs/swagger": "^8.0.5",
"@nestjs/terminus": "^10.2.3",
"@nestjs/throttler": "^6.2.1",
"@nestjs/typeorm": "^10.0.2",
"amqp-connection-manager": "^4.1.14",
"amqplib": "^0.10.4",
"bcrypt": "^5.1.1",
"cacheable": "^1.8.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"decimal.js": "^10.6.0",
"firebase-admin": "^13.0.2",
"google-libphonenumber": "^3.2.39",
"handlebars": "^4.7.8",
"handlebars-layouts": "^3.1.4",
"jwk-to-pem": "^2.0.7",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"nestjs-i18n": "^10.4.9",
"nestjs-pino": "^4.1.0",
"nestjs-twilio": "^4.4.0",
"nodemailer": "^6.9.16",
"oci-common": "^2.99.0",
"oci-sdk": "^2.99.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.13.1",
"pino-http": "^10.3.0",
"pino-pretty": "^13.0.0",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20",
"typeorm-transactional": "^0.5.0"
},
"devDependencies": {
"@golevelup/ts-jest": "^0.6.0",
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.0",
"@types/google-libphonenumber": "^7.4.30",
"@types/jest": "^29.5.2",
"@types/jwk-to-pem": "^2.0.3",
"@types/lodash": "^4.17.13",
"@types/multer": "^1.4.12",
"@types/node": "^20.3.1",
"@types/nodemailer": "^6.4.16",
"@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.5",
"@types/supertest": "^6.0.0",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2",
"eslint": "^8.39.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-security": "^1.7.1",
"i": "^0.3.7",
"jest": "^29.5.0",
"lint-staged": "^13.2.2",
"npm": "^10.9.2",
"prettier": "^2.8.8",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"lint-staged": {
"*.{js,ts}": [
"eslint --fix"
],
"*.{js,ts,json,md}": [
"prettier --write"
]
}
}
View File
-22
View File
@@ -1,22 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CardModule } from '~/card/card.module';
import { JuniorModule } from '~/junior/junior.module';
import { AllowanceController } from './controllers';
import { AllowanceCredit, AllowanceSchedule } from './entities';
import { AllowanceCreditRepository, AllowanceScheduleRepository } from './repositories';
import { AllowanceQueueService, AllowanceService, AllowanceWorkerService } from './services';
@Module({
imports: [TypeOrmModule.forFeature([AllowanceSchedule, AllowanceCredit]), JuniorModule, CardModule],
controllers: [AllowanceController],
providers: [
AllowanceService,
AllowanceScheduleRepository,
AllowanceCreditRepository,
AllowanceQueueService,
AllowanceWorkerService,
],
exports: [AllowanceScheduleRepository, AllowanceQueueService, AllowanceCreditRepository],
})
export class AllowanceModule {}
@@ -1,5 +0,0 @@
export const ALLOWANCE_QUEUE_NAME = 'allowance.schedule';
export const ALLOWANCE_RETRY_QUEUE_NAME = 'allowance.schedule.retry';
export const ALLOWANCE_DLQ_NAME = 'allowance.schedule.dlq';
export const ALLOWANCE_RETRY_EXCHANGE = 'allowance.schedule.retry.exchange';
export const ALLOWANCE_DLQ_EXCHANGE = 'allowance.schedule.dlq.exchange';
-1
View File
@@ -1 +0,0 @@
export * from './allowance-queue.constants';
@@ -1,78 +0,0 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, 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 { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import { CreateAllowanceScheduleRequestDto, UpdateAllowanceScheduleRequestDto } from '../dtos/request';
import {
AllowanceScheduleResponseDto,
AllowanceSchedulesListResponseDto,
AllowanceSummaryResponseDto,
} from '../dtos/response';
import { AllowanceService } from '../services';
@Controller('guardians/me/allowances')
@ApiTags('Allowances')
@ApiBearerAuth()
@ApiLangRequestHeader()
@UseGuards(AccessTokenGuard, RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
export class AllowanceController {
constructor(private readonly allowanceService: AllowanceService) {}
@Get()
@ApiOperation({ summary: 'Get all allowance schedules for the authenticated guardian' })
@ApiDataResponse(AllowanceSchedulesListResponseDto)
async getSchedules(@AuthenticatedUser() { sub }: IJwtPayload) {
const { withSchedule, withoutSchedule, monthlyTotal } =
await this.allowanceService.getSchedulesByGuardian(sub);
return ResponseFactory.data(
new AllowanceSchedulesListResponseDto(withSchedule, withoutSchedule, monthlyTotal),
);
}
@Get('summary')
@ApiOperation({ summary: 'Get allowance summary for home page (lightweight)' })
@ApiDataResponse(AllowanceSummaryResponseDto)
async getSummary(@AuthenticatedUser() { sub }: IJwtPayload) {
const { nextPaymentAt, monthlyTotal } = await this.allowanceService.getSummary(sub);
return ResponseFactory.data(new AllowanceSummaryResponseDto(nextPaymentAt, monthlyTotal));
}
@Post(':juniorId')
@ApiOperation({ summary: 'Create a new allowance schedule for a junior' })
@ApiDataResponse(AllowanceScheduleResponseDto)
async createSchedule(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('juniorId') juniorId: string,
@Body() body: CreateAllowanceScheduleRequestDto,
) {
const schedule = await this.allowanceService.createSchedule(sub, juniorId, body);
return ResponseFactory.data(new AllowanceScheduleResponseDto(schedule));
}
@Patch(':scheduleId')
@ApiOperation({ summary: 'Update an existing allowance schedule' })
@ApiDataResponse(AllowanceScheduleResponseDto)
async updateSchedule(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('scheduleId') scheduleId: string,
@Body() body: UpdateAllowanceScheduleRequestDto,
) {
const schedule = await this.allowanceService.updateSchedule(sub, scheduleId, body);
return ResponseFactory.data(new AllowanceScheduleResponseDto(schedule));
}
@Delete(':scheduleId')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete an allowance schedule' })
async deleteSchedule(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('scheduleId') scheduleId: string,
) {
await this.allowanceService.deleteSchedule(sub, scheduleId);
}
}
-1
View File
@@ -1 +0,0 @@
export * from './allowance.controller';
@@ -1,38 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsNumber, IsPositive } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { AllowanceFrequency, AllowanceScheduleStatus } from '~/allowance/enums';
export class CreateAllowanceScheduleRequestDto {
@ApiProperty({ example: 400 })
@IsNotEmpty({
message: i18n('validation.IsNotEmpty', { path: 'general', property: 'allowance.amount' }),
})
@IsNumber(
{ maxDecimalPlaces: 2 },
{ message: i18n('validation.IsNumber', { path: 'general', property: 'allowance.amount' }) },
)
@IsPositive({
message: i18n('validation.IsPositive', { path: 'general', property: 'allowance.amount' }),
})
amount!: number;
@ApiProperty({ enum: AllowanceFrequency, example: AllowanceFrequency.WEEKLY })
@IsNotEmpty({
message: i18n('validation.IsNotEmpty', { path: 'general', property: 'allowance.frequency' }),
})
@IsEnum(AllowanceFrequency, {
message: i18n('validation.IsEnum', { path: 'general', property: 'allowance.frequency' }),
})
frequency!: AllowanceFrequency;
@ApiProperty({ enum: AllowanceScheduleStatus, example: AllowanceScheduleStatus.ON })
@IsNotEmpty({
message: i18n('validation.IsNotEmpty', { path: 'general', property: 'allowance.status' }),
})
@IsEnum(AllowanceScheduleStatus, {
message: i18n('validation.IsEnum', { path: 'general', property: 'allowance.status' }),
})
status!: AllowanceScheduleStatus;
}
-2
View File
@@ -1,2 +0,0 @@
export * from './create-allowance-schedule.request.dto';
export * from './update-allowance-schedule.request.dto';
@@ -1,40 +0,0 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsNumber, IsOptional, IsPositive } from 'class-validator';
import { i18nValidationMessage } from 'nestjs-i18n';
import { AllowanceFrequency, AllowanceScheduleStatus } from '~/allowance/enums';
export class UpdateAllowanceScheduleRequestDto {
@ApiPropertyOptional({ example: 150, description: 'Allowance amount' })
@IsOptional()
@IsNotEmpty({ message: i18nValidationMessage('validation.NOT_EMPTY') })
@IsNumber(
{ maxDecimalPlaces: 2 },
{ message: i18nValidationMessage('validation.INVALID_NUMBER', { field: 'general.amount' }) },
)
@IsPositive({ message: i18nValidationMessage('validation.MUST_BE_POSITIVE', { field: 'general.amount' }) })
amount?: number;
@ApiPropertyOptional({
enum: AllowanceFrequency,
example: AllowanceFrequency.WEEKLY,
description: 'How often the allowance is paid',
})
@IsOptional()
@IsNotEmpty({ message: i18nValidationMessage('validation.NOT_EMPTY') })
@IsEnum(AllowanceFrequency, {
message: i18nValidationMessage('validation.INVALID_ENUM', { field: 'general.allowance.frequency' }),
})
frequency?: AllowanceFrequency;
@ApiPropertyOptional({
enum: AllowanceScheduleStatus,
example: AllowanceScheduleStatus.ON,
description: 'Whether the schedule is active or paused',
})
@IsOptional()
@IsNotEmpty({ message: i18nValidationMessage('validation.NOT_EMPTY') })
@IsEnum(AllowanceScheduleStatus, {
message: i18nValidationMessage('validation.INVALID_ENUM', { field: 'general.allowance.status' }),
})
status?: AllowanceScheduleStatus;
}
@@ -1,48 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { AllowanceSchedule } from '~/allowance/entities/allowance-schedule.entity';
import { AllowanceFrequency, AllowanceScheduleStatus } from '~/allowance/enums';
export class AllowanceScheduleResponseDto {
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' })
id!: string;
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' })
guardianId!: string;
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' })
juniorId!: string;
@ApiProperty({ example: 400 })
amount!: number;
@ApiProperty({ enum: AllowanceFrequency, example: AllowanceFrequency.WEEKLY })
frequency!: AllowanceFrequency;
@ApiProperty({ enum: AllowanceScheduleStatus, example: AllowanceScheduleStatus.ON })
status!: AllowanceScheduleStatus;
@ApiProperty({ example: '2026-01-01T00:00:00.000Z' })
nextRunAt!: Date;
@ApiProperty({ example: null })
lastRunAt!: Date | null;
@ApiProperty({ example: '2026-01-01T00:00:00.000Z' })
createdAt!: Date;
@ApiProperty({ example: '2026-01-01T00:00:00.000Z' })
updatedAt!: Date;
constructor(data: AllowanceSchedule) {
this.id = data.id;
this.guardianId = data.guardianId;
this.juniorId = data.juniorId;
this.amount = Number(data.amount);
this.frequency = data.frequency;
this.status = data.status;
this.nextRunAt = data.nextRunAt;
this.lastRunAt = data.lastRunAt;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
}
}
@@ -1,36 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { AllowanceSchedule } from '~/allowance/entities/allowance-schedule.entity';
import { Junior } from '~/junior/entities';
import { JuniorWithScheduleDto, JuniorWithoutScheduleDto } from './junior-allowance-info.response.dto';
export class AllowanceSchedulesListResponseDto {
@ApiProperty({
type: [JuniorWithScheduleDto],
description: 'Children who have an allowance schedule configured',
})
withSchedule!: JuniorWithScheduleDto[];
@ApiProperty({
type: [JuniorWithoutScheduleDto],
description: 'Children who do not have an allowance schedule yet',
})
withoutSchedule!: JuniorWithoutScheduleDto[];
@ApiProperty({
example: 1600,
description: 'Total monthly equivalent amount for all active schedules',
})
monthlyTotal!: number;
constructor(
juniorsWithSchedule: { junior: Junior; schedule: AllowanceSchedule }[],
juniorsWithoutSchedule: Junior[],
monthlyTotal: number,
) {
this.withSchedule = juniorsWithSchedule.map(
({ junior, schedule }) => new JuniorWithScheduleDto(junior, schedule),
);
this.withoutSchedule = juniorsWithoutSchedule.map((j) => new JuniorWithoutScheduleDto(j));
this.monthlyTotal = monthlyTotal;
}
}
@@ -1,21 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class AllowanceSummaryResponseDto {
@ApiPropertyOptional({
example: '2026-02-01T00:00:00.000Z',
description: 'The nearest upcoming payment date among all active schedules (null if no active schedules)',
nullable: true,
})
nextPaymentAt!: Date | null;
@ApiProperty({
example: 1600,
description: 'Total monthly equivalent amount for all active schedules',
})
monthlyTotal!: number;
constructor(nextPaymentAt: Date | null, monthlyTotal: number) {
this.nextPaymentAt = nextPaymentAt;
this.monthlyTotal = monthlyTotal;
}
}
-4
View File
@@ -1,4 +0,0 @@
export * from './allowance-schedule.response.dto';
export * from './allowance-schedules-list.response.dto';
export * from './allowance-summary.response.dto';
export * from './junior-allowance-info.response.dto';
@@ -1,89 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { Junior } from '~/junior/entities';
import { AllowanceSchedule } from '~/allowance/entities';
import { AllowanceFrequency, AllowanceScheduleStatus } from '~/allowance/enums';
/**
* Junior without an allowance schedule - basic info only
*/
export class JuniorWithoutScheduleDto {
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' })
juniorId!: string;
@ApiProperty({ example: 'Ahmed' })
firstName!: string;
@ApiProperty({ example: 'Al-Khair' })
lastName!: string;
@ApiProperty({ example: 'https://example.com/profile.jpg', nullable: true })
profilePictureUrl!: string | null;
constructor(junior: Junior) {
this.juniorId = junior.id;
this.firstName = junior.customer?.user?.firstName || '';
this.lastName = junior.customer?.user?.lastName || '';
this.profilePictureUrl = junior.customer?.user?.profilePicture?.url || null;
}
}
/**
* Junior with their allowance schedule - all data flattened into one object
*/
export class JuniorWithScheduleDto {
// Junior info
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' })
juniorId!: string;
@ApiProperty({ example: 'Ahmed' })
firstName!: string;
@ApiProperty({ example: 'Al-Khair' })
lastName!: string;
@ApiProperty({ example: 'https://example.com/profile.jpg', nullable: true })
profilePictureUrl!: string | null;
// Schedule info
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' })
scheduleId!: string;
@ApiProperty({ example: 100 })
amount!: number;
@ApiProperty({ enum: AllowanceFrequency, example: AllowanceFrequency.WEEKLY })
frequency!: AllowanceFrequency;
@ApiProperty({ enum: AllowanceScheduleStatus, example: AllowanceScheduleStatus.ON })
status!: AllowanceScheduleStatus;
@ApiProperty({ example: '2026-02-05T00:00:00.000Z' })
nextRunAt!: Date;
@ApiProperty({ example: null, nullable: true })
lastRunAt!: Date | null;
@ApiProperty({ example: '2026-01-15T10:30:00.000Z' })
createdAt!: Date;
@ApiProperty({ example: '2026-01-15T10:30:00.000Z' })
updatedAt!: Date;
constructor(junior: Junior, schedule: AllowanceSchedule) {
// Junior info
this.juniorId = junior.id;
this.firstName = junior.customer?.user?.firstName || '';
this.lastName = junior.customer?.user?.lastName || '';
this.profilePictureUrl = junior.customer?.user?.profilePicture?.url || null;
// Schedule info
this.scheduleId = schedule.id;
this.amount = Number(schedule.amount);
this.frequency = schedule.frequency;
this.status = schedule.status;
this.nextRunAt = schedule.nextRunAt;
this.lastRunAt = schedule.lastRunAt;
this.createdAt = schedule.createdAt;
this.updatedAt = schedule.updatedAt;
}
}
@@ -1,42 +0,0 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Transaction } from '~/card/entities/transaction.entity';
import { AllowanceSchedule } from './allowance-schedule.entity';
@Entity('allowance_credits')
export class AllowanceCredit extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'decimal', precision: 12, scale: 2, name: 'amount' })
amount!: number;
@Column({ type: 'timestamp with time zone', name: 'run_at' })
runAt!: Date;
@CreateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', name: 'credited_at' })
creditedAt!: Date;
@Column({ type: 'uuid', name: 'schedule_id' })
scheduleId!: string;
@ManyToOne(() => AllowanceSchedule, (schedule) => schedule.credits, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'schedule_id' })
schedule!: AllowanceSchedule;
@Column({ type: 'uuid', name: 'transaction_id', nullable: true })
transactionId!: string | null;
@ManyToOne(() => Transaction, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'transaction_id' })
transaction!: Transaction | null;
}
@@ -1,60 +0,0 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Guardian } from '~/guardian/entities/guradian.entity';
import { Junior } from '~/junior/entities';
import { AllowanceFrequency, AllowanceScheduleStatus } from '../enums';
import { AllowanceCredit } from './allowance-credit.entity';
@Entity('allowance_schedules')
export class AllowanceSchedule extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'decimal', precision: 12, scale: 2, name: 'amount' })
amount!: number;
@Column({ type: 'varchar', name: 'frequency' })
frequency!: AllowanceFrequency;
@Column({ type: 'varchar', name: 'status', default: AllowanceScheduleStatus.ON })
status!: AllowanceScheduleStatus;
@Column({ type: 'timestamp with time zone', name: 'next_run_at' })
nextRunAt!: Date;
@Column({ type: 'timestamp with time zone', name: 'last_run_at', nullable: true })
lastRunAt!: Date | null;
@CreateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', name: 'updated_at' })
updatedAt!: Date;
@Column({ type: 'uuid', name: 'guardian_id' })
guardianId!: string;
@ManyToOne(() => Guardian, (guardian) => guardian.allowanceSchedules, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'guardian_id' })
guardian!: Guardian;
@Column({ type: 'uuid', name: 'junior_id' })
juniorId!: string;
@ManyToOne(() => Junior, (junior) => junior.allowanceSchedules, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'junior_id' })
junior!: Junior;
@OneToMany(() => AllowanceCredit, (credit) => credit.schedule)
credits!: AllowanceCredit[];
}
-2
View File
@@ -1,2 +0,0 @@
export * from './allowance-credit.entity';
export * from './allowance-schedule.entity';
@@ -1,5 +0,0 @@
export enum AllowanceFrequency {
DAILY = 'DAILY',
WEEKLY = 'WEEKLY',
MONTHLY = 'MONTHLY',
}
@@ -1,4 +0,0 @@
export enum AllowanceScheduleStatus {
ON = 'ON',
OFF = 'OFF',
}
-2
View File
@@ -1,2 +0,0 @@
export * from './allowance-frequency.enum';
export * from './allowance-schedule-status.enum';
@@ -1,13 +0,0 @@
import { Junior } from '~/junior/entities';
import { AllowanceSchedule } from '../entities/allowance-schedule.entity';
export interface AllowanceSchedulesGrouped {
withSchedule: { junior: Junior; schedule: AllowanceSchedule }[];
withoutSchedule: Junior[];
monthlyTotal: number;
}
export interface AllowanceSummary {
nextPaymentAt: Date | null;
monthlyTotal: number;
}
-1
View File
@@ -1 +0,0 @@
export * from './allowance-schedules-grouped.interface';
@@ -1,32 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AllowanceCredit } from '~/allowance/entities';
@Injectable()
export class AllowanceCreditRepository {
constructor(
@InjectRepository(AllowanceCredit)
private readonly allowanceCreditRepository: Repository<AllowanceCredit>,
) {}
createCredit(scheduleId: string, amount: number, runAt: Date): Promise<AllowanceCredit> {
return this.allowanceCreditRepository.save(
this.allowanceCreditRepository.create({
scheduleId,
amount,
runAt,
}),
);
}
findByScheduleAndRunAt(scheduleId: string, runAt: Date): Promise<AllowanceCredit | null> {
return this.allowanceCreditRepository.findOne({
where: { scheduleId, runAt },
});
}
deleteById(id: string): Promise<void> {
return this.allowanceCreditRepository.delete({ id }).then(() => undefined);
}
}
@@ -1,96 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AllowanceSchedule } from '~/allowance/entities/allowance-schedule.entity';
import { CreateAllowanceScheduleRequestDto } from '~/allowance/dtos/request';
import { AllowanceScheduleStatus } from '~/allowance/enums';
@Injectable()
export class AllowanceScheduleRepository {
constructor(
@InjectRepository(AllowanceSchedule)
private readonly allowanceScheduleRepository: Repository<AllowanceSchedule>,
) {}
findByGuardianAndJunior(guardianId: string, juniorId: string): Promise<AllowanceSchedule | null> {
return this.allowanceScheduleRepository.findOne({
where: { guardianId, juniorId },
});
}
findByGuardianId(guardianId: string): Promise<AllowanceSchedule[]> {
return this.allowanceScheduleRepository.find({
where: { guardianId },
order: { createdAt: 'DESC' },
});
}
/**
* Finds only active (ON) schedules for a guardian, ordered by nextRunAt (nearest first)
*/
findActiveByGuardianId(guardianId: string): Promise<AllowanceSchedule[]> {
return this.allowanceScheduleRepository.find({
where: { guardianId, status: AllowanceScheduleStatus.ON },
order: { nextRunAt: 'ASC' },
});
}
createSchedule(guardianId: string, juniorId: string, body: CreateAllowanceScheduleRequestDto, nextRunAt: Date) {
return this.allowanceScheduleRepository.save(
this.allowanceScheduleRepository.create({
guardianId,
juniorId,
amount: body.amount,
frequency: body.frequency,
status: body.status,
nextRunAt,
}),
);
}
findDueSchedulesBatch(
limit: number,
cursor?: { nextRunAt: Date; id: string },
): Promise<AllowanceSchedule[]> {
const query = this.allowanceScheduleRepository
.createQueryBuilder('schedule')
.where('schedule.status = :status', { status: AllowanceScheduleStatus.ON })
.andWhere('schedule.nextRunAt <= :now', { now: new Date() });
if (cursor) {
query.andWhere(
'(schedule.nextRunAt > :cursorDate OR (schedule.nextRunAt = :cursorDate AND schedule.id > :cursorId))',
{
cursorDate: cursor.nextRunAt,
cursorId: cursor.id,
},
);
}
return query
.orderBy('schedule.nextRunAt', 'ASC')
.addOrderBy('schedule.id', 'ASC')
.take(limit)
.getMany();
}
findById(id: string): Promise<AllowanceSchedule | null> {
return this.allowanceScheduleRepository.findOne({ where: { id } });
}
findByIdAndGuardian(id: string, guardianId: string): Promise<AllowanceSchedule | null> {
return this.allowanceScheduleRepository.findOne({ where: { id, guardianId } });
}
updateScheduleRun(id: string, lastRunAt: Date, nextRunAt: Date) {
return this.allowanceScheduleRepository.update({ id }, { lastRunAt, nextRunAt });
}
async updateSchedule(schedule: AllowanceSchedule): Promise<AllowanceSchedule> {
return this.allowanceScheduleRepository.save(schedule);
}
async deleteById(id: string): Promise<void> {
await this.allowanceScheduleRepository.delete({ id });
}
}
-2
View File
@@ -1,2 +0,0 @@
export * from './allowance-credit.repository';
export * from './allowance-schedule.repository';
@@ -1,90 +0,0 @@
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AmqpConnectionManager, ChannelWrapper, connect } from 'amqp-connection-manager';
import {
ALLOWANCE_DLQ_EXCHANGE,
ALLOWANCE_DLQ_NAME,
ALLOWANCE_QUEUE_NAME,
ALLOWANCE_RETRY_EXCHANGE,
ALLOWANCE_RETRY_QUEUE_NAME,
} from '../constants';
@Injectable()
export class AllowanceQueueService implements OnModuleDestroy {
private readonly logger = new Logger(AllowanceQueueService.name);
private connection?: AmqpConnectionManager;
private channel?: ChannelWrapper;
private readonly queueName: string;
private readonly rabbitUrl?: string;
private readonly retryDelayMs: number;
constructor(private readonly configService: ConfigService) {
this.queueName = this.configService.get<string>('ALLOWANCE_QUEUE_NAME') || ALLOWANCE_QUEUE_NAME;
this.rabbitUrl = this.configService.get<string>('RABBITMQ_URL');
this.retryDelayMs = Number(this.configService.get<string>('ALLOWANCE_RETRY_DELAY_MS') || 10 * 60 * 1000);
}
async enqueueSchedule(scheduleId: string, runAt: Date): Promise<void> {
if (!this.rabbitUrl) {
this.logger.warn('RABBITMQ_URL is not set; skipping allowance enqueue.');
return;
}
if (!this.connection || !this.channel) {
this.connection = connect([this.rabbitUrl]);
this.connection.on('connect', () => {
this.logger.log('RabbitMQ connected (publisher).');
});
this.connection.on('disconnect', (params) => {
this.logger.error(`RabbitMQ disconnected (publisher): ${params?.err?.message || 'unknown error'}`);
});
this.channel = this.connection.createChannel({
setup: async (channel: any) => {
await channel.assertExchange(ALLOWANCE_RETRY_EXCHANGE, 'direct', { durable: true });
await channel.assertExchange(ALLOWANCE_DLQ_EXCHANGE, 'direct', { durable: true });
await channel.assertQueue(ALLOWANCE_RETRY_QUEUE_NAME, {
durable: true,
messageTtl: this.retryDelayMs,
deadLetterExchange: '',
deadLetterRoutingKey: this.queueName,
});
await channel.assertQueue(ALLOWANCE_DLQ_NAME, {
durable: true,
});
await channel.bindQueue(ALLOWANCE_RETRY_QUEUE_NAME, ALLOWANCE_RETRY_EXCHANGE, this.queueName);
await channel.bindQueue(ALLOWANCE_DLQ_NAME, ALLOWANCE_DLQ_EXCHANGE, this.queueName);
await channel.assertQueue(this.queueName, {
durable: true,
deadLetterExchange: ALLOWANCE_RETRY_EXCHANGE,
deadLetterRoutingKey: this.queueName,
});
},
});
}
const payload = {
scheduleId,
runAt: runAt.toISOString(),
};
const messageId = `allowance:${scheduleId}:${runAt.toISOString()}`;
const options: any = {
persistent: true,
messageId,
contentType: 'application/json',
};
this.logger.log(`Enqueueing allowance job - scheduleId: ${scheduleId}, runAt: ${runAt.toISOString()}`);
await this.channel.sendToQueue(this.queueName, Buffer.from(JSON.stringify(payload)), options);
this.logger.log(`Allowance job enqueued successfully - messageId: ${messageId}`);
}
async onModuleDestroy() {
await this.channel?.close();
await this.connection?.close();
}
}
@@ -1,212 +0,0 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AmqpConnectionManager, ChannelWrapper, connect } from 'amqp-connection-manager';
import moment from 'moment';
import { CardService } from '~/card/services';
import { AllowanceScheduleRepository } from '../repositories/allowance-schedule.repository';
import { AllowanceCreditRepository } from '../repositories/allowance-credit.repository';
import { ALLOWANCE_DLQ_EXCHANGE, ALLOWANCE_QUEUE_NAME, ALLOWANCE_RETRY_EXCHANGE } from '../constants';
import { AllowanceFrequency, AllowanceScheduleStatus } from '../enums';
type AllowanceQueuePayload = {
scheduleId: string;
runAt: string;
};
@Injectable()
export class AllowanceWorkerService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(AllowanceWorkerService.name);
private connection?: AmqpConnectionManager;
private channel?: ChannelWrapper;
private readonly queueName: string;
private readonly rabbitUrl?: string;
private readonly maxRetries: number;
private readonly isTestMode: boolean;
constructor(
private readonly configService: ConfigService,
private readonly allowanceScheduleRepository: AllowanceScheduleRepository,
private readonly allowanceCreditRepository: AllowanceCreditRepository,
private readonly cardService: CardService,
) {
this.queueName = this.configService.get<string>('ALLOWANCE_QUEUE_NAME') || ALLOWANCE_QUEUE_NAME;
this.rabbitUrl = this.configService.get<string>('RABBITMQ_URL');
this.maxRetries = Number(this.configService.get<string>('ALLOWANCE_MAX_RETRIES') || 5);
this.isTestMode = this.configService.get<string>('ALLOWANCE_TEST_MODE') === 'true';
if (this.isTestMode) {
this.logger.warn('ALLOWANCE_TEST_MODE is enabled - using short intervals (5/10/15 min)');
}
}
async onModuleInit() {
if (!this.rabbitUrl) {
this.logger.warn('RABBITMQ_URL is not set; allowance worker is disabled.');
return;
}
this.connection = connect([this.rabbitUrl]);
this.connection.on('connect', () => {
this.logger.log('RabbitMQ connected (worker).');
});
this.connection.on('disconnect', (params) => {
this.logger.error(`RabbitMQ disconnected (worker): ${params?.err?.message || 'unknown error'}`);
});
this.channel = this.connection.createChannel({
setup: async (channel: any) => {
await channel.assertQueue(this.queueName, {
durable: true,
deadLetterExchange: ALLOWANCE_RETRY_EXCHANGE,
deadLetterRoutingKey: this.queueName,
});
await channel.prefetch(10);
await channel.consume(this.queueName, (msg: any) => this.handleMessage(channel, msg), {
noAck: false,
});
},
});
}
async onModuleDestroy() {
await this.channel?.close();
await this.connection?.close();
}
private async handleMessage(channel: any, msg: any) {
if (!msg) {
return;
}
let payload: AllowanceQueuePayload;
try {
payload = JSON.parse(msg.content.toString()) as AllowanceQueuePayload;
} catch (error) {
const stack = error instanceof Error ? error.stack : undefined;
this.logger.error('Invalid allowance queue payload', stack || error);
channel.ack(msg);
return;
}
try {
await this.processAllowanceJob(payload);
channel.ack(msg);
} catch (error) {
const stack = error instanceof Error ? error.stack : undefined;
this.logger.error(`Allowance job failed for schedule ${payload.scheduleId}`, stack || error);
const retryCount = this.getRetryCount(msg);
if (retryCount >= this.maxRetries) {
this.logger.error(`Allowance job exceeded max retries (${this.maxRetries}). Sending to DLQ.`);
channel.publish(ALLOWANCE_DLQ_EXCHANGE, this.queueName, msg.content, {
contentType: msg.properties.contentType,
messageId: msg.properties.messageId,
headers: msg.properties.headers,
persistent: true,
});
channel.ack(msg);
return;
}
channel.nack(msg, false, false);
}
}
private async processAllowanceJob(payload: AllowanceQueuePayload): Promise<void> {
const runAt = new Date(payload.runAt);
if (!payload.scheduleId || Number.isNaN(runAt.getTime())) {
this.logger.warn(`Invalid payload - scheduleId: ${payload.scheduleId}, runAt: ${payload.runAt}`);
return;
}
this.logger.log(`Processing allowance job - scheduleId: ${payload.scheduleId}, runAt: ${runAt.toISOString()}`);
const schedule = await this.allowanceScheduleRepository.findById(payload.scheduleId);
if (!schedule) {
this.logger.warn(`Schedule not found: ${payload.scheduleId}`);
return;
}
this.logger.debug(`Schedule found - juniorId: ${schedule.juniorId}, amount: ${schedule.amount}, status: ${schedule.status}, nextRunAt: ${schedule.nextRunAt}`);
if (schedule.status !== AllowanceScheduleStatus.ON) {
this.logger.warn(`Schedule ${payload.scheduleId} is not ON (status: ${schedule.status}). Skipping.`);
return;
}
if (schedule.nextRunAt > runAt) {
this.logger.warn(`Schedule ${payload.scheduleId} nextRunAt (${schedule.nextRunAt}) > runAt (${runAt}). Skipping.`);
return;
}
// Convert amount from decimal string to number
const amount = Number(schedule.amount);
if (isNaN(amount) || amount <= 0) {
this.logger.error(`Invalid amount for schedule ${payload.scheduleId}: ${schedule.amount}`);
return;
}
this.logger.log(`Creating allowance credit - scheduleId: ${payload.scheduleId}, amount: ${amount}`);
let credit = null;
try {
credit = await this.allowanceCreditRepository.createCredit(schedule.id, amount, runAt);
this.logger.log(`Credit created: ${credit.id}`);
} catch (error: any) {
if (error?.code === '23505') {
this.logger.warn(`Credit already exists for schedule ${payload.scheduleId} at ${runAt.toISOString()} (idempotency check)`);
return;
}
throw error;
}
try {
this.logger.log(`Transferring ${amount} to junior ${schedule.juniorId}`);
await this.cardService.transferToChild(schedule.juniorId, amount);
this.logger.log(`Transfer successful for junior ${schedule.juniorId}`);
const nextRunAt = this.computeNextRunAt(schedule.frequency);
await this.allowanceScheduleRepository.updateScheduleRun(schedule.id, runAt, nextRunAt);
this.logger.log(`Schedule ${payload.scheduleId} updated - lastRunAt: ${runAt.toISOString()}, nextRunAt: ${nextRunAt.toISOString()}`);
} catch (error) {
this.logger.error(`Transfer failed for schedule ${payload.scheduleId}: ${error instanceof Error ? error.message : error}`);
if (credit) {
await this.allowanceCreditRepository.deleteById(credit.id);
this.logger.log(`Credit ${credit.id} deleted due to transfer failure`);
}
throw error;
}
}
private computeNextRunAt(frequency: AllowanceFrequency): Date {
const base = moment();
if (this.isTestMode) {
// Test mode: DAILY=5min, WEEKLY=10min, MONTHLY=15min
switch (frequency) {
case AllowanceFrequency.DAILY:
return base.add(5, 'minutes').toDate();
case AllowanceFrequency.WEEKLY:
return base.add(10, 'minutes').toDate();
case AllowanceFrequency.MONTHLY:
return base.add(15, 'minutes').toDate();
default:
return base.toDate();
}
}
// Production mode: real intervals
switch (frequency) {
case AllowanceFrequency.DAILY:
return base.add(1, 'day').toDate();
case AllowanceFrequency.WEEKLY:
return base.add(1, 'week').toDate();
case AllowanceFrequency.MONTHLY:
return base.add(1, 'month').toDate();
default:
return base.toDate();
}
}
private getRetryCount(msg: any): number {
const deaths = (msg.properties.headers?.['x-death'] as Array<{ count?: number }> | undefined) || [];
const retryDeath = deaths.find((death) => death?.count != null);
return retryDeath?.count ?? 0;
}
}
-213
View File
@@ -1,213 +0,0 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import moment from 'moment';
import { Junior } from '~/junior/entities';
import { JuniorService } from '~/junior/services';
import { CreateAllowanceScheduleRequestDto, UpdateAllowanceScheduleRequestDto } from '../dtos/request';
import { AllowanceSchedule } from '../entities/allowance-schedule.entity';
import { AllowanceFrequency, AllowanceScheduleStatus } from '../enums';
import { AllowanceSchedulesGrouped, AllowanceSummary } from '../interfaces';
import { AllowanceScheduleRepository } from '../repositories';
@Injectable()
export class AllowanceService {
private readonly logger = new Logger(AllowanceService.name);
private readonly isTestMode: boolean;
constructor(
private readonly allowanceScheduleRepository: AllowanceScheduleRepository,
private readonly juniorService: JuniorService,
private readonly configService: ConfigService,
) {
this.isTestMode = this.configService.get<string>('ALLOWANCE_TEST_MODE') === 'true';
if (this.isTestMode) {
this.logger.warn('ALLOWANCE_TEST_MODE is enabled - using short intervals (5/10/15 min)');
}
}
/**
* Gets all allowance schedules for a guardian, grouped by juniors with and without schedules.
*/
async getSchedulesByGuardian(guardianId: string): Promise<AllowanceSchedulesGrouped> {
// Fetch all juniors for this guardian (with pagination workaround - large size)
const [juniors] = await this.juniorService.findJuniorsByGuardianId(guardianId, {
page: 1,
size: 1000, // Assuming no guardian has more than 1000 children
});
// Fetch all schedules for this guardian
const schedules = await this.allowanceScheduleRepository.findByGuardianId(guardianId);
// Create a map of juniorId -> schedule for quick lookup
const scheduleMap = new Map<string, AllowanceSchedule>();
for (const schedule of schedules) {
scheduleMap.set(schedule.juniorId, schedule);
}
// Separate juniors into two groups
const withSchedule: { junior: Junior; schedule: AllowanceSchedule }[] = [];
const withoutSchedule: Junior[] = [];
for (const junior of juniors) {
const schedule = scheduleMap.get(junior.id);
if (schedule) {
withSchedule.push({ junior, schedule });
} else {
withoutSchedule.push(junior);
}
}
const monthlyTotal = this.calculateMonthlyTotal(schedules);
return { withSchedule, withoutSchedule, monthlyTotal };
}
/**
* Calculates the monthly equivalent total for all active schedules.
* - DAILY: amount * 30
* - WEEKLY: amount * 4
* - MONTHLY: amount * 1
*/
private calculateMonthlyTotal(schedules: AllowanceSchedule[]): number {
return schedules
.filter((s) => s.status === AllowanceScheduleStatus.ON)
.reduce((total, schedule) => {
const amount = Number(schedule.amount);
switch (schedule.frequency) {
case AllowanceFrequency.DAILY:
return total + amount * 30;
case AllowanceFrequency.WEEKLY:
return total + amount * 4;
case AllowanceFrequency.MONTHLY:
return total + amount;
default:
return total;
}
}, 0);
}
async createSchedule(
guardianId: string,
juniorId: string,
body: CreateAllowanceScheduleRequestDto,
): Promise<AllowanceSchedule> {
const doesBelong = await this.juniorService.doesJuniorBelongToGuardian(guardianId, juniorId);
if (!doesBelong) {
this.logger.error(`Junior ${juniorId} does not belong to guardian ${guardianId}`);
throw new BadRequestException('JUNIOR.NOT_BELONG_TO_GUARDIAN');
}
const existingSchedule = await this.allowanceScheduleRepository.findByGuardianAndJunior(guardianId, juniorId);
if (existingSchedule) {
throw new BadRequestException('ALLOWANCE.ALREADY_EXISTS');
}
const nextRunAt = this.computeNextRunAt(body.frequency, body.status);
return this.allowanceScheduleRepository.createSchedule(guardianId, juniorId, body, nextRunAt);
}
private computeNextRunAt(frequency: AllowanceFrequency, status: AllowanceScheduleStatus): Date {
const base = moment();
if (status === AllowanceScheduleStatus.OFF) {
return base.toDate();
}
if (this.isTestMode) {
// Test mode: DAILY=5min, WEEKLY=10min, MONTHLY=15min
switch (frequency) {
case AllowanceFrequency.DAILY:
return base.add(5, 'minutes').toDate();
case AllowanceFrequency.WEEKLY:
return base.add(10, 'minutes').toDate();
case AllowanceFrequency.MONTHLY:
return base.add(15, 'minutes').toDate();
default:
return base.toDate();
}
}
// Production mode: real intervals
switch (frequency) {
case AllowanceFrequency.DAILY:
return base.add(1, 'day').toDate();
case AllowanceFrequency.WEEKLY:
return base.add(1, 'week').toDate();
case AllowanceFrequency.MONTHLY:
return base.add(1, 'month').toDate();
default:
return base.toDate();
}
}
/**
* Updates an existing allowance schedule.
* Recalculates nextRunAt if frequency or status changes.
*/
async updateSchedule(
guardianId: string,
scheduleId: string,
body: UpdateAllowanceScheduleRequestDto,
): Promise<AllowanceSchedule> {
const schedule = await this.allowanceScheduleRepository.findByIdAndGuardian(scheduleId, guardianId);
if (!schedule) {
this.logger.error(`Schedule ${scheduleId} not found for guardian ${guardianId}`);
throw new NotFoundException('ALLOWANCE.NOT_FOUND');
}
// Check if frequency or status is changing (need to recalculate nextRunAt)
const frequencyChanged = body.frequency && body.frequency !== schedule.frequency;
const statusChanged = body.status && body.status !== schedule.status;
// Update fields if provided
if (body.amount !== undefined) {
schedule.amount = body.amount;
}
if (body.frequency !== undefined) {
schedule.frequency = body.frequency;
}
if (body.status !== undefined) {
schedule.status = body.status;
}
// Recalculate nextRunAt if frequency or status changed
if (frequencyChanged || statusChanged) {
schedule.nextRunAt = this.computeNextRunAt(schedule.frequency, schedule.status);
}
this.logger.log(`Updating schedule ${scheduleId} for guardian ${guardianId}`);
return this.allowanceScheduleRepository.updateSchedule(schedule);
}
/**
* Gets a lightweight summary of allowances for the home page.
* Only fetches active schedules for efficiency.
*/
async getSummary(guardianId: string): Promise<AllowanceSummary> {
// Only fetch active schedules, ordered by nextRunAt (nearest first)
const activeSchedules = await this.allowanceScheduleRepository.findActiveByGuardianId(guardianId);
// The first one is the nearest (already sorted by nextRunAt ASC)
const nextPaymentAt = activeSchedules.length > 0 ? activeSchedules[0].nextRunAt : null;
// Calculate monthly total from active schedules
const monthlyTotal = this.calculateMonthlyTotal(activeSchedules);
return { nextPaymentAt, monthlyTotal };
}
/**
* Deletes an allowance schedule.
*/
async deleteSchedule(guardianId: string, scheduleId: string): Promise<void> {
const schedule = await this.allowanceScheduleRepository.findByIdAndGuardian(scheduleId, guardianId);
if (!schedule) {
this.logger.error(`Schedule ${scheduleId} not found for guardian ${guardianId}`);
throw new NotFoundException('ALLOWANCE.NOT_FOUND');
}
this.logger.log(`Deleting schedule ${scheduleId} for guardian ${guardianId}`);
await this.allowanceScheduleRepository.deleteById(scheduleId);
}
}
-3
View File
@@ -1,3 +0,0 @@
export * from './allowance-queue.service';
export * from './allowance.service';
export * from './allowance-worker.service';
-99
View File
@@ -1,99 +0,0 @@
import { MiddlewareConsumer, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
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 { I18nMiddleware, I18nModule } from 'nestjs-i18n';
import { LoggerModule } from 'nestjs-pino';
import { DataSource } from 'typeorm';
import { addTransactionalDataSource } from 'typeorm-transactional';
import { AuthModule } from './auth/auth.module';
import { AllowanceModule } from './allowance/allowance.module';
import { CardModule } from './card/card.module';
import { CacheModule } from './common/modules/cache/cache.module';
import { LookupModule } from './common/modules/lookup/lookup.module';
import { NeoLeapModule } from './common/modules/neoleap/neoleap.module';
import { NotificationModule } from './common/modules/notification/notification.module';
import { OtpModule } from './common/modules/otp/otp.module';
import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters';
import { buildConfigOptions, buildLoggerOptions, buildTypeormOptions } from './core/module-options';
import { buildI18nOptions } from './core/module-options/i18n-options';
import { buildValidationPipe } from './core/pipes';
import { CronModule } from './cron/cron.module';
import { CustomerModule } from './customer/customer.module';
import { migrations } from './db';
import { DocumentModule } from './document/document.module';
import { GuardianModule } from './guardian/guardian.module';
import { HealthModule } from './health/health.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({
controllers: [],
imports: [
ConfigModule.forRoot(buildConfigOptions()),
TypeOrmModule.forRootAsync({
imports: [],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
return buildTypeormOptions(config, migrations);
},
async dataSourceFactory(options) {
if (!options) {
throw new Error('Invalid options passed');
}
return addTransactionalDataSource(new DataSource(options));
},
}),
LoggerModule.forRootAsync({
useFactory: (config: ConfigService) => buildLoggerOptions(config),
inject: [ConfigService],
}),
I18nModule.forRoot(buildI18nOptions()),
CacheModule,
EventEmitterModule.forRoot(),
ScheduleModule.forRoot(),
// App modules
AuthModule,
AllowanceModule,
UserModule,
CustomerModule,
JuniorModule,
GuardianModule,
CardModule,
NotificationModule,
OtpModule,
DocumentModule,
LookupModule,
HealthModule,
CronModule,
NeoLeapModule,
WebhookModule,
MoneyRequestModule,
],
providers: [
// Global Pipes
{
inject: [ConfigService],
provide: APP_PIPE,
useFactory: (config: ConfigService) => buildValidationPipe(config),
},
// Global Filters (order matters)
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
{ provide: APP_FILTER, useValue: buildI18nValidationExceptionFilter() },
],
})
export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(I18nMiddleware).forRoutes('*');
}
}
-16
View File
@@ -1,16 +0,0 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { JuniorModule } from '~/junior/junior.module';
import { UserModule } from '~/user/user.module';
import { AuthController } from './controllers';
import { AuthService } from './services';
import { AccessTokenStrategy } from './strategies';
@Module({
imports: [JwtModule.register({}), UserModule, JuniorModule, HttpModule],
providers: [AuthService, AccessTokenStrategy],
controllers: [AuthController],
exports: [],
})
export class AuthModule {}
@@ -1 +0,0 @@
export const COUNTRY_CODE_REGEX = /^\+\d{1,3}$/;
-3
View File
@@ -1,3 +0,0 @@
export * from './country-code-regex.constant.';
export * from './passcode-regext.constant';
export * from './password-regex.constant';
@@ -1 +0,0 @@
export const PASSCODE_REGEX = /^\d{6}$/;
@@ -1 +0,0 @@
export const PASSWORD_REGEX = /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[@$!%*?&#\-_])[A-Za-z\d@$!%*?&#\-_]{8,}$/;
-107
View File
@@ -1,107 +0,0 @@
import { Body, Controller, HttpCode, HttpStatus, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { AuthenticatedUser, Public } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards';
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import {
ChangePasswordRequestDto,
CreateUnverifiedUserRequestDto,
ForgetPasswordRequestDto,
JuniorLoginRequestDto,
LoginRequestDto,
RefreshTokenRequestDto,
SendForgetPasswordOtpRequestDto,
setJuniorPasswordRequestDto,
VerifyForgetPasswordOtpRequestDto,
VerifyUserRequestDto,
} from '../dtos/request';
import { SendForgetPasswordOtpResponseDto, SendRegisterOtpResponseDto } from '../dtos/response';
import { LoginResponseDto } from '../dtos/response/login.response.dto';
import { VerifyForgetPasswordOtpResponseDto } from '../dtos/response/verify-forget-password-otp.response.dto';
import { IJwtPayload } from '../interfaces';
import { AuthService } from '../services';
@Controller('auth')
@ApiTags('Auth')
@ApiBearerAuth()
@ApiLangRequestHeader()
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register/otp')
async register(@Body() createUnverifiedUserDto: CreateUnverifiedUserRequestDto) {
const phoneNumber = await this.authService.sendRegisterOtp(createUnverifiedUserDto);
return ResponseFactory.data(new SendRegisterOtpResponseDto(phoneNumber));
}
@Post('register/verify')
async verifyUser(@Body() verifyUserDto: VerifyUserRequestDto) {
const [res, user] = await this.authService.verifyUser(verifyUserDto);
return ResponseFactory.data(new LoginResponseDto(res, user));
}
@Post('login')
async login(@Body() verifyUserDto: LoginRequestDto) {
const [res, user] = await this.authService.loginWithPassword(verifyUserDto);
return ResponseFactory.data(new LoginResponseDto(res, user));
}
@Post('forget-password/otp')
async forgetPassword(@Body() sendForgetPasswordOtpDto: SendForgetPasswordOtpRequestDto) {
const maskedNumber = await this.authService.sendForgetPasswordOtp(sendForgetPasswordOtpDto);
return ResponseFactory.data(new SendForgetPasswordOtpResponseDto(maskedNumber));
}
@Post('forget-password/verify')
@HttpCode(HttpStatus.OK)
@ApiDataResponse(VerifyForgetPasswordOtpResponseDto)
async verifyForgetPasswordOtp(@Body() forgetPasswordDto: VerifyForgetPasswordOtpRequestDto) {
const { token, user } = await this.authService.verifyForgetPasswordOtp(forgetPasswordDto);
return ResponseFactory.data(new VerifyForgetPasswordOtpResponseDto(token, user));
}
@Post('forget-password/reset')
@HttpCode(HttpStatus.NO_CONTENT)
resetPassword(@Body() forgetPasswordDto: ForgetPasswordRequestDto) {
return this.authService.resetPassword(forgetPasswordDto);
}
@Post('change-password')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard)
changePassword(@AuthenticatedUser() { sub }: IJwtPayload, @Body() forgetPasswordDto: ChangePasswordRequestDto) {
return this.authService.changePassword(sub, forgetPasswordDto);
}
@Post('junior/set-password')
@HttpCode(HttpStatus.NO_CONTENT)
@Public()
setJuniorPasscode(@Body() setPassworddto: setJuniorPasswordRequestDto) {
return this.authService.setJuniorPassword(setPassworddto);
}
@Post('junior/login')
@HttpCode(HttpStatus.OK)
@ApiDataResponse(LoginResponseDto)
async juniorLogin(@Body() juniorLoginDto: JuniorLoginRequestDto) {
const [res, user] = await this.authService.juniorLogin(juniorLoginDto);
return ResponseFactory.data(new LoginResponseDto(res, user));
}
@Post('refresh-token')
@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);
}
}
-1
View File
@@ -1 +0,0 @@
export * from './auth.controller';
@@ -1,23 +0,0 @@
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;
}
@@ -1,4 +0,0 @@
import { OmitType } from '@nestjs/swagger';
import { VerifyUserRequestDto } from './verify-user.request.dto';
export class CreateUnverifiedUserRequestDto extends OmitType(VerifyUserRequestDto, ['otp']) {}
@@ -1,34 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, Matches } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { COUNTRY_CODE_REGEX, PASSWORD_REGEX } from '~/auth/constants';
import { IsValidPhoneNumber } from '~/core/decorators/validations';
export class ForgetPasswordRequestDto {
@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: 'Abcd1234@' })
@Matches(PASSWORD_REGEX, {
message: i18n('validation.Matches', { path: 'general', property: 'auth.password' }),
})
password!: string;
@ApiProperty({ example: 'Abcd1234@' })
@Matches(PASSWORD_REGEX, {
message: i18n('validation.Matches', { path: 'general', property: 'auth.confirmPassword' }),
})
confirmPassword!: string;
@ApiProperty({ example: 'reset-token-32423123' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.resetPasswordToken' }) })
resetPasswordToken!: string;
}
-11
View File
@@ -1,11 +0,0 @@
export * from './change-password.request.dto';
export * from './create-unverified-user.request.dto';
export * from './forget-password.request.dto';
export * from './junior-login.request.dto';
export * from './login.request.dto';
export * from './refresh-token.request.dto';
export * from './send-forget-password-otp.request.dto';
export * from './set-junior-password.request.dto';
export * from './verify-forget-password-otp.request.dto';
export * from './verify-otp.request.dto';
export * from './verify-user.request.dto';
@@ -1,35 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsOptional, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class JuniorLoginRequestDto {
@ApiProperty({ example: '[email protected]' })
@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;
@ApiProperty({ example: 'device-123', description: 'Unique device identifier', required: false })
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) })
deviceId?: string;
@ApiProperty({
example: 'cXYzABC:APA91bHunvwY7rKpn8N7y6vDxS0qmQ5RZx2C8K...',
description: 'Firebase Cloud Messaging token for push notifications',
required: false,
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) })
fcmToken?: string;
@ApiProperty({
example: 'Asia/Riyadh',
description: 'Device timezone (auto-detected from device OS)',
required: false,
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.timezone' }) })
timezone?: string;
}
@@ -1,47 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, Matches, ValidateIf } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { COUNTRY_CODE_REGEX } from '~/auth/constants';
import { GrantType } from '~/auth/enums';
import { IsValidPhoneNumber } from '~/core/decorators/validations';
export class LoginRequestDto {
@ApiProperty({ example: '+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: 'Abcd1234@' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) })
@ValidateIf((o) => o.grantType === GrantType.PASSWORD)
password!: string;
@ApiProperty({ example: 'device-123', description: 'Unique device identifier', required: false })
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) })
deviceId?: string;
@ApiProperty({
example: 'cXYzABC:APA91bHunvwY7rKpn8N7y6vDxS0qmQ5RZx2C8K...',
description: 'Firebase Cloud Messaging token for push notifications',
required: false,
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) })
fcmToken?: string;
@ApiProperty({
example: 'Asia/Riyadh',
description: 'Device timezone (auto-detected from device OS)',
required: false,
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.timezone' }) })
timezone?: string;
}
@@ -1,9 +0,0 @@
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;
}
@@ -1,4 +0,0 @@
import { PickType } from '@nestjs/swagger';
import { LoginRequestDto } from './login.request.dto';
export class SendForgetPasswordOtpRequestDto extends PickType(LoginRequestDto, ['countryCode', 'phoneNumber']) {}
@@ -1,13 +0,0 @@
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;
}
@@ -1,23 +0,0 @@
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;
}
@@ -1,19 +0,0 @@
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;
}
@@ -1,127 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsEnum,
IsNotEmpty,
IsNumberString,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { COUNTRY_CODE_REGEX, PASSWORD_REGEX } from '~/auth/constants';
import { CountryIso } from '~/common/enums';
import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants';
import { 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: 'JO' })
@IsEnum(CountryIso, {
message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }),
})
@IsOptional()
countryOfResidence: CountryIso = CountryIso.SAUDI_ARABIA;
// Address fields (optional during registration, required for card creation)
@ApiProperty({ example: 'SA', description: 'Country code', required: false })
@IsEnum(CountryIso, {
message: i18n('validation.IsEnum', { path: 'general', property: 'customer.country' }),
})
@IsOptional()
country?: CountryIso;
@ApiProperty({ example: 'Riyadh', description: 'Region/Province', required: false })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.region' }) })
@IsOptional()
region?: string;
@ApiProperty({ example: 'Riyadh', description: 'City', required: false })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.city' }) })
@IsOptional()
city?: string;
@ApiProperty({ example: 'Al Olaya', description: 'Neighborhood/District', required: false })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.neighborhood' }) })
@IsOptional()
neighborhood?: string;
@ApiProperty({ example: 'King Fahd Road', description: 'Street name', required: false })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.street' }) })
@IsOptional()
street?: string;
@ApiProperty({ example: '123', description: 'Building number', required: false })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.building' }) })
@IsOptional()
building?: string;
@ApiProperty({ example: 'Abcd1234@' })
@Matches(PASSWORD_REGEX, {
message: i18n('validation.Matches', { path: 'general', property: 'auth.password' }),
})
password!: string;
@ApiProperty({ example: 'Abcd1234@' })
@Matches(PASSWORD_REGEX, {
message: i18n('validation.Matches', { path: 'general', property: 'auth.confirmPassword' }),
})
confirmPassword!: string;
@ApiProperty({ example: '111111' })
@IsNumberString(
{ no_symbols: true },
{ message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.otp' }) },
)
@MaxLength(DEFAULT_OTP_LENGTH, {
message: i18n('validation.MaxLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }),
})
@MinLength(DEFAULT_OTP_LENGTH, {
message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }),
})
otp!: string;
@ApiProperty({ example: 'device-123', description: 'Unique device identifier', required: false })
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) })
deviceId?: string;
@ApiProperty({
example: 'cXYzABC:APA91bHunvwY7rKpn8N7y6vDxS0qmQ5RZx2C8K...',
description: 'Firebase Cloud Messaging token for push notifications',
required: false,
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) })
fcmToken?: string;
@ApiProperty({
example: 'Asia/Riyadh',
description: 'Device timezone (auto-detected from device OS)',
required: false,
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.timezone' }) })
timezone?: string;
}
-4
View File
@@ -1,4 +0,0 @@
export * from './send-forget-password.response.dto';
export * from './send-register-otp.response.dto';
export * from './user.response.dto';
export * from './verify-user.response.dto';
@@ -1,30 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { ILoginResponse } from '~/auth/interfaces';
import { CustomerResponseDto } from '~/customer/dtos/response';
import { User } from '~/user/entities';
import { UserResponseDto } from './user.response.dto';
export class LoginResponseDto {
@ApiProperty()
accessToken!: string;
@ApiProperty()
refreshToken!: string;
@ApiProperty()
expiresAt!: Date;
@ApiProperty({ example: UserResponseDto })
user!: UserResponseDto;
@ApiProperty({ type: CustomerResponseDto })
customer!: CustomerResponseDto | null;
constructor(IVerifyUserResponse: ILoginResponse, user: User) {
this.user = new UserResponseDto(user);
this.customer = user.customer ? new CustomerResponseDto(user.customer) : null;
this.accessToken = IVerifyUserResponse.accessToken;
this.refreshToken = IVerifyUserResponse.refreshToken;
this.expiresAt = IVerifyUserResponse.expiresAt;
}
}
@@ -1,7 +0,0 @@
export class SendForgetPasswordOtpResponseDto {
maskedNumber!: string;
constructor(maskedNumber: string) {
this.maskedNumber = maskedNumber;
}
}
@@ -1,10 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
export class SendRegisterOtpResponseDto {
@ApiProperty()
maskedNumber!: string;
constructor(maskedNumber: string) {
this.maskedNumber = maskedNumber;
}
}
@@ -1,10 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
export class SendRegisterOtpV2ResponseDto {
@ApiProperty()
maskedNumber!: string;
constructor(maskedNumber: string) {
this.maskedNumber = maskedNumber;
}
}
@@ -1,54 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Gender } from '~/customer/enums';
import { DocumentMetaResponseDto } from '~/document/dtos/response';
import { User } from '~/user/entities';
export class UserResponseDto {
@ApiProperty()
id!: string;
@ApiProperty()
countryCode!: string;
@ApiProperty()
phoneNumber!: string;
@ApiProperty()
email!: string;
@ApiProperty()
firstName!: string;
@ApiProperty()
lastName!: string;
@ApiProperty()
dateOfBirth!: Date;
@ApiPropertyOptional({ type: DocumentMetaResponseDto, nullable: true })
profilePicture!: DocumentMetaResponseDto | null;
@ApiProperty()
isPhoneVerified!: boolean;
@ApiProperty()
isEmailVerified!: boolean;
@ApiPropertyOptional({ enum: Gender, nullable: true })
gender!: Gender | null;
constructor(user: User) {
this.id = user.id;
this.countryCode = user.countryCode;
this.phoneNumber = user.phoneNumber;
this.dateOfBirth = user.customer?.dateOfBirth;
this.email = user.email;
this.firstName = user.firstName;
this.lastName = user.lastName;
this.profilePicture = user.profilePicture ? new DocumentMetaResponseDto(user.profilePicture) : null;
this.isEmailVerified = user.isEmailVerified;
this.isPhoneVerified = user.isPhoneVerified;
this.gender = (user.customer?.gender as Gender) || null;
}
}
@@ -1,19 +0,0 @@
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;
}
}
@@ -1,24 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { ILoginResponse } from '~/auth/interfaces';
import { User } from '~/user/entities';
export class VerifyUserResponseDto {
@ApiProperty()
accessToken!: string;
@ApiProperty()
refreshToken!: string;
@ApiProperty()
expiresAt!: Date;
@ApiProperty()
user!: User;
constructor(data: ILoginResponse, user: User) {
this.accessToken = data.accessToken;
this.refreshToken = data.refreshToken;
this.expiresAt = data.expiresAt;
this.user = user;
}
}
-4
View File
@@ -1,4 +0,0 @@
export enum GrantType {
PASSWORD = 'PASSWORD',
BIOMETRIC = 'BIOMETRIC',
}
-2
View File
@@ -1,2 +0,0 @@
export * from './grant-type.enum';
export * from './roles.enum';
-6
View File
@@ -1,6 +0,0 @@
export enum Roles {
JUNIOR = 'JUNIOR',
GUARDIAN = 'GUARDIAN',
CHECKER = 'CHECKER',
SUPER_ADMIN = 'SUPER_ADMIN',
}
-2
View File
@@ -1,2 +0,0 @@
export * from './jwt-payload.interface';
export * from './login-response.interface';
@@ -1,6 +0,0 @@
import { Roles } from '../enums';
export interface IJwtPayload {
sub: string;
roles: Roles[];
}
@@ -1,5 +0,0 @@
export interface ILoginResponse {
accessToken: string;
refreshToken: string;
expiresAt: Date;
}
-398
View File
@@ -1,398 +0,0 @@
import { BadRequestException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { Request } from 'express';
import moment from 'moment';
import { CacheService } from '~/common/modules/cache/services';
import { OtpScope, OtpType } from '~/common/modules/otp/enums';
import { OtpService } from '~/common/modules/otp/services';
import { UserType } from '~/user/enums';
import { DeviceService, UserService, UserTokenService } from '~/user/services';
import { User } from '../../user/entities';
import {
ChangePasswordRequestDto,
CreateUnverifiedUserRequestDto,
ForgetPasswordRequestDto,
JuniorLoginRequestDto,
LoginRequestDto,
SendForgetPasswordOtpRequestDto,
setJuniorPasswordRequestDto,
VerifyForgetPasswordOtpRequestDto,
VerifyUserRequestDto,
} from '../dtos/request';
import { Roles } from '../enums';
import { IJwtPayload, ILoginResponse } from '../interfaces';
const ONE_THOUSAND = 1000;
const SALT_ROUNDS = 10;
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly otpService: OtpService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly userService: UserService,
private readonly deviceService: DeviceService,
private readonly userTokenService: UserTokenService,
private readonly cacheService: CacheService,
) {}
async sendRegisterOtp(body: CreateUnverifiedUserRequestDto) {
if (body.password !== body.confirmPassword) {
this.logger.error('Password and confirm password do not match');
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
}
this.logger.log(`Sending OTP to ${body.countryCode + body.phoneNumber}`);
const user = await this.userService.findOrCreateUser(body);
return this.otpService.generateAndSendOtp({
userId: user.id,
recipient: user.fullPhoneNumber,
scope: OtpScope.VERIFY_PHONE,
otpType: OtpType.SMS,
});
}
async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> {
this.logger.log(`Verifying user with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber}`);
const user = await this.userService.findUserOrThrow({
phoneNumber: verifyUserDto.phoneNumber,
countryCode: verifyUserDto.countryCode,
});
if (user.isPhoneVerified) {
this.logger.error(`User with phone number ${user.fullPhoneNumber} already verified`);
throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_VERIFIED');
}
const isOtpValid = await this.otpService.verifyOtp({
userId: user.id,
scope: OtpScope.VERIFY_PHONE,
otpType: OtpType.SMS,
value: verifyUserDto.otp,
});
if (!isOtpValid) {
this.logger.error(`Invalid OTP for user with phone number ${user.fullPhoneNumber}`);
throw new BadRequestException('OTP.INVALID_OTP');
}
await this.userService.verifyUser(user.id, verifyUserDto);
await user.reload();
const tokens = await this.generateAuthToken(user);
this.logger.log(`User with phone number ${user.fullPhoneNumber} verified successfully`);
// Register/update device with FCM token and timezone if provided
if (verifyUserDto.fcmToken && verifyUserDto.deviceId) {
await this.registerDeviceToken(user.id, verifyUserDto.deviceId, verifyUserDto.fcmToken, verifyUserDto.timezone);
}
return [tokens, user];
}
async sendForgetPasswordOtp({ countryCode, phoneNumber }: SendForgetPasswordOtpRequestDto) {
this.logger.log(`Sending forget password OTP to ${countryCode + phoneNumber}`);
const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber });
return this.otpService.generateAndSendOtp({
userId: user.id,
recipient: user.fullPhoneNumber,
scope: OtpScope.FORGET_PASSWORD,
otpType: OtpType.SMS,
});
}
async verifyForgetPasswordOtp({ countryCode, phoneNumber, otp }: VerifyForgetPasswordOtpRequestDto) {
const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber });
const isOtpValid = await this.otpService.verifyOtp({
userId: user.id,
scope: OtpScope.FORGET_PASSWORD,
otpType: OtpType.SMS,
value: otp,
});
if (!isOtpValid) {
this.logger.error(`Invalid OTP for user with phone number ${user.fullPhoneNumber}`);
throw new BadRequestException('OTP.INVALID_OTP');
}
// generate a token for the user to reset password
const token = await this.userTokenService.generateToken(user.id, moment().add(5, 'minutes').toDate());
return { token, user };
}
async resetPassword({
countryCode,
phoneNumber,
resetPasswordToken,
password,
confirmPassword,
}: ForgetPasswordRequestDto) {
this.logger.log(`Verifying forget password OTP for ${countryCode + phoneNumber}`);
const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber });
await this.userTokenService.validateToken(
resetPasswordToken,
user.roles.includes(Roles.GUARDIAN) ? UserType.GUARDIAN : UserType.JUNIOR,
);
if (password !== confirmPassword) {
this.logger.error('Password and confirm password do not match');
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
}
const isOldPassword = bcrypt.compareSync(password, user.password);
if (isOldPassword) {
this.logger.error(
`New password cannot be the same as the current password for user with phone number ${user.fullPhoneNumber}`,
);
throw new BadRequestException('AUTH.PASSWORD_SAME_AS_CURRENT');
}
const hashedPassword = bcrypt.hashSync(password, user.salt);
await this.userService.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 changePassword(userId: string, { currentPassword, newPassword, confirmNewPassword }: ChangePasswordRequestDto) {
const user = await this.userService.findUserOrThrow({ id: userId });
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) {
this.logger.error(`User not found with phone number ${loginDto.countryCode + loginDto.phoneNumber}`);
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
}
if (!user.password) {
this.logger.error(`Password not set for user with phone number ${loginDto.countryCode + loginDto.phoneNumber}`);
throw new UnauthorizedException('AUTH.PHONE_NUMBER_NOT_VERIFIED');
}
this.logger.log(`validating password for user with phone ${loginDto.countryCode + loginDto.phoneNumber}`);
const isPasswordValid = bcrypt.compareSync(loginDto.password, user.password);
if (!isPasswordValid) {
this.logger.error(`Invalid password for user with phone ${loginDto.countryCode + loginDto.phoneNumber}`);
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
}
const tokens = await this.generateAuthToken(user);
this.logger.log(`Password validated successfully for user`);
// Register/update device with FCM token and timezone if provided
if (loginDto.fcmToken && loginDto.deviceId) {
await this.registerDeviceToken(user.id, loginDto.deviceId, loginDto.fcmToken, loginDto.timezone);
}
return [tokens, user];
}
async juniorLogin(juniorLoginDto: JuniorLoginRequestDto): Promise<[ILoginResponse, User]> {
const user = await this.userService.findUser({ email: juniorLoginDto.email });
if (!user || !user.roles.includes(Roles.JUNIOR)) {
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
}
this.logger.log(`validating password for user with email ${juniorLoginDto.email}`);
const isPasswordValid = bcrypt.compareSync(juniorLoginDto.password, user.password);
if (!isPasswordValid) {
this.logger.error(`Invalid password for user with email ${juniorLoginDto.email}`);
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
}
const tokens = await this.generateAuthToken(user);
this.logger.log(`Password validated successfully for user`);
// Register/update device with FCM token and timezone if provided
if (juniorLoginDto.fcmToken && juniorLoginDto.deviceId) {
await this.registerDeviceToken(user.id, juniorLoginDto.deviceId, juniorLoginDto.fcmToken, juniorLoginDto.timezone);
}
return [tokens, user];
}
/**
* Register or update device with FCM token and timezone
* This method handles:
* 1. Device already exists for this user → Update FCM token and timezone
* 2. Device exists for different user → Transfer device to new user
* 3. Device doesn't exist → Create new device
*/
private async registerDeviceToken(userId: string, deviceId: string, fcmToken: string, timezone?: string): Promise<void> {
try {
this.logger.log(`Registering/updating device ${deviceId} with FCM token for user ${userId}`);
// Step 1: Check if device already exists for this user
const existingDeviceForUser = await this.deviceService.findUserDeviceById(deviceId, userId);
if (existingDeviceForUser) {
// Device exists for this user → Update FCM token, timezone, and last access time
await this.deviceService.updateDevice(deviceId, {
fcmToken,
userId,
timezone, // Update timezone if provided
lastAccessOn: new Date(),
});
this.logger.log(`Device ${deviceId} updated with new FCM token and timezone for user ${userId}`);
return;
}
// Step 2: Check if device exists for any user (different user scenario)
const existingDevice = await this.deviceService.findByDeviceId(deviceId);
if (existingDevice) {
// Device exists for different user → Transfer device to new user
this.logger.log(
`Device ${deviceId} exists for user ${existingDevice.userId}, transferring to user ${userId}`
);
await this.deviceService.updateDevice(deviceId, {
userId,
fcmToken,
timezone, // Update timezone if provided
lastAccessOn: new Date(),
});
this.logger.log(`Device ${deviceId} transferred from user ${existingDevice.userId} to user ${userId}`);
return;
}
// Step 3: Device doesn't exist → Create new device
await this.deviceService.createDevice({
deviceId,
userId,
fcmToken,
timezone, // Store timezone if provided
lastAccessOn: new Date(),
});
this.logger.log(`New device ${deviceId} registered with FCM token for user ${userId}`);
} catch (error) {
// Log error but don't fail the login/signup process
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
this.logger.error(`Failed to register device token for user ${userId}: ${errorMessage}`, errorStack);
}
}
private async generateAuthToken(user: User) {
this.logger.log(`Generating auth token for user with id ${user.id}`);
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.sign(
{ sub: user.id, roles: user.roles },
{
expiresIn: this.configService.getOrThrow('JWT_ACCESS_TOKEN_EXPIRY'),
secret: this.configService.getOrThrow('JWT_ACCESS_TOKEN_SECRET'),
},
),
this.jwtService.sign(
{ sub: user.id, roles: user.roles },
{
expiresIn: this.configService.getOrThrow('JWT_REFRESH_TOKEN_EXPIRY'),
secret: this.configService.getOrThrow('JWT_REFRESH_TOKEN_SECRET'),
},
),
]);
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) };
}
}
-1
View File
@@ -1 +0,0 @@
export * from './auth.service';
@@ -1,27 +0,0 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { UserService } from '~/user/services';
import { IJwtPayload } from '../interfaces';
@Injectable()
export class AccessTokenStrategy extends PassportStrategy(Strategy, 'access-token') {
constructor(configService: ConfigService, private userService: UserService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_ACCESS_TOKEN_SECRET'),
});
}
async validate(payload: IJwtPayload) {
const user = await this.userService.findUser({ id: payload.sub });
if (!user) {
throw new UnauthorizedException();
}
return payload;
}
}
-1
View File
@@ -1 +0,0 @@
export * from './access-token.strategy';
-26
View File
@@ -1,26 +0,0 @@
import * as crypto from 'crypto';
export function verifySignature(
publicKeyBase64: string,
signatureBase64: string,
message: string,
algorithm: 'SHA1' | 'SHA384',
) {
const signatureBuffer = Buffer.from(signatureBase64, 'base64');
const publicKeyPEM = '-----BEGIN PUBLIC KEY-----\n' + publicKeyBase64 + '\n-----END PUBLIC KEY-----';
const verifier = crypto.createVerify(algorithm);
verifier.update(message, 'utf8');
return verifier.verify(
{
key: publicKeyPEM,
padding: crypto.constants.RSA_PKCS1_PADDING,
},
signatureBuffer,
);
}
export function removePadding(originalSignature: string) {
const buffer = Buffer.from(originalSignature, 'base64');
return buffer.toString('base64');
}
-1
View File
@@ -1 +0,0 @@
export * from './crypt';
-33
View File
@@ -1,33 +0,0 @@
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, AccountService],
controllers: [CardsController],
})
export class CardModule {}
-86
View File
@@ -1,86 +0,0 @@
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);
}
}
-1
View File
@@ -1 +0,0 @@
export * from './cards.controller';
@@ -1,9 +0,0 @@
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;
}
-1
View File
@@ -1 +0,0 @@
export * from './fund-iban.request.dto';
@@ -1,10 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
export class AccountIbanResponseDto {
@ApiProperty({ example: 'DE89370400440532013000' })
iban!: string;
constructor(iban: string) {
this.iban = iban;
}
}
@@ -1,66 +0,0 @@
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;
}
}
@@ -1,48 +0,0 @@
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: '[email protected]' })
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;
}
}
@@ -1,16 +0,0 @@
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;
}
@@ -1,17 +0,0 @@
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;
}
}
-15
View File
@@ -1,15 +0,0 @@
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';
@@ -1,16 +0,0 @@
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;
}
}
@@ -1,33 +0,0 @@
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;
}
}
@@ -1,33 +0,0 @@
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;
}
}
@@ -1,33 +0,0 @@
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;
}
}
@@ -1,16 +0,0 @@
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;
}
}
@@ -1,16 +0,0 @@
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;
}

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