Compare commits

..

69 Commits

Author SHA1 Message Date
1f521dfc41 Merge pull request #98 from Zod-Alkhair/feature/allowance-scheduling-cron-queue
feat(allowance): add dead letter exchange to allowance queue configur…
2026-02-04 15:08:27 +03:00
f7f22de65c feat(allowance): add dead letter exchange to allowance queue configuration
- Introduced ALLOWANCE_RETRY_EXCHANGE for handling message retries in the allowance worker service.
- Updated queue assertion to include dead letter exchange and routing key for improved message processing reliability.
2026-02-04 15:05:55 +03:00
0640c8b59a Merge pull request #97 from Zod-Alkhair/feature/allowance-scheduling-cron-queue
feat(allowance): enhance logging in allowance queue and worker services
2026-02-04 11:45:45 +03:00
6e11812925 feat(allowance): enhance logging in allowance queue and worker services
- Added detailed logging for enqueueing allowance jobs, processing jobs, and handling errors.
- Improved validation checks with warnings for invalid payloads and schedules.
- Enhanced logging for credit creation and transfer processes, including success and failure scenarios.
- Updated cron job logging to track the processing of due schedules and completion status.
2026-02-04 11:29:20 +03:00
75e0f14bd9 Merge pull request #96 from Zod-Alkhair/feature/allowance-scheduling-cron-queue
feat(allowance): add delete API and configurable test intervals
2026-02-03 12:36:02 +03:00
4d7549d02e feat(allowance): add delete API and configurable test intervals
- Add DELETE /guardians/me/allowances/:scheduleId endpoint
- Add ALLOWANCE_TEST_MODE env variable for testing intervals:
  - true: DAILY=5min, WEEKLY=10min, MONTHLY=15min
  - false: DAILY=1day, WEEKLY=1week, MONTHLY=1month
- Add deleteById to repository and deleteSchedule to service
2026-02-03 12:34:33 +03:00
64a6cc9ddd Merge pull request #95 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: add profile update notification handling for child users
2026-02-01 14:46:49 +03:00
2ab9554c0c feat: add profile update notification handling for child users
- Implemented logic to skip notifications for child users (roles.JUNIOR) when their profiles are updated, preventing unnecessary notifications to both child and parent.
- Enhanced logging to indicate when notifications are skipped for child users.
2026-02-01 14:44:42 +03:00
a7dee2dc1e Merge pull request #94 from Zod-Alkhair/feature/help-support-faq-lookup
fix: update phone number change instructions in help/support FAQs for…
2026-02-01 14:25:29 +03:00
1822f074c6 fix: update phone number change instructions in help/support FAQs for clarity and localization 2026-02-01 14:24:35 +03:00
95d0f0f4b0 Merge pull request #93 from Zod-Alkhair/feature/allowance-scheduling-cron-queue
feat(allowance): add GET and PATCH endpoints for allowance schedules
2026-02-01 13:22:19 +03:00
799b9b883d feat(allowance): add GET and PATCH endpoints for allowance schedules
- GET /guardians/me/allowances: list all children grouped by schedule status
  - withSchedule: children with configured allowances (flattened response)
  - withoutSchedule: children without allowances
  - monthlyTotal: sum of active schedules converted to monthly equivalent

- GET /guardians/me/allowances/summary: lightweight endpoint for home page
  - nextPaymentAt: nearest upcoming payment date
  - monthlyTotal: monthly equivalent total

- PATCH /guardians/me/allowances/:scheduleId: update existing schedule
  - supports partial updates (amount, frequency, status)
  - recalculates nextRunAt when frequency or status changes

- Added interfaces directory for type definitions
- Added response DTOs with flattened junior + schedule data
2026-02-01 13:21:02 +03:00
8d56a8da0f Merge pull request #92 from Zod-Alkhair/feature/allowance-scheduling-cron-queue
feat: add allowance scheduling with cron, queue, and worker
2026-01-28 16:04:56 +03:00
1a0bd0bf91 feat: add allowance scheduling with cron, queue, and worker 2026-01-28 16:03:41 +03:00
52fb5f3984 Merge pull request #91 from Zod-Alkhair/feature/help-support-faq-lookup
fix build error
2026-01-25 14:13:14 +03:00
db946b9531 fix build error 2026-01-25 14:10:46 +03:00
d34508dca9 Merge pull request #90 from Zod-Alkhair/feature/help-support-faq-lookup
feat(lookup): add localized help/support FAQs
2026-01-25 13:16:36 +03:00
25ede3c9e7 feat(lookup): add localized help/support FAQs 2026-01-25 13:15:59 +03:00
47b825c4b2 Merge pull request #89 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: enhance card service and transaction notification for shared ac…
2026-01-20 16:28:42 +03:00
f5c3b03264 feat: enhance card service and transaction notification for shared account handling
- Updated CardService to differentiate between shared and separate accounts during card control updates, optimizing balance allocation.
- Enhanced TransactionNotificationListener to accurately reflect balance based on account structure for internal transfers and top-ups.
- Improved logging for better traceability of account operations and balance calculations.
2026-01-20 16:27:32 +03:00
6a250efd5e Merge pull request #88 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: enhance transaction notification listener for internal transfer…
2026-01-20 15:21:45 +03:00
a09b84e475 feat: enhance transaction notification listener for internal transfer support
- Updated TransactionNotificationListener to differentiate between internal transfers and external top-ups for child accounts.
- Added new notification scopes and messages for internal transfers from parent to child.
- Improved balance retrieval logic to ensure accurate account balances are displayed in notifications.
- Enhanced localization support by adding relevant keys for internal transfer notifications in both English and Arabic.
2026-01-20 15:20:58 +03:00
604cb7ce25 Merge pull request #87 from Zod-Alkhair/feature/notification-system-fcm-registration
fix the messages
2026-01-20 14:42:03 +03:00
4305c4b75f fix the messages 2026-01-20 14:40:16 +03:00
ef5572440c Merge pull request #86 from Zod-Alkhair/fix/spending-history-junior-id-query
fix: correct junior ID to customer ID mapping in transaction queries
2026-01-20 12:50:34 +03:00
64623c7cea fix: correct junior ID to customer ID mapping in transaction queries
Fixed spending history and related transaction queries that were incorrectly
using juniorId as customerId. The queries now properly join through the
Customer -> Junior relationship to filter by junior ID.

Affected methods:
- getTransactionsForCardWithinDateRange (spending history)
- findTransfersToJunior (transfers list)
- countTransfersToJunior (transfers count)
- findTransactionById (transaction details)

This fixes the spending history endpoint which was returning empty results
due to ID mismatch between Junior entity ID and Customer entity ID.

Performance impact: Minimal (~1-2ms overhead from additional joins on
indexed foreign keys). The queries now return correct results instead of
0 results.
2026-01-20 12:39:10 +03:00
4ca8123a67 Merge pull request #85 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: refine transaction notification listener for improved balance c…
2026-01-14 16:57:36 +03:00
7c9e0f0b51 feat: refine transaction notification listener for improved balance calculations
- Updated TransactionNotificationListener to differentiate between child top-up and spending notifications, ensuring accurate balance retrieval.
- Enhanced error handling and fallback mechanisms for both child and parent account balance fetching.
- Improved logging to provide detailed insights into balance calculations, including available balance after accounting for reserved amounts.
2026-01-14 16:56:22 +03:00
e734060c52 Merge pull request #84 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: improve transaction notification listener for accurate balance …
2026-01-14 16:33:05 +03:00
1086166e04 feat: improve transaction notification listener for accurate balance retrieval
- Enhanced TransactionNotificationListener to fetch updated balances for child and parent accounts by bypassing entity cache.
- Implemented error handling and fallback mechanisms to ensure reliable balance notifications.
- Updated logging for better traceability of balance fetching processes.
2026-01-14 16:31:47 +03:00
5e6c8d96de Merge pull request #83 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: add timezone support to user and device entities
2026-01-14 16:12:59 +03:00
6d6dc1471f feat: add timezone support to user and device entities
- Introduced optional timezone fields in User and Device entities to store user preferences and device timezones.
- Updated request DTOs for login and user updates to include timezone information.
- Enhanced AuthService to handle timezone during device registration and updates.
- Added migration to incorporate timezone fields in the database schema.
2026-01-14 16:12:08 +03:00
0f56381703 Merge pull request #82 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: enhance card and transaction services for balance updates
2026-01-14 14:52:57 +03:00
1b0d6cb284 feat: enhance card and transaction services for balance updates
- Added functionality to credit child account balance and decrease parent account balance in CardService.
- Updated TransactionService to reload card details for accurate balance after transactions.
- Improved TransactionNotificationListener to fetch updated balances for both child and parent accounts, ensuring accurate notifications.
2026-01-14 14:51:45 +03:00
887bd20217 Merge pull request #81 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: enhance card service validation and notification integration
2026-01-14 13:27:56 +03:00
c963b57904 feat: enhance card service validation and notification integration
- Added validation for card reference and limit in CardService to ensure data integrity.
- Improved error handling with detailed logging for invalid card states.
- Updated transaction notification listener to fetch parent account details and adjust balance notifications accordingly.
- Enhanced notification creation process to include status management for better tracking.
2026-01-14 13:27:02 +03:00
2e21acac7f Merge pull request #80 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: implement KYC and card notification events
2026-01-14 12:57:11 +03:00
145e6c62b8 feat: implement KYC and card notification events
- Added KycNotificationListener to handle notifications for KYC approval and rejection events.
- Introduced CardNotificationListener to manage notifications for card creation and blocking events.
- Enhanced CardService to emit events for card creation and blocking, integrating with the new notification system.
- Updated notification constants and interfaces to include new KYC and card-related events.
- Improved notification message formatting and added localization support for new events.
2026-01-14 12:31:48 +03:00
652359b1bf Merge pull request #79 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: enhance transaction notification logging and error handling
2026-01-12 16:48:13 +03:00
45acf73a4a feat: enhance transaction notification logging and error handling
- Added console logging for emitted transaction creation events in TransactionService.
- Improved error handling in TransactionNotificationListener for i18n translation failures, providing fallback messages.
- Updated amount parsing in MoneyRequestNotificationListener to ensure consistent handling of string and numeric values.
2026-01-12 16:47:28 +03:00
2d6524be9f Merge pull request #78 from Zod-Alkhair/feature/notification-system-fcm-registration
refactor: standardize notification message formatting
2026-01-12 16:30:32 +03:00
d3ff755439 refactor: standardize notification message formatting
- Updated notification message arguments to use consistent object syntax for better readability.
- Modified Arabic and English translation files to reflect the new argument format in notification messages.
2026-01-12 16:28:26 +03:00
3ab00dfc29 Merge pull request #76 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: implement money request notification system
2026-01-12 16:15:19 +03:00
21653efc46 feat: implement money request notification system
- Added MoneyRequestNotificationListener to handle notifications for money request events (created, approved, declined).
- Introduced new notification event constants for money requests.
- Updated notification interfaces to include money request event payloads.
- Enhanced existing notification system to support money request notifications, notifying parents and children appropriately.
- Updated device service to support finding devices by ID for improved functionality.
2026-01-12 16:07:48 +03:00
11b2b25adc Merge pull request #75 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: enhance Redis module exports for pub/sub functionality
2026-01-11 14:40:12 +03:00
63b0a42eca feat: enhance Redis module exports for pub/sub functionality
- Added 'REDIS_PUBLISHER' and 'REDIS_SUBSCRIBER' to the exports of RedisModule to improve pub/sub capabilities.
2026-01-11 14:38:59 +03:00
ed8cf4b4f8 Merge pull request #74 from Zod-Alkhair/feature/notification-system-fcm-registration
Feature/notification system fcm registration
2026-01-11 14:31:58 +03:00
b1cda5e7dc feat: Complete Phase 2 notification system implementation
- Implement messaging system factory pattern
- Fix all transaction notification blockers
- Complete listener logic for all notification types
2026-01-11 11:17:08 +03:00
2c8de913f8 refactor: update notification titles and enhance notification creation process
- Simplified notification titles by removing emojis for better clarity.
- Modified createNotification method to include automatic publishing to Redis, improving notification delivery.
- Updated email and OTP notification methods to leverage the new createNotification functionality.
2026-01-06 16:22:21 +03:00
98f6aaf01f Merge pull request #73 from Zod-Alkhair/feature/notification-system-fcm-registration
add eveint lestiner to the parent
2026-01-06 14:52:52 +03:00
170aa903c7 add eveint lestiner to the parent 2026-01-06 14:51:44 +03:00
16f8756b74 Merge pull request #72 from Zod-Alkhair/feature/notification-system-fcm-registration
merge conflect
2026-01-06 12:58:49 +03:00
2f74aa36a9 merge conflect 2026-01-06 12:57:13 +03:00
f849003142 Merge pull request #71 from Zod-Alkhair/feature/notification-system-fcm-registration
Feature/notification system fcm registration
2026-01-06 12:54:45 +03:00
2562515574 Merge branch 'dev' of github.com:HamzaSha1/zod-backend into feature/notification-system-fcm-registration 2026-01-06 12:53:44 +03:00
93b509b256 feat: add notification event handling and notification factory service
- Introduce constants for notification event names
- Implement interfaces for transaction created events
- Create a transaction notification listener to handle transaction notifications
- Develop a notification factory service for sending notifications based on user preferences
- Add a migration to include a data column in the notifications table
2026-01-06 12:38:19 +03:00
9c93a35093 feat: implement notification system with FCM token registration
- Add FCM token registration during login/signup
- Implement transaction notification listeners
- Add notification data column to database
- Update Firebase service with data payload support
- Add transaction notification scopes
- Update card repository to load relations for notifications
2026-01-06 12:29:01 +03:00
d77d59a793 Merge pull request #70 from Zod-Alkhair/feature/kyc-onboarding
Feature/kyc onboarding
2025-12-18 14:26:27 +03:00
110a6fb0ee refactor: remove address fields from customer entity and related services
- Removed address-related fields from Customer entity, DTOs, and services to streamline KYC process.
- Updated KYC initiation and customer update logic to default to Saudi Arabia for country and use fixed address values.
- Added migration to drop address columns from the database.
2025-12-18 12:35:32 +03:00
83787c7c67 Merge pull request #69 from Zod-Alkhair/feature/kyc-onboarding
feat: enhance KYC process with external customer ID validation
2025-12-17 12:56:32 +03:00
24bcb10d76 feat: enhance KYC process with external customer ID validation
- Added validation to ensure customer has a neoleapExternalCustomerId before card creation.
- Updated KYC status update to include neoleapExternalCustomerId in the customer record.
- Enhanced application info to include ExternalCorporateId for better integration with Neoleap.
2025-12-17 12:51:20 +03:00
a3cdf50cb7 Merge pull request #68 from Zod-Alkhair/feature/kyc-onboarding
refactor: remove obsolete customer fields and update migration
2025-12-16 16:42:30 +03:00
cfd02e8c30 refactor: remove obsolete customer fields and update migration
- Removed unused fields: sourceOfIncome, profession, and professionType from Customer entity and DTOs.
- Updated KYC callback mock to reflect the removal of professionType.
- Added migration to drop the corresponding columns from the database.
2025-12-16 16:40:13 +03:00
0fb76d712d Merge pull request #67 from Zod-Alkhair/feature/kyc-onboarding
Feature/kyc onboarding
2025-12-16 14:59:28 +03:00
5e708c16fe chore: remove migration from wrong directory
Migration already exists in correct location: src/db/migrations/
2025-12-16 14:57:03 +03:00
fe11f35b32 feat: send the adress data to noleap 2025-12-16 14:51:21 +03:00
3200f60821 feat: Complete KYC implementation with address fields
- Added address fields to registration (verify-user DTO)
- Added address fields to KYC initiation (initiate-kyc DTO)
- Added national_id column to kyc_transactions table
- Changed duplicate KYC check from customerId to nationalId
- Added KYC webhook endpoint (/api/neoleap-webhooks/kyc)
- Added webhook processing logic
- Updated customer service to save address during registration and KYC
- Added validation to require address before card creation
- Removed duplicate src/migrations/ directory
2025-12-16 14:44:07 +03:00
24521c4223 Merge pull request #66 from Zod-Alkhair/chore/remove-email-dob-from-signup
chore: remove email and dob from guardian signup flow
2025-12-11 12:15:54 +03:00
ce1f6341b7 Merge pull request #65 from HamzaSha1/dev
Dev to main
2025-11-26 09:58:33 +03:00
126 changed files with 5940 additions and 378 deletions

View File

@ -0,0 +1,291 @@
# 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`

0
queries/Query.sql Normal file
View File

View File

@ -0,0 +1,22 @@
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 {}

View File

@ -0,0 +1,5 @@
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';

View File

@ -0,0 +1 @@
export * from './allowance-queue.constants';

View File

@ -0,0 +1,78 @@
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);
}
}

View File

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

View File

@ -0,0 +1,38 @@
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;
}

View File

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

View File

@ -0,0 +1,40 @@
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;
}

View File

@ -0,0 +1,48 @@
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;
}
}

View File

@ -0,0 +1,36 @@
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;
}
}

View File

@ -0,0 +1,21 @@
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;
}
}

View File

@ -0,0 +1,4 @@
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';

View File

@ -0,0 +1,89 @@
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;
}
}

View File

@ -0,0 +1,42 @@
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;
}

View File

@ -0,0 +1,60 @@
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[];
}

View File

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

View File

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

View File

@ -0,0 +1,4 @@
export enum AllowanceScheduleStatus {
ON = 'ON',
OFF = 'OFF',
}

View File

@ -0,0 +1,2 @@
export * from './allowance-frequency.enum';
export * from './allowance-schedule-status.enum';

View File

@ -0,0 +1,13 @@
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;
}

View File

@ -0,0 +1 @@
export * from './allowance-schedules-grouped.interface';

View File

@ -0,0 +1,32 @@
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);
}
}

View File

@ -0,0 +1,96 @@
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 });
}
}

View File

@ -0,0 +1,2 @@
export * from './allowance-credit.repository';
export * from './allowance-schedule.repository';

View File

@ -0,0 +1,90 @@
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();
}
}

View File

@ -0,0 +1,212 @@
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;
}
}

View File

@ -0,0 +1,213 @@
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);
}
}

View File

@ -0,0 +1,3 @@
export * from './allowance-queue.service';
export * from './allowance.service';
export * from './allowance-worker.service';

View File

@ -9,6 +9,7 @@ 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';
@ -58,6 +59,7 @@ import { MoneyRequestModule } from './money-request/money-request.module';
ScheduleModule.forRoot(),
// App modules
AuthModule,
AllowanceModule,
UserModule,
CustomerModule,

