Compare commits

...

13 Commits

Author SHA1 Message Date
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
56 changed files with 1620 additions and 106 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`

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,32 @@
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces';
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard, RolesGuard } from '~/common/guards';
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import { CreateAllowanceScheduleRequestDto } from '../dtos/request';
import { AllowanceScheduleResponseDto } 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) {}
@Post(':juniorId')
@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));
}
}

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 @@
export * from './create-allowance-schedule.request.dto';

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 @@
export * from './allowance-schedule.response.dto';

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,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,67 @@
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 },
});
}
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 } });
}
updateScheduleRun(id: string, lastRunAt: Date, nextRunAt: Date) {
return this.allowanceScheduleRepository.update({ id }, { lastRunAt, nextRunAt });
}
}

View File

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

View File

@ -0,0 +1,88 @@
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',
};
await this.channel.sendToQueue(this.queueName, Buffer.from(JSON.stringify(payload)), options);
}
async onModuleDestroy() {
await this.channel?.close();
await this.connection?.close();
}
}

View File

@ -0,0 +1,162 @@
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 } 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;
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);
}
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 });
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())) {
return;
}
const schedule = await this.allowanceScheduleRepository.findById(payload.scheduleId);
if (!schedule) {
return;
}
if (schedule.status !== AllowanceScheduleStatus.ON) {
return;
}
if (schedule.nextRunAt > runAt) {
return;
}
let credit = null;
try {
credit = await this.allowanceCreditRepository.createCredit(schedule.id, schedule.amount, runAt);
} catch (error: any) {
if (error?.code === '23505') {
return;
}
throw error;
}
try {
await this.cardService.transferToChild(schedule.juniorId, schedule.amount);
const nextRunAt = this.computeNextRunAt(schedule.frequency);
await this.allowanceScheduleRepository.updateScheduleRun(schedule.id, runAt, nextRunAt);
} catch (error) {
if (credit) {
await this.allowanceCreditRepository.deleteById(credit.id);
}
throw error;
}
}
private computeNextRunAt(frequency: AllowanceFrequency): Date {
const base = moment();
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,55 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import moment from 'moment';
import { JuniorService } from '~/junior/services';
import { CreateAllowanceScheduleRequestDto } from '../dtos/request';
import { AllowanceSchedule } from '../entities/allowance-schedule.entity';
import { AllowanceFrequency, AllowanceScheduleStatus } from '../enums';
import { AllowanceScheduleRepository } from '../repositories';
@Injectable()
export class AllowanceService {
private readonly logger = new Logger(AllowanceService.name);
constructor(
private readonly allowanceScheduleRepository: AllowanceScheduleRepository,
private readonly juniorService: JuniorService,
) {}
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();
}
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();
}
}
}

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

@ -240,16 +240,37 @@ export class CardService {
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
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 (only if parent is funding)
card.parentId ? this.accountService.decreaseAccountBalance(fundingAccount.accountReference, amount) : Promise.resolve(),
]);
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);

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": "You cannot update your phone number",
"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

@ -6,10 +6,14 @@ export enum NotificationScope {
OTP = 'OTP',
USER_INVITED = 'USER_INVITED',
// Transaction notifications - Top-up
// 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',

View File

@ -139,14 +139,39 @@ export class MoneyRequestNotificationListener {
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} - ${reason}`
`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: 'Money Request',
message: `${childName} requested ${formattedAmount} ${currency}. Reason: ${reason}`,
title,
message,
scope: NotificationScope.MONEY_REQUEST_CREATED,
preferences: this.getUserPreferences(parentUser),
data: {

View File

@ -97,36 +97,86 @@ export class TransactionNotificationListener {
return;
}
const scope = isTopUp
? NotificationScope.CHILD_TOP_UP
: NotificationScope.CHILD_SPENDING;
// 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 top-up notifications, show card.limit (newAmount) instead of account balance
// card.limit represents the total spending limit on the card after the transfer
// For child notifications, show the appropriate balance based on account structure
let balance = 0;
let accountCurrency: string | undefined;
if (isTopUp) {
// For top-up: show card limit (newAmount from transfer response)
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 limit
const cardWithUpdatedLimit = await this.cardService.getCardById(card.id);
balance = cardWithUpdatedLimit.limit || card.limit || 0;
accountCurrency = cardWithUpdatedLimit.account?.currency || card.account?.currency;
this.logger.debug(
`[Child Top-Up Notification] Using card limit (newAmount) - limit: ${balance} ${accountCurrency}`
);
// 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 Top-Up Notification] Could not reload card: ${error?.message}. Using card limit from event.`
`[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 {
@ -176,36 +226,44 @@ export class TransactionNotificationListener {
let message: string;
try {
title = isTopUp
? this.i18n.t('app.NOTIFICATION.CHILD_TOP_UP_TITLE', { lang: locale })
: this.i18n.t('app.NOTIFICATION.CHILD_SPENDING_TITLE', { lang: locale });
message = isTopUp
? this.i18n.t('app.NOTIFICATION.CHILD_TOP_UP_MESSAGE', {
lang: locale,
args: {
amount: formattedAmount,
currency: currency,
balance: formattedBalance,
},
})
: this.i18n.t('app.NOTIFICATION.CHILD_SPENDING_MESSAGE', {
lang: locale,
args: {
amount: formattedAmount,
currency: currency,
merchant: merchant,
balance: formattedBalance,
},
});
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 ? 'Card Topped Up' : 'Purchase Successful';
message = isTopUp
? `You received ${formattedAmount} ${currency}. Total balance: ${formattedBalance} ${currency}`
: `You spent ${formattedAmount} ${currency} at ${merchant}. Balance: ${formattedBalance} ${currency}`;
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(
@ -263,29 +321,80 @@ export class TransactionNotificationListener {
const amount = transaction.transactionAmount;
const merchant = transaction.merchantName || 'a merchant';
// Get parent's account balance (not child's balance) - reload to get fresh balance
// 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) {
// Always reload parent account to get fresh balance after transaction
const parentAccount = await this.accountService.getAccountByCustomerId(card.parentId);
parentAccountBalance = parentAccount.balance;
parentAccountCurrency = parentAccount.currency;
this.logger.debug(`Fetched parent account balance: ${parentAccountBalance}, currency: ${parentAccountCurrency}`);
// 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?.cards?.[0]?.account) {
// Reload to get fresh balance
const parentAccount = await this.accountService.getAccountByCustomerId(parentCustomer.id);
parentAccountBalance = parentAccount.balance;
parentAccountCurrency = parentAccount.currency;
this.logger.debug(`Fetched parent account balance via customer: ${parentAccountBalance}, currency: ${parentAccountCurrency}`);
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(`Could not fetch parent account for parent notification: ${error?.message}, using child account balance as fallback`);
parentAccountBalance = card.account?.balance || 0;
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;
}
@ -297,11 +406,12 @@ export class TransactionNotificationListener {
);
this.logger.debug(
`[Parent Spending Notification] Parent account currency: ${parentAccountCurrency}, Account currency: ${accountCurrency}, Transaction currency: ${transaction.transactionCurrency}, Final currency: ${currency}, Parent balance: ${parentAccountBalance}, Amount: ${amount}`
`[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);
const formattedBalance = formatCurrencyAmount(parentAccountBalance, 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}`
@ -325,8 +435,8 @@ export class TransactionNotificationListener {
} catch (i18nError: any) {
console.error(`[TransactionNotificationListener] i18n error in parent spending:`, i18nError);
this.logger.error(`i18n translation failed: ${i18nError?.message}`, i18nError?.stack);
title = 'Child Spending Alert';
message = `${childName} spent ${formattedAmount} ${currency} at ${merchant}. Balance: ${formattedBalance} ${currency}`;
title = 'Spending Alert';
message = `${childName} spent ${formattedAmount} ${currency} at ${merchant}. Remaining balance: ${formattedBalance} ${currency}`;
}
await this.notificationFactory.send({
@ -360,7 +470,7 @@ export class TransactionNotificationListener {
}
/**
* Notify parent when they top up their child's card
* 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> {
@ -479,15 +589,15 @@ export class TransactionNotificationListener {
const formattedBalance = formatCurrencyAmount(balance, currency);
this.logger.debug(
`Notifying parent (user ${parentUser.id}): Transferred ${formattedAmount} ${currency} to ${childName}, parent balance: ${formattedBalance} ${currency}`
`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_TOP_UP_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.PARENT_TOP_UP_MESSAGE', {
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,
@ -497,17 +607,17 @@ export class TransactionNotificationListener {
},
});
} catch (i18nError: any) {
console.error(`[TransactionNotificationListener] i18n error in parent top-up:`, i18nError);
console.error(`[TransactionNotificationListener] i18n error in parent internal transfer:`, i18nError);
this.logger.error(`i18n translation failed: ${i18nError?.message}`, i18nError?.stack);
title = 'Top-Up Confirmation';
message = `You transferred ${formattedAmount} ${currency} to ${childName}. Balance: ${formattedBalance} ${currency}`;
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_TOP_UP_CONFIRMATION,
scope: NotificationScope.PARENT_INTERNAL_TRANSFER,
preferences: this.getUserPreferences(parentUser),
data: {
transactionId: transaction.id,

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,63 @@
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() {
const hasLock = await this.baseCronService.acquireLock(this.lockKey, 240);
if (!hasLock) {
return;
}
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);
if (!schedules.length) {
break;
}
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;
}
}
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

