Compare commits

...

12 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
16 changed files with 524 additions and 13 deletions

View File

@ -1,13 +1,17 @@
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Roles } from '~/auth/enums'; import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces'; import { IJwtPayload } from '~/auth/interfaces';
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators'; import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard, RolesGuard } from '~/common/guards'; import { AccessTokenGuard, RolesGuard } from '~/common/guards';
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators'; import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils'; import { ResponseFactory } from '~/core/utils';
import { CreateAllowanceScheduleRequestDto } from '../dtos/request'; import { CreateAllowanceScheduleRequestDto, UpdateAllowanceScheduleRequestDto } from '../dtos/request';
import { AllowanceScheduleResponseDto } from '../dtos/response'; import {
AllowanceScheduleResponseDto,
AllowanceSchedulesListResponseDto,
AllowanceSummaryResponseDto,
} from '../dtos/response';
import { AllowanceService } from '../services'; import { AllowanceService } from '../services';
@Controller('guardians/me/allowances') @Controller('guardians/me/allowances')
@ -19,7 +23,27 @@ import { AllowanceService } from '../services';
export class AllowanceController { export class AllowanceController {
constructor(private readonly allowanceService: AllowanceService) {} 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') @Post(':juniorId')
@ApiOperation({ summary: 'Create a new allowance schedule for a junior' })
@ApiDataResponse(AllowanceScheduleResponseDto) @ApiDataResponse(AllowanceScheduleResponseDto)
async createSchedule( async createSchedule(
@AuthenticatedUser() { sub }: IJwtPayload, @AuthenticatedUser() { sub }: IJwtPayload,
@ -29,4 +53,26 @@ export class AllowanceController {
const schedule = await this.allowanceService.createSchedule(sub, juniorId, body); const schedule = await this.allowanceService.createSchedule(sub, juniorId, body);
return ResponseFactory.data(new AllowanceScheduleResponseDto(schedule)); 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

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

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

@ -18,6 +18,23 @@ export class AllowanceScheduleRepository {
}); });
} }
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) { createSchedule(guardianId: string, juniorId: string, body: CreateAllowanceScheduleRequestDto, nextRunAt: Date) {
return this.allowanceScheduleRepository.save( return this.allowanceScheduleRepository.save(
this.allowanceScheduleRepository.create({ this.allowanceScheduleRepository.create({
@ -61,7 +78,19 @@ export class AllowanceScheduleRepository {
return this.allowanceScheduleRepository.findOne({ where: { id } }); 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) { updateScheduleRun(id: string, lastRunAt: Date, nextRunAt: Date) {
return this.allowanceScheduleRepository.update({ id }, { lastRunAt, nextRunAt }); 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

@ -78,7 +78,9 @@ export class AllowanceQueueService implements OnModuleDestroy {
contentType: 'application/json', 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); await this.channel.sendToQueue(this.queueName, Buffer.from(JSON.stringify(payload)), options);
this.logger.log(`Allowance job enqueued successfully - messageId: ${messageId}`);
} }
async onModuleDestroy() { async onModuleDestroy() {

View File

@ -5,7 +5,7 @@ import moment from 'moment';
import { CardService } from '~/card/services'; import { CardService } from '~/card/services';
import { AllowanceScheduleRepository } from '../repositories/allowance-schedule.repository'; import { AllowanceScheduleRepository } from '../repositories/allowance-schedule.repository';
import { AllowanceCreditRepository } from '../repositories/allowance-credit.repository'; import { AllowanceCreditRepository } from '../repositories/allowance-credit.repository';
import { ALLOWANCE_DLQ_EXCHANGE, ALLOWANCE_QUEUE_NAME } from '../constants'; import { ALLOWANCE_DLQ_EXCHANGE, ALLOWANCE_QUEUE_NAME, ALLOWANCE_RETRY_EXCHANGE } from '../constants';
import { AllowanceFrequency, AllowanceScheduleStatus } from '../enums'; import { AllowanceFrequency, AllowanceScheduleStatus } from '../enums';
type AllowanceQueuePayload = { type AllowanceQueuePayload = {
@ -21,6 +21,7 @@ export class AllowanceWorkerService implements OnModuleInit, OnModuleDestroy {
private readonly queueName: string; private readonly queueName: string;
private readonly rabbitUrl?: string; private readonly rabbitUrl?: string;
private readonly maxRetries: number; private readonly maxRetries: number;
private readonly isTestMode: boolean;
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
@ -31,6 +32,10 @@ export class AllowanceWorkerService implements OnModuleInit, OnModuleDestroy {
this.queueName = this.configService.get<string>('ALLOWANCE_QUEUE_NAME') || ALLOWANCE_QUEUE_NAME; this.queueName = this.configService.get<string>('ALLOWANCE_QUEUE_NAME') || ALLOWANCE_QUEUE_NAME;
this.rabbitUrl = this.configService.get<string>('RABBITMQ_URL'); this.rabbitUrl = this.configService.get<string>('RABBITMQ_URL');
this.maxRetries = Number(this.configService.get<string>('ALLOWANCE_MAX_RETRIES') || 5); 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() { async onModuleInit() {
@ -48,7 +53,11 @@ export class AllowanceWorkerService implements OnModuleInit, OnModuleDestroy {
}); });
this.channel = this.connection.createChannel({ this.channel = this.connection.createChannel({
setup: async (channel: any) => { setup: async (channel: any) => {
await channel.assertQueue(this.queueName, { durable: true }); await channel.assertQueue(this.queueName, {
durable: true,
deadLetterExchange: ALLOWANCE_RETRY_EXCHANGE,
deadLetterRoutingKey: this.queueName,
});
await channel.prefetch(10); await channel.prefetch(10);
await channel.consume(this.queueName, (msg: any) => this.handleMessage(channel, msg), { await channel.consume(this.queueName, (msg: any) => this.handleMessage(channel, msg), {
noAck: false, noAck: false,
@ -102,39 +111,64 @@ export class AllowanceWorkerService implements OnModuleInit, OnModuleDestroy {
private async processAllowanceJob(payload: AllowanceQueuePayload): Promise<void> { private async processAllowanceJob(payload: AllowanceQueuePayload): Promise<void> {
const runAt = new Date(payload.runAt); const runAt = new Date(payload.runAt);
if (!payload.scheduleId || Number.isNaN(runAt.getTime())) { if (!payload.scheduleId || Number.isNaN(runAt.getTime())) {
this.logger.warn(`Invalid payload - scheduleId: ${payload.scheduleId}, runAt: ${payload.runAt}`);
return; return;
} }
this.logger.log(`Processing allowance job - scheduleId: ${payload.scheduleId}, runAt: ${runAt.toISOString()}`);
const schedule = await this.allowanceScheduleRepository.findById(payload.scheduleId); const schedule = await this.allowanceScheduleRepository.findById(payload.scheduleId);
if (!schedule) { if (!schedule) {
this.logger.warn(`Schedule not found: ${payload.scheduleId}`);
return; return;
} }
this.logger.debug(`Schedule found - juniorId: ${schedule.juniorId}, amount: ${schedule.amount}, status: ${schedule.status}, nextRunAt: ${schedule.nextRunAt}`);
if (schedule.status !== AllowanceScheduleStatus.ON) { if (schedule.status !== AllowanceScheduleStatus.ON) {
this.logger.warn(`Schedule ${payload.scheduleId} is not ON (status: ${schedule.status}). Skipping.`);
return; return;
} }
if (schedule.nextRunAt > runAt) { if (schedule.nextRunAt > runAt) {
this.logger.warn(`Schedule ${payload.scheduleId} nextRunAt (${schedule.nextRunAt}) > runAt (${runAt}). Skipping.`);
return; 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; let credit = null;
try { try {
credit = await this.allowanceCreditRepository.createCredit(schedule.id, schedule.amount, runAt); credit = await this.allowanceCreditRepository.createCredit(schedule.id, amount, runAt);
this.logger.log(`Credit created: ${credit.id}`);
} catch (error: any) { } catch (error: any) {
if (error?.code === '23505') { if (error?.code === '23505') {
this.logger.warn(`Credit already exists for schedule ${payload.scheduleId} at ${runAt.toISOString()} (idempotency check)`);
return; return;
} }
throw error; throw error;
} }
try { try {
await this.cardService.transferToChild(schedule.juniorId, schedule.amount); 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); const nextRunAt = this.computeNextRunAt(schedule.frequency);
await this.allowanceScheduleRepository.updateScheduleRun(schedule.id, runAt, nextRunAt); await this.allowanceScheduleRepository.updateScheduleRun(schedule.id, runAt, nextRunAt);
this.logger.log(`Schedule ${payload.scheduleId} updated - lastRunAt: ${runAt.toISOString()}, nextRunAt: ${nextRunAt.toISOString()}`);
} catch (error) { } catch (error) {
this.logger.error(`Transfer failed for schedule ${payload.scheduleId}: ${error instanceof Error ? error.message : error}`);
if (credit) { if (credit) {
await this.allowanceCreditRepository.deleteById(credit.id); await this.allowanceCreditRepository.deleteById(credit.id);
this.logger.log(`Credit ${credit.id} deleted due to transfer failure`);
} }
throw error; throw error;
} }
@ -142,6 +176,22 @@ export class AllowanceWorkerService implements OnModuleInit, OnModuleDestroy {
private computeNextRunAt(frequency: AllowanceFrequency): Date { private computeNextRunAt(frequency: AllowanceFrequency): Date {
const base = moment(); 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) { switch (frequency) {
case AllowanceFrequency.DAILY: case AllowanceFrequency.DAILY:
return base.add(1, 'day').toDate(); return base.add(1, 'day').toDate();

View File

@ -1,19 +1,90 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import moment from 'moment'; import moment from 'moment';
import { Junior } from '~/junior/entities';
import { JuniorService } from '~/junior/services'; import { JuniorService } from '~/junior/services';
import { CreateAllowanceScheduleRequestDto } from '../dtos/request'; import { CreateAllowanceScheduleRequestDto, UpdateAllowanceScheduleRequestDto } from '../dtos/request';
import { AllowanceSchedule } from '../entities/allowance-schedule.entity'; import { AllowanceSchedule } from '../entities/allowance-schedule.entity';
import { AllowanceFrequency, AllowanceScheduleStatus } from '../enums'; import { AllowanceFrequency, AllowanceScheduleStatus } from '../enums';
import { AllowanceSchedulesGrouped, AllowanceSummary } from '../interfaces';
import { AllowanceScheduleRepository } from '../repositories'; import { AllowanceScheduleRepository } from '../repositories';
@Injectable() @Injectable()
export class AllowanceService { export class AllowanceService {
private readonly logger = new Logger(AllowanceService.name); private readonly logger = new Logger(AllowanceService.name);
private readonly isTestMode: boolean;
constructor( constructor(
private readonly allowanceScheduleRepository: AllowanceScheduleRepository, private readonly allowanceScheduleRepository: AllowanceScheduleRepository,
private readonly juniorService: JuniorService, 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( async createSchedule(
guardianId: string, guardianId: string,
@ -41,6 +112,21 @@ export class AllowanceService {
return base.toDate(); 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) { switch (frequency) {
case AllowanceFrequency.DAILY: case AllowanceFrequency.DAILY:
return base.add(1, 'day').toDate(); return base.add(1, 'day').toDate();
@ -52,4 +138,76 @@ export class AllowanceService {
return base.toDate(); 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

@ -30,9 +30,9 @@
{ {
"id": "change_phone_number", "id": "change_phone_number",
"question_en": "How do I change my phone number?", "question_en": "How do I change my phone number?",
"answer_en": "You cannot update your 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": "كيف أغيّر رقم هاتفي؟", "question_ar": "كيف أغيّر رقم هاتفي؟",
"answer_ar": "لا يمكنك تحديث رقم هاتفك." "answer_ar": "حاليًا لا يمكن تغيير أرقام الهواتف مباشرةً داخل التطبيق. يرجى التواصل مع فريق الدعم، وسيساعدونك في تحديثه."
}, },
{ {
"id": "activate_card", "id": "activate_card",

View File

@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { I18nService } from 'nestjs-i18n'; import { I18nService } from 'nestjs-i18n';
import { Roles } from '~/auth/enums';
import { NotificationFactory, NotificationPreferences } from '../services/notification-factory.service'; import { NotificationFactory, NotificationPreferences } from '../services/notification-factory.service';
import { UserService } from '~/user/services/user.service'; import { UserService } from '~/user/services/user.service';
import { NOTIFICATION_EVENTS } from '../constants/event-names.constant'; import { NOTIFICATION_EVENTS } from '../constants/event-names.constant';
@ -24,6 +25,14 @@ export class ProfileNotificationListener {
try { try {
const { user, updatedFields } = event; 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( this.logger.log(
`Processing profile updated notification for user ${user.id} - Updated fields: ${updatedFields.join(', ')}` `Processing profile updated notification for user ${user.id} - Updated fields: ${updatedFields.join(', ')}`
); );

View File

@ -20,11 +20,15 @@ export class AllowanceScheduleCron {
@Cron(CronExpression.EVERY_5_MINUTES) @Cron(CronExpression.EVERY_5_MINUTES)
async enqueueDueSchedules() { async enqueueDueSchedules() {
this.logger.log('Starting allowance schedule cron job');
const hasLock = await this.baseCronService.acquireLock(this.lockKey, 240); const hasLock = await this.baseCronService.acquireLock(this.lockKey, 240);
if (!hasLock) { if (!hasLock) {
this.logger.warn('Could not acquire lock for allowance cron job - another instance may be running');
return; return;
} }
this.logger.log('Lock acquired, starting to process due schedules');
const cronRun = await this.cronRunService.start(this.jobName); const cronRun = await this.cronRunService.start(this.jobName);
let processedCount = 0; let processedCount = 0;
try { try {
@ -34,10 +38,17 @@ export class AllowanceScheduleCron {
while (processedBatches < 50) { while (processedBatches < 50) {
const schedules = await this.allowanceScheduleRepository.findDueSchedulesBatch(batchSize, cursor); const schedules = await this.allowanceScheduleRepository.findDueSchedulesBatch(batchSize, cursor);
this.logger.log(`Found ${schedules.length} due schedules in batch ${processedBatches + 1}`);
if (!schedules.length) { if (!schedules.length) {
this.logger.log('No more due schedules to process');
break; 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( await Promise.all(
schedules.map((schedule) => this.allowanceQueueService.enqueueSchedule(schedule.id, schedule.nextRunAt)), schedules.map((schedule) => this.allowanceQueueService.enqueueSchedule(schedule.id, schedule.nextRunAt)),
); );
@ -51,6 +62,8 @@ export class AllowanceScheduleCron {
break; break;
} }
} }
this.logger.log(`Allowance cron job completed - processed ${processedCount} schedules`);
await this.cronRunService.success(cronRun.id, processedCount); await this.cronRunService.success(cronRun.id, processedCount);
} catch (error) { } catch (error) {
const stack = error instanceof Error ? error.stack : undefined; const stack = error instanceof Error ? error.stack : undefined;