View File

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString } from 'class-validator';
import { IsEmail, IsOptional, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class JuniorLoginRequestDto {
@ApiProperty({ example: 'test@junior.com' })
@ -9,4 +9,27 @@ export class JuniorLoginRequestDto {
@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;
}

View File

@ -21,4 +21,27 @@ export class LoginRequestDto {
@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;
}

View File

@ -44,6 +44,39 @@ export class VerifyUserRequestDto {
@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' }),
@ -68,4 +101,27 @@ export class VerifyUserRequestDto {
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;
}

View File

@ -86,6 +86,12 @@ export class AuthService {
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];
}
@ -271,6 +277,12 @@ export class AuthService {
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];
}
@ -291,9 +303,76 @@ export class AuthService {
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([

View File

@ -27,7 +27,7 @@ import { TransactionService } from './services/transaction.service';
AccountService,
AccountRepository,
],
exports: [CardService, TransactionService],
exports: [CardService, TransactionService, AccountService],
controllers: [CardsController],
})
export class CardModule {}

View File

@ -42,7 +42,18 @@ export class CardRepository {
}
getCardById(id: string): Promise<Card | null> {
return this.cardRepository.findOne({ where: { id }, relations: ['account'] });
return this.cardRepository.findOne({
where: { id },
relations: [
'account',
'customer',
'customer.user',
'customer.junior',
'customer.junior.guardian',
'customer.junior.guardian.customer',
'customer.junior.guardian.customer.user',
],
});
}
findCardByChildId(guardianId: string, childId: string): Promise<Card | null> {
@ -59,14 +70,30 @@ export class CardRepository {
getCardByVpan(vpan: string): Promise<Card | null> {
return this.cardRepository.findOne({
where: { vpan },
relations: ['account'],
relations: [
'account',
'customer',
'customer.user',
'customer.junior',
'customer.junior.guardian',
'customer.junior.guardian.customer',
'customer.junior.guardian.customer.user',
],
});
}
getCardByCustomerId(customerId: string): Promise<Card | null> {
return this.cardRepository.findOne({
where: { customerId },
relations: ['account'],
relations: [
'account',
'customer',
'customer.user',
'customer.junior',
'customer.junior.guardian',
'customer.junior.guardian.customer',
'customer.junior.guardian.customer.user',
],
});
}

View File

@ -92,7 +92,9 @@ export class TransactionRepository {
return this.transactionRepository
.createQueryBuilder('transaction')
.innerJoinAndSelect('transaction.card', 'card')
.where('card.customerId = :juniorId', { juniorId })
.innerJoin('card.customer', 'customer')
.innerJoin('customer.junior', 'junior')
.where('junior.id = :juniorId', { juniorId })
.andWhere('transaction.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('transaction.transactionType = :type', { type: TransactionType.EXTERNAL })
.andWhere('transaction.transactionDate BETWEEN :startDate AND :endDate', { startDate, endDate })
@ -153,7 +155,9 @@ export class TransactionRepository {
.createQueryBuilder('tx')
.innerJoinAndSelect('tx.card', 'card')
.innerJoinAndSelect('card.account', 'account')
.where('card.customerId = :juniorId', { juniorId })
.innerJoin('card.customer', 'customer')
.innerJoin('customer.junior', 'junior')
.where('junior.id = :juniorId', { juniorId })
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
.orderBy('tx.transactionDate', 'DESC')
@ -166,7 +170,9 @@ export class TransactionRepository {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoin('tx.card', 'card')
.where('card.customerId = :juniorId', { juniorId })
.innerJoin('card.customer', 'customer')
.innerJoin('customer.junior', 'junior')
.where('junior.id = :juniorId', { juniorId })
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
.getCount();
@ -176,8 +182,10 @@ export class TransactionRepository {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoinAndSelect('tx.card', 'card')
.innerJoin('card.customer', 'customer')
.innerJoin('customer.junior', 'junior')
.where('tx.id = :transactionId', { transactionId })
.andWhere('card.customerId = :juniorId', { juniorId })
.andWhere('junior.id = :juniorId', { juniorId })
.getOne();
}
}

View File

@ -1,6 +1,9 @@
import { BadRequestException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import Decimal from 'decimal.js';
import { Transactional } from 'typeorm-transactional';
import { NOTIFICATION_EVENTS } from '~/common/modules/notification/constants/event-names.constant';
import { ICardBlockedEvent, ICardCreatedEvent } from '~/common/modules/notification/interfaces/notification-events.interface';
import { AccountCardStatusChangedWebhookRequest } from '~/common/modules/neoleap/dtos/requests';
import { NeoLeapService } from '~/common/modules/neoleap/services';
import { Customer } from '~/customer/entities';
@ -8,7 +11,7 @@ import { KycStatus } from '~/customer/enums';
import { CustomerService } from '~/customer/services';
import { OciService } from '~/document/services';
import { Card } from '../entities';
import { CardColors } from '../enums';
import { CardColors, CardStatus } from '../enums';
import { CardStatusMapper } from '../mappers/card-status.mapper';
import { CardRepository } from '../repositories';
import { AccountService } from './account.service';
@ -24,6 +27,7 @@ export class CardService {
@Inject(forwardRef(() => TransactionService)) private readonly transactionService: TransactionService,
@Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService,
@Inject(forwardRef(() => CustomerService)) private readonly customerService: CustomerService,
private readonly eventEmitter: EventEmitter2,
) {}
@Transactional()
@ -34,15 +38,40 @@ export class CardService {
throw new BadRequestException('CUSTOMER.KYC_NOT_APPROVED');
}
if (!customer.neoleapExternalCustomerId) {
throw new BadRequestException('CUSTOMER.KYC_NOT_COMPLETED');
}
if (customer.cards.length > 0) {
throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD');
}
// Validate required fields for card creation
const missingFields = [];
if (!customer.nationalId) missingFields.push('nationalId');
if (!customer.dateOfBirth) missingFields.push('dateOfBirth');
if (!customer.nationalIdExpiry) missingFields.push('nationalIdExpiry');
if (missingFields.length > 0) {
throw new BadRequestException(
`CUSTOMER.MISSING_REQUIRED_FIELDS: ${missingFields.join(', ')}. Please complete your profile.`
);
}
const data = await this.neoleapService.createApplication(customer);
const account = await this.accountService.createAccount(data);
const createdCard = await this.cardRepository.createCard(customerId, account.id, data);
return this.getCardById(createdCard.id);
const cardWithRelations = await this.getCardById(createdCard.id);
const event: ICardCreatedEvent = {
card: cardWithRelations,
timestamp: new Date(),
};
this.eventEmitter.emit(NOTIFICATION_EVENTS.CARD_CREATED, event);
this.logger.log(`Emitted CARD_CREATED event for card ${cardWithRelations.id}`);
return cardWithRelations;
}
async getChildCards(guardianId: string): Promise<Card[]> {
@ -61,7 +90,16 @@ export class CardService {
parentCustomer.id,
);
return this.getCardById(createdCard.id);
const cardWithRelations = await this.getCardById(createdCard.id);
const event: ICardCreatedEvent = {
card: cardWithRelations,
timestamp: new Date(),
};
this.eventEmitter.emit(NOTIFICATION_EVENTS.CARD_CREATED, event);
this.logger.log(`Emitted CARD_CREATED event for child card ${cardWithRelations.id}`);
return cardWithRelations;
}
async getCardByChildId(guardianId: string, childId: string): Promise<Card> {
@ -112,9 +150,24 @@ export class CardService {
async updateCardStatus(body: AccountCardStatusChangedWebhookRequest) {
const card = await this.getCardByVpan(body.cardId);
const previousStatus = card.status;
const { description, status } = CardStatusMapper[body.newStatus] || CardStatusMapper['99'];
return this.cardRepository.updateCardStatus(card.id, status, description);
await this.cardRepository.updateCardStatus(card.id, status, description);
if (status === CardStatus.BLOCKED) {
const updatedCard = await this.getCardById(card.id);
const event: ICardBlockedEvent = {
card: updatedCard,
previousStatus,
blockReason: description,
timestamp: new Date(),
};
this.eventEmitter.emit(NOTIFICATION_EVENTS.CARD_BLOCKED, event);
this.logger.log(`Emitted CARD_BLOCKED event for card ${updatedCard.id}`);
}
return { id: card.id, status, description };
}
async getEmbossingInformation(customerId: string) {
@ -163,13 +216,64 @@ export class CardService {
throw new BadRequestException('CARD.INSUFFICIENT_BALANCE');
}
const finalAmount = Decimal(amount).plus(card.limit);
await Promise.all([
this.neoleapService.updateCardControl(card.cardReference, finalAmount.toNumber()),
this.updateCardLimit(card.id, finalAmount.toNumber()),
this.accountService.increaseReservedBalance(fundingAccount, amount),
this.transactionService.createInternalChildTransaction(card.id, amount),
]);
// Validate card reference exists
if (!card.cardReference) {
this.logger.error(`Card ${card.id} does not have a cardReference`);
throw new BadRequestException('CARD.INVALID_CARD_REFERENCE');
}
// Validate card limit is a valid number
const cardLimit = card.limit || 0;
if (isNaN(cardLimit) || cardLimit < 0) {
this.logger.error(`Card ${card.id} has invalid limit: ${cardLimit}`);
throw new BadRequestException('CARD.INVALID_CARD_LIMIT');
}
const finalAmount = Decimal(amount).plus(cardLimit);
const finalAmountNumber = finalAmount.toNumber();
// Validate final amount is positive
if (finalAmountNumber <= 0 || !isFinite(finalAmountNumber)) {
this.logger.error(`Invalid final amount calculated: ${finalAmountNumber} (amount: ${amount}, limit: ${cardLimit})`);
throw new BadRequestException('CARD.INVALID_AMOUNT');
}
this.logger.debug(`Updating card control - cardReference: ${card.cardReference}, finalAmount: ${finalAmountNumber}`);
// Check if child and parent share the same account
const isSharedAccount = card.parentId && card.account.id === fundingAccount.id;
this.logger.debug(
`Account structure - Child account: ${card.account.id}, Parent account: ${fundingAccount.id}, ` +
`Shared: ${isSharedAccount ? 'YES' : 'NO'}`
);
// First, ensure all external operations succeed before creating transaction
if (isSharedAccount) {
// Shared account: Only update card limit and reserved balance
// Money is already in the shared account, just allocate it to the child
this.logger.debug(`Shared account detected - only updating card limit and reserved balance`);
await Promise.all([
this.neoleapService.updateCardControl(card.cardReference, finalAmountNumber),
this.updateCardLimit(card.id, finalAmountNumber),
this.accountService.increaseReservedBalance(fundingAccount, amount),
]);
} else {
// Separate accounts: Transfer money from parent to child
this.logger.debug(`Separate accounts - transferring money from parent to child`);
await Promise.all([
this.neoleapService.updateCardControl(card.cardReference, finalAmountNumber),
this.updateCardLimit(card.id, finalAmountNumber),
this.accountService.increaseReservedBalance(fundingAccount, amount),
// Increase child account balance
this.accountService.creditAccountBalance(card.account.accountReference, amount),
// Decrease parent account balance
this.accountService.decreaseAccountBalance(fundingAccount.accountReference, amount),
]);
}
// Only create transaction and emit event after all operations succeed
await this.transactionService.createInternalChildTransaction(card.id, amount);
return finalAmount.toNumber();
}

View File

@ -1,4 +1,5 @@
import { forwardRef, Inject, Injectable, UnprocessableEntityException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import Decimal from 'decimal.js';
import moment from 'moment';
import { Transactional } from 'typeorm-transactional';
@ -6,6 +7,8 @@ import {
AccountTransactionWebhookRequest,
CardTransactionWebhookRequest,
} from '~/common/modules/neoleap/dtos/requests';
import { NOTIFICATION_EVENTS } from '~/common/modules/notification/constants/event-names.constant';
import { ITransactionCreatedEvent } from '~/common/modules/notification/interfaces/notification-events.interface';
import { Transaction } from '../entities/transaction.entity';
import { CustomerType, TransactionType } from '../enums';
import { TransactionRepository } from '../repositories/transaction.repository';
@ -27,6 +30,7 @@ export class TransactionService {
private readonly transactionRepository: TransactionRepository,
private readonly accountService: AccountService,
@Inject(forwardRef(() => CardService)) private readonly cardService: CardService,
private readonly eventEmitter: EventEmitter2,
) {}
@Transactional()
@ -58,6 +62,15 @@ export class TransactionService {
await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber());
}
const event: ITransactionCreatedEvent = {
transaction,
card,
isTopUp: false,
isChildSpending: card.customerType === CustomerType.CHILD,
timestamp: new Date(),
};
this.eventEmitter.emit(NOTIFICATION_EVENTS.TRANSACTION_CREATED, event);
return transaction;
}
@ -74,12 +87,44 @@ export class TransactionService {
const transaction = await this.transactionRepository.createAccountTransaction(account, body);
await this.accountService.creditAccountBalance(account.accountReference, body.amount);
const accountWithCards = await this.accountService.getAccountByAccountNumber(body.accountId);
const card = accountWithCards.cards?.[0]
? await this.cardService.getCardById(accountWithCards.cards[0].id)
: null;
if (card) {
const event: ITransactionCreatedEvent = {
transaction,
card,
isTopUp: true,
isChildSpending: false,
timestamp: new Date(),
};
this.eventEmitter.emit(NOTIFICATION_EVENTS.TRANSACTION_CREATED, event);
}
return transaction;
}
async createInternalChildTransaction(cardId: string, amount: number) {
const card = await this.cardService.getCardById(cardId);
const transaction = await this.transactionRepository.createInternalChildTransaction(card, amount);
// Reload card to get updated account balance after the transfer
const cardWithUpdatedBalance = await this.cardService.getCardById(cardId);
const event: ITransactionCreatedEvent = {
transaction,
card: cardWithUpdatedBalance, // Use card with updated balance
isTopUp: true,
isChildSpending: true,
timestamp: new Date(),
};
console.log(`[TransactionService] Emitting TRANSACTION_CREATED event for transaction ${transaction.id}`);
this.eventEmitter.emit(NOTIFICATION_EVENTS.TRANSACTION_CREATED, event);
console.log(`[TransactionService] Event emitted successfully`);
return transaction;
}

View File

@ -1,9 +1,12 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { BadRequestException, Controller, Get, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { AccessTokenGuard } from '~/common/guards';
import { ApiDataArrayResponse } from '~/core/decorators';
import { ApiDataArrayResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import { DocumentMetaResponseDto } from '~/document/dtos/response';
import { HelpSupportFaqResponseDto } from '../dtos/response';
import { LookupLanguage } from '../enums';
import { LookupService } from '../services';
@Controller('lookup')
@ -29,4 +32,24 @@ export class LookupController {
return ResponseFactory.dataArray(avatars.map((avatar) => new DocumentMetaResponseDto(avatar)));
}
@UseGuards(AccessTokenGuard)
@Get('help-support-faqs')
@ApiDataArrayResponse(HelpSupportFaqResponseDto)
@ApiLangRequestHeader()
getHelpSupportFaqs(@Req() request: Request) {
const header = request.headers['accept-language'];
const lang = Array.isArray(header) ? header[0] : header;
const normalized = (lang || '').trim().toLowerCase();
if (normalized && normalized !== LookupLanguage.EN && normalized !== LookupLanguage.AR) {
throw new BadRequestException('Accept-Language must be "en" or "ar".');
}
const faqs = this.lookupService.getHelpSupportFaqs(
normalized === LookupLanguage.AR ? LookupLanguage.AR : LookupLanguage.EN,
);
return ResponseFactory.dataArray(faqs);
}
}

View File

@ -0,0 +1,72 @@
[
{
"id": "transfer_failed_or_delayed",
"question_en": "What happens if a transfer fails or is delayed?",
"answer_en": "If a transfer fails, the amount will not be deducted from your balance. If a transfer is delayed, it will usually complete within a short time. If the issue continues, please contact customer support.",
"question_ar": "ماذا يحدث إذا فشل التحويل أو تأخر؟",
"answer_ar": "إذا فشل التحويل، لن يُخصم المبلغ من رصيدك. إذا تأخر التحويل، فعادةً يكتمل خلال وقت قصير. إذا استمرت المشكلة، يرجى التواصل مع دعم العملاء."
},
{
"id": "same_email_or_phone_multiple_accounts",
"question_en": "Can I use the same email or phone number for multiple accounts?",
"answer_en": "No. Each email address and phone number can be used for only one account.",
"question_ar": "هل يمكنني استخدام نفس البريد الإلكتروني أو رقم الهاتف لعدة حسابات؟",
"answer_ar": "لا. يمكن استخدام كل بريد إلكتروني ورقم هاتف لحساب واحد فقط."
},
{
"id": "update_profile_details",
"question_en": "How do I update my profile details?",
"answer_en": "Go to Profile - Edit Profile, update your details, and save the changes.",
"question_ar": "كيف يمكنني تحديث بيانات ملفي الشخصي؟",
"answer_ar": "انتقل إلى الملف الشخصي - تعديل الملف الشخصي، حدّث بياناتك ثم احفظ التغييرات."
},
{
"id": "change_email_address",
"question_en": "How do I change my email address?",
"answer_en": "You can update your email or phone number from Profile - Edit Profile. You may be asked to re-login for security reasons.",
"question_ar": "كيف أغيّر عنوان بريدي الإلكتروني؟",
"answer_ar": "يمكنك تحديث بريدك الإلكتروني أو رقم هاتفك من الملف الشخصي - تعديل الملف الشخصي. قد يُطلب منك تسجيل الدخول مرة أخرى لأسباب أمنية."
},
{
"id": "change_phone_number",
"question_en": "How do I change my phone number?",
"answer_en": "At the moment, phone numbers cant be changed directly in the app. Please contact our support team, and theyll assist you with updating it.",
"question_ar": "كيف أغيّر رقم هاتفي؟",
"answer_ar": "حاليًا لا يمكن تغيير أرقام الهواتف مباشرةً داخل التطبيق. يرجى التواصل مع فريق الدعم، وسيساعدونك في تحديثه."
},
{
"id": "activate_card",
"question_en": "How do I activate my card?",
"answer_en": "Transfer 20 SAR to activate your card. Once completed, your card will become active.",
"question_ar": "كيف أقوم بتفعيل بطاقتي؟",
"answer_ar": "حوّل 20 ريالًا سعوديًا لتفعيل بطاقتك. بعد إتمام التحويل، ستصبح البطاقة نشطة."
},
{
"id": "why_transfer_20_sar",
"question_en": "Why do I need to transfer 20 SAR to activate the card?",
"answer_en": "This amount is required to activate the card and enable usage.",
"question_ar": "لماذا يجب علي تحويل 20 ريالًا سعوديًا لتفعيل البطاقة؟",
"answer_ar": "هذا المبلغ مطلوب لتفعيل البطاقة وتمكين استخدامها."
},
{
"id": "where_activation_amount_go",
"question_en": "Where does the activation amount go?",
"answer_en": "The activation amount is collected by the company as part of the card activation process.",
"question_ar": "أين يذهب مبلغ التفعيل؟",
"answer_ar": "يتم تحصيل مبلغ التفعيل من قبل الشركة كجزء من عملية تفعيل البطاقة."
},
{
"id": "top_up_external_bank",
"question_en": "Can I top up from an external bank account?",
"answer_en": "Yes. You can transfer funds from your external bank to your ZOD account",
"question_ar": "هل يمكنني الشحن من حساب بنكي خارجي؟",
"answer_ar": "نعم. يمكنك تحويل الأموال من بنكك الخارجي إلى حسابك في زد."
},
{
"id": "data_security",
"question_en": "Is my data secure in the ZOD app?",
"answer_en": "Yes. We use secure systems and encryption to protect your data.",
"question_ar": "هل بياناتي آمنة في تطبيق زد؟",
"answer_ar": "نعم. نستخدم أنظمة آمنة وتشفيرًا لحماية بياناتك."
}
]

View File

@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
export class HelpSupportFaqResponseDto {
@ApiProperty()
id!: string;
@ApiProperty()
question!: string;
@ApiProperty()
answer!: string;
constructor(item: { id: string; question: string; answer: string }) {
this.id = item.id;
this.question = item.question;
this.answer = item.answer;
}
}

View File

@ -0,0 +1 @@
export * from './help-support-faq.response.dto';

View File

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

View File

@ -0,0 +1,4 @@
export enum LookupLanguage {
EN = 'en',
AR = 'ar',
}

View File

@ -1,6 +1,9 @@
import { Injectable, Logger } from '@nestjs/common';
import { DocumentType } from '~/document/enums';
import { DocumentService, OciService } from '~/document/services';
import { HelpSupportFaqResponseDto } from '../dtos/response';
import { LookupLanguage } from '../enums';
import helpSupportFaqs from '../data/help-support-faqs.json';
@Injectable()
export class LookupService {
@ -33,4 +36,16 @@ export class LookupService {
this.logger.log(`Default tasks logos returned successfully`);
return documents;
}
getHelpSupportFaqs(lang: LookupLanguage = LookupLanguage.EN): HelpSupportFaqResponseDto[] {
const useArabic = lang === LookupLanguage.AR;
return helpSupportFaqs.map((faq) =>
new HelpSupportFaqResponseDto({
id: faq.id,
question: useArabic ? faq.question_ar : faq.question_en,
answer: useArabic ? faq.answer_ar : faq.answer_en,
}),
);
}
}

View File

@ -17,7 +17,6 @@ export const getKycCallbackMock = (nationalId: string) => {
salaryMax: '1000',
incomeSource: 'Salary',
professionTitle: 'Software Engineer',
professionType: 'Full-Time',
isPep: 'N',
country: '682',
region: 'Mecca',

View File

@ -1,129 +1,50 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose } from 'class-transformer';
import { IsString } from 'class-validator';
export class KycWebhookRequest {
@Expose({ name: 'InstId' })
@IsString()
@ApiProperty({ name: 'InstId', example: '1100' })
instId!: string;
import { IsEnum, IsObject, IsString } from 'class-validator';
@Expose()
@IsString()
@ApiProperty({ example: '3136fd60-3f89-4d24-a92f-b9c63a53807f' })
transId!: string;
@Expose()
@IsString()
@ApiProperty({ example: '20250807' })
date!: string;
@Expose()
@IsString()
@ApiProperty({ example: '150000' })
time!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'SUCCESS' })
status!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'John' })
firstName!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'Doe' })
lastName!: string;
@Expose()
@IsString()
@ApiProperty({ example: '19990107' })
dob!: string;
@Expose()
@IsString()
@ApiProperty({ example: '682' })
nationality!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'M' })
gender!: string;
@Expose()
@IsString()
@ApiProperty({ example: '20310917' })
nationalIdExpiry!: string;
@Expose()
@IsString()
@ApiProperty({ example: '1250820840' })
nationalId!: string;
@Expose()
@IsString()
@ApiProperty({ example: '+962798765432' })
mobile!: string;
@Expose()
@IsString()
@ApiProperty({ example: '500' })
salaryMin!: string;
@Expose()
@IsString()
@ApiProperty({ example: '1000' })
salaryMax!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'Salary' })
incomeSource!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'Software Engineer' })
professionTitle!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'Full-Time' })
professionType!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'N' })
isPep!: string;
@Expose()
@IsString()
@ApiProperty({ example: '682' })
country!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'Mecca' })
region!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'At-Taif' })
city!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'Al-Hamra' })
neighborhood!: string;
@Expose()
@IsString()
@ApiProperty({ example: 'Al-Masjid Al-Haram' })
street!: string;
@Expose()
@IsString()
@ApiProperty({ example: '123' })
building!: string;
export enum NeoleapKycWebhookStatus {
ONBOARDING_SUCCESS = 'ONBOARDING_SUCCESS',
ONBOARDING_FAILURE = 'ONBOARDING_FAILURE',
IN_PROGRESS = 'IN_PROGRESS',
}
class KycEntityDto {
@ApiProperty({ example: 'INDIVIDUAL', description: 'Entity type - INDIVIDUAL for KYC' })
@IsString()
type!: string;
@ApiProperty({ example: 'FIN-TECK-CUSTOMER-20393', description: 'Customer external ID from Neoleap' })
@IsString()
externalId!: string;
}
export class KycWebhookRequest {
@ApiProperty({
example: '8a745b1b-1252-4921-a569-b3d4406c25fd',
description: 'Transaction ID, the same as returned from onboard API response'
})
@IsString()
stateId!: string;
@ApiProperty({
example: '8a745b1b-1252-4921-a569-b3d4406c25fd',
description: 'Unique callback ID used as reference and for tracking'
})
@IsString()
callbackId!: string;
@ApiProperty({ example: '1100', description: 'Fintech ID (1100 for ZOD)' })
@IsString()
externalFintechId!: string;
@ApiProperty({ type: KycEntityDto })
@IsObject()
entity!: KycEntityDto;
@ApiProperty({
enum: NeoleapKycWebhookStatus,
example: NeoleapKycWebhookStatus.ONBOARDING_SUCCESS,
description: 'Status of onboarding: ONBOARDING_SUCCESS or ONBOARDING_FAILURE'
})
@IsEnum(NeoleapKycWebhookStatus)
status!: NeoleapKycWebhookStatus;
}