@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateAllowanceSchedulesAndCredits1769423488963 implements MigrationInterface {
name = 'CreateAllowanceSchedulesAndCredits1769423488963'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "allowance_credits" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "schedule_id" uuid NOT NULL, "transaction_id" uuid, "amount" numeric(12,2) NOT NULL, "run_at" TIMESTAMP WITH TIME ZONE NOT NULL, "credited_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_4d8f104f20199b6c23a92e265bf" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "allowance_schedules" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "amount" numeric(12,2) NOT NULL, "frequency" character varying NOT NULL, "status" character varying NOT NULL DEFAULT 'ON', "next_run_at" TIMESTAMP WITH TIME ZONE NOT NULL, "last_run_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "guardian_id" uuid NOT NULL, "junior_id" uuid NOT NULL, CONSTRAINT "PK_27ebe08d13044e8739af557b7a5" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "allowance_credits" ADD CONSTRAINT "FK_9b15567a82f05604001fde8a914" FOREIGN KEY ("schedule_id") REFERENCES "allowance_schedules"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "allowance_credits" ADD CONSTRAINT "FK_b179c206f7bb6f10b5168e583b8" FOREIGN KEY ("transaction_id") REFERENCES "transactions"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "allowance_schedules" ADD CONSTRAINT "FK_43eb94744e09d8349811c148351" FOREIGN KEY ("guardian_id") REFERENCES "guardians"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "allowance_schedules" ADD CONSTRAINT "FK_dd8a608d7f50cb120fa6f7b163f" FOREIGN KEY ("junior_id") REFERENCES "juniors"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "allowance_schedules" DROP CONSTRAINT "FK_dd8a608d7f50cb120fa6f7b163f"`);
await queryRunner.query(`ALTER TABLE "allowance_schedules" DROP CONSTRAINT "FK_43eb94744e09d8349811c148351"`);
await queryRunner.query(`ALTER TABLE "allowance_credits" DROP CONSTRAINT "FK_b179c206f7bb6f10b5168e583b8"`);
await queryRunner.query(`ALTER TABLE "allowance_credits" DROP CONSTRAINT "FK_9b15567a82f05604001fde8a914"`);
await queryRunner.query(`DROP TABLE "allowance_schedules"`);
await queryRunner.query(`DROP TABLE "allowance_credits"`);
}
}

