mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2026-03-10 17:11:44 +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 { 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
export * from './create-allowance-schedule.request.dto';
|
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-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) {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user