mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2026-03-10 18:41:46 +00:00
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:
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,15 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<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,
|
||||
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<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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user