diff --git a/src/allowance/controllers/allowance.controller.ts b/src/allowance/controllers/allowance.controller.ts index 6f1ee89..3d2f980 100644 --- a/src/allowance/controllers/allowance.controller.ts +++ b/src/allowance/controllers/allowance.controller.ts @@ -1,13 +1,17 @@ -import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Get, 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,16 @@ 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)); + } } diff --git a/src/allowance/dtos/request/index.ts b/src/allowance/dtos/request/index.ts index 6171859..f4ece39 100644 --- a/src/allowance/dtos/request/index.ts +++ b/src/allowance/dtos/request/index.ts @@ -1 +1,2 @@ export * from './create-allowance-schedule.request.dto'; +export * from './update-allowance-schedule.request.dto'; diff --git a/src/allowance/dtos/request/update-allowance-schedule.request.dto.ts b/src/allowance/dtos/request/update-allowance-schedule.request.dto.ts new file mode 100644 index 0000000..cd4818b --- /dev/null +++ b/src/allowance/dtos/request/update-allowance-schedule.request.dto.ts @@ -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; +} diff --git a/src/allowance/dtos/response/allowance-schedules-list.response.dto.ts b/src/allowance/dtos/response/allowance-schedules-list.response.dto.ts new file mode 100644 index 0000000..36787f1 --- /dev/null +++ b/src/allowance/dtos/response/allowance-schedules-list.response.dto.ts @@ -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; + } +} diff --git a/src/allowance/dtos/response/allowance-summary.response.dto.ts b/src/allowance/dtos/response/allowance-summary.response.dto.ts new file mode 100644 index 0000000..c0dd451 --- /dev/null +++ b/src/allowance/dtos/response/allowance-summary.response.dto.ts @@ -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; + } +} diff --git a/src/allowance/dtos/response/index.ts b/src/allowance/dtos/response/index.ts index 8ae4e5c..5e1bb7f 100644 --- a/src/allowance/dtos/response/index.ts +++ b/src/allowance/dtos/response/index.ts @@ -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'; diff --git a/src/allowance/dtos/response/junior-allowance-info.response.dto.ts b/src/allowance/dtos/response/junior-allowance-info.response.dto.ts new file mode 100644 index 0000000..baf928a --- /dev/null +++ b/src/allowance/dtos/response/junior-allowance-info.response.dto.ts @@ -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; + } +} diff --git a/src/allowance/interfaces/allowance-schedules-grouped.interface.ts b/src/allowance/interfaces/allowance-schedules-grouped.interface.ts new file mode 100644 index 0000000..8840298 --- /dev/null +++ b/src/allowance/interfaces/allowance-schedules-grouped.interface.ts @@ -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; +} diff --git a/src/allowance/interfaces/index.ts b/src/allowance/interfaces/index.ts new file mode 100644 index 0000000..0b29fc5 --- /dev/null +++ b/src/allowance/interfaces/index.ts @@ -0,0 +1 @@ +export * from './allowance-schedules-grouped.interface'; diff --git a/src/allowance/repositories/allowance-schedule.repository.ts b/src/allowance/repositories/allowance-schedule.repository.ts index 1b60f3e..214f96f 100644 --- a/src/allowance/repositories/allowance-schedule.repository.ts +++ b/src/allowance/repositories/allowance-schedule.repository.ts @@ -18,6 +18,23 @@ export class AllowanceScheduleRepository { }); } + findByGuardianId(guardianId: string): Promise { + 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 { + 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,15 @@ export class AllowanceScheduleRepository { return this.allowanceScheduleRepository.findOne({ where: { id } }); } + findByIdAndGuardian(id: string, guardianId: string): Promise { + 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 { + return this.allowanceScheduleRepository.save(schedule); + } } diff --git a/src/allowance/services/allowance.service.ts b/src/allowance/services/allowance.service.ts index 2104ed6..fc1df67 100644 --- a/src/allowance/services/allowance.service.ts +++ b/src/allowance/services/allowance.service.ts @@ -1,9 +1,11 @@ -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; 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() @@ -15,6 +17,67 @@ export class AllowanceService { private readonly juniorService: JuniorService, ) {} + /** + * Gets all allowance schedules for a guardian, grouped by juniors with and without schedules. + */ + async getSchedulesByGuardian(guardianId: string): Promise { + // 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(); + for (const schedule of schedules) { + scheduleMap.set(schedule.juniorId, schedule); + } + + // Separate juniors into two groups + const withSchedule: { junior: Junior; schedule: AllowanceSchedule }[] = []; + const withoutSchedule: Junior[] = []; + + for (const junior of juniors) { + const schedule = scheduleMap.get(junior.id); + if (schedule) { + withSchedule.push({ junior, schedule }); + } else { + withoutSchedule.push(junior); + } + } + + const monthlyTotal = this.calculateMonthlyTotal(schedules); + + return { withSchedule, withoutSchedule, monthlyTotal }; + } + + /** + * Calculates the monthly equivalent total for all active schedules. + * - DAILY: amount * 30 + * - WEEKLY: amount * 4 + * - MONTHLY: amount * 1 + */ + private calculateMonthlyTotal(schedules: AllowanceSchedule[]): number { + return schedules + .filter((s) => s.status === AllowanceScheduleStatus.ON) + .reduce((total, schedule) => { + const amount = Number(schedule.amount); + switch (schedule.frequency) { + case AllowanceFrequency.DAILY: + return total + amount * 30; + case AllowanceFrequency.WEEKLY: + return total + amount * 4; + case AllowanceFrequency.MONTHLY: + return total + amount; + default: + return total; + } + }, 0); + } + async createSchedule( guardianId: string, juniorId: string, @@ -52,4 +115,61 @@ 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 { + 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 { + // 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 }; + } }