View File

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateCronRunsTable1769602683670 implements MigrationInterface {
name = 'CreateCronRunsTable1769602683670'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "cron_runs" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "job_name" character varying NOT NULL, "status" character varying NOT NULL DEFAULT 'SUCCESS', "processed_count" integer NOT NULL DEFAULT '0', "error_message" text, "started_at" TIMESTAMP WITH TIME ZONE NOT NULL, "finished_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_43e18ce1778dc9b20a137b84fd5" PRIMARY KEY ("id"))`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "cron_runs"`);
}
}

View File

@ -11,4 +11,5 @@ export * from './1765804942393-AddKycFieldsAndTransactions';
export * from './1765877128065-AddNationalIdToKycTransactions';
export * from './1765891028260-RemoveOldCustomerColumns';
export * from './1765975126402-RemoveAddressColumns';
export * from './1768395622276-AddTimezoneFields';
export * from './1768395622276-AddTimezoneFields';export * from './1769423488963-CreateAllowanceSchedulesAndCredits';
export * from './1769602683670-CreateCronRunsTable';

View File

@ -11,6 +11,7 @@ import {
} from 'typeorm';
import { Customer } from '~/customer/entities';
import { Junior } from '~/junior/entities';
import { AllowanceSchedule } from '~/allowance/entities/allowance-schedule.entity';
import { MoneyRequest } from '~/money-request/entities/money-request.entity';
@Entity('guardians')
@ -31,6 +32,9 @@ export class Guardian extends BaseEntity {
@OneToMany(() => MoneyRequest, (moneyRequest) => moneyRequest.guardian)
moneyRequests!: MoneyRequest[];
@OneToMany(() => AllowanceSchedule, (schedule) => schedule.guardian)
allowanceSchedules!: AllowanceSchedule[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;

View File

@ -1,4 +1,5 @@
import { Controller, Get } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ApiTags } from '@nestjs/swagger';
import { HealthCheck, HealthCheckService, HttpHealthIndicator, TypeOrmHealthIndicator } from '@nestjs/terminus';
import { SkipThrottle } from '@nestjs/throttler';
@ -11,6 +12,7 @@ export class HealthController {
private readonly healthCheckService: HealthCheckService,
private readonly databaseHealthIndicator: TypeOrmHealthIndicator,
private readonly httpHealthIndicator: HttpHealthIndicator,
private readonly configService: ConfigService,
) {}
@Get()
@ -24,9 +26,23 @@ export class HealthController {
checkHealthDetails() {
const healthIndicators = [
() => this.databaseHealthIndicator.pingCheck('database'),
// add your own health indicators here
];
const rabbitManagementUrl = this.configService.get<string>('RABBITMQ_MANAGEMENT_URL');
if (rabbitManagementUrl) {
const user = this.configService.get<string>('RABBITMQ_MANAGEMENT_USER');
const pass = this.configService.get<string>('RABBITMQ_MANAGEMENT_PASS');
const headers: Record<string, string> = {};
if (user && pass) {
const encoded = Buffer.from(`${user}:${pass}`).toString('base64');
headers.Authorization = `Basic ${encoded}`;
}
healthIndicators.push(() =>
this.httpHealthIndicator.pingCheck('rabbitmq', rabbitManagementUrl, { headers }),
);
}
return this.healthCheckService.check(healthIndicators);
}
}

View File

@ -41,6 +41,7 @@
"ALLOWANCE": {
"START_DATE_BEFORE_TODAY": "لا يمكن أن يكون تاريخ البدء قبل اليوم.",
"START_DATE_AFTER_END_DATE": "لا يمكن أن يكون تاريخ البدء بعد تاريخ النهاية.",
"ALREADY_EXISTS": "يوجد جدول مصروف قائم لهذا الطفل بالفعل.",
"NOT_FOUND": "لم يتم العثور على المصروف.",
"DOES_NOT_BELONG_TO_JUNIOR": "المصروف لا يخص الطفل."
},
@ -112,17 +113,19 @@
"NOT_FOUND": "لم يتم العثور على البطاقة."
},
"NOTIFICATION": {
"CHILD_TOP_UP_TITLE": "تم شحن البطاقة",
"CHILD_TOP_UP_MESSAGE": "لقد استلمت {amount} {currency}. الرصيد الإجمالي: {balance} {currency}",
"CHILD_SPENDING_TITLE": "تمت العملية بنجاح",
"CHILD_SPENDING_MESSAGE": "لقد أنفقت {amount} {currency} في {merchant}. الرصيد: {balance} {currency}",
"PARENT_TOP_UP_TITLE": "تأكيد الشحن",
"PARENT_TOP_UP_MESSAGE": "لقد قمت بتحويل {amount} {currency} إلى {childName}. الرصيد: {balance} {currency}",
"PARENT_SPENDING_TITLE": "تنبيه إنفاق الطفل",
"PARENT_SPENDING_MESSAGE": "أنفق {childName} {amount} {currency} في {merchant}. الرصيد: {balance} {currency}",
"CHILD_TOP_UP_TITLE": "تم إضافة رصيد",
"CHILD_TOP_UP_MESSAGE": "تمت إضافة {amount} {currency} إلى بطاقتك. إجمالي الرصيد: {balance} {currency}",
"CHILD_INTERNAL_TRANSFER_TITLE": "تم إضافة رصيد",
"CHILD_INTERNAL_TRANSFER_MESSAGE": "تمت إضافة {amount} {currency} إلى بطاقتك. إجمالي الرصيد: {balance} {currency}",
"PARENT_INTERNAL_TRANSFER_TITLE": "اكتمل التحويل",
"PARENT_INTERNAL_TRANSFER_MESSAGE": "تم تحويل {amount} {currency} إلى بطاقة {childName}. رصيد {childName}: {balance} {currency}",
"CHILD_SPENDING_TITLE": "عملية شراء ناجحة",
"CHILD_SPENDING_MESSAGE": "قمت بإنفاق {amount} {currency} في {merchant}",
"PARENT_SPENDING_TITLE": "تنبيه صرف",
"PARENT_SPENDING_MESSAGE": "قام {childName} بإنفاق {amount} {currency} في {merchant}. الرصيد المتبقي: {balance} {currency}",
"YOUR_CHILD": "طفلك",
"MONEY_REQUEST_CREATED_TITLE": "طلب مال",
"MONEY_REQUEST_CREATED_MESSAGE": "طلب {childName} مبلغ {amount} {currency}. السبب: {reason}",
"MONEY_REQUEST_CREATED_TITLE": "طلب مبلغ مالي",
"MONEY_REQUEST_CREATED_MESSAGE": "طلب {childName} مبلغ {amount} {currency} لـ {reason}",
"MONEY_REQUEST_APPROVED_TITLE": "تمت الموافقة على طلب المال",
"MONEY_REQUEST_APPROVED_MESSAGE": "تمت الموافقة على طلبك بمبلغ {amount} {currency}. تمت إضافة المال إلى حسابك.",
"MONEY_REQUEST_DECLINED_TITLE": "تم رفض طلب المال",

View File

@ -94,10 +94,12 @@
"name": "الاسم",
"amount": "المبلغ",
"type": "النوع",
"frequency": "التكرار",
"startDate": "تاريخ البدء",
"endDate": "تاريخ النهاية",
"numberOfTransactions": "عدد المعاملات",
"juniorId": "معرّف الطفل"
"juniorId": "معرّف الطفل",
"status": "الحالة"
},
"allowanceChangeRequest": {
"reason": "السبب",

View File

@ -40,6 +40,7 @@
"ALLOWANCE": {
"START_DATE_BEFORE_TODAY": "The start date cannot be before today.",
"START_DATE_AFTER_END_DATE": "The start date cannot be after the end date.",
"ALREADY_EXISTS": "An allowance schedule already exists for this child.",
"NOT_FOUND": "The allowance was not found.",
"DOES_NOT_BELONG_TO_JUNIOR": "The allowance does not belong to the junior."
},
@ -111,17 +112,19 @@
"NOT_FOUND": "The card was not found."
},
"NOTIFICATION": {
"CHILD_TOP_UP_TITLE": "Card Topped Up",
"CHILD_TOP_UP_MESSAGE": "You received {amount} {currency}. Total balance: {balance} {currency}",
"CHILD_TOP_UP_TITLE": "Funds Credited",
"CHILD_TOP_UP_MESSAGE": "{amount} {currency} has been added to your card. Total balance: {balance} {currency}",
"CHILD_INTERNAL_TRANSFER_TITLE": "Funds Credited",
"CHILD_INTERNAL_TRANSFER_MESSAGE": "{amount} {currency} has been added to your card. Total balance: {balance} {currency}",
"PARENT_INTERNAL_TRANSFER_TITLE": "Internal Transfer Completed",
"PARENT_INTERNAL_TRANSFER_MESSAGE": "{amount} {currency} has been transferred to {childName}'s card. {childName}'s balance is {balance} {currency}",
"CHILD_SPENDING_TITLE": "Purchase Successful",
"CHILD_SPENDING_MESSAGE": "You spent {amount} {currency} at {merchant}. Balance: {balance} {currency}",
"PARENT_TOP_UP_TITLE": "Top-Up Confirmation",
"PARENT_TOP_UP_MESSAGE": "You transferred {amount} {currency} to {childName}. Balance: {balance} {currency}",
"PARENT_SPENDING_TITLE": "Child Spending Alert",
"PARENT_SPENDING_MESSAGE": "{childName} spent {amount} {currency} at {merchant}. Balance: {balance} {currency}",
"CHILD_SPENDING_MESSAGE": "You spent {amount} {currency} at {merchant}",
"PARENT_SPENDING_TITLE": "Spending Alert",
"PARENT_SPENDING_MESSAGE": "{childName} spent {amount} {currency} at {merchant}. Remaining balance: {balance} {currency}",
"YOUR_CHILD": "Your child",
"MONEY_REQUEST_CREATED_TITLE": "Money Request",
"MONEY_REQUEST_CREATED_MESSAGE": "{childName} requested {amount} {currency}. Reason: {reason}",
"MONEY_REQUEST_CREATED_MESSAGE": "{childName} has requested {amount} {currency} for {reason}.",
"MONEY_REQUEST_APPROVED_TITLE": "Money Request Approved",
"MONEY_REQUEST_APPROVED_MESSAGE": "Your request for {amount} {currency} has been approved. The money has been added to your account.",
"MONEY_REQUEST_DECLINED_TITLE": "Money Request Declined",

View File

@ -98,10 +98,12 @@
"name": "Name",
"amount": "Amount",
"type": "Type",
"frequency": "Frequency",
"startDate": "Start date",
"endDate": "End date",
"numberOfTransactions": "Number of transactions",
"juniorId": "Junior ID"
"juniorId": "Junior ID",
"status": "Status"
},
"allowanceChangeRequest": {
"reason": "Reason",

View File

@ -11,6 +11,7 @@ import {
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { AllowanceSchedule } from '~/allowance/entities/allowance-schedule.entity';
import { Customer } from '~/customer/entities';
import { Guardian } from '~/guardian/entities/guradian.entity';
import { MoneyRequest } from '~/money-request/entities/money-request.entity';
@ -45,6 +46,9 @@ export class Junior extends BaseEntity {
@OneToMany(() => MoneyRequest, (moneyRequest) => moneyRequest.junior)
moneyRequests!: MoneyRequest[];
@OneToMany(() => AllowanceSchedule, (schedule) => schedule.junior)
allowanceSchedules!: AllowanceSchedule[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;

1
src/types/amqplib.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module 'amqplib';