mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2026-03-10 17:11:44 +00:00
Compare commits
4 Commits
1a0bd0bf91
...
f7f22de65c
| Author | SHA1 | Date | |
|---|---|---|---|
| f7f22de65c | |||
| 6e11812925 | |||
| 4d7549d02e | |||
| 799b9b883d |
@ -1,13 +1,17 @@
|
||||
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Roles } from '~/auth/enums';
|
||||
import { IJwtPayload } from '~/auth/interfaces';
|
||||
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
|
||||
import { AccessTokenGuard, RolesGuard } from '~/common/guards';
|
||||
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
|
||||
import { ResponseFactory } from '~/core/utils';
|
||||
import { CreateAllowanceScheduleRequestDto } from '../dtos/request';
|
||||
import { AllowanceScheduleResponseDto } from '../dtos/response';
|
||||
import { CreateAllowanceScheduleRequestDto, UpdateAllowanceScheduleRequestDto } from '../dtos/request';
|
||||
import {
|
||||
AllowanceScheduleResponseDto,
|
||||
AllowanceSchedulesListResponseDto,
|
||||
AllowanceSummaryResponseDto,
|
||||
} from '../dtos/response';
|
||||
import { AllowanceService } from '../services';
|
||||
|
||||
@Controller('guardians/me/allowances')
|
||||
@ -19,7 +23,27 @@ import { AllowanceService } from '../services';
|
||||
export class AllowanceController {
|
||||
constructor(private readonly allowanceService: AllowanceService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all allowance schedules for the authenticated guardian' })
|
||||
@ApiDataResponse(AllowanceSchedulesListResponseDto)
|
||||
async getSchedules(@AuthenticatedUser() { sub }: IJwtPayload) {
|
||||
const { withSchedule, withoutSchedule, monthlyTotal } =
|
||||
await this.allowanceService.getSchedulesByGuardian(sub);
|
||||
return ResponseFactory.data(
|
||||
new AllowanceSchedulesListResponseDto(withSchedule, withoutSchedule, monthlyTotal),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
@ApiOperation({ summary: 'Get allowance summary for home page (lightweight)' })
|
||||
@ApiDataResponse(AllowanceSummaryResponseDto)
|
||||
async getSummary(@AuthenticatedUser() { sub }: IJwtPayload) {
|
||||
const { nextPaymentAt, monthlyTotal } = await this.allowanceService.getSummary(sub);
|
||||
return ResponseFactory.data(new AllowanceSummaryResponseDto(nextPaymentAt, monthlyTotal));
|
||||
}
|
||||
|
||||
@Post(':juniorId')
|
||||
@ApiOperation({ summary: 'Create a new allowance schedule for a junior' })
|
||||
@ApiDataResponse(AllowanceScheduleResponseDto)
|
||||
async createSchedule(
|
||||
@AuthenticatedUser() { sub }: IJwtPayload,
|
||||
@ -29,4 +53,26 @@ export class AllowanceController {
|
||||
const schedule = await this.allowanceService.createSchedule(sub, juniorId, body);
|
||||
return ResponseFactory.data(new AllowanceScheduleResponseDto(schedule));
|
||||
}
|
||||
|
||||
@Patch(':scheduleId')
|
||||
@ApiOperation({ summary: 'Update an existing allowance schedule' })
|
||||
@ApiDataResponse(AllowanceScheduleResponseDto)
|
||||
async updateSchedule(
|
||||
@AuthenticatedUser() { sub }: IJwtPayload,
|
||||
@Param('scheduleId') scheduleId: string,
|
||||
@Body() body: UpdateAllowanceScheduleRequestDto,
|
||||
) {
|
||||
const schedule = await this.allowanceService.updateSchedule(sub, scheduleId, body);
|
||||
return ResponseFactory.data(new AllowanceScheduleResponseDto(schedule));
|
||||
}
|
||||
|
||||
@Delete(':scheduleId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Delete an allowance schedule' })
|
||||
async deleteSchedule(
|
||||
@AuthenticatedUser() { sub }: IJwtPayload,
|
||||
@Param('scheduleId') scheduleId: string,
|
||||
) {
|
||||
await this.allowanceService.deleteSchedule(sub, scheduleId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from './create-allowance-schedule.request.dto';
|
||||
export * from './update-allowance-schedule.request.dto';
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1 +1,4 @@
|
||||
export * from './allowance-schedule.response.dto';
|
||||
export * from './allowance-schedules-list.response.dto';
|
||||
export * from './allowance-summary.response.dto';
|
||||
export * from './junior-allowance-info.response.dto';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
1
src/allowance/interfaces/index.ts
Normal file
1
src/allowance/interfaces/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './allowance-schedules-grouped.interface';
|
||||
@ -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) {
|
||||
return this.allowanceScheduleRepository.save(
|
||||
this.allowanceScheduleRepository.create({
|
||||
@ -61,7 +78,19 @@ export class AllowanceScheduleRepository {
|
||||
return this.allowanceScheduleRepository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
findByIdAndGuardian(id: string, guardianId: string): Promise<AllowanceSchedule | null> {
|
||||
return this.allowanceScheduleRepository.findOne({ where: { id, guardianId } });
|
||||
}
|
||||
|
||||
updateScheduleRun(id: string, lastRunAt: Date, nextRunAt: Date) {
|
||||
return this.allowanceScheduleRepository.update({ id }, { lastRunAt, nextRunAt });
|
||||
}
|
||||
|
||||
async updateSchedule(schedule: AllowanceSchedule): Promise<AllowanceSchedule> {
|
||||
return this.allowanceScheduleRepository.save(schedule);
|
||||
}
|
||||
|
||||
async deleteById(id: string): Promise<void> {
|
||||
await this.allowanceScheduleRepository.delete({ id });
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,7 +78,9 @@ export class AllowanceQueueService implements OnModuleDestroy {
|
||||
contentType: 'application/json',
|
||||
};
|
||||
|
||||
this.logger.log(`Enqueueing allowance job - scheduleId: ${scheduleId}, runAt: ${runAt.toISOString()}`);
|
||||
await this.channel.sendToQueue(this.queueName, Buffer.from(JSON.stringify(payload)), options);
|
||||
this.logger.log(`Allowance job enqueued successfully - messageId: ${messageId}`);
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
|
||||
@ -5,7 +5,7 @@ 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 { ALLOWANCE_DLQ_EXCHANGE, ALLOWANCE_QUEUE_NAME, ALLOWANCE_RETRY_EXCHANGE } from '../constants';
|
||||
import { AllowanceFrequency, AllowanceScheduleStatus } from '../enums';
|
||||
|
||||
type AllowanceQueuePayload = {
|
||||
@ -21,6 +21,7 @@ export class AllowanceWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly queueName: string;
|
||||
private readonly rabbitUrl?: string;
|
||||
private readonly maxRetries: number;
|
||||
private readonly isTestMode: boolean;
|
||||
|
||||
constructor(
|
||||
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.rabbitUrl = this.configService.get<string>('RABBITMQ_URL');
|
||||
this.maxRetries = Number(this.configService.get<string>('ALLOWANCE_MAX_RETRIES') || 5);
|
||||
this.isTestMode = this.configService.get<string>('ALLOWANCE_TEST_MODE') === 'true';
|
||||
if (this.isTestMode) {
|
||||
this.logger.warn('ALLOWANCE_TEST_MODE is enabled - using short intervals (5/10/15 min)');
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
@ -48,7 +53,11 @@ export class AllowanceWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
this.channel = this.connection.createChannel({
|
||||
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.consume(this.queueName, (msg: any) => this.handleMessage(channel, msg), {
|
||||
noAck: false,
|
||||
@ -102,39 +111,64 @@ export class AllowanceWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
private async processAllowanceJob(payload: AllowanceQueuePayload): Promise<void> {
|
||||
const runAt = new Date(payload.runAt);
|
||||
if (!payload.scheduleId || Number.isNaN(runAt.getTime())) {
|
||||
this.logger.warn(`Invalid payload - scheduleId: ${payload.scheduleId}, runAt: ${payload.runAt}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Processing allowance job - scheduleId: ${payload.scheduleId}, runAt: ${runAt.toISOString()}`);
|
||||
|
||||
const schedule = await this.allowanceScheduleRepository.findById(payload.scheduleId);
|
||||
if (!schedule) {
|
||||
this.logger.warn(`Schedule not found: ${payload.scheduleId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug(`Schedule found - juniorId: ${schedule.juniorId}, amount: ${schedule.amount}, status: ${schedule.status}, nextRunAt: ${schedule.nextRunAt}`);
|
||||
|
||||
if (schedule.status !== AllowanceScheduleStatus.ON) {
|
||||
this.logger.warn(`Schedule ${payload.scheduleId} is not ON (status: ${schedule.status}). Skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schedule.nextRunAt > runAt) {
|
||||
this.logger.warn(`Schedule ${payload.scheduleId} nextRunAt (${schedule.nextRunAt}) > runAt (${runAt}). Skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert amount from decimal string to number
|
||||
const amount = Number(schedule.amount);
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
this.logger.error(`Invalid amount for schedule ${payload.scheduleId}: ${schedule.amount}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Creating allowance credit - scheduleId: ${payload.scheduleId}, amount: ${amount}`);
|
||||
|
||||
let credit = null;
|
||||
try {
|
||||
credit = await this.allowanceCreditRepository.createCredit(schedule.id, schedule.amount, runAt);
|
||||
credit = await this.allowanceCreditRepository.createCredit(schedule.id, amount, runAt);
|
||||
this.logger.log(`Credit created: ${credit.id}`);
|
||||
} catch (error: any) {
|
||||
if (error?.code === '23505') {
|
||||
this.logger.warn(`Credit already exists for schedule ${payload.scheduleId} at ${runAt.toISOString()} (idempotency check)`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
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);
|
||||
await this.allowanceScheduleRepository.updateScheduleRun(schedule.id, runAt, nextRunAt);
|
||||
this.logger.log(`Schedule ${payload.scheduleId} updated - lastRunAt: ${runAt.toISOString()}, nextRunAt: ${nextRunAt.toISOString()}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Transfer failed for schedule ${payload.scheduleId}: ${error instanceof Error ? error.message : error}`);
|
||||
if (credit) {
|
||||
await this.allowanceCreditRepository.deleteById(credit.id);
|
||||
this.logger.log(`Credit ${credit.id} deleted due to transfer failure`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@ -142,6 +176,22 @@ export class AllowanceWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
private computeNextRunAt(frequency: AllowanceFrequency): Date {
|
||||
const base = moment();
|
||||
|
||||
if (this.isTestMode) {
|
||||
// Test mode: DAILY=5min, WEEKLY=10min, MONTHLY=15min
|
||||
switch (frequency) {
|
||||
case AllowanceFrequency.DAILY:
|
||||
return base.add(5, 'minutes').toDate();
|
||||
case AllowanceFrequency.WEEKLY:
|
||||
return base.add(10, 'minutes').toDate();
|
||||
case AllowanceFrequency.MONTHLY:
|
||||
return base.add(15, 'minutes').toDate();
|
||||
default:
|
||||
return base.toDate();
|
||||
}
|
||||
}
|
||||
|
||||
// Production mode: real intervals
|
||||
switch (frequency) {
|
||||
case AllowanceFrequency.DAILY:
|
||||
return base.add(1, 'day').toDate();
|
||||
|
||||
@ -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 { Junior } from '~/junior/entities';
|
||||
import { JuniorService } from '~/junior/services';
|
||||
import { CreateAllowanceScheduleRequestDto } from '../dtos/request';
|
||||
import { CreateAllowanceScheduleRequestDto, UpdateAllowanceScheduleRequestDto } from '../dtos/request';
|
||||
import { AllowanceSchedule } from '../entities/allowance-schedule.entity';
|
||||
import { AllowanceFrequency, AllowanceScheduleStatus } from '../enums';
|
||||
import { AllowanceSchedulesGrouped, AllowanceSummary } from '../interfaces';
|
||||
import { AllowanceScheduleRepository } from '../repositories';
|
||||
|
||||
@Injectable()
|
||||
export class AllowanceService {
|
||||
private readonly logger = new Logger(AllowanceService.name);
|
||||
private readonly isTestMode: boolean;
|
||||
|
||||
constructor(
|
||||
private readonly allowanceScheduleRepository: AllowanceScheduleRepository,
|
||||
private readonly juniorService: JuniorService,
|
||||
) {}
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.isTestMode = this.configService.get<string>('ALLOWANCE_TEST_MODE') === 'true';
|
||||
if (this.isTestMode) {
|
||||
this.logger.warn('ALLOWANCE_TEST_MODE is enabled - using short intervals (5/10/15 min)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all allowance schedules for a guardian, grouped by juniors with and without schedules.
|
||||
*/
|
||||
async getSchedulesByGuardian(guardianId: string): Promise<AllowanceSchedulesGrouped> {
|
||||
// Fetch all juniors for this guardian (with pagination workaround - large size)
|
||||
const [juniors] = await this.juniorService.findJuniorsByGuardianId(guardianId, {
|
||||
page: 1,
|
||||
size: 1000, // Assuming no guardian has more than 1000 children
|
||||
});
|
||||
|
||||
// Fetch all schedules for this guardian
|
||||
const schedules = await this.allowanceScheduleRepository.findByGuardianId(guardianId);
|
||||
|
||||
// Create a map of juniorId -> schedule for quick lookup
|
||||
const scheduleMap = new Map<string, AllowanceSchedule>();
|
||||
for (const schedule of schedules) {
|
||||
scheduleMap.set(schedule.juniorId, schedule);
|
||||
}
|
||||
|
||||
// Separate juniors into two groups
|
||||
const withSchedule: { junior: Junior; schedule: AllowanceSchedule }[] = [];
|
||||
const withoutSchedule: Junior[] = [];
|
||||
|
||||
for (const junior of juniors) {
|
||||
const schedule = scheduleMap.get(junior.id);
|
||||
if (schedule) {
|
||||
withSchedule.push({ junior, schedule });
|
||||
} else {
|
||||
withoutSchedule.push(junior);
|
||||
}
|
||||
}
|
||||
|
||||
const monthlyTotal = this.calculateMonthlyTotal(schedules);
|
||||
|
||||
return { withSchedule, withoutSchedule, monthlyTotal };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the monthly equivalent total for all active schedules.
|
||||
* - DAILY: amount * 30
|
||||
* - WEEKLY: amount * 4
|
||||
* - MONTHLY: amount * 1
|
||||
*/
|
||||
private calculateMonthlyTotal(schedules: AllowanceSchedule[]): number {
|
||||
return schedules
|
||||
.filter((s) => s.status === AllowanceScheduleStatus.ON)
|
||||
.reduce((total, schedule) => {
|
||||
const amount = Number(schedule.amount);
|
||||
switch (schedule.frequency) {
|
||||
case AllowanceFrequency.DAILY:
|
||||
return total + amount * 30;
|
||||
case AllowanceFrequency.WEEKLY:
|
||||
return total + amount * 4;
|
||||
case AllowanceFrequency.MONTHLY:
|
||||
return total + amount;
|
||||
default:
|
||||
return total;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async createSchedule(
|
||||
guardianId: string,
|
||||
@ -41,6 +112,21 @@ export class AllowanceService {
|
||||
return base.toDate();
|
||||
}
|
||||
|
||||
if (this.isTestMode) {
|
||||
// Test mode: DAILY=5min, WEEKLY=10min, MONTHLY=15min
|
||||
switch (frequency) {
|
||||
case AllowanceFrequency.DAILY:
|
||||
return base.add(5, 'minutes').toDate();
|
||||
case AllowanceFrequency.WEEKLY:
|
||||
return base.add(10, 'minutes').toDate();
|
||||
case AllowanceFrequency.MONTHLY:
|
||||
return base.add(15, 'minutes').toDate();
|
||||
default:
|
||||
return base.toDate();
|
||||
}
|
||||
}
|
||||
|
||||
// Production mode: real intervals
|
||||
switch (frequency) {
|
||||
case AllowanceFrequency.DAILY:
|
||||
return base.add(1, 'day').toDate();
|
||||
@ -52,4 +138,76 @@ export class AllowanceService {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,11 +20,15 @@ export class AllowanceScheduleCron {
|
||||
|
||||
@Cron(CronExpression.EVERY_5_MINUTES)
|
||||
async enqueueDueSchedules() {
|
||||
this.logger.log('Starting allowance schedule cron job');
|
||||
|
||||
const hasLock = await this.baseCronService.acquireLock(this.lockKey, 240);
|
||||
if (!hasLock) {
|
||||
this.logger.warn('Could not acquire lock for allowance cron job - another instance may be running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log('Lock acquired, starting to process due schedules');
|
||||
const cronRun = await this.cronRunService.start(this.jobName);
|
||||
let processedCount = 0;
|
||||
try {
|
||||
@ -34,10 +38,17 @@ export class AllowanceScheduleCron {
|
||||
|
||||
while (processedBatches < 50) {
|
||||
const schedules = await this.allowanceScheduleRepository.findDueSchedulesBatch(batchSize, cursor);
|
||||
this.logger.log(`Found ${schedules.length} due schedules in batch ${processedBatches + 1}`);
|
||||
|
||||
if (!schedules.length) {
|
||||
this.logger.log('No more due schedules to process');
|
||||
break;
|
||||
}
|
||||
|
||||
for (const schedule of schedules) {
|
||||
this.logger.debug(`Enqueueing schedule ${schedule.id} - juniorId: ${schedule.juniorId}, amount: ${schedule.amount}, nextRunAt: ${schedule.nextRunAt}`);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
schedules.map((schedule) => this.allowanceQueueService.enqueueSchedule(schedule.id, schedule.nextRunAt)),
|
||||
);
|
||||
@ -51,6 +62,8 @@ export class AllowanceScheduleCron {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Allowance cron job completed - processed ${processedCount} schedules`);
|
||||
await this.cronRunService.success(cronRun.id, processedCount);
|
||||
} catch (error) {
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
|
||||
Reference in New Issue
Block a user