View File

@ -48,47 +48,109 @@ export class NeoLeapService {
this.useKycMock = [true, 'true'].includes(this.configService.get<boolean>('USE_KYC_MOCK', true));
}
initiateKyc(customerId: string, body: InitiateKycRequestDto) {
const responseKey = 'InitiateKycResponseDetails';
async initiateKycOnboarding(dto: InitiateKycRequestDto) {
// Mock mode for development
if (this.useKycMock) {
const responseDto = plainToInstance(InitiateKycResponseDto, INITIATE_KYC_MOCK[responseKey], {
excludeExtraneousValues: true,
});
const mockResponse = {
externalCustomerId: `FIN-TECK-CUSTOMER-${Date.now()}`,
externalFintechId: '1100',
nafathRandomCode: '38',
stateId: uuid(),
status: 'IN_PROGRESS',
};
// Trigger mock webhook after 7 seconds
setTimeout(() => {
this.httpService
.post(`${this.zodApiUrl}/neoleap-webhooks/kyc`, getKycCallbackMock(body.nationalId), {
headers: {
'Content-Type': 'application/json',
.post(`${this.zodApiUrl}/neoleap-webhooks/kyc`, {
stateId: mockResponse.stateId,
callbackId: uuid(),
externalFintechId: '1100',
entity: {
type: 'INDIVIDUAL',
externalId: mockResponse.externalCustomerId,
},
status: 'ONBOARDING_SUCCESS',
})
.subscribe({
next: () => this.logger.log('Mock KYC webhook sent'),
error: (err) => console.error(err),
next: () => this.logger.log('Mock KYC webhook sent successfully'),
error: (err) => this.logger.error('Mock KYC webhook failed:', err.message),
});
}, 7000);
return responseDto;
return mockResponse;
}
// Real API call to Neoleap
const payload = {
InitiateKycRequestDetails: {
CustomerIdentifier: {
InstitutionCode: this.institutionCode,
Id: customerId,
NationalId: body.nationalId,
poiNumber: dto.poiNumber,
poiType: dto.poiType,
mobileNumber: dto.mobileNumber,
email: dto.email,
dateOfBirth: dto.dateOfBirth,
jobSector: dto.jobSector,
employer: dto.employer,
incomeSource: dto.incomeSource,
jobCategory: dto.jobCategory,
incomeRange: dto.incomeRange,
// Use default address values for Neoleap KYC
address: {
national: {
buildingNumber: '1',
additionalNumber: '',
street: 'King Fahd Road',
streetEn: 'King Fahd Road',
city: 'Riyadh',
cityEn: 'Riyadh',
zipcode: '',
unitNumber: '',
district: 'Al Olaya',
districtEn: 'Al Olaya',
},
general: {
address: '1, King Fahd Road, Al Olaya, Riyadh, Riyadh',
website: '',
email: dto.email || '',
telephone1: dto.mobileNumber || '',
telephone2: '',
fax1: '',
fax2: '',
postalBox1: '',
postalBox2: '',
zipcode: '',
},
},
RequestHeader: this.prepareHeaders('InitiateKyc'),
};
return this.sendRequestToNeoLeap<typeof payload, InitiateKycResponseDto>(
'kyc/InitiateKyc',
payload,
responseKey,
InitiateKycResponseDto,
);
try {
const { data } = await this.httpService.axiosRef.post(
`${this.gatewayBaseUrl}/kyc/onboardCustomer`,
payload,
{
headers: {
'Content-Type': 'application/json',
Authorization: this.apiKey,
'X-Request-id': uuid(),
'X-Session-Language': 'ar',
},
},
);
return data.data;
} catch (error: any) {
this.logger.error('Error initiating KYC:', error.response?.data || error.message);
// Handle specific Neoleap errors
if (error.response?.data?.errorCode === 'E810109') {
throw new BadRequestException('National ID is already registered with Neoleap');
}
if (error.response?.data?.error === 'schema validation failed') {
throw new BadRequestException('Invalid data format for KYC verification');
}
throw new InternalServerErrorException('Failed to initiate KYC verification');
}
}
createApplication(customer: Customer) {
@ -124,7 +186,9 @@ export class NeoLeapService {
},
BillingCycle: 'C1',
},
ApplicationOtherInfo: {},
ApplicationOtherInfo: {
ExternalCorporateId: customer.neoleapExternalCustomerId,
},
ApplicationCustomerDetails: {
FirstName: customer.firstName,
LastName: customer.lastName,
@ -137,14 +201,14 @@ export class NeoLeapService {
Title: customer.gender === Gender.MALE ? 'Mr' : 'Ms',
Gender: customer.gender === Gender.MALE ? 'M' : 'F',
LocalizedDateOfBirth: moment(customer.dateOfBirth).format('YYYY-MM-DD'),
Nationality: CountriesNumericISO[customer.countryOfResidence],
Nationality: CountriesNumericISO[customer.countryOfResidence || 'SA'],
},
ApplicationAddress: {
City: customer.city,
Country: CountriesNumericISO[customer.country],
Region: customer.region,
AddressLine1: `${customer.street} ${customer.building}`,
AddressLine2: customer.neighborhood,
City: 'Riyadh',
Country: CountriesNumericISO['SA'],
Region: 'Riyadh',
AddressLine1: 'King Fahd Road 1',
AddressLine2: 'Al Olaya',
AddressRole: 0,
Email: customer.user.email,
Phone1: customer.user.phoneNumber,
@ -213,14 +277,14 @@ export class NeoLeapService {
Title: parent.gender === Gender.MALE ? 'Mr' : 'Ms',
Gender: parent.gender === Gender.MALE ? 'M' : 'F',
LocalizedDateOfBirth: moment(parent.dateOfBirth).format('YYYY-MM-DD'),
Nationality: CountriesNumericISO[parent.countryOfResidence],
Nationality: CountriesNumericISO[parent.countryOfResidence || 'SA'],
},
ApplicationAddress: {
City: parent.city,
Country: CountriesNumericISO[parent.country],
Region: parent.region,
AddressLine1: `${parent.street} ${parent.building}`,
AddressLine2: parent.neighborhood,
City: 'Riyadh',
Country: CountriesNumericISO['SA'],
Region: 'Riyadh',
AddressLine1: 'King Fahd Road 1',
AddressLine2: 'Al Olaya',
AddressRole: 0,
Email: child.user.email,
Phone1: child.user.phoneNumber,
@ -363,10 +427,18 @@ export class NeoLeapService {
});
} catch (error: any) {
if (error.status === 400) {
console.error('Error sending request to NeoLeap:', error);
throw new BadRequestException(error.response?.data?.ResponseHeader?.ResponseDescription || error.message);
const errorMessage = error.response?.data?.ResponseHeader?.ResponseDescription ||
error.response?.data?.message ||
error.message;
const errorCode = error.response?.data?.ResponseHeader?.ResponseCode || 'UNKNOWN';
this.logger.error(
`NeoLeap API returned 400 error for endpoint ${endpoint}. ` +
`Error Code: ${errorCode}, Message: ${errorMessage}. ` +
`Payload: ${JSON.stringify(payload)}`
);
throw new BadRequestException(errorMessage || 'Request failed with status code 400');
}
console.error('Error sending request to NeoLeap:', error);
this.logger.error(`Error sending request to NeoLeap endpoint ${endpoint}:`, error);
throw new InternalServerErrorException('Error communicating with NeoLeap service');
}
}

View File

@ -0,0 +1,37 @@
/**
* Notification event names
* These are the event identifiers used throughout the notification system
*/
export const NOTIFICATION_EVENTS = {
// Transaction events
TRANSACTION_CREATED: 'notification.transaction.created',
// Money Request events
MONEY_REQUEST_CREATED: 'notification.money-request.created',
MONEY_REQUEST_APPROVED: 'notification.money-request.approved',
MONEY_REQUEST_DECLINED: 'notification.money-request.declined',
// KYC Update events
KYC_APPROVED: 'notification.kyc.approved',
KYC_REJECTED: 'notification.kyc.rejected',
// Card Status events
CARD_CREATED: 'notification.card.created',
CARD_BLOCKED: 'notification.card.blocked',
CARD_REISSUED: 'notification.card.reissued',
// Profile Update events
PROFILE_UPDATED: 'notification.profile.updated',
// System Alert events
MAINTENANCE_ALERT: 'notification.system.maintenance',
TRANSACTION_FAILED: 'notification.system.transaction-failed',
SUSPICIOUS_LOGIN: 'notification.system.suspicious-login',
} as const;
export type NotificationEventName =
typeof NOTIFICATION_EVENTS[keyof typeof NOTIFICATION_EVENTS];

View File

@ -0,0 +1,3 @@
// Export all constants from this folder
export * from './event-names.constant';

View File

@ -23,6 +23,17 @@ export class NotificationsResponseDto {
this.title = notification.title;
this.body = notification.message;
this.status = notification.status!;
this.createdAt = notification.createdAt;
// Use event timestamp from data if available, otherwise use notification creation time
// This ensures notifications show when the event occurred, not when notification was saved
// Note: Timestamps are stored in UTC. The client should convert to the user's local timezone.
if (notification.data?.timestamp) {
// Parse the ISO string timestamp (which is in UTC)
// The client should convert this to the user's local timezone based on their device settings
this.createdAt = new Date(notification.data.timestamp);
} else {
// Use notification creation time (also in UTC)
this.createdAt = notification.createdAt;
}
}
}

View File

@ -36,6 +36,9 @@ export class Notification {
@Column('uuid', { name: 'user_id', nullable: true })
userId!: string;
@Column('jsonb', { name: 'data', nullable: true })
data?: Record<string, any>;
@ManyToOne(() => User, (user) => user.notifications, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'user_id' })
user!: User;

View File

@ -1,7 +1,67 @@
export enum NotificationScope {
// Existing scopes
USER_REGISTERED = 'USER_REGISTERED',
TASK_COMPLETED = 'TASK_COMPLETED',
GIFT_RECEIVED = 'GIFT_RECEIVED',
OTP = 'OTP',
USER_INVITED = 'USER_INVITED',
// Transaction notifications - Top-up (external funds)
CHILD_TOP_UP = 'CHILD_TOP_UP',
PARENT_TOP_UP_CONFIRMATION = 'PARENT_TOP_UP_CONFIRMATION',
// Transaction notifications - Internal Transfer (parent to child)
CHILD_INTERNAL_TRANSFER = 'CHILD_INTERNAL_TRANSFER',
PARENT_INTERNAL_TRANSFER = 'PARENT_INTERNAL_TRANSFER',
// Transaction notifications - Spending
CHILD_SPENDING = 'CHILD_SPENDING',
PARENT_SPENDING_ALERT = 'PARENT_SPENDING_ALERT',
// Money Request notifications
MONEY_REQUEST_CREATED = 'MONEY_REQUEST_CREATED',
MONEY_REQUEST_APPROVED = 'MONEY_REQUEST_APPROVED',
MONEY_REQUEST_DECLINED = 'MONEY_REQUEST_DECLINED',
// KYC Update notifications
KYC_APPROVED = 'KYC_APPROVED',
KYC_REJECTED = 'KYC_REJECTED',
// Card Status notifications
CARD_CREATED = 'CARD_CREATED',
CARD_BLOCKED = 'CARD_BLOCKED',
CARD_REISSUED = 'CARD_REISSUED',
// Profile Update notifications
PROFILE_UPDATED = 'PROFILE_UPDATED',
// System Alert notifications
MAINTENANCE_ALERT = 'MAINTENANCE_ALERT',
TRANSACTION_FAILED = 'TRANSACTION_FAILED',
SUSPICIOUS_LOGIN = 'SUSPICIOUS_LOGIN',
}
/**
* Critical notification scopes that require guaranteed delivery
* These will use RabbitMQ/Kafka instead of Redis PubSub when configured
*
* Add scopes here when you need guaranteed delivery for specific notification types
* Examples:
* - ACCOUNT_LOCKED
* - SUSPICIOUS_ACTIVITY
* - LARGE_TRANSACTION_ALERT
* - PAYMENT_FAILED
*/
export const CRITICAL_NOTIFICATION_SCOPES = new Set<NotificationScope>([
// Add critical scopes here as needed
// Example: NotificationScope.ACCOUNT_LOCKED,
]);
/**
* Check if a notification scope requires guaranteed delivery
* @param scope - Notification scope to check
* @returns true if the scope requires guaranteed delivery
*/
export function requiresGuaranteedDelivery(scope: NotificationScope): boolean {
return CRITICAL_NOTIFICATION_SCOPES.has(scope);
}

View File

@ -1 +1,3 @@
export * from './notification-page-meta.interface';
export * from './notification-events.interface';
export * from './messaging-system.interface';

View File

@ -0,0 +1,25 @@
/**
* Interface for messaging systems (Redis PubSub, RabbitMQ, Kafka, etc.)
* Allows switching between different messaging systems based on notification requirements
*/
export interface IMessagingSystem {
/**
* Publish a notification event
* @param channel - Channel/topic name
* @param payload - Notification payload
*/
publish(channel: string, payload: any): Promise<void>;
/**
* Subscribe to a channel
* @param channel - Channel/topic name
* @param handler - Message handler function
*/
subscribe(channel: string, handler: (message: any) => Promise<void>): Promise<void>;
/**
* Get system name (for logging)
*/
getName(): string;
}

View File

@ -0,0 +1,221 @@
import { Transaction } from '~/card/entities/transaction.entity';
import { Card } from '~/card/entities/card.entity';
import { MoneyRequest } from '~/money-request/entities/money-request.entity';
import { Customer } from '~/customer/entities';
import { KycStatus } from '~/customer/enums';
/**
* Event payload for when a transaction is created
* Used to notify users about transactions (spending or top-ups)
*/
export interface ITransactionCreatedEvent {
/** The transaction that was created */
transaction: Transaction;
/** The card used in the transaction (with all relations loaded) */
card: Card;
/** True if this is a top-up/load transaction, false if spending */
isTopUp: boolean;
/** True if this transaction was made by a child (requires parent notification) */
isChildSpending: boolean;
/** When the event occurred */
timestamp: Date;
}
/**
* Event payload for when a money request is created
* Used to notify parents when their child requests money
*/
export interface IMoneyRequestCreatedEvent {
/** The money request that was created */
moneyRequest: MoneyRequest;
/** When the event occurred */
timestamp: Date;
}
/**
* Event payload for when a money request is approved
* Used to notify children when their money request is approved
*/
export interface IMoneyRequestApprovedEvent {
/** The money request that was approved */
moneyRequest: MoneyRequest;
/** When the event occurred */
timestamp: Date;
}
/**
* Event payload for when a money request is declined
* Used to notify children when their money request is declined
*/
export interface IMoneyRequestDeclinedEvent {
/** The money request that was declined */
moneyRequest: MoneyRequest;
/** Rejection reason provided by parent */
rejectionReason?: string;
/** When the event occurred */
timestamp: Date;
}
/**
* Event payload for when KYC is approved
* Used to notify users when their KYC verification is approved
*/
export interface IKycApprovedEvent {
/** The customer whose KYC was approved */
customer: Customer;
/** Previous KYC status */
previousStatus: KycStatus;
/** When the event occurred */
timestamp: Date;
}
/**
* Event payload for when KYC is rejected
* Used to notify users when their KYC verification is rejected
*/
export interface IKycRejectedEvent {
/** The customer whose KYC was rejected */
customer: Customer;
/** Previous KYC status */
previousStatus: KycStatus;
/** Rejection reason (if provided) */
rejectionReason?: string;
/** When the event occurred */
timestamp: Date;
}
/**
* Event payload for when a card is created
* Used to notify users when their card is successfully created
*/
export interface ICardCreatedEvent {
/** The card that was created */
card: Card;
/** When the event occurred */
timestamp: Date;
}
/**
* Event payload for when a card is blocked
* Used to notify users when their card is blocked
*/
export interface ICardBlockedEvent {
/** The card that was blocked */
card: Card;
/** Previous card status */
previousStatus: string;
/** Block reason/description */
blockReason?: string;
/** When the event occurred */
timestamp: Date;
}
/**
* Event payload for when a card is reissued
* Used to notify users when their card is reissued
*/
export interface ICardReissuedEvent {
/** The new card that was issued */
card: Card;
/** The old card that was replaced */
oldCardId?: string;
/** When the event occurred */
timestamp: Date;
}
/**
* Event payload for when a user profile is updated
* Used to notify users when their profile information is changed
*/
export interface IProfileUpdatedEvent {
/** The user whose profile was updated */
user: any;
/** Fields that were updated */
updatedFields: string[];
/** When the event occurred */
timestamp: Date;
}
/**
* Event payload for system maintenance alerts
* Used to notify users about scheduled or unscheduled maintenance
*/
export interface IMaintenanceAlertEvent {
/** User ID to notify (null for broadcast to all users) */
userId: string | null;
/** Maintenance message */
message: string;
/** Scheduled start time */
startTime?: Date;
/** Scheduled end time */
endTime?: Date;
/** When the event occurred */
timestamp: Date;
}
/**
* Event payload for failed transaction alerts
* Used to notify users when a transaction fails
*/
export interface ITransactionFailedEvent {
/** The user whose transaction failed */
userId: string;
/** Transaction details */
transactionId?: string;
/** Failure reason */
reason: string;
/** Transaction amount (if applicable) */
amount?: number;
/** When the event occurred */
timestamp: Date;
}
/**
* Event payload for suspicious login detection
* Used to notify users about suspicious login attempts
*/
export interface ISuspiciousLoginEvent {
/** The user whose account had suspicious activity */
userId: string;
/** IP address of the login attempt */
ipAddress?: string;
/** Location of the login attempt */
location?: string;
/** Device information */
device?: string;
/** When the event occurred */
timestamp: Date;
}

View File

@ -0,0 +1,162 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { I18nService } from 'nestjs-i18n';
import { NotificationFactory, NotificationPreferences } from '../services/notification-factory.service';
import { UserService } from '~/user/services/user.service';
import { NOTIFICATION_EVENTS } from '../constants/event-names.constant';
import { ICardBlockedEvent, ICardCreatedEvent } from '../interfaces/notification-events.interface';
import { NotificationScope } from '../enums/notification-scope.enum';
import { User } from '~/user/entities';
import { UserLocale } from '~/core/enums/user-locale.enum';
@Injectable()
export class CardNotificationListener {
private readonly logger = new Logger(CardNotificationListener.name);
constructor(
private readonly notificationFactory: NotificationFactory,
private readonly userService: UserService,
private readonly i18n: I18nService,
) {}
@OnEvent(NOTIFICATION_EVENTS.CARD_CREATED)
async handleCardCreated(event: ICardCreatedEvent): Promise<void> {
try {
const { card } = event;
const user = card?.customer?.user;
if (!user) {
this.logger.warn(`No user found for card ${card.id}, skipping card created notification`);
return;
}
const locale = this.getUserLocale(user);
const lastFourDigits = card.lastFourDigits;
let title: string;
let message: string;
try {
title = this.i18n.t('app.NOTIFICATION.CARD_CREATED_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.CARD_CREATED_MESSAGE', {
lang: locale,
args: {
lastFourDigits: lastFourDigits,
},
});
} catch (i18nError: any) {
this.logger.error(
`[CardNotificationListener] i18n error for user ${user.id}: ${i18nError?.message || 'Unknown i18n error'}. Falling back to English.`,
i18nError?.stack
);
title = 'Card Created';
message = `Your card ending in ${lastFourDigits} has been created successfully. You can start using it once it's activated.`;
}
this.logger.debug(
`Notifying user (user ${user.id}): Card created - ${lastFourDigits}`
);
await this.notificationFactory.send({
userId: user.id,
title,
message,
scope: NotificationScope.CARD_CREATED,
preferences: this.getUserPreferences(user),
data: {
cardId: card.id,
lastFourDigits: lastFourDigits,
cardReference: card.cardReference,
status: card.status,
timestamp: event.timestamp.toISOString(),
action: 'VIEW_CARD',
},
});
this.logger.log(`✅ Notified user ${user.id} about card creation`);
} catch (error: any) {
this.logger.error(
`Failed to process card created notification: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
@OnEvent(NOTIFICATION_EVENTS.CARD_BLOCKED)
async handleCardBlocked(event: ICardBlockedEvent): Promise<void> {
try {
const { card, blockReason } = event;
const user = card?.customer?.user;
if (!user) {
this.logger.warn(`No user found for card ${card.id}, skipping card blocked notification`);
return;
}
const locale = this.getUserLocale(user);
const lastFourDigits = card.lastFourDigits;
const reason = blockReason || 'Card has been blocked';
let title: string;
let message: string;
try {
title = this.i18n.t('app.NOTIFICATION.CARD_BLOCKED_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.CARD_BLOCKED_MESSAGE', {
lang: locale,
args: {
lastFourDigits: lastFourDigits,
reason: reason,
},
});
} catch (i18nError: any) {
this.logger.error(
`[CardNotificationListener] i18n error for user ${user.id}: ${i18nError?.message || 'Unknown i18n error'}. Falling back to English.`,
i18nError?.stack
);
title = 'Card Blocked';
message = `Your card ending in ${lastFourDigits} has been blocked. Reason: ${reason}. Please contact support for assistance.`;
}
this.logger.debug(
`Notifying user (user ${user.id}): Card blocked - ${lastFourDigits}, reason: ${reason}`
);
await this.notificationFactory.send({
userId: user.id,
title,
message,
scope: NotificationScope.CARD_BLOCKED,
preferences: this.getUserPreferences(user),
data: {
cardId: card.id,
lastFourDigits: lastFourDigits,
cardReference: card.cardReference,
status: card.status,
blockReason: reason,
timestamp: event.timestamp.toISOString(),
action: 'CONTACT_SUPPORT',
},
});
this.logger.log(`✅ Notified user ${user.id} about card being blocked`);
} catch (error: any) {
this.logger.error(
`Failed to process card blocked notification: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
private getUserPreferences(user: User): NotificationPreferences {
return {
isPushEnabled: user.isPushEnabled,
isEmailEnabled: user.isEmailEnabled,
isSmsEnabled: user.isSmsEnabled,
};
}
private getUserLocale(user: User): UserLocale {
return UserLocale.ENGLISH;
}
}

View File

@ -1 +1,7 @@
export * from './notification-created.listener';
export * from './transaction-notification.listener';
export * from './money-request-notification.listener';
export * from './kyc-notification.listener';
export * from './card-notification.listener';
export * from './profile-notification.listener';
export * from './system-alert-notification.listener';

View File

@ -0,0 +1,233 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { I18nService } from 'nestjs-i18n';
import { NotificationFactory, NotificationPreferences } from '../services/notification-factory.service';
import { UserService } from '~/user/services/user.service';
import { NOTIFICATION_EVENTS } from '../constants/event-names.constant';
import {
IKycApprovedEvent,
IKycRejectedEvent,
} from '../interfaces/notification-events.interface';
import { NotificationScope } from '../enums/notification-scope.enum';
import { User } from '~/user/entities';
import { UserLocale } from '~/core/enums/user-locale.enum';
/**
* KycNotificationListener
*
* Handles notifications for KYC update events.
* Notifies users when their KYC verification is approved or rejected.
*
* Responsibilities:
* - Listen for KYC approval/rejection events
* - Determine notification recipient (the user whose KYC was updated)
* - Construct appropriate messages with rejection reason if applicable
* - Fetch user preferences
* - Call NotificationFactory to send
*/
@Injectable()
export class KycNotificationListener {
private readonly logger = new Logger(KycNotificationListener.name);
constructor(
private readonly notificationFactory: NotificationFactory,
private readonly userService: UserService,
private readonly i18n: I18nService,
) {}
/**
* Handle KYC approved event
* Notifies user when their KYC verification is approved
*/
@OnEvent(NOTIFICATION_EVENTS.KYC_APPROVED)
async handleKycApproved(event: IKycApprovedEvent): Promise<void> {
try {
const { customer } = event;
this.logger.log(
`Processing KYC approved notification for customer ${customer.id}`
);
await this.notifyUserOfKycApproval(customer);
this.logger.log(
`KYC approved notification processed successfully for customer ${customer.id}`
);
} catch (error: any) {
this.logger.error(
`Failed to process KYC approved notification: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Handle KYC rejected event
* Notifies user when their KYC verification is rejected
*/
@OnEvent(NOTIFICATION_EVENTS.KYC_REJECTED)
async handleKycRejected(event: IKycRejectedEvent): Promise<void> {
try {
const { customer, rejectionReason } = event;
this.logger.log(
`Processing KYC rejected notification for customer ${customer.id} - Reason: ${rejectionReason || 'Not provided'}`
);
await this.notifyUserOfKycRejection(customer, rejectionReason);
this.logger.log(
`KYC rejected notification processed successfully for customer ${customer.id}`
);
} catch (error: any) {
this.logger.error(
`Failed to process KYC rejected notification: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Notify user when their KYC is approved
*/
private async notifyUserOfKycApproval(customer: any): Promise<void> {
try {
const user = customer?.user;
if (!user) {
this.logger.warn(`No user found for customer ${customer.id}, skipping notification`);
return;
}
const locale = this.getUserLocale(user);
let title: string;
let message: string;
try {
title = this.i18n.t('app.NOTIFICATION.KYC_APPROVED_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.KYC_APPROVED_MESSAGE', {
lang: locale,
});
} catch (i18nError: any) {
this.logger.error(
`[KycNotificationListener] i18n error for user ${user.id}: ${i18nError?.message || 'Unknown i18n error'}. Falling back to English.`,
i18nError?.stack
);
title = 'KYC Verification Approved';
message = 'Your KYC verification has been approved. You can now use all features of the app.';
}
this.logger.debug(
`Notifying user (user ${user.id}): KYC approved`
);
await this.notificationFactory.send({
userId: user.id,
title,
message,
scope: NotificationScope.KYC_APPROVED,
preferences: this.getUserPreferences(user),
data: {
customerId: customer.id,
kycStatus: 'APPROVED',
timestamp: new Date().toISOString(),
type: 'KYC_APPROVED',
action: 'VIEW_PROFILE',
},
});
this.logger.log(`✅ Notified user ${user.id} about KYC approval`);
} catch (error: any) {
this.logger.error(
`Failed to notify user of KYC approval: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Notify user when their KYC is rejected
*/
private async notifyUserOfKycRejection(customer: any, rejectionReason?: string): Promise<void> {
try {
const user = customer?.user;
if (!user) {
this.logger.warn(`No user found for customer ${customer.id}, skipping notification`);
return;
}
const locale = this.getUserLocale(user);
const reason = rejectionReason || customer.rejectionReason || 'KYC verification failed';
let title: string;
let message: string;
try {
title = this.i18n.t('app.NOTIFICATION.KYC_REJECTED_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.KYC_REJECTED_MESSAGE', {
lang: locale,
args: {
reason: reason,
},
});
} catch (i18nError: any) {
this.logger.error(
`[KycNotificationListener] i18n error for user ${user.id}: ${i18nError?.message || 'Unknown i18n error'}. Falling back to English.`,
i18nError?.stack
);
title = 'KYC Verification Rejected';
message = `Your KYC verification has been rejected. Reason: ${reason}. Please review your information and try again.`;
}
this.logger.debug(
`Notifying user (user ${user.id}): KYC rejected - ${reason}`
);
await this.notificationFactory.send({
userId: user.id,
title,
message,
scope: NotificationScope.KYC_REJECTED,
preferences: this.getUserPreferences(user),
data: {
customerId: customer.id,
kycStatus: 'REJECTED',
rejectionReason: reason,
timestamp: new Date().toISOString(),
type: 'KYC_REJECTED',
action: 'RETRY_KYC',
},
});
this.logger.log(`✅ Notified user ${user.id} about KYC rejection`);
} catch (error: any) {
this.logger.error(
`Failed to notify user of KYC rejection: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Extract user preferences from User entity
* Converts User properties to NotificationPreferences interface
*/
private getUserPreferences(user: User): NotificationPreferences {
return {
isPushEnabled: user.isPushEnabled,
isEmailEnabled: user.isEmailEnabled,
isSmsEnabled: user.isSmsEnabled,
};
}
/**
* Get user locale for i18n translations
* Defaults to English if not specified
* TODO: Add locale field to User entity in the future
*/
private getUserLocale(user: User): UserLocale {
// For now, default to English
// In the future, this can read from user.locale or user.preferences.locale
return UserLocale.ENGLISH;
}
}

View File

@ -0,0 +1,335 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { I18nService } from 'nestjs-i18n';
import { NotificationFactory, NotificationPreferences } from '../services/notification-factory.service';
import { UserService } from '~/user/services/user.service';
import { NOTIFICATION_EVENTS } from '../constants/event-names.constant';
import {
IMoneyRequestApprovedEvent,
IMoneyRequestCreatedEvent,
IMoneyRequestDeclinedEvent,
} from '../interfaces/notification-events.interface';
import { NotificationScope } from '../enums/notification-scope.enum';
import { User } from '~/user/entities';
import { MoneyRequest } from '~/money-request/entities/money-request.entity';
import { UserLocale } from '~/core/enums/user-locale.enum';
import { formatCurrencyAmount, getCurrency } from '~/common/utils/currency.util';
/**
* MoneyRequestNotificationListener
*
* Handles notifications for money request events.
* Notifies parents when children request money, and children when requests are approved/declined.
*
* Responsibilities:
* - Listen for money request events (created, approved, declined)
* - Determine notification recipients (parent or child)
* - Construct appropriate messages
* - Fetch user preferences
* - Call NotificationFactory to send
*/
@Injectable()
export class MoneyRequestNotificationListener {
private readonly logger = new Logger(MoneyRequestNotificationListener.name);
constructor(
private readonly notificationFactory: NotificationFactory,
private readonly userService: UserService,
private readonly i18n: I18nService,
) {}
/**
* Handle money request created event
* Notifies parent when child requests money
*/
@OnEvent(NOTIFICATION_EVENTS.MONEY_REQUEST_CREATED)
async handleMoneyRequestCreated(event: IMoneyRequestCreatedEvent): Promise<void> {
try {
const { moneyRequest } = event;
this.logger.log(
`Processing money request notification for request ${moneyRequest.id} - ` +
`Amount: $${moneyRequest.amount}, Reason: ${moneyRequest.reason}`
);
await this.notifyParentOfMoneyRequest(moneyRequest);
this.logger.log(
`Money request notification processed successfully for request ${moneyRequest.id}`
);
} catch (error: any) {
this.logger.error(
`Failed to process money request notification: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Handle money request approved event
* Notifies child when their money request is approved
*/
@OnEvent(NOTIFICATION_EVENTS.MONEY_REQUEST_APPROVED)
async handleMoneyRequestApproved(event: IMoneyRequestApprovedEvent): Promise<void> {
try {
const { moneyRequest } = event;
this.logger.log(
`Processing money request approved notification for request ${moneyRequest.id}`
);
await this.notifyChildOfApproval(moneyRequest);
this.logger.log(
`Money request approved notification processed successfully for request ${moneyRequest.id}`
);
} catch (error: any) {
this.logger.error(
`Failed to process money request approved notification: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Handle money request declined event
* Notifies child when their money request is declined
*/
@OnEvent(NOTIFICATION_EVENTS.MONEY_REQUEST_DECLINED)
async handleMoneyRequestDeclined(event: IMoneyRequestDeclinedEvent): Promise<void> {
try {
const { moneyRequest, rejectionReason } = event;
this.logger.log(
`Processing money request declined notification for request ${moneyRequest.id}`
);
await this.notifyChildOfRejection(moneyRequest, rejectionReason);
this.logger.log(
`Money request declined notification processed successfully for request ${moneyRequest.id}`
);
} catch (error: any) {
this.logger.error(
`Failed to process money request declined notification: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Notify parent when child requests money
*/
private async notifyParentOfMoneyRequest(moneyRequest: any): Promise<void> {
try {
const guardian = moneyRequest?.guardian;
const parentUser = guardian?.customer?.user;
if (!parentUser) {
this.logger.warn(`No parent user found for money request ${moneyRequest.id}, skipping notification`);
return;
}
const child = moneyRequest?.junior;
const childUser = child?.customer?.user;
const childName = childUser?.firstName || 'Your child';
const amount = typeof moneyRequest.amount === 'string' ? parseFloat(moneyRequest.amount) : moneyRequest.amount;
const reason = moneyRequest.reason || 'No reason provided';
const accountCurrency = child?.customer?.cards?.[0]?.account?.currency;
const currency = getCurrency(accountCurrency, null, 'SAR');
const formattedAmount = formatCurrencyAmount(amount, currency);
const locale = this.getUserLocale(parentUser);
this.logger.debug(
`Notifying parent (user ${parentUser.id}): ${childName} requested ${formattedAmount} ${currency} for ${reason}`
);
let title: string;
let message: string;
try {
title = this.i18n.t('app.NOTIFICATION.MONEY_REQUEST_CREATED_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.MONEY_REQUEST_CREATED_MESSAGE', {
lang: locale,
args: {
childName: childName,
amount: formattedAmount,
currency: currency,
reason: reason,
},
});
} catch (i18nError: any) {
this.logger.error(
`[MoneyRequestNotificationListener] i18n error for parent ${parentUser.id}: ${i18nError?.message || 'Unknown i18n error'}. Falling back to English.`,
i18nError?.stack
);
title = 'Money Request';
message = `${childName} has requested ${formattedAmount} ${currency} for ${reason}.`;
}
await this.notificationFactory.send({
userId: parentUser.id,
title,
message,
scope: NotificationScope.MONEY_REQUEST_CREATED,
preferences: this.getUserPreferences(parentUser),
data: {
moneyRequestId: moneyRequest.id,
childId: childUser?.id,
childName: childName,
amount: formattedAmount,
currency: currency,
reason: reason,
timestamp: moneyRequest.createdAt.toISOString(),
type: 'MONEY_REQUEST',
action: 'VIEW_MONEY_REQUEST',
},
});
this.logger.log(`✅ Notified parent ${parentUser.id} about money request ${moneyRequest.id}`);
} catch (error: any) {
this.logger.error(
`Failed to notify parent of money request: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Notify child when their money request is approved
*/
private async notifyChildOfApproval(moneyRequest: any): Promise<void> {
try {
const child = moneyRequest?.junior;
const childUser = child?.customer?.user;
if (!childUser) {
this.logger.warn(`No child user found for money request ${moneyRequest.id}, skipping notification`);
return;
}
const amount = typeof moneyRequest.amount === 'string' ? parseFloat(moneyRequest.amount) : moneyRequest.amount;
const accountCurrency = child?.customer?.cards?.[0]?.account?.currency;
const currency = getCurrency(accountCurrency, null, 'SAR');
const formattedAmount = formatCurrencyAmount(amount, currency);
this.logger.debug(
`Notifying child (user ${childUser.id}): Money request of ${formattedAmount} ${currency} was approved`
);
await this.notificationFactory.send({
userId: childUser.id,
title: 'Money Request Approved',
message: `Your request for ${formattedAmount} ${currency} has been approved. The money has been added to your account.`,
scope: NotificationScope.MONEY_REQUEST_APPROVED,
preferences: this.getUserPreferences(childUser),
data: {
moneyRequestId: moneyRequest.id,
amount: formattedAmount,
currency: currency,
timestamp: moneyRequest.updatedAt.toISOString(),
type: 'MONEY_REQUEST_APPROVED',
action: 'VIEW_MONEY_REQUEST',
},
});
this.logger.log(`✅ Notified child ${childUser.id} about approved money request ${moneyRequest.id}`);
} catch (error: any) {
this.logger.error(
`Failed to notify child of approval: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Notify child when their money request is declined
*/
private async notifyChildOfRejection(moneyRequest: any, rejectionReason?: string): Promise<void> {
try {
const child = moneyRequest?.junior;
const childUser = child?.customer?.user;
if (!childUser) {
this.logger.warn(`No child user found for money request ${moneyRequest.id}, skipping notification`);
return;
}
const amount = typeof moneyRequest.amount === 'string' ? parseFloat(moneyRequest.amount) : moneyRequest.amount;
const accountCurrency = child?.customer?.cards?.[0]?.account?.currency;
const currency = getCurrency(accountCurrency, null, 'SAR');
const formattedAmount = formatCurrencyAmount(amount, currency);
const reason = rejectionReason || 'No reason provided';
const locale = this.getUserLocale(childUser);
this.logger.debug(
`Notifying child (user ${childUser.id}): Money request of ${formattedAmount} ${currency} was declined`
);
let title: string;
let message: string;
try {
title = this.i18n.t('app.NOTIFICATION.MONEY_REQUEST_DECLINED_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.MONEY_REQUEST_DECLINED_MESSAGE', {
lang: locale,
args: {
amount: formattedAmount,
currency: currency,
reason: reason,
},
});
} catch (i18nError: any) {
this.logger.error(
`[MoneyRequestNotificationListener] i18n error for child ${childUser.id}: ${i18nError?.message || 'Unknown i18n error'}. Falling back to English.`,
i18nError?.stack
);
title = 'Money Request Declined';
message = `Your request for ${formattedAmount} ${currency} has been declined. Reason: ${reason}`;
}
await this.notificationFactory.send({
userId: childUser.id,
title,
message,
scope: NotificationScope.MONEY_REQUEST_DECLINED,
preferences: this.getUserPreferences(childUser),
data: {
moneyRequestId: moneyRequest.id,
amount: formattedAmount,
currency: currency,
rejectionReason: reason,
timestamp: moneyRequest.updatedAt.toISOString(),
type: 'MONEY_REQUEST_DECLINED',
action: 'VIEW_MONEY_REQUEST',
},
});
this.logger.log(`✅ Notified child ${childUser.id} about declined money request ${moneyRequest.id}`);
} catch (error: any) {
this.logger.error(
`Failed to notify child of rejection: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Extract user preferences from User entity
* Converts User properties to NotificationPreferences interface
*/
private getUserPreferences(user: User): NotificationPreferences {
return {
isPushEnabled: user.isPushEnabled,
isEmailEnabled: user.isEmailEnabled,
isSmsEnabled: user.isSmsEnabled,
};
}
private getUserLocale(user: User): UserLocale {
// For now, default to English
// In the future, this can read from user.locale or user.preferences.locale
return UserLocale.ENGLISH;
}
}

View File

@ -6,6 +6,7 @@ import { EventType, NotificationChannel, NotificationScope } from '~/common/modu
import { FirebaseService, TwilioService } from '~/common/modules/notification/services';
import { IEventInterface } from '~/common/redis/interface';
import { DeviceService } from '~/user/services';
import { UserService } from '~/user/services/user.service';
@Injectable()
export class NotificationCreatedListener {
@ -16,6 +17,7 @@ export class NotificationCreatedListener {
private readonly deviceService: DeviceService,
private readonly mailerService: MailerService,
private readonly firebaseService: FirebaseService,
private readonly userService: UserService,
) {}
/**
@ -31,7 +33,7 @@ export class NotificationCreatedListener {
return this.sendSMS(event.recipient!, event.message);
case NotificationChannel.PUSH:
return this.sendPushNotification(event.userId, event.title, event.message);
return this.sendPushNotification(event.userId, event.title, event.message, event.data);
case NotificationChannel.EMAIL:
return this.sendEmail({
@ -54,15 +56,54 @@ export class NotificationCreatedListener {
}
}
private async sendPushNotification(userId: string, title: string, body: string) {
this.logger.log(`Sending push notification to user ${userId}`);
const tokens = await this.deviceService.getTokens(userId);
private async sendPushNotification(
userId: string,
title: string,
body: string,
data?: Record<string, any>,
) {
try {
// Check if user has push notifications enabled
const user = await this.userService.findUser({ id: userId });
if (!user) {
this.logger.warn(`User ${userId} not found, skipping push notification`);
return;
}
if (!tokens.length) {
this.logger.log(`No device tokens found for user ${userId}, but notification was created in the DB.`);
return;
if (!user.isPushEnabled) {
this.logger.log(
`Push notifications disabled for user ${userId}, notification saved to DB but push not sent`
);
return;
}
this.logger.log(`Sending push notification to user ${userId}`);
const tokens = await this.deviceService.getTokens(userId);
if (!tokens.length) {
this.logger.log(`No device tokens found for user ${userId}, but notification was created in the DB.`);
return;
}
// Convert data to string values (Firebase requires string values in data payload)
const stringData: Record<string, string> | undefined = data
? Object.entries(data).reduce(
(acc, [key, value]) => {
acc[key] = String(value);
return acc;
},
{} as Record<string, string>,
)
: undefined;
return this.firebaseService.sendNotification(tokens, title, body, stringData);
} catch (error: any) {
this.logger.error(
`Failed to send push notification to user ${userId}: ${error?.message || 'Unknown error'}`,
error?.stack
);
// Don't throw - notification is already saved to DB
}
return this.firebaseService.sendNotification(tokens, title, body);
}
private async sendSMS(to: string, body: string) {

View File

@ -0,0 +1,158 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { I18nService } from 'nestjs-i18n';
import { Roles } from '~/auth/enums';
import { NotificationFactory, NotificationPreferences } from '../services/notification-factory.service';
import { UserService } from '~/user/services/user.service';
import { NOTIFICATION_EVENTS } from '../constants/event-names.constant';
import { IProfileUpdatedEvent } from '../interfaces/notification-events.interface';
import { NotificationScope } from '../enums/notification-scope.enum';
import { User } from '~/user/entities';
import { UserLocale } from '~/core/enums/user-locale.enum';
@Injectable()
export class ProfileNotificationListener {
private readonly logger = new Logger(ProfileNotificationListener.name);
constructor(
private readonly notificationFactory: NotificationFactory,
private readonly userService: UserService,
private readonly i18n: I18nService,
) {}
@OnEvent(NOTIFICATION_EVENTS.PROFILE_UPDATED)
async handleProfileUpdated(event: IProfileUpdatedEvent): Promise<void> {
try {
const { user, updatedFields } = event;
// Do not notify when a child updates their profile (no notification to child or parent)
if (user?.roles?.includes(Roles.JUNIOR)) {
this.logger.log(
`Skipping profile updated notification for child user ${user.id} - no notification sent`
);
return;
}
this.logger.log(
`Processing profile updated notification for user ${user.id} - Updated fields: ${updatedFields.join(', ')}`
);
await this.notifyUserOfProfileUpdate(user, updatedFields);
this.logger.log(
`Profile updated notification processed successfully for user ${user.id}`
);
} catch (error: any) {
this.logger.error(
`Failed to process profile updated notification: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
private async notifyUserOfProfileUpdate(user: any, updatedFields: string[]): Promise<void> {
try {
if (!user) {
this.logger.warn(`No user found, skipping profile update notification`);
return;
}
const locale = this.getUserLocale(user);
const isEmailUpdate = updatedFields.includes('email');
const isPasswordUpdate = updatedFields.includes('password');
const isProfilePictureUpdate = updatedFields.includes('profilePictureId');
const isNameUpdate = updatedFields.includes('firstName') || updatedFields.includes('lastName');
let title: string;
let message: string;
try {
if (isEmailUpdate) {
title = this.i18n.t('app.NOTIFICATION.PROFILE_EMAIL_UPDATED_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.PROFILE_EMAIL_UPDATED_MESSAGE', {
lang: locale,
args: {
email: user.email || 'your email',
},
});
} else if (isPasswordUpdate) {
title = this.i18n.t('app.NOTIFICATION.PROFILE_PASSWORD_UPDATED_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.PROFILE_PASSWORD_UPDATED_MESSAGE', {
lang: locale,
});
} else if (isProfilePictureUpdate) {
title = this.i18n.t('app.NOTIFICATION.PROFILE_PICTURE_UPDATED_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.PROFILE_PICTURE_UPDATED_MESSAGE', {
lang: locale,
});
} else if (isNameUpdate) {
title = this.i18n.t('app.NOTIFICATION.PROFILE_NAME_UPDATED_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.PROFILE_NAME_UPDATED_MESSAGE', {
lang: locale,
});
} else {
title = this.i18n.t('app.NOTIFICATION.PROFILE_UPDATED_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.PROFILE_UPDATED_MESSAGE', {
lang: locale,
args: {
fields: updatedFields.join(', '),
},
});
}
} catch (i18nError: any) {
this.logger.error(
`[ProfileNotificationListener] i18n error for user ${user.id}: ${i18nError?.message || 'Unknown i18n error'}. Falling back to English.`,
i18nError?.stack
);
if (isEmailUpdate) {
title = 'Email Updated';
message = `Your email has been updated to ${user.email || 'a new email'}. Please verify your new email address.`;
} else if (isPasswordUpdate) {
title = 'Password Updated';
message = 'Your password has been successfully updated. If you did not make this change, please contact support immediately.';
} else {
title = 'Profile Updated';
message = `Your profile has been updated. Changes: ${updatedFields.join(', ')}`;
}
}
this.logger.debug(
`Notifying user (user ${user.id}): Profile updated - ${updatedFields.join(', ')}`
);
await this.notificationFactory.send({
userId: user.id,
title,
message,
scope: NotificationScope.PROFILE_UPDATED,
preferences: this.getUserPreferences(user),
data: {
updatedFields: updatedFields,
timestamp: new Date().toISOString(),
type: 'PROFILE_UPDATE',
action: 'VIEW_PROFILE',
},
});
this.logger.log(`✅ Notified user ${user.id} about profile update`);
} catch (error: any) {
this.logger.error(
`Failed to notify user of profile update: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
private getUserPreferences(user: User): NotificationPreferences {
return {
isPushEnabled: user.isPushEnabled,
isEmailEnabled: user.isEmailEnabled,
isSmsEnabled: user.isSmsEnabled,
};
}
private getUserLocale(user: User): UserLocale {
return UserLocale.ENGLISH;
}
}

View File

@ -0,0 +1,282 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { I18nService } from 'nestjs-i18n';
import { NotificationFactory, NotificationPreferences } from '../services/notification-factory.service';
import { UserService } from '~/user/services/user.service';
import { NOTIFICATION_EVENTS } from '../constants/event-names.constant';
import {
IMaintenanceAlertEvent,
ISuspiciousLoginEvent,
ITransactionFailedEvent,
} from '../interfaces/notification-events.interface';
import { NotificationScope } from '../enums/notification-scope.enum';
import { User } from '~/user/entities';
import { UserLocale } from '~/core/enums/user-locale.enum';
@Injectable()
export class SystemAlertNotificationListener {
private readonly logger = new Logger(SystemAlertNotificationListener.name);
constructor(
private readonly notificationFactory: NotificationFactory,
private readonly userService: UserService,
private readonly i18n: I18nService,
) {}
@OnEvent(NOTIFICATION_EVENTS.MAINTENANCE_ALERT)
async handleMaintenanceAlert(event: IMaintenanceAlertEvent): Promise<void> {
try {
const { userId, message, startTime, endTime } = event;
this.logger.log(
`Processing maintenance alert notification - User: ${userId || 'ALL'}, Message: ${message}`
);
if (userId) {
await this.notifyUserOfMaintenance(userId, message, startTime, endTime);
} else {
this.logger.warn('Broadcast maintenance alerts to all users not yet implemented');
}
this.logger.log(`Maintenance alert notification processed successfully`);
} catch (error: any) {
this.logger.error(
`Failed to process maintenance alert notification: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
@OnEvent(NOTIFICATION_EVENTS.TRANSACTION_FAILED)
async handleTransactionFailed(event: ITransactionFailedEvent): Promise<void> {
try {
const { userId, transactionId, reason, amount } = event;
this.logger.log(
`Processing transaction failed notification for user ${userId} - Transaction: ${transactionId}, Reason: ${reason}`
);
await this.notifyUserOfTransactionFailure(userId, transactionId, reason, amount);
this.logger.log(`Transaction failed notification processed successfully for user ${userId}`);
} catch (error: any) {
this.logger.error(
`Failed to process transaction failed notification: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
@OnEvent(NOTIFICATION_EVENTS.SUSPICIOUS_LOGIN)
async handleSuspiciousLogin(event: ISuspiciousLoginEvent): Promise<void> {
try {
const { userId, ipAddress, location, device } = event;
this.logger.log(
`Processing suspicious login notification for user ${userId} - IP: ${ipAddress}, Location: ${location}`
);
await this.notifyUserOfSuspiciousLogin(userId, ipAddress, location, device);
this.logger.log(`Suspicious login notification processed successfully for user ${userId}`);
} catch (error: any) {
this.logger.error(
`Failed to process suspicious login notification: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
private async notifyUserOfMaintenance(
userId: string,
message: string,
startTime?: Date,
endTime?: Date,
): Promise<void> {
try {
const user = await this.userService.findUserOrThrow({ id: userId });
const locale = this.getUserLocale(user);
let title: string;
let notificationMessage: string;
try {
title = this.i18n.t('app.NOTIFICATION.MAINTENANCE_ALERT_TITLE', { lang: locale });
notificationMessage = this.i18n.t('app.NOTIFICATION.MAINTENANCE_ALERT_MESSAGE', {
lang: locale,
args: {
message: message,
startTime: startTime ? startTime.toLocaleString() : '',
endTime: endTime ? endTime.toLocaleString() : '',
},
});
} catch (i18nError: any) {
this.logger.error(
`[SystemAlertNotificationListener] i18n error for user ${userId}: ${i18nError?.message || 'Unknown i18n error'}. Falling back to English.`,
i18nError?.stack
);
title = 'Scheduled Maintenance';
notificationMessage = message || 'The system will be under maintenance. Please check back later.';
if (startTime && endTime) {
notificationMessage += ` Scheduled from ${startTime.toLocaleString()} to ${endTime.toLocaleString()}.`;
}
}
await this.notificationFactory.send({
userId: user.id,
title,
message: notificationMessage,
scope: NotificationScope.MAINTENANCE_ALERT,
preferences: this.getUserPreferences(user),
data: {
message: message,
startTime: startTime?.toISOString(),
endTime: endTime?.toISOString(),
timestamp: new Date().toISOString(),
type: 'MAINTENANCE',
action: 'VIEW_STATUS',
},
});
this.logger.log(`✅ Notified user ${userId} about maintenance`);
} catch (error: any) {
this.logger.error(
`Failed to notify user of maintenance: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
private async notifyUserOfTransactionFailure(
userId: string,
transactionId: string | undefined,
reason: string,
amount?: number,
): Promise<void> {
try {
const user = await this.userService.findUserOrThrow({ id: userId });
const locale = this.getUserLocale(user);
let title: string;
let message: string;
try {
title = this.i18n.t('app.NOTIFICATION.TRANSACTION_FAILED_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.TRANSACTION_FAILED_MESSAGE', {
lang: locale,
args: {
reason: reason,
amount: amount ? amount.toString() : '',
},
});
} catch (i18nError: any) {
this.logger.error(
`[SystemAlertNotificationListener] i18n error for user ${userId}: ${i18nError?.message || 'Unknown i18n error'}. Falling back to English.`,
i18nError?.stack
);
title = 'Transaction Failed';
message = `Your transaction could not be completed. Reason: ${reason}.`;
if (amount) {
message += ` Amount: ${amount}`;
}
message += ' Please try again or contact support if the issue persists.';
}
await this.notificationFactory.send({
userId: user.id,
title,
message,
scope: NotificationScope.TRANSACTION_FAILED,
preferences: this.getUserPreferences(user),
data: {
transactionId: transactionId,
reason: reason,
amount: amount,
timestamp: new Date().toISOString(),
type: 'TRANSACTION_FAILED',
action: 'RETRY_TRANSACTION',
},
});
this.logger.log(`✅ Notified user ${userId} about failed transaction`);
} catch (error: any) {
this.logger.error(
`Failed to notify user of transaction failure: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
private async notifyUserOfSuspiciousLogin(
userId: string,
ipAddress?: string,
location?: string,
device?: string,
): Promise<void> {
try {
const user = await this.userService.findUserOrThrow({ id: userId });
const locale = this.getUserLocale(user);
let title: string;
let message: string;
try {
title = this.i18n.t('app.NOTIFICATION.SUSPICIOUS_LOGIN_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.SUSPICIOUS_LOGIN_MESSAGE', {
lang: locale,
args: {
location: location || 'unknown location',
device: device || 'unknown device',
ipAddress: ipAddress || 'unknown IP',
},
});
} catch (i18nError: any) {
this.logger.error(
`[SystemAlertNotificationListener] i18n error for user ${userId}: ${i18nError?.message || 'Unknown i18n error'}. Falling back to English.`,
i18nError?.stack
);
title = 'Suspicious Login Detected';
message = `We detected a login attempt from ${location || 'an unknown location'} (${ipAddress || 'unknown IP'})`;
if (device) {
message += ` using ${device}`;
}
message += '. If this was not you, please change your password immediately and contact support.';
}
await this.notificationFactory.send({
userId: user.id,
title,
message,
scope: NotificationScope.SUSPICIOUS_LOGIN,
preferences: this.getUserPreferences(user),
data: {
ipAddress: ipAddress,
location: location,
device: device,
timestamp: new Date().toISOString(),
type: 'SUSPICIOUS_LOGIN',
action: 'CHANGE_PASSWORD',
},
});
this.logger.log(`✅ Notified user ${userId} about suspicious login`);
} catch (error: any) {
this.logger.error(
`Failed to notify user of suspicious login: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
private getUserPreferences(user: User): NotificationPreferences {
return {
isPushEnabled: user.isPushEnabled,
isEmailEnabled: user.isEmailEnabled,
isSmsEnabled: user.isSmsEnabled,
};
}
private getUserLocale(user: User): UserLocale {
return UserLocale.ENGLISH;
}
}

View File

@ -0,0 +1,668 @@
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { I18nService } from 'nestjs-i18n';
import { NotificationFactory, NotificationPreferences } from '../services/notification-factory.service';
import { UserService } from '~/user/services/user.service';
import { AccountService } from '~/card/services/account.service';
import { CardService } from '~/card/services/card.service';
import { NOTIFICATION_EVENTS } from '../constants/event-names.constant';
import { ITransactionCreatedEvent } from '../interfaces/notification-events.interface';
import { NotificationScope } from '../enums/notification-scope.enum';
import { Transaction } from '~/card/entities/transaction.entity';
import { Card } from '~/card/entities/card.entity';
import { User } from '~/user/entities';
import { UserLocale } from '~/core/enums/user-locale.enum';
import { formatCurrencyAmount, getCurrency, numericToCurrencyCode } from '~/common/utils/currency.util';
/**
* TransactionNotificationListener
*
* Handles notifications for transaction events.
* Determines who should be notified and what message to send.
*
* Responsibilities:
* - Listen for transaction events
* - Determine notification recipients (child, parent, or both)
* - Construct appropriate messages
* - Fetch user preferences
* - Call NotificationFactory to send
*/
@Injectable()
export class TransactionNotificationListener {
private readonly logger = new Logger(TransactionNotificationListener.name);
constructor(
private readonly notificationFactory: NotificationFactory,
private readonly userService: UserService,
private readonly i18n: I18nService,
@Inject(forwardRef(() => AccountService))
private readonly accountService: AccountService,
@Inject(forwardRef(() => CardService))
private readonly cardService: CardService,
) {}
/**
* Main event handler for transaction created events
* Routes to appropriate notification logic based on transaction type
*/
@OnEvent(NOTIFICATION_EVENTS.TRANSACTION_CREATED)
async handleTransactionCreated(event: ITransactionCreatedEvent): Promise<void> {
try {
console.log(`[TransactionNotificationListener] Event received: ${NOTIFICATION_EVENTS.TRANSACTION_CREATED}`);
const { transaction, card, isTopUp, isChildSpending } = event;
this.logger.log(
`Processing transaction notification for transaction ${transaction.id} - ` +
`isTopUp: ${isTopUp}, isChildSpending: ${isChildSpending}`
);
console.log(`[TransactionNotificationListener] Transaction: ${transaction.id}, Card: ${card?.id}, isTopUp: ${isTopUp}, isChildSpending: ${isChildSpending}`);
await this.notifyTransactionOwner(transaction, card, isTopUp, isChildSpending);
if (isChildSpending) {
if (isTopUp) {
await this.notifyParentOfTopUp(transaction, card);
} else {
await this.notifyParentOfChildSpending(transaction, card);
}
}
this.logger.log(
`Transaction notification processed successfully for transaction ${transaction.id}`
);
} catch (error: any) {
console.error(`[TransactionNotificationListener] ERROR:`, error);
this.logger.error(
`Failed to process transaction notification: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Notify the transaction owner (the cardholder)
* Could be a child or a parent depending on whose card was used
*/
private async notifyTransactionOwner(
transaction: Transaction,
card: Card,
isTopUp: boolean,
isChildSpending: boolean
): Promise<void> {
try {
const user = card?.customer?.user;
if (!user) {
this.logger.warn(`No user found for transaction ${transaction.id}, skipping notification`);
return;
}
// Determine scope: internal transfer (parent to child) vs external top-up
let scope: NotificationScope;
if (isTopUp) {
scope = isChildSpending
? NotificationScope.CHILD_INTERNAL_TRANSFER // Parent transferring to child
: NotificationScope.CHILD_TOP_UP; // External top-up
} else {
scope = NotificationScope.CHILD_SPENDING;
}
const locale = this.getUserLocale(user);
const amount = transaction.transactionAmount;
const merchant = transaction.merchantName || 'merchant';
// For child notifications, show the appropriate balance based on account structure
let balance = 0;
let accountCurrency: string | undefined;
if (isTopUp && isChildSpending) {
// Internal transfer: For shared accounts, show card limit (child's spending power)
// For separate accounts, show child's account balance
try {
// Reload card to get updated data
const cardWithUpdatedBalance = await this.cardService.getCardById(card.id);
// Check if child has parent (shared account scenario)
if (cardWithUpdatedBalance.parentId) {
// Likely shared account - use card limit as the child's "balance"
balance = cardWithUpdatedBalance.limit || card.limit || 0;
accountCurrency = cardWithUpdatedBalance.account?.currency || card.account?.currency;
this.logger.debug(
`[Child Internal Transfer] Shared account - using card limit: ${balance} ${accountCurrency}`
);
} else {
// Separate account - use child's account balance
if (cardWithUpdatedBalance?.account?.accountReference) {
const account = await this.accountService.getAccountByReferenceNumber(
cardWithUpdatedBalance.account.accountReference
);
balance = account.balance;
accountCurrency = account.currency;
this.logger.debug(
`[Child Internal Transfer] Separate account - using account balance: ${balance} ${accountCurrency}`
);
} else {
balance = cardWithUpdatedBalance.account?.balance || card.account?.balance || 0;
accountCurrency = cardWithUpdatedBalance.account?.currency || card.account?.currency;
}
}
} catch (error: any) {
this.logger.warn(
`[Child Internal Transfer] Could not fetch balance: ${error?.message}. Using card limit.`
);
balance = card.limit || 0;
accountCurrency = card.account?.currency;
}
} else if (isTopUp) {
// External top-up: show child's account balance
try {
const cardWithUpdatedBalance = await this.cardService.getCardById(card.id);
if (cardWithUpdatedBalance?.account?.accountReference) {
const account = await this.accountService.getAccountByReferenceNumber(
cardWithUpdatedBalance.account.accountReference
);
balance = account.balance;
accountCurrency = account.currency;
this.logger.debug(
`[Child Top-Up Notification] Fetched account by reference - balance: ${balance} ${accountCurrency}`
);
} else {
balance = cardWithUpdatedBalance.account?.balance || card.account?.balance || 0;
accountCurrency = cardWithUpdatedBalance.account?.currency || card.account?.currency;
}
} catch (error: any) {
this.logger.warn(
`[Child Top-Up Notification] Could not fetch account: ${error?.message}. Using card balance.`
);
balance = card.account?.balance || 0;
accountCurrency = card.account?.currency;
}
} else {
// For spending: show account balance
try {
// Reload card to get account reference
const cardWithUpdatedBalance = await this.cardService.getCardById(card.id);
if (cardWithUpdatedBalance?.account?.accountReference) {
// Fetch by reference number to get fresh balance from database
const account = await this.accountService.getAccountByReferenceNumber(
cardWithUpdatedBalance.account.accountReference
);
balance = account.balance;
accountCurrency = account.currency;
this.logger.debug(
`[Child Spending Notification] Fetched account by reference - balance: ${balance} ${accountCurrency}`
);
} else {
// Fallback: use card's account balance
balance = cardWithUpdatedBalance.account?.balance || card.account?.balance || 0;
accountCurrency = cardWithUpdatedBalance.account?.currency || card.account?.currency;
this.logger.debug(
`[Child Spending Notification] Using card account balance - balance: ${balance} ${accountCurrency}`
);
}
} catch (error: any) {
this.logger.warn(
`[Child Spending Notification] Could not fetch account by reference: ${error?.message}. Using card account balance.`
);
// Fallback: use card's account balance
balance = card.account?.balance || 0;
accountCurrency = card.account?.currency;
}
}
const currency = getCurrency(
accountCurrency,
transaction.transactionCurrency,
'SAR'
);
this.logger.debug(
`[Child Notification] Account currency: ${accountCurrency}, Transaction currency: ${transaction.transactionCurrency}, Final currency: ${currency}, Balance: ${balance}, Amount: ${amount}`
);
const formattedAmount = formatCurrencyAmount(amount, currency);
const formattedBalance = formatCurrencyAmount(balance, currency);
let title: string;
let message: string;
try {
if (isTopUp) {
// Internal transfer or external top-up
const titleKey = isChildSpending
? 'app.NOTIFICATION.CHILD_INTERNAL_TRANSFER_TITLE'
: 'app.NOTIFICATION.CHILD_TOP_UP_TITLE';
const messageKey = isChildSpending
? 'app.NOTIFICATION.CHILD_INTERNAL_TRANSFER_MESSAGE'
: 'app.NOTIFICATION.CHILD_TOP_UP_MESSAGE';
title = this.i18n.t(titleKey, { lang: locale });
message = this.i18n.t(messageKey, {
lang: locale,
args: {
amount: formattedAmount,
currency: currency,
balance: formattedBalance,
},
});
} else {
// Spending
title = this.i18n.t('app.NOTIFICATION.CHILD_SPENDING_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.CHILD_SPENDING_MESSAGE', {
lang: locale,
args: {
amount: formattedAmount,
currency: currency,
merchant: merchant,
},
});
}
} catch (i18nError: any) {
console.error(`[TransactionNotificationListener] i18n error:`, i18nError);
this.logger.error(`i18n translation failed: ${i18nError?.message}`, i18nError?.stack);
// Fallback to English without i18n
title = isTopUp ? 'Funds Credited' : 'Purchase Successful';
message = isTopUp
? `${formattedAmount} ${currency} has been added to your card. Total balance: ${formattedBalance} ${currency}`
: `You spent ${formattedAmount} ${currency} at ${merchant}`;
}
this.logger.debug(
`Notifying transaction owner (user ${user.id}) - Amount: ${amount} ${currency}, Merchant: ${merchant}`
);
await this.notificationFactory.send({
userId: user.id,
title,
message,
scope,
preferences: this.getUserPreferences(user),
data: {
transactionId: transaction.id,
amount: formattedAmount,
currency: currency, // ISO currency code (SAR, USD, etc.)
merchant: merchant,
merchantCategory: transaction.merchantCategoryCode || 'OTHER',
balance: formattedBalance,
timestamp: transaction.transactionDate.toISOString(),
type: isTopUp ? 'TOP_UP' : 'SPENDING',
action: 'OPEN_TRANSACTION',
},
});
this.logger.log(`✅ Notified user ${user.id} for transaction ${transaction.id}`);
} catch (error: any) {
this.logger.error(
`Failed to notify transaction owner: ${error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Notify parent when their child makes a purchase
* This is a spending alert for parents to monitor their children's expenses
*/
private async notifyParentOfChildSpending(transaction: Transaction, card: Card): Promise<void> {
try {
this.logger.debug(`Checking for parent to notify about child spending`);
const customer = card?.customer;
const parentUser = customer?.junior?.guardian?.customer?.user;
if (!parentUser) {
this.logger.debug(`No parent found for transaction ${transaction.id}, skipping parent notification`);
return;
}
const childUser = customer.user;
const locale = this.getUserLocale(parentUser);
const defaultChildName = this.i18n.t('app.NOTIFICATION.YOUR_CHILD', { lang: locale });
const childName = childUser?.firstName || defaultChildName;
const amount = transaction.transactionAmount;
const merchant = transaction.merchantName || 'a merchant';
// Get parent's available balance (balance - reserved_balance) - reload to get fresh balance
let parentAccountBalance = 0;
let parentAccountReservedBalance = 0;
let parentAccountCurrency: string | undefined;
let availableBalance = 0;
try {
if (card.parentId) {
// Get parent's card to access their account reference
const parentCard = await this.cardService.getCardByCustomerId(card.parentId);
if (parentCard?.account?.accountReference) {
// Fetch by reference number to get fresh balance from database
const parentAccount = await this.accountService.getAccountByReferenceNumber(
parentCard.account.accountReference
);
parentAccountBalance = parentAccount.balance;
parentAccountReservedBalance = parentAccount.reservedBalance;
availableBalance = parentAccountBalance - parentAccountReservedBalance;
parentAccountCurrency = parentAccount.currency;
this.logger.debug(
`[Parent Spending] Fetched parent account by reference - balance: ${parentAccountBalance}, reserved: ${parentAccountReservedBalance}, available: ${availableBalance} ${parentAccountCurrency}`
);
} else {
// Fallback: try by customer ID
const parentAccount = await this.accountService.getAccountByCustomerId(card.parentId);
parentAccountBalance = parentAccount.balance;
parentAccountReservedBalance = parentAccount.reservedBalance;
availableBalance = parentAccountBalance - parentAccountReservedBalance;
parentAccountCurrency = parentAccount.currency;
this.logger.debug(
`[Parent Spending] Fetched parent account by customer ID - balance: ${parentAccountBalance}, reserved: ${parentAccountReservedBalance}, available: ${availableBalance} ${parentAccountCurrency}`
);
}
} else {
const parentCustomer = customer?.junior?.guardian?.customer;
if (parentCustomer?.id) {
try {
const parentCard = await this.cardService.getCardByCustomerId(parentCustomer.id);
if (parentCard?.account?.accountReference) {
const parentAccount = await this.accountService.getAccountByReferenceNumber(
parentCard.account.accountReference
);
parentAccountBalance = parentAccount.balance;
parentAccountReservedBalance = parentAccount.reservedBalance;
availableBalance = parentAccountBalance - parentAccountReservedBalance;
parentAccountCurrency = parentAccount.currency;
this.logger.debug(
`[Parent Spending] Fetched parent account via customer relation (by reference) - balance: ${parentAccountBalance}, reserved: ${parentAccountReservedBalance}, available: ${availableBalance} ${parentAccountCurrency}`
);
} else {
const parentAccount = await this.accountService.getAccountByCustomerId(parentCustomer.id);
parentAccountBalance = parentAccount.balance;
parentAccountReservedBalance = parentAccount.reservedBalance;
availableBalance = parentAccountBalance - parentAccountReservedBalance;
parentAccountCurrency = parentAccount.currency;
this.logger.debug(
`[Parent Spending] Fetched parent account via customer relation - balance: ${parentAccountBalance}, reserved: ${parentAccountReservedBalance}, available: ${availableBalance} ${parentAccountCurrency}`
);
}
} catch (error: any) {
this.logger.warn(
`[Parent Spending] Could not fetch parent account via customer: ${error?.message}. Using child account balance as fallback.`
);
availableBalance = card.account?.balance || 0;
parentAccountCurrency = card.account?.currency;
}
} else {
availableBalance = card.account?.balance || 0;
parentAccountCurrency = card.account?.currency;
}
}
} catch (error: any) {
this.logger.warn(`[Parent Spending] Could not fetch parent account: ${error?.message}, using child account balance as fallback`);
availableBalance = card.account?.balance || 0;
parentAccountCurrency = card.account?.currency;
}
const accountCurrency = parentAccountCurrency || card.account?.currency;
const currency = getCurrency(
accountCurrency,
transaction.transactionCurrency,
'SAR'
);
this.logger.debug(
`[Parent Spending Notification] Parent account currency: ${parentAccountCurrency}, Account currency: ${accountCurrency}, Transaction currency: ${transaction.transactionCurrency}, Final currency: ${currency}, Parent available balance: ${availableBalance}, Amount: ${amount}`
);
const formattedAmount = formatCurrencyAmount(amount, currency);
// Use available balance for parent spending notification
const formattedBalance = formatCurrencyAmount(availableBalance, currency);
this.logger.debug(
`Notifying parent (user ${parentUser.id}): ${childName} spent ${formattedAmount} ${currency} at ${merchant}`
);
let title: string;
let message: string;
try {
title = this.i18n.t('app.NOTIFICATION.PARENT_SPENDING_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.PARENT_SPENDING_MESSAGE', {
lang: locale,
args: {
childName: childName,
amount: formattedAmount,
currency: currency,
merchant: merchant,
balance: formattedBalance,
},
});
} catch (i18nError: any) {
console.error(`[TransactionNotificationListener] i18n error in parent spending:`, i18nError);
this.logger.error(`i18n translation failed: ${i18nError?.message}`, i18nError?.stack);
title = 'Spending Alert';
message = `${childName} spent ${formattedAmount} ${currency} at ${merchant}. Remaining balance: ${formattedBalance} ${currency}`;
}
await this.notificationFactory.send({
userId: parentUser.id,
title,
message,
scope: NotificationScope.PARENT_SPENDING_ALERT,
preferences: this.getUserPreferences(parentUser),
data: {
transactionId: transaction.id,
childId: childUser.id,
childName: childName,
amount: formattedAmount, // Use formatted amount instead of raw amount
currency: currency, // ISO currency code (SAR, USD, etc.)
merchant: merchant,
merchantCategory: transaction.merchantCategoryCode || 'OTHER',
balance: formattedBalance,
timestamp: transaction.transactionDate.toISOString(),
type: 'CHILD_SPENDING',
action: 'OPEN_TRANSACTION',
},
});
this.logger.log(`✅ Notified parent ${parentUser.id} about child spending`);
} catch (error: any) {
this.logger.error(
`Failed to notify parent of child spending: ${ error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Notify parent when they transfer money to their child's card (internal transfer)
* This is a confirmation notification for the parent
*/
private async notifyParentOfTopUp(transaction: Transaction, card: Card): Promise<void> {
try {
this.logger.debug(`Checking for parent to notify about top-up`);
const customer = card?.customer;
const parentUser = customer?.junior?.guardian?.customer?.user;
if (!parentUser) {
this.logger.debug(`No parent found for transaction ${transaction.id}, skipping parent notification`);
return;
}
const childUser = customer.user;
const locale = this.getUserLocale(parentUser);
const defaultChildName = this.i18n.t('app.NOTIFICATION.YOUR_CHILD', { lang: locale });
const childName = childUser?.firstName || defaultChildName;
const amount = transaction.transactionAmount;
// Fetch parent account by reference number to get fresh balance (bypasses entity cache)
// For parent notification, show available_balance = balance - reserved_balance
let parentAccountBalance = 0;
let parentAccountReservedBalance = 0;
let parentAccountCurrency: string | undefined;
let availableBalance = 0;
if (card.parentId) {
try {
// Get parent's card to access their account reference
// card.parentId is the parent's CUSTOMER ID
const parentCard = await this.cardService.getCardByCustomerId(card.parentId);
if (parentCard?.account?.accountReference) {
// Fetch by reference number to get fresh balance from database
const parentAccount = await this.accountService.getAccountByReferenceNumber(
parentCard.account.accountReference
);
parentAccountBalance = parentAccount.balance;
parentAccountReservedBalance = parentAccount.reservedBalance;
availableBalance = parentAccountBalance - parentAccountReservedBalance;
parentAccountCurrency = parentAccount.currency;
this.logger.debug(
`[Parent Top-Up] Fetched parent account by reference - balance: ${parentAccountBalance}, reserved: ${parentAccountReservedBalance}, available: ${availableBalance} ${parentAccountCurrency}`
);
} else {
// Fallback: try by customer ID
const parentAccount = await this.accountService.getAccountByCustomerId(card.parentId);
parentAccountBalance = parentAccount.balance;
parentAccountReservedBalance = parentAccount.reservedBalance;
availableBalance = parentAccountBalance - parentAccountReservedBalance;
parentAccountCurrency = parentAccount.currency;
this.logger.debug(
`[Parent Top-Up] Fetched parent account by customer ID - balance: ${parentAccountBalance}, reserved: ${parentAccountReservedBalance}, available: ${availableBalance} ${parentAccountCurrency}`
);
}
} catch (error: any) {
this.logger.warn(
`[Parent Top-Up] Could not fetch parent account for customer ${card.parentId}: ${error?.message}. Using child account balance as fallback.`
);
availableBalance = card.account?.balance || 0;
parentAccountCurrency = card.account?.currency;
}
} else {
// If no parentId, try via customer relation
const parentCustomer = customer?.junior?.guardian?.customer;
if (parentCustomer?.id) {
try {
const parentCard = await this.cardService.getCardByCustomerId(parentCustomer.id);
if (parentCard?.account?.accountReference) {
const parentAccount = await this.accountService.getAccountByReferenceNumber(
parentCard.account.accountReference
);
parentAccountBalance = parentAccount.balance;
parentAccountReservedBalance = parentAccount.reservedBalance;
availableBalance = parentAccountBalance - parentAccountReservedBalance;
parentAccountCurrency = parentAccount.currency;
this.logger.debug(
`[Parent Top-Up] Fetched parent account via customer relation (by reference) - balance: ${parentAccountBalance}, reserved: ${parentAccountReservedBalance}, available: ${availableBalance} ${parentAccountCurrency}`
);
} else {
const parentAccount = await this.accountService.getAccountByCustomerId(parentCustomer.id);
parentAccountBalance = parentAccount.balance;
parentAccountReservedBalance = parentAccount.reservedBalance;
availableBalance = parentAccountBalance - parentAccountReservedBalance;
parentAccountCurrency = parentAccount.currency;
this.logger.debug(
`[Parent Top-Up] Fetched parent account via customer relation - balance: ${parentAccountBalance}, reserved: ${parentAccountReservedBalance}, available: ${availableBalance} ${parentAccountCurrency}`
);
}
} catch (error: any) {
this.logger.warn(
`[Parent Top-Up] Could not fetch parent account via customer: ${error?.message}. Using child account balance as fallback.`
);
availableBalance = card.account?.balance || 0;
parentAccountCurrency = card.account?.currency;
}
} else {
availableBalance = card.account?.balance || 0;
parentAccountCurrency = card.account?.currency;
}
}
// Use available_balance for parent notification (balance - reserved_balance)
const balance = availableBalance;
const accountCurrency = parentAccountCurrency;
const currency = getCurrency(
accountCurrency,
transaction.transactionCurrency,
);
this.logger.debug(
`[Parent Top-Up Notification] Parent account currency: ${parentAccountCurrency}, Account currency: ${accountCurrency}, Transaction currency: ${transaction.transactionCurrency}, Final currency: ${currency}, Parent balance: ${balance}, Amount: ${amount}`
);
const formattedAmount = formatCurrencyAmount(amount, currency);
const formattedBalance = formatCurrencyAmount(balance, currency);
this.logger.debug(
`Notifying parent (user ${parentUser.id}): Transferred ${formattedAmount} ${currency} to ${childName}, child balance: ${formattedBalance} ${currency}`
);
let title: string;
let message: string;
try {
title = this.i18n.t('app.NOTIFICATION.PARENT_INTERNAL_TRANSFER_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.PARENT_INTERNAL_TRANSFER_MESSAGE', {
lang: locale,
args: {
amount: formattedAmount,
currency: currency,
childName: childName,
balance: formattedBalance,
},
});
} catch (i18nError: any) {
console.error(`[TransactionNotificationListener] i18n error in parent internal transfer:`, i18nError);
this.logger.error(`i18n translation failed: ${i18nError?.message}`, i18nError?.stack);
title = 'Internal Transfer Completed';
message = `${formattedAmount} ${currency} has been transferred to ${childName}'s card. ${childName}'s balance is ${formattedBalance} ${currency}`;
}
await this.notificationFactory.send({
userId: parentUser.id,
title,
message,
scope: NotificationScope.PARENT_INTERNAL_TRANSFER,
preferences: this.getUserPreferences(parentUser),
data: {
transactionId: transaction.id,
childId: childUser.id,
childName: childName,
amount: formattedAmount,
currency: currency, // ISO currency code (SAR, USD, etc.)
balance: formattedBalance,
timestamp: transaction.transactionDate.toISOString(),
type: 'TOP_UP',
action: 'OPEN_TRANSACTION',
},
});
this.logger.log(`✅ Notified parent ${parentUser.id} about top-up`);
} catch (error: any) {
this.logger.error(
`Failed to notify parent of top-up: ${ error?.message || 'Unknown error'}`,
error?.stack
);
}
}
/**
* Extract user preferences from User entity
* Converts User properties to NotificationPreferences interface
*/
private getUserPreferences(user: User): NotificationPreferences {
return {
isPushEnabled: user.isPushEnabled,
isEmailEnabled: user.isEmailEnabled,
isSmsEnabled: user.isSmsEnabled,
};
}
/**
* Get user locale for i18n translations
* Defaults to English if not specified
* TODO: Add locale field to User entity in the future
*/
private getUserLocale(user: User): UserLocale {
// For now, default to English
// In the future, this can read from user.locale or user.preferences.locale
return UserLocale.ENGLISH;
}
}

View File

@ -3,19 +3,30 @@ import { forwardRef, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TwilioModule } from 'nestjs-twilio';
import { CardModule } from '~/card/card.module';
import { RedisModule } from '~/common/redis/redis.module';
import { buildMailerOptions, buildTwilioOptions } from '~/core/module-options';
import { UserModule } from '~/user/user.module';
import { NotificationsController } from './controllers';
import { Notification } from './entities';
import { NotificationCreatedListener } from './listeners';
import {
CardNotificationListener,
KycNotificationListener,
MoneyRequestNotificationListener,
NotificationCreatedListener,
ProfileNotificationListener,
SystemAlertNotificationListener,
TransactionNotificationListener,
} from './listeners';
import { NotificationsRepository } from './repositories';
import { FirebaseService, NotificationsService, TwilioService } from './services';
import { FirebaseService, NotificationFactory, NotificationsService, TwilioService } from './services';
import { MessagingSystemFactory, RedisPubSubMessagingService } from './services/messaging';
@Module({
imports: [
forwardRef(() => RedisModule.register()),
forwardRef(() => UserModule),
forwardRef(() => CardModule),
TypeOrmModule.forFeature([Notification]),
TwilioModule.forRootAsync({
useFactory: buildTwilioOptions,
@ -28,12 +39,21 @@ import { FirebaseService, NotificationsService, TwilioService } from './services
],
providers: [
NotificationsService,
NotificationFactory,
FirebaseService,
NotificationsRepository,
TwilioService,
NotificationCreatedListener,
TransactionNotificationListener,
MoneyRequestNotificationListener,
KycNotificationListener,
CardNotificationListener,
ProfileNotificationListener,
SystemAlertNotificationListener,
RedisPubSubMessagingService,
MessagingSystemFactory,
],
exports: [NotificationsService, NotificationCreatedListener],
exports: [NotificationsService, NotificationFactory, NotificationCreatedListener],
controllers: [NotificationsController],
})
export class NotificationModule {}

View File

@ -1,29 +1,77 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as admin from 'firebase-admin';
@Injectable()
export class FirebaseService {
private readonly logger = new Logger(FirebaseService.name);
constructor(private readonly configService: ConfigService) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: this.configService.get('FIREBASE_PROJECT_ID'),
clientEmail: this.configService.get('FIREBASE_CLIENT_EMAIL'),
privateKey: this.configService.get('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'),
}),
});
try {
this.logger.log('🔥 Initializing Firebase Admin SDK...');
const projectId = this.configService.get('FIREBASE_PROJECT_ID');
const clientEmail = this.configService.get('FIREBASE_CLIENT_EMAIL');
const privateKey = this.configService.get('FIREBASE_PRIVATE_KEY');
// Log configuration (without exposing sensitive data)
this.logger.log(`📋 Project ID: ${projectId}`);
this.logger.log(`📋 Client Email: ${clientEmail}`);
this.logger.log(`📋 Private Key: ${privateKey ? 'SET ✅' : 'MISSING ❌'}`);
admin.initializeApp({
credential: admin.credential.cert({
projectId,
clientEmail,
privateKey: privateKey.replace(/\\n/g, '\n'),
}),
});
this.logger.log('✅ Firebase Admin SDK initialized successfully!');
this.logger.log(`📱 Connected to project: ${projectId}`);
} catch (error: any) {
this.logger.error('❌ Failed to initialize Firebase Admin SDK');
this.logger.error(`Error: ${error.message}`);
throw error;
}
}
sendNotification(tokens: string | string[], title: string, body: string) {
this.logger.log(`Sending push notification to ${tokens}`);
const message = {
notification: {
title,
body,
},
tokens: Array.isArray(tokens) ? tokens : [tokens],
};
async sendNotification(tokens: string | string[], title: string, body: string, data?: Record<string, string>) {
this.logger.log(
`Sending push notification to ${Array.isArray(tokens) ? tokens.length : 1} device(s)`,
);
admin.messaging().sendEachForMulticast(message);
try {
const message = {
notification: {
title,
body,
},
data: data || {},
tokens: Array.isArray(tokens) ? tokens : [tokens],
};
const response = await admin.messaging().sendEachForMulticast(message);
this.logger.log(
`✅ Push sent! Success: ${response.successCount}, Failed: ${response.failureCount}`,
);
// Log failed tokens for debugging
if (response.failureCount > 0) {
response.responses.forEach((resp, idx) => {
if (!resp.success) {
this.logger.warn(
`Failed to send to token ${idx}: ${resp.error?.code} - ${resp.error?.message}`,
);
}
});
}
return response;
} catch (error: any) {
this.logger.error(`❌ Failed to send push notification: ${error.message}`);
throw error;
}
}
}

View File

@ -1,3 +1,4 @@
export * from './firebase.service';
export * from './notification-factory.service';
export * from './notifications.service';
export * from './twilio.service';

View File

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

View File

@ -0,0 +1,58 @@
import { Injectable, Logger, Optional } from '@nestjs/common';
import { NotificationScope, requiresGuaranteedDelivery } from '../../enums/notification-scope.enum';
import { IMessagingSystem } from '../../interfaces/messaging-system.interface';
import { RedisPubSubMessagingService } from './redis-pubsub-messaging.service';
/**
* Messaging System Factory
*
* Determines which messaging system to use based on notification requirements.
*
* - Regular notifications → Redis PubSub (fast, 2-5ms)
* - Critical notifications → RabbitMQ/Kafka (guaranteed delivery, 20-50ms)
*
* Usage:
* ```typescript
* const system = factory.getMessagingSystem(NotificationScope.CHILD_SPENDING);
* await system.publish('NOTIFICATION_CREATED', payload);
* ```
*/
@Injectable()
export class MessagingSystemFactory {
private readonly logger = new Logger(MessagingSystemFactory.name);
constructor(
private readonly redisPubSubService: RedisPubSubMessagingService,
) {}
/**
* Get the appropriate messaging system based on notification scope
*
* @param scope - Notification scope
* @returns Messaging system to use
*/
getMessagingSystem(scope: NotificationScope): IMessagingSystem {
const needsGuaranteedDelivery = requiresGuaranteedDelivery(scope);
if (needsGuaranteedDelivery) {
this.logger.warn(
`[Factory] Critical notification ${scope} requires guaranteed delivery, ` +
`but RabbitMQ not configured. Falling back to Redis PubSub.`
);
return this.redisPubSubService;
} else {
this.logger.debug(`[Factory] Using Redis PubSub for notification: ${scope}`);
return this.redisPubSubService;
}
}
/**
* Get default messaging system (Redis PubSub)
*
* @returns Default messaging system
*/
getDefaultMessagingSystem(): IMessagingSystem {
return this.redisPubSubService;
}
}

View File

@ -0,0 +1,55 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { RedisClientType } from '@keyv/redis';
import { IMessagingSystem } from '../../interfaces/messaging-system.interface';
/**
* Redis PubSub Messaging System Implementation
*
* Fast, real-time messaging for regular notifications.
* Uses Redis PubSub for 2-5ms latency.
*
* Note: Messages are not persisted (fire-and-forget).
* Suitable for notifications that are already saved in PostgreSQL.
*/
@Injectable()
export class RedisPubSubMessagingService implements IMessagingSystem {
private readonly logger = new Logger(RedisPubSubMessagingService.name);
constructor(
@Inject('REDIS_PUBLISHER') private readonly publisher: RedisClientType,
@Inject('REDIS_SUBSCRIBER') private readonly subscriber: RedisClientType,
) {}
getName(): string {
return 'Redis PubSub';
}
async publish(channel: string, payload: any): Promise<void> {
try {
const message = JSON.stringify(payload);
const subscriberCount = await this.publisher.publish(channel, message);
this.logger.debug(
`[Redis PubSub] Published to ${channel}, ${subscriberCount} subscriber(s) received`
);
} catch (error: any) {
this.logger.error(`[Redis PubSub] Failed to publish to ${channel}: ${error?.message}`);
throw error;
}
}
async subscribe(channel: string, handler: (message: any) => Promise<void>): Promise<void> {
await this.subscriber.subscribe(channel, async (message) => {
try {
const data = JSON.parse(message);
await handler(data);
} catch (error: any) {
this.logger.error(
`[Redis PubSub] Failed to process message from ${channel}: ${error?.message}`
);
}
});
this.logger.log(`[Redis PubSub] Subscribed to channel: ${channel}`);
}
}

View File

@ -0,0 +1,150 @@
import { Injectable, Logger } from '@nestjs/common';
import { NotificationsService } from './notifications.service';
import { NotificationChannel } from '../enums/notification-channel.enum';
import { NotificationScope } from '../enums/notification-scope.enum';
/**
* User notification preferences
* Determines which channels are enabled for a user
*/
export interface NotificationPreferences {
/** Whether push notifications are enabled */
isPushEnabled: boolean;
/** Whether email notifications are enabled */
isEmailEnabled: boolean;
/** Whether SMS notifications are enabled */
isSmsEnabled: boolean;
}
/**
* Payload for sending a notification
*/
export interface NotificationPayload {
/** ID of the user to notify */
userId: string;
/** Notification title */
title: string;
/** Notification message body */
message: string;
/** Category/type of notification */
scope: NotificationScope;
/**
* User's notification preferences
* If not provided, defaults to push-only
*/
preferences?: NotificationPreferences;
/** Additional data to attach to the notification */
data?: Record<string, any>;
}
/**
* NotificationFactory
*
* Central service for sending notifications.
* Independent service with no external dependencies (microservice-ready).
*
* Handles:
* - Channel routing based on provided preferences
* - Parallel notification delivery
* - Error handling
*
* Note: Caller is responsible for providing user preferences.
* This keeps the factory independent and testable.
*
* Usage:
* await notificationFactory.send({
* userId: 'user-123',
* title: 'Transaction Alert',
* message: 'You spent $50.00',
* scope: NotificationScope.CHILD_SPENDING,
* preferences: {
* isPushEnabled: true,
* isEmailEnabled: false,
* isSmsEnabled: false,
* },
* });
*/
@Injectable()
export class NotificationFactory {
private readonly logger = new Logger(NotificationFactory.name);
constructor(
private readonly notificationsService: NotificationsService,
) {}
/**
* Send a notification to a user
* Routes to enabled channels based on provided preferences
*
* Note: Notifications are always saved to the database (via PUSH channel)
* for history/audit purposes, even if push notifications are disabled.
* The preferences only control whether push/email/SMS are actually sent.
*
* @param payload - Notification payload including preferences
*/
async send(payload: NotificationPayload): Promise<void> {
try {
this.logger.log(`Sending notification to user ${payload.userId} - ${payload.title}`);
const preferences = payload.preferences || {
isPushEnabled: true,
isEmailEnabled: false,
isSmsEnabled: false,
};
const promises: Promise<any>[] = [];
// Always create notification record in database (via PUSH channel for storage)
// This ensures notifications are saved for history, even if push is disabled
this.logger.debug(`Creating notification record for user ${payload.userId}`);
promises.push(
this.sendToChannel(payload, NotificationChannel.PUSH)
);
// Only send via additional channels if enabled
// Note: PUSH channel is already added above for database storage
// The actual push delivery will check preferences in FirebaseService
await Promise.all(promises);
const activeChannels = preferences.isPushEnabled ? 1 : 0;
this.logger.log(
`Notification sent to user ${payload.userId} via ${activeChannels} active channel(s) ` +
`(saved to database regardless of preferences)`
);
} catch (error: any) {
this.logger.error(
`Failed to send notification to user ${payload.userId}: ${error?.message || 'Unknown error'}`,
error?.stack
);
// Don't throw - prevents breaking the main business flow
// Notification failures should not break transactions, etc.
}
}
/**
* Send notification via a specific channel
* Creates the notification record and publishes it for delivery
*/
private async sendToChannel(
payload: NotificationPayload,
channel: NotificationChannel
): Promise<void> {
await this.notificationsService.createNotification({
userId: payload.userId,
title: payload.title,
message: payload.message,
scope: payload.scope,
channel,
data: payload.data,
});
}
}

View File

@ -6,8 +6,9 @@ import { OtpType } from '../../otp/enums';
import { ISendOtp } from '../../otp/interfaces';
import { SendEmailRequestDto } from '../dtos/request';
import { Notification } from '../entities';
import { EventType, NotificationChannel, NotificationScope } from '../enums';
import { EventType, NotificationChannel, NotificationScope, NotificationStatus } from '../enums';
import { NotificationsRepository } from '../repositories';
import { MessagingSystemFactory } from './messaging/messaging-system-factory.service';
@Injectable()
export class NotificationsService {
@ -17,6 +18,7 @@ export class NotificationsService {
@Inject(forwardRef(() => RedisPubSubService))
private readonly redisPubSubService: RedisPubSubService,
private readonly messagingSystemFactory: MessagingSystemFactory,
) {}
async getNotifications(userId: string, pageOptionsDto: PageOptionsRequestDto) {
@ -31,9 +33,32 @@ export class NotificationsService {
return { notifications, count, unreadCount };
}
createNotification(notification: Partial<Notification>) {
async createNotification(notification: Partial<Notification>) {
this.logger.log(`Creating notification for user ${notification.userId}`);
return this.notificationRepository.createNotification(notification);
const savedNotification = await this.notificationRepository.createNotification({
...notification,
status: notification.status || NotificationStatus.UNREAD,
});
const scope = notification.scope || NotificationScope.USER_REGISTERED;
const messagingSystem = this.messagingSystemFactory.getMessagingSystem(scope);
this.logger.log(
`Publishing ${EventType.NOTIFICATION_CREATED} event to ${messagingSystem.getName()}`
);
messagingSystem.publish(EventType.NOTIFICATION_CREATED, {
...savedNotification,
data: notification.data || savedNotification.data,
}).catch((error) => {
this.logger.error(
`Failed to publish notification ${savedNotification.id} to ${messagingSystem.getName()}: ` +
`${error?.message || 'Unknown error'}`,
error?.stack
);
});
return savedNotification;
}
markAsRead(userId: string) {
@ -42,34 +67,25 @@ export class NotificationsService {
}
async sendEmailAsync(data: SendEmailRequestDto) {
this.logger.log(`emitting ${EventType.NOTIFICATION_CREATED} event`);
const notification = await this.createNotification({
this.logger.log(`Creating email notification for ${data.to}`);
await this.createNotification({
recipient: data.to,
title: data.subject,
message: '',
scope: NotificationScope.USER_INVITED,
channel: NotificationChannel.EMAIL,
});
// return this.redisPubSubService.emit(EventType.NOTIFICATION_CREATED, notification, data.data);
this.redisPubSubService.publishEvent(EventType.NOTIFICATION_CREATED, {
...notification,
data,
data: data.data,
});
}
async sendOtpNotification(sendOtpRequest: ISendOtp, otp: string) {
this.logger.log(`Sending OTP to ${sendOtpRequest.recipient}`);
const notification = await this.createNotification({
return this.createNotification({
recipient: sendOtpRequest.recipient,
title: OTP_TITLE,
message: OTP_BODY.replace('{otp}', otp),
scope: NotificationScope.OTP,
channel: sendOtpRequest.otpType === OtpType.EMAIL ? NotificationChannel.EMAIL : NotificationChannel.SMS,
});
this.logger.log(`emitting ${EventType.NOTIFICATION_CREATED} event`);
return this.redisPubSubService.publishEvent(EventType.NOTIFICATION_CREATED, {
...notification,
data: { otp },
});
}

View File

@ -14,7 +14,15 @@ export class RedisModule {
{
provide: 'REDIS_PUBLISHER',
useFactory: async (configService: ConfigService) => {
const publisher = createClient({ url: configService.get<string>('REDIS_URL') });
// Skip Redis connection during migration generation
if (process.env.MIGRATIONS_RUN === 'false') {
return null;
}
const redisUrl = configService.get<string>('REDIS_URL');
if (!redisUrl) {
return null;
}
const publisher = createClient({ url: redisUrl });
await publisher.connect();
return publisher;
},
@ -24,7 +32,15 @@ export class RedisModule {
{
provide: 'REDIS_SUBSCRIBER',
useFactory: async (configService: ConfigService) => {
const subscriber = createClient({ url: configService.get<string>('REDIS_URL') });
// Skip Redis connection during migration generation
if (process.env.MIGRATIONS_RUN === 'false') {
return null;
}
const redisUrl = configService.get<string>('REDIS_URL');
if (!redisUrl) {
return null;
}
const subscriber = createClient({ url: redisUrl });
await subscriber.connect();
return subscriber;
},
@ -32,7 +48,11 @@ export class RedisModule {
},
RedisPubSubService,
],
exports: [RedisPubSubService],
exports: [
RedisPubSubService,
'REDIS_PUBLISHER',
'REDIS_SUBSCRIBER',
],
imports: [NotificationModule],
};
}

View File

@ -15,6 +15,10 @@ export class RedisPubSubService implements OnModuleInit {
) {}
onModuleInit() {
// Skip subscription during migration generation
if (process.env.MIGRATIONS_RUN === 'false' || !this.subscriber) {
return;
}
this.subscriber.subscribe(EventType.NOTIFICATION_CREATED, async (message) => {
const data = JSON.parse(message);
this.logger.log('Received message on NOTIFICATION_CREATED channel:', data);

View File

@ -0,0 +1,111 @@
/**
* Currency utility functions
* Handles currency code mapping and formatting
*/
/**
* ISO 4217 numeric currency codes to ISO currency code mapping
* Common codes used in the system:
* - 682: SAR (Saudi Riyal)
* - 900: USD (US Dollar) - if used
* - 784: AED (UAE Dirham)
* - 414: KWD (Kuwaiti Dinar)
* - 512: OMR (Omani Rial)
* - 048: BHD (Bahraini Dinar)
* - 400: JOD (Jordanian Dinar)
*/
export const NUMERIC_TO_CURRENCY_CODE: Record<string, string> = {
'682': 'SAR',
'900': 'USD',
'784': 'AED',
'414': 'KWD',
'512': 'OMR',
'048': 'BHD',
'400': 'JOD',
'586': 'PKR',
};
/**
* Currency decimal places mapping
* ISO 4217 standard decimal places for each currency
*/
export const CURRENCY_DECIMAL_PLACES: Record<string, number> = {
'SAR': 2, // Saudi Riyal
'USD': 2, // US Dollar
'AED': 2, // UAE Dirham
'KWD': 3, // Kuwaiti Dinar
'OMR': 3, // Omani Rial
'BHD': 3, // Bahraini Dinar
'JOD': 3, // Jordanian Dinar
'PKR': 2, // Pakistani Rupee
'JPY': 0, // Japanese Yen (if used)
'KRW': 0, // South Korean Won (if used)
};
/**
* Convert numeric currency code to ISO currency code
* @param numericCode - Numeric currency code (e.g., '682')
* @returns ISO currency code (e.g., 'SAR') or the original code if not found
*/
export function numericToCurrencyCode(numericCode: string | null | undefined): string {
if (!numericCode) {
return 'SAR'; // Default fallback
}
// If already an ISO code (3 letters), return as is
if (/^[A-Z]{3}$/.test(numericCode)) {
return numericCode;
}
// Map numeric code to ISO code
return NUMERIC_TO_CURRENCY_CODE[numericCode] || numericCode;
}
/**
* Format amount based on currency decimal places
* @param amount - Amount to format (number or string)
* @param currency - ISO currency code (e.g., 'SAR', 'KWD')
* @returns Formatted amount string
*/
export function formatCurrencyAmount(amount: number | string, currency: string): string {
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
if (isNaN(numAmount)) {
return '0';
}
const decimalPlaces = CURRENCY_DECIMAL_PLACES[currency] ?? 2;
return numAmount.toFixed(decimalPlaces);
}
/**
* Get currency from account or transaction, with fallback
* @param accountCurrency - Currency from account entity (may be numeric like '682')
* @param transactionCurrency - Currency from transaction entity (may be numeric)
* @param fallback - Fallback currency (default: 'SAR')
* @returns ISO currency code
*/
export function getCurrency(
accountCurrency?: string | null,
transactionCurrency?: string | null,
fallback: string = 'SAR'
): string {
// Convert account currency first (it may be numeric like '682')
if (accountCurrency) {
const converted = numericToCurrencyCode(accountCurrency);
if (converted && converted !== accountCurrency) {
return converted; // Successfully converted from numeric to ISO
}
// If already ISO format, return as is
if (/^[A-Z]{3}$/.test(accountCurrency)) {
return accountCurrency;
}
}
// Convert transaction currency (may be numeric)
if (transactionCurrency) {
return numericToCurrencyCode(transactionCurrency);
}
return fallback;
}

View File

@ -1,8 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AllowanceModule } from '~/allowance/allowance.module';
import { CronRun } from './entities';
import { CronRunRepository } from './repositories';
import { BaseCronService } from './services';
import { CronRunService } from './services/cron-run.service';
import { AllowanceScheduleCron } from './tasks';
@Module({
imports: [],
providers: [BaseCronService],
imports: [AllowanceModule, TypeOrmModule.forFeature([CronRun])],
providers: [BaseCronService, CronRunService, CronRunRepository, AllowanceScheduleCron],
})
export class CronModule {}

View File

@ -0,0 +1,39 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { CronRunStatus } from '../enums/cron-run-status.enum';
@Entity('cron_runs')
export class CronRun extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', name: 'job_name' })
jobName!: string;
@Column({ type: 'varchar', name: 'status', default: CronRunStatus.SUCCESS })
status!: CronRunStatus;
@Column({ type: 'int', name: 'processed_count', default: 0 })
processedCount!: number;
@Column({ type: 'text', name: 'error_message', nullable: true })
errorMessage!: string | null;
@Column({ type: 'timestamp with time zone', name: 'started_at' })
startedAt!: Date;
@Column({ type: 'timestamp with time zone', name: 'finished_at', nullable: true })
finishedAt!: 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;
}

View File

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

View File

@ -0,0 +1,4 @@
export enum CronRunStatus {
SUCCESS = 'SUCCESS',
FAILED = 'FAILED',
}

View File

@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CronRun } from '../entities';
import { CronRunStatus } from '../enums/cron-run-status.enum';
@Injectable()
export class CronRunRepository {
constructor(@InjectRepository(CronRun) private readonly cronRunRepository: Repository<CronRun>) {}
createRun(jobName: string, startedAt: Date): Promise<CronRun> {
return this.cronRunRepository.save(
this.cronRunRepository.create({
jobName,
startedAt,
status: CronRunStatus.SUCCESS,
}),
);
}
markSuccess(id: string, processedCount: number, finishedAt: Date) {
return this.cronRunRepository.update(
{ id },
{
status: CronRunStatus.SUCCESS,
processedCount,
finishedAt,
errorMessage: null,
},
);
}
markFailure(id: string, processedCount: number, finishedAt: Date, errorMessage: string) {
return this.cronRunRepository.update(
{ id },
{
status: CronRunStatus.FAILED,
processedCount,
finishedAt,
errorMessage,
},
);
}
}

View File

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

View File

@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { CronRun } from '../entities';
import { CronRunRepository } from '../repositories';
@Injectable()
export class CronRunService {
constructor(private readonly cronRunRepository: CronRunRepository) {}
start(jobName: string): Promise<CronRun> {
return this.cronRunRepository.createRun(jobName, new Date());
}
success(id: string, processedCount: number) {
return this.cronRunRepository.markSuccess(id, processedCount, new Date());
}
failure(id: string, processedCount: number, errorMessage: string) {
return this.cronRunRepository.markFailure(id, processedCount, new Date(), errorMessage);
}
}

View File

@ -1 +1,2 @@
export * from './base-cron.service';
export * from './cron-run.service';

View File

@ -0,0 +1,76 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { AllowanceQueueService } from '~/allowance/services';
import { AllowanceScheduleRepository } from '~/allowance/repositories';
import { BaseCronService } from '../services';
import { CronRunService } from '../services/cron-run.service';
@Injectable()
export class AllowanceScheduleCron {
private readonly logger = new Logger(AllowanceScheduleCron.name);
private readonly lockKey = 'cron:allowance:enqueue';
private readonly jobName = 'allowance.schedule.enqueue';
constructor(
private readonly baseCronService: BaseCronService,
private readonly allowanceScheduleRepository: AllowanceScheduleRepository,
private readonly allowanceQueueService: AllowanceQueueService,
private readonly cronRunService: CronRunService,
) {}
@Cron(CronExpression.EVERY_5_MINUTES)
async enqueueDueSchedules() {
this.logger.log('Starting allowance schedule cron job');
const hasLock = await this.baseCronService.acquireLock(this.lockKey, 240);
if (!hasLock) {
this.logger.warn('Could not acquire lock for allowance cron job - another instance may be running');
return;
}
this.logger.log('Lock acquired, starting to process due schedules');
const cronRun = await this.cronRunService.start(this.jobName);
let processedCount = 0;
try {
const batchSize = 100;
let cursor: { nextRunAt: Date; id: string } | undefined;
let processedBatches = 0;
while (processedBatches < 50) {
const schedules = await this.allowanceScheduleRepository.findDueSchedulesBatch(batchSize, cursor);
this.logger.log(`Found ${schedules.length} due schedules in batch ${processedBatches + 1}`);
if (!schedules.length) {
this.logger.log('No more due schedules to process');
break;
}
for (const schedule of schedules) {
this.logger.debug(`Enqueueing schedule ${schedule.id} - juniorId: ${schedule.juniorId}, amount: ${schedule.amount}, nextRunAt: ${schedule.nextRunAt}`);
}
await Promise.all(
schedules.map((schedule) => this.allowanceQueueService.enqueueSchedule(schedule.id, schedule.nextRunAt)),
);
const last = schedules[schedules.length - 1];
cursor = { nextRunAt: last.nextRunAt, id: last.id };
processedBatches += 1;
processedCount += schedules.length;
if (schedules.length < batchSize) {
break;
}
}
this.logger.log(`Allowance cron job completed - processed ${processedCount} schedules`);
await this.cronRunService.success(cronRun.id, processedCount);
} catch (error) {
const stack = error instanceof Error ? error.stack : undefined;
this.logger.error('Failed to enqueue allowance schedules', stack || error);
await this.cronRunService.failure(cronRun.id, processedCount, String(stack || error));
} finally {
await this.baseCronService.releaseLock(this.lockKey);
}
}
}

View File

@ -0,0 +1 @@
export * from './allowance-schedule.cron';

View File

@ -30,7 +30,7 @@ export class CustomerController {
async initiateKyc(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: InitiateKycRequestDto) {
const res = await this.customerService.initiateKycRequest(sub, body);
return ResponseFactory.data(new InitiateKycResponseDto(res.randomNumber));
return ResponseFactory.data(new InitiateKycResponseDto(res));
}
@Get('/kyc/onboard-metadata')

View File

@ -4,14 +4,14 @@ import { NeoLeapModule } from '~/common/modules/neoleap/neoleap.module';
import { GuardianModule } from '~/guardian/guardian.module';
import { UserModule } from '~/user/user.module';
import { CustomerController } from './controllers';
import { Customer } from './entities';
import { CustomerRepository } from './repositories/customer.repository';
import { Customer, KycTransaction } from './entities';
import { CustomerRepository, KycTransactionRepository } from './repositories';
import { CustomerService, MetadataService } from './services';
@Module({
imports: [TypeOrmModule.forFeature([Customer]), GuardianModule, forwardRef(() => UserModule), NeoLeapModule],
imports: [TypeOrmModule.forFeature([Customer, KycTransaction]), GuardianModule, forwardRef(() => UserModule), NeoLeapModule],
controllers: [CustomerController],
providers: [CustomerService, CustomerRepository, MetadataService],
providers: [CustomerService, CustomerRepository, KycTransactionRepository, MetadataService],
exports: [CustomerService],
})
export class CustomerModule {}

View File

@ -1,8 +1,55 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDateString, IsEmail, IsEnum, IsOptional, IsString, Matches } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { isValidSaudiId } from '~/core/decorators/validations';
import { Gender, IncomeRange, IncomeSource, JobCategory, JobSector, PoiType } from '~/customer/enums';
export class InitiateKycRequestDto {
@ApiProperty({ example: '999300024' })
@isValidSaudiId({ message: i18n('validation.isValidSaudiId', { path: 'general', property: 'customer.nationalId' }) })
nationalId!: string;
@ApiProperty({ example: '2586234623', description: 'Saudi National ID or Iqama number' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.poiNumber' }) })
poiNumber!: string;
@ApiProperty({ enum: PoiType, example: PoiType.NAT, default: PoiType.NAT })
@IsEnum(PoiType, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.poiType' }) })
poiType!: PoiType;
@ApiProperty({ example: '0512345678', pattern: '^05\\d{8}$' })
@Matches(/^05\d{8}$/, { message: i18n('validation.Matches', { path: 'general', property: 'customer.mobileNumber' }) })
mobileNumber!: string;
@ApiProperty({ example: 'user@zodwallet.com', required: false })
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'customer.email' }) })
@IsOptional()
email?: string;
@ApiProperty({ example: '1990-01-01', format: 'date' })
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) })
dateOfBirth!: string;
@ApiProperty({ example: '2030-12-31', format: 'date', description: 'National ID expiry date' })
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.nationalIdExpiry' }) })
nationalIdExpiry!: string;
@ApiProperty({ enum: Gender, example: Gender.MALE })
@IsEnum(Gender, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.gender' }) })
gender!: Gender;
@ApiProperty({ enum: JobSector, example: JobSector.PRIVATE_SECTOR })
@IsEnum(JobSector, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.jobSector' }) })
jobSector!: JobSector;
@ApiProperty({ example: 'Test Company Ltd' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.employer' }) })
employer!: string;
@ApiProperty({ enum: IncomeSource, example: IncomeSource.SALARY })
@IsEnum(IncomeSource, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.incomeSource' }) })
incomeSource!: IncomeSource;
@ApiProperty({ enum: JobCategory, example: JobCategory.ENGINEER })
@IsEnum(JobCategory, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.jobCategory' }) })
jobCategory!: JobCategory;
@ApiProperty({ enum: IncomeRange, example: IncomeRange.RANGE_10000_20000 })
@IsEnum(IncomeRange, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.incomeRange' }) })
incomeRange!: IncomeRange;
}

View File

@ -34,15 +34,6 @@ export class CustomerResponseDto {
@ApiProperty({ example: 'JO' })
countryOfResidence!: string;
@ApiProperty({ example: 'Employee' })
sourceOfIncome!: string;
@ApiProperty({ example: 'Software Development' })
profession!: string;
@ApiProperty({ example: 'Full-time' })
professionType!: string;
@ApiProperty({ example: false })
isPep!: boolean;
@ -58,24 +49,6 @@ export class CustomerResponseDto {
@ApiProperty({ example: 12345 })
waitingNumber!: number;
@ApiProperty({ example: 'SA' })
country!: string | null;
@ApiProperty({ example: 'Riyadh' })
region!: string | null;
@ApiProperty({ example: 'Riyadh City' })
city!: string | null;
@ApiProperty({ example: 'Al-Masif' })
neighborhood!: string | null;
@ApiProperty({ example: 'King Fahd Road' })
street!: string | null;
@ApiProperty({ example: '123' })
building!: string | null;
@ApiPropertyOptional({ type: DocumentMetaResponseDto })
profilePicture!: DocumentMetaResponseDto | null;
@ -90,19 +63,10 @@ export class CustomerResponseDto {
this.nationalId = customer.nationalId;
this.nationalIdExpiry = customer.nationalIdExpiry;
this.countryOfResidence = customer.countryOfResidence;
this.sourceOfIncome = customer.sourceOfIncome;
this.profession = customer.profession;
this.professionType = customer.professionType;
this.isPep = customer.isPep;
this.gender = customer.gender;
this.isJunior = customer.isJunior;
this.isGuardian = customer.isGuardian;
this.waitingNumber = customer.applicationNumber;
this.country = customer.country;
this.region = customer.region;
this.city = customer.city;
this.neighborhood = customer.neighborhood;
this.street = customer.street;
this.building = customer.building;
}
}

View File

@ -1,10 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose } from 'class-transformer';
export class InitiateKycResponseDto {
@ApiProperty()
randomNumber!: string;
@ApiProperty({ description: 'Internal transaction ID to track this KYC attempt' })
@Expose()
transactionId!: string;
constructor(randomNumber: string) {
this.randomNumber = randomNumber;
@ApiProperty({ description: 'Neoleap state ID for tracking' })
@Expose()
stateId!: string;
@ApiProperty({ description: 'Nafath random code to show to the user', example: '38' })
@Expose()
nafathRandomCode!: string;
@ApiProperty({ description: 'Current status', example: 'IN_PROGRESS' })
@Expose()
status!: string;
@ApiProperty({ description: 'External customer ID from Neoleap' })
@Expose()
externalCustomerId!: string;
constructor(data: Partial<InitiateKycResponseDto>) {
Object.assign(this, data);
}
}

View File

@ -49,15 +49,6 @@ export class Customer extends BaseEntity {
@Column('varchar', { length: 255, nullable: true, name: 'country_of_residence' })
countryOfResidence!: CountryIso;
@Column('varchar', { length: 255, nullable: true, name: 'source_of_income' })
sourceOfIncome!: string;
@Column('varchar', { length: 255, nullable: true, name: 'profession' })
profession!: string;
@Column('varchar', { length: 255, nullable: true, name: 'profession_type' })
professionType!: string;
@Column('boolean', { default: false, name: 'is_pep' })
isPep!: boolean;
@ -77,23 +68,27 @@ export class Customer extends BaseEntity {
@Column('varchar', { name: 'user_id' })
userId!: string;
@Column('varchar', { name: 'country', length: 255, nullable: true })
country!: CountryIso;
// KYC-specific fields
@Column('varchar', { length: 255, nullable: true, name: 'neoleap_external_customer_id' })
neoleapExternalCustomerId!: string | null;
@Column('varchar', { name: 'region', length: 255, nullable: true })
region!: string;
@Column('varchar', { length: 100, nullable: true, name: 'job_sector' })
jobSector!: string | null;
@Column('varchar', { name: 'city', length: 255, nullable: true })
city!: string;
@Column('varchar', { length: 255, nullable: true, name: 'employer' })
employer!: string | null;
@Column('varchar', { name: 'neighborhood', length: 255, nullable: true })
neighborhood!: string;
@Column('varchar', { length: 100, nullable: true, name: 'income_source' })
incomeSource!: string | null;
@Column('varchar', { name: 'street', length: 255, nullable: true })
street!: string;
@Column('varchar', { length: 100, nullable: true, name: 'job_category' })
jobCategory!: string | null;
@Column('varchar', { name: 'building', length: 255, nullable: true })
building!: string;
@Column('varchar', { length: 100, nullable: true, name: 'income_range' })
incomeRange!: string | null;
@Column('varchar', { length: 20, nullable: true, name: 'mobile_number' })
mobileNumber!: string | null;
@OneToOne(() => User, (user) => user.customer, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })

View File

@ -1 +1,2 @@
export * from './customer.entity';
export * from './kyc-transaction.entity';

View File

@ -0,0 +1,76 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Customer } from './customer.entity';
import { User } from '~/user/entities';
@Entity('kyc_transactions')
export class KycTransaction extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column('uuid', { name: 'customer_id' })
customerId!: string;
@Column('uuid', { name: 'user_id' })
userId!: string;
// National ID from form
@Column('varchar', { length: 50, name: 'national_id', nullable: false })
nationalId!: string;
// Neoleap IDs
@Column('varchar', { length: 255, unique: true, name: 'state_id' })
stateId!: string;
@Column('varchar', { length: 255, nullable: true, name: 'external_customer_id' })
externalCustomerId!: string | null;
// Nafath details
@Column('varchar', { length: 10, nullable: true, name: 'nafath_random_code' })
nafathRandomCode!: string | null;
// Status tracking
@Column('varchar', { length: 50, default: 'INITIATED', name: 'status' })
status!: string;
// Audit trail
@Column('jsonb', { name: 'form_data' })
formData!: any;
@Column('varchar', { length: 255, nullable: true, name: 'callback_id' })
callbackId!: string | null;
// Timestamps
@Column('timestamp', { default: () => 'CURRENT_TIMESTAMP', name: 'initiated_at' })
initiatedAt!: Date;
@Column('timestamp', { nullable: true, name: 'completed_at' })
completedAt!: Date | null;
@Column('timestamp', { nullable: true, name: 'expires_at' })
expiresAt!: 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;
// Relationships
@ManyToOne(() => Customer, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'customer_id' })
customer!: Customer;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User;
}

View File

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

View File

@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { KycTransaction } from '../entities';
@Injectable()
export class KycTransactionRepository {
constructor(
@InjectRepository(KycTransaction)
private readonly kycTransactionRepository: Repository<KycTransaction>,
) {}
async create(data: Partial<KycTransaction>): Promise<KycTransaction> {
const transaction = this.kycTransactionRepository.create(data);
return this.kycTransactionRepository.save(transaction);
}
async findByStateId(stateId: string): Promise<KycTransaction | null> {
return this.kycTransactionRepository.findOne({
where: { stateId },
relations: ['customer', 'user'],
});
}
async findActiveByNationalId(nationalId: string): Promise<KycTransaction | null> {
return this.kycTransactionRepository.findOne({
where: {
nationalId,
status: 'IN_PROGRESS',
},
order: { initiatedAt: 'DESC' },
});
}
async updateByStateId(stateId: string, data: Partial<KycTransaction>): Promise<void> {
await this.kycTransactionRepository.update({ stateId }, data);
}
async findAllByCustomerId(customerId: string): Promise<KycTransaction[]> {
return this.kycTransactionRepository.find({
where: { customerId },
order: { initiatedAt: 'DESC' },
});
}
}

View File

@ -1,17 +1,23 @@
import { BadRequestException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { BadRequestException, ConflictException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import moment from 'moment';
import { Transactional } from 'typeorm-transactional';
import { CountryIso } from '~/common/enums';
import { NumericToCountryIso } from '~/common/mappers';
import { KycWebhookRequest } from '~/common/modules/neoleap/dtos/requests';
import { NeoLeapService } from '~/common/modules/neoleap/services';
import { NOTIFICATION_EVENTS } from '~/common/modules/notification/constants/event-names.constant';
import {
IKycApprovedEvent,
IKycRejectedEvent,
} from '~/common/modules/notification/interfaces/notification-events.interface';
import { GuardianService } from '~/guardian/services';
import { CreateJuniorRequestDto } from '~/junior/dtos/request';
import { User } from '~/user/entities';
import { InitiateKycRequestDto } from '../dtos/request';
import { Customer } from '../entities';
import { Gender, KycStatus } from '../enums';
import { CustomerRepository } from '../repositories/customer.repository';
import { CustomerRepository, KycTransactionRepository } from '../repositories';
import { MetadataService } from './metadata.service';
@Injectable()
@ -19,9 +25,11 @@ export class CustomerService {
private readonly logger = new Logger(CustomerService.name);
constructor(
private readonly customerRepository: CustomerRepository,
private readonly kycTransactionRepo: KycTransactionRepository,
private readonly guardianService: GuardianService,
@Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService,
private readonly metadataService: MetadataService,
private readonly eventEmitter: EventEmitter2,
) {}
async updateCustomer(userId: string, data: Partial<Customer>): Promise<Customer> {
@ -55,23 +63,68 @@ export class CustomerService {
}
async initiateKycRequest(customerId: string, body: InitiateKycRequestDto) {
this.logger.log(`Initiating KYC request for user ${customerId}`);
this.logger.log(`Initiating KYC request for customer ${customerId}`);
const customer = await this.findCustomerById(customerId);
// Validate customer is not already verified
if (customer.kycStatus === KycStatus.APPROVED) {
this.logger.error(`KYC for customer ${customerId} is already approved`);
throw new BadRequestException('CUSTOMER.KYC_ALREADY_APPROVED');
}
// I will assume the api for initiating KYC is not allowing me to send customerId as correlationId so I will store the nationalId in the customer entity
// Check for active KYC transaction by National ID
const activeTransaction = await this.kycTransactionRepo.findActiveByNationalId(body.poiNumber);
if (activeTransaction) {
this.logger.error(`KYC verification already in progress for National ID ${body.poiNumber}`);
throw new ConflictException('KYC verification already in progress for this National ID');
}
// Update customer with KYC data
await this.customerRepository.updateCustomer(customerId, {
nationalId: body.nationalId,
nationalId: body.poiNumber,
dateOfBirth: new Date(body.dateOfBirth),
nationalIdExpiry: new Date(body.nationalIdExpiry),
gender: body.gender,
countryOfResidence: CountryIso.SAUDI_ARABIA, // Always default to Saudi Arabia
mobileNumber: body.mobileNumber,
jobSector: body.jobSector,
employer: body.employer,
incomeSource: body.incomeSource,
jobCategory: body.jobCategory,
incomeRange: body.incomeRange,
kycStatus: KycStatus.PENDING,
});
return this.neoleapService.initiateKyc(customerId, body);
// Call Neoleap KYC API
const neoleapResponse = await this.neoleapService.initiateKycOnboarding(body);
// Create transaction record
const transaction = await this.kycTransactionRepo.create({
customerId,
userId: customer.userId,
nationalId: body.poiNumber,
stateId: neoleapResponse.stateId,
externalCustomerId: neoleapResponse.externalCustomerId,
nafathRandomCode: neoleapResponse.nafathRandomCode,
status: neoleapResponse.status,
formData: body,
initiatedAt: new Date(),
});
// Update customer with external ID
await this.customerRepository.updateCustomer(customerId, {
neoleapExternalCustomerId: neoleapResponse.externalCustomerId,
});
// Return formatted response
return {
transactionId: transaction.id,
stateId: neoleapResponse.stateId,
nafathRandomCode: neoleapResponse.nafathRandomCode,
status: neoleapResponse.status,
externalCustomerId: neoleapResponse.externalCustomerId,
};
}
@Transactional()
@ -94,34 +147,59 @@ export class CustomerService {
}
async updateCustomerKyc(body: KycWebhookRequest) {
this.logger.log(`Updating KYC for customer with national ID ${body.nationalId}`);
this.logger.log(`Updating KYC for stateId ${body.stateId}`);
const customer = await this.customerRepository.findOne({ nationalId: body.nationalId });
if (!customer) {
throw new BadRequestException('CUSTOMER.NOT_FOUND');
// Find transaction by stateId
const transaction = await this.kycTransactionRepo.findByStateId(body.stateId);
if (!transaction) {
this.logger.error(`KYC transaction not found for stateId ${body.stateId}`);
throw new BadRequestException('KYC transaction not found');
}
await this.customerRepository.updateCustomer(customer.id, {
kycStatus: body.status === 'SUCCESS' ? KycStatus.APPROVED : KycStatus.REJECTED,
firstName: body.firstName,
lastName: body.lastName,
dateOfBirth: moment(body.dob, 'YYYYMMDD').toDate(),
nationalId: body.nationalId,
nationalIdExpiry: moment(body.nationalIdExpiry, 'YYYYMMDD').toDate(),
countryOfResidence: NumericToCountryIso[body.country],
country: NumericToCountryIso[body.country],
gender: body.gender === 'M' ? Gender.MALE : Gender.FEMALE,
sourceOfIncome: body.incomeSource,
profession: body.professionTitle,
professionType: body.professionType,
isPep: body.isPep === 'Y',
city: body.city,
region: body.region,
neighborhood: body.neighborhood,
street: body.street,
building: body.building,
const customer = await this.findCustomerById(transaction.customerId);
const previousStatus = customer.kycStatus;
// Update transaction record
await this.kycTransactionRepo.updateByStateId(body.stateId, {
status: body.status,
callbackId: body.callbackId,
completedAt: new Date(),
});
// Update customer KYC status and external customer ID
const kycStatus = body.status === 'ONBOARDING_SUCCESS' ? KycStatus.APPROVED : KycStatus.REJECTED;
await this.customerRepository.updateCustomer(customer.id, {
kycStatus,
neoleapExternalCustomerId: body.entity.externalId,
rejectionReason: kycStatus === KycStatus.REJECTED ? 'KYC verification failed' : null,
});
// Reload customer with updated data
const updatedCustomer = await this.findCustomerById(customer.id);
// Emit notification event
if (kycStatus === KycStatus.APPROVED) {
const event: IKycApprovedEvent = {
customer: updatedCustomer,
previousStatus,
timestamp: new Date(),
};
this.eventEmitter.emit(NOTIFICATION_EVENTS.KYC_APPROVED, event);
this.logger.log(`Emitted KYC_APPROVED event for customer ${customer.id}`);
} else {
const event: IKycRejectedEvent = {
customer: updatedCustomer,
previousStatus,
rejectionReason: updatedCustomer.rejectionReason || 'KYC verification failed',
timestamp: new Date(),
};
this.eventEmitter.emit(NOTIFICATION_EVENTS.KYC_REJECTED, event);
this.logger.log(`Emitted KYC_REJECTED event for customer ${customer.id}`);
}
this.logger.log(`KYC updated successfully for customer ${customer.id}, status: ${body.status}, externalId: ${body.entity.externalId}`);
}
// TO BE REMOVED: This function is for testing only and will be removed
@ -134,12 +212,6 @@ export class CustomerService {
nationalId: '1089055972',
nationalIdExpiry: moment('2031-09-17').toDate(),
countryOfResidence: CountryIso.SAUDI_ARABIA,
country: CountryIso.SAUDI_ARABIA,
region: 'Mecca',
city: 'AT Taif',
neighborhood: 'Al Faisaliah',
street: 'Al Faisaliah Street',
building: '4',
});
await User.update(userId, {

View File

@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddKycFieldsAndTransactions1765804942393 implements MigrationInterface {
name = 'AddKycFieldsAndTransactions1765804942393'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "kyc_transactions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "customer_id" uuid NOT NULL, "user_id" uuid NOT NULL, "state_id" character varying(255) NOT NULL, "external_customer_id" character varying(255), "nafath_random_code" character varying(10), "status" character varying(50) NOT NULL DEFAULT 'INITIATED', "form_data" jsonb NOT NULL, "callback_id" character varying(255), "initiated_at" TIMESTAMP NOT NULL DEFAULT now(), "completed_at" TIMESTAMP, "expires_at" TIMESTAMP, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_231ce1d974b00919a8202e9ca3f" UNIQUE ("state_id"), CONSTRAINT "PK_aa56e3feebd4323c684ca146418" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "customers" ADD "neoleap_external_customer_id" character varying(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "job_sector" character varying(100)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "employer" character varying(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "income_source" character varying(100)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "job_category" character varying(100)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "income_range" character varying(100)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "mobile_number" character varying(20)`);
await queryRunner.query(`ALTER TABLE "kyc_transactions" ADD CONSTRAINT "FK_7651cf2e3ae6381377d8b9ed963" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "kyc_transactions" ADD CONSTRAINT "FK_336a3791fd94d386e5c428850db" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "kyc_transactions" DROP CONSTRAINT "FK_336a3791fd94d386e5c428850db"`);
await queryRunner.query(`ALTER TABLE "kyc_transactions" DROP CONSTRAINT "FK_7651cf2e3ae6381377d8b9ed963"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "mobile_number"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "income_range"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "job_category"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "income_source"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "employer"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "job_sector"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "neoleap_external_customer_id"`);
await queryRunner.query(`DROP TABLE "kyc_transactions"`);
}
}

View File

@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddNationalIdToKycTransactions1765877128065 implements MigrationInterface {
name = 'AddNationalIdToKycTransactions1765877128065'
public async up(queryRunner: QueryRunner): Promise<void> {
// Add column as nullable first (to handle existing records)
await queryRunner.query(`ALTER TABLE "kyc_transactions" ADD "national_id" character varying(50)`);
// Backfill existing records from form_data->poiNumber
await queryRunner.query(`
UPDATE "kyc_transactions"
SET "national_id" = form_data->>'poiNumber'
WHERE "national_id" IS NULL AND form_data->>'poiNumber' IS NOT NULL
`);
// Now make it NOT NULL with a default empty string for safety
await queryRunner.query(`ALTER TABLE "kyc_transactions" ALTER COLUMN "national_id" SET DEFAULT ''`);
await queryRunner.query(`ALTER TABLE "kyc_transactions" ALTER COLUMN "national_id" SET NOT NULL`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "kyc_transactions" DROP COLUMN "national_id"`);
}
}

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