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
This commit is contained in:
Abdalhamid Alhamad
2026-02-01 13:21:02 +03:00
parent 1a0bd0bf91
commit 799b9b883d
11 changed files with 391 additions and 6 deletions

View File

@ -1,13 +1,17 @@
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, 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,16 @@ 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));
}
} }

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,15 @@ 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);
}
} }

View File

@ -1,9 +1,11 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
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()
@ -15,6 +17,67 @@ export class AllowanceService {
private readonly juniorService: JuniorService, private readonly juniorService: JuniorService,
) {} ) {}
/**
* 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,
juniorId: string, juniorId: string,
@ -52,4 +115,61 @@ 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 };
}
} }