Files
zod-backend/src/allowance/services/allowance.service.ts
Abdalhamid Alhamad 4d7549d02e feat(allowance): add delete API and configurable test intervals
- Add DELETE /guardians/me/allowances/:scheduleId endpoint
- Add ALLOWANCE_TEST_MODE env variable for testing intervals:
  - true: DAILY=5min, WEEKLY=10min, MONTHLY=15min
  - false: DAILY=1day, WEEKLY=1week, MONTHLY=1month
- Add deleteById to repository and deleteSchedule to service
2026-02-03 12:34:33 +03:00

214 lines
7.7 KiB
TypeScript

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, 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,
juniorId: string,
body: CreateAllowanceScheduleRequestDto,
): Promise<AllowanceSchedule> {
const doesBelong = await this.juniorService.doesJuniorBelongToGuardian(guardianId, juniorId);
if (!doesBelong) {
this.logger.error(`Junior ${juniorId} does not belong to guardian ${guardianId}`);
throw new BadRequestException('JUNIOR.NOT_BELONG_TO_GUARDIAN');
}
const existingSchedule = await this.allowanceScheduleRepository.findByGuardianAndJunior(guardianId, juniorId);
if (existingSchedule) {
throw new BadRequestException('ALLOWANCE.ALREADY_EXISTS');
}
const nextRunAt = this.computeNextRunAt(body.frequency, body.status);
return this.allowanceScheduleRepository.createSchedule(guardianId, juniorId, body, nextRunAt);
}
private computeNextRunAt(frequency: AllowanceFrequency, status: AllowanceScheduleStatus): Date {
const base = moment();
if (status === AllowanceScheduleStatus.OFF) {
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();
case AllowanceFrequency.WEEKLY:
return base.add(1, 'week').toDate();
case AllowanceFrequency.MONTHLY:
return base.add(1, 'month').toDate();
default:
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);
}
}