diff --git a/src/allowance/entities/allowance.entity.ts b/src/allowance/entities/allowance.entity.ts index a0c4c84..12ecf7b 100644 --- a/src/allowance/entities/allowance.entity.ts +++ b/src/allowance/entities/allowance.entity.ts @@ -84,7 +84,13 @@ export class Allowance { const calculateNextDate = (unit: moment.unitOfTime.Diff) => { const diff = now.diff(startDate, unit); const nextDate = startDate.clone().add(diff, unit); - return nextDate.isSameOrAfter(now) ? nextDate.toDate() : nextDate.add('1', unit).toDate(); + const adjustedDate = nextDate.isSameOrAfter(now) ? nextDate : nextDate.add('1', unit); + + if (endDate && adjustedDate.isAfter(endDate)) { + return null; + } + + return adjustedDate.toDate(); }; switch (this.frequency) { diff --git a/src/cron/cron.module.ts b/src/cron/cron.module.ts index 973cd29..c14a995 100644 --- a/src/cron/cron.module.ts +++ b/src/cron/cron.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { AllowanceModule } from '~/allowance/allowance.module'; +import { MoneyRequestModule } from '~/money-request/money-request.module'; import { BaseCronService } from './services'; -import { AllowanceTask } from './tasks'; +import { AllowanceTask, MoneyRequestTask } from './tasks'; @Module({ - imports: [AllowanceModule], - providers: [AllowanceTask, BaseCronService], + imports: [AllowanceModule, MoneyRequestModule], + providers: [AllowanceTask, MoneyRequestTask, BaseCronService], }) export class CronModule {} diff --git a/src/cron/tasks/allowance.task.ts b/src/cron/tasks/allowance.task.ts index 9efd787..44ddd2e 100644 --- a/src/cron/tasks/allowance.task.ts +++ b/src/cron/tasks/allowance.task.ts @@ -15,7 +15,7 @@ export class AllowanceTask extends BaseCronService { private readonly logger = new Logger(AllowanceTask.name); private readonly cronLockKey = `${AllowanceTask.name}-lock`; private readonly cronLockTtl = TEN_MINUTES; - constructor(cacheService: CacheService, private allowanceService: AllowancesService) { + constructor(cacheService: CacheService, private readonly allowanceService: AllowancesService) { super(cacheService); } diff --git a/src/cron/tasks/index.ts b/src/cron/tasks/index.ts index 42da291..8a1925a 100644 --- a/src/cron/tasks/index.ts +++ b/src/cron/tasks/index.ts @@ -1 +1,2 @@ export * from './allowance.task'; +export * from './money-request.task'; diff --git a/src/cron/tasks/money-request.task.ts b/src/cron/tasks/money-request.task.ts new file mode 100644 index 0000000..a88ca35 --- /dev/null +++ b/src/cron/tasks/money-request.task.ts @@ -0,0 +1,54 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import moment from 'moment'; +import { CacheService } from '~/common/modules/cache/services'; +import { MoneyRequestsService } from '~/money-request/services'; +import { BaseCronService } from '../services'; +const TEN = 10; +const SIXTY = 60; +const THOUSAND = 1000; +const TEN_MINUTES = TEN * SIXTY * THOUSAND; +const CHUNK_SIZE = 50; +@Injectable() +export class MoneyRequestTask extends BaseCronService { + private readonly cronLockKey = `${MoneyRequestTask.name}-lock`; + private readonly cronLockTtl = TEN_MINUTES; + private readonly logger = new Logger(MoneyRequestTask.name); + constructor(cacheService: CacheService, private readonly moneyRequestService: MoneyRequestsService) { + super(cacheService); + } + + @Cron(CronExpression.EVERY_DAY_AT_3AM) + async handleCron() { + try { + const isLockAcquired = await this.acquireLock(this.cronLockKey, this.cronLockTtl); + if (!isLockAcquired) { + this.logger.log('Lock already acquired. Skipping cron job for MoneyRequestTask.'); + return; + } + this.logger.log('Processing cron job for MoneyRequestTask'); + await this.processJob(); + } catch (error) { + this.logger.error('Error processing MoneyRequestTask cron job', error); + } finally { + this.logger.log('Releasing lock for MoneyRequestTask'); + await this.releaseLock(this.cronLockKey); + } + } + + private async processJob() { + const today = moment().startOf('day'); + const moneyRequestsChunks = await this.moneyRequestService.findMoneyRequestsChunks(CHUNK_SIZE); + for await (const moneyRequests of moneyRequestsChunks) { + for (const moneyRequest of moneyRequests) { + this.logger.log( + `Processing money request ${moneyRequest.id} with next payment date ${moneyRequest.nextPaymentDate}`, + ); + // if today is the same as money request payment date + if (moment(moneyRequest.nextPaymentDate).startOf('day').isSame(today)) { + this.logger.log(`Today is the payment date for money request ${moneyRequest.id}`); + } + } + } + } +} diff --git a/src/db/migrations/1734503895302-create-money-request-entity.ts b/src/db/migrations/1734503895302-create-money-request-entity.ts index 9967869..88201e3 100644 --- a/src/db/migrations/1734503895302-create-money-request-entity.ts +++ b/src/db/migrations/1734503895302-create-money-request-entity.ts @@ -12,6 +12,7 @@ export class CreateMoneyRequestEntity1734503895302 implements MigrationInterface "frequency" character varying NOT NULL DEFAULT 'ONE_TIME', "status" character varying NOT NULL DEFAULT 'PENDING', "reviewed_at" TIMESTAMP WITH TIME ZONE, + "start_date" TIMESTAMP WITH TIME ZONE, "end_date" TIMESTAMP WITH TIME ZONE, "requester_id" uuid NOT NULL, "reviewer_id" uuid NOT NULL, diff --git a/src/money-request/dtos/request/create-money-request.request.dto.ts b/src/money-request/dtos/request/create-money-request.request.dto.ts index 08ed0a7..f7b1dab 100644 --- a/src/money-request/dtos/request/create-money-request.request.dto.ts +++ b/src/money-request/dtos/request/create-money-request.request.dto.ts @@ -1,5 +1,15 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsDateString, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; +import { + IsDateString, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Max, + Min, + ValidateIf, +} from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; import { MoneyRequestFrequency } from '~/money-request/enums'; const MIN_REQUESTED_AMOUNT = 0.01; @@ -30,6 +40,14 @@ export class CreateMoneyRequestRequestDto { @IsOptional() frequency: MoneyRequestFrequency = MoneyRequestFrequency.ONE_TIME; + @ApiPropertyOptional({ example: '2021-01-01' }) + @IsDateString( + {}, + { message: i18n('validation.IsDateString', { path: 'general', property: 'moneyRequest.startDate' }) }, + ) + @ValidateIf((o) => o.frequency !== MoneyRequestFrequency.ONE_TIME) + startDate?: string; + @ApiPropertyOptional({ example: '2021-01-01' }) @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'moneyRequest.endDate' }) }) @IsOptional() diff --git a/src/money-request/dtos/response/money-request.response.dto.ts b/src/money-request/dtos/response/money-request.response.dto.ts index 45ebfa1..0d28e61 100644 --- a/src/money-request/dtos/response/money-request.response.dto.ts +++ b/src/money-request/dtos/response/money-request.response.dto.ts @@ -22,6 +22,9 @@ export class MoneyRequestResponseDto { @ApiProperty({ example: MoneyRequestFrequency.ONE_TIME }) frequency!: MoneyRequestFrequency; + @ApiProperty({ example: '2021-01-01' }) + startDate!: Date | null; + @ApiProperty({ example: '2021-01-01' }) endDate!: Date | null; @@ -41,6 +44,7 @@ export class MoneyRequestResponseDto { this.requestedAmount = moneyRequest.requestedAmount; this.message = moneyRequest.message; this.frequency = moneyRequest.frequency; + this.startDate = moneyRequest.startDate || null; this.endDate = moneyRequest.endDate || null; this.status = moneyRequest.status; this.reviewedAt = moneyRequest.reviewedAt || null; diff --git a/src/money-request/entities/money-request.entity.ts b/src/money-request/entities/money-request.entity.ts index dcf6f99..ac2cca5 100644 --- a/src/money-request/entities/money-request.entity.ts +++ b/src/money-request/entities/money-request.entity.ts @@ -1,3 +1,4 @@ +import moment from 'moment'; import { Column, CreateDateColumn, @@ -31,6 +32,9 @@ export class MoneyRequest { @Column({ type: 'timestamp with time zone', name: 'reviewed_at', nullable: true }) reviewedAt!: Date; + @Column({ type: 'timestamp with time zone', name: 'start_date', nullable: true }) + startDate!: Date | null; + @Column({ type: 'timestamp with time zone', name: 'end_date', nullable: true }) endDate!: Date | null; @@ -53,4 +57,41 @@ export class MoneyRequest { @UpdateDateColumn({ type: 'timestamp with time zone', name: 'updated_at' }) updatedAt!: Date; + + get nextPaymentDate(): Date | null { + if (this.frequency === MoneyRequestFrequency.ONE_TIME) { + return null; + } + const startDate = moment(this.startDate).clone().startOf('day'); + const endDate = this.endDate ? moment(this.endDate).endOf('day') : null; + const now = moment().startOf('day'); + + if (endDate && moment().isAfter(endDate)) { + return null; + } + + const calculateNextDate = (unit: moment.unitOfTime.Diff) => { + const diff = now.diff(startDate, unit); + const nextDate = startDate.clone().add(diff, unit); + + const adjustedDate = nextDate.isSameOrAfter(now) ? nextDate : nextDate.add('1', unit); + + if (endDate && adjustedDate.isAfter(endDate)) { + return null; + } + + return adjustedDate.toDate(); + }; + + switch (this.frequency) { + case MoneyRequestFrequency.DAILY: + return calculateNextDate('days'); + case MoneyRequestFrequency.WEEKLY: + return calculateNextDate('weeks'); + case MoneyRequestFrequency.MONTHLY: + return calculateNextDate('months'); + default: + return null; + } + } } diff --git a/src/money-request/money-request.module.ts b/src/money-request/money-request.module.ts index cf0bb40..7cd4f11 100644 --- a/src/money-request/money-request.module.ts +++ b/src/money-request/money-request.module.ts @@ -9,7 +9,7 @@ import { MoneyRequestsService } from './services'; @Module({ controllers: [MoneyRequestsController], providers: [MoneyRequestsService, MoneyRequestsRepository], - exports: [], + exports: [MoneyRequestsService], imports: [TypeOrmModule.forFeature([MoneyRequest]), JuniorModule], }) export class MoneyRequestModule {} diff --git a/src/money-request/repositories/money-requests.repository.ts b/src/money-request/repositories/money-requests.repository.ts index 6ddddae..db93dea 100644 --- a/src/money-request/repositories/money-requests.repository.ts +++ b/src/money-request/repositories/money-requests.repository.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { IsNull, MoreThan, Not, Repository } from 'typeorm'; import { CreateMoneyRequestRequestDto, MoneyRequestsFiltersRequestDto } from '../dtos/request'; import { MoneyRequest } from '../entities'; -import { MoneyRequestStatus } from '../enums'; +import { MoneyRequestFrequency, MoneyRequestStatus } from '../enums'; const ONE = 1; @Injectable() export class MoneyRequestsRepository { @@ -16,6 +16,7 @@ export class MoneyRequestsRepository { reviewerId, requestedAmount: body.requestedAmount, message: body.message, + startDate: body.startDate, endDate: body.endDate, frequency: body.frequency, }), @@ -46,4 +47,33 @@ export class MoneyRequestsRepository { updateMoneyRequestStatus(moneyRequestId: string, reviewerId: string, status: MoneyRequestStatus) { return this.moneyRequestRepository.update({ id: moneyRequestId, reviewerId }, { status, reviewedAt: new Date() }); } + + async *findMoneyRequestsChunks(chunkSize: number) { + let offset = 0; + while (true) { + const moneyRequests = await this.moneyRequestRepository.find({ + take: chunkSize, + skip: offset, + where: [ + { + status: MoneyRequestStatus.APPROVED, + frequency: Not(MoneyRequestFrequency.ONE_TIME), + endDate: MoreThan(new Date()), + }, + { + status: MoneyRequestStatus.APPROVED, + frequency: Not(MoneyRequestFrequency.ONE_TIME), + endDate: IsNull(), + }, + ], + }); + + if (!moneyRequests.length) { + break; + } + + yield moneyRequests; + offset += chunkSize; + } + } } diff --git a/src/money-request/services/money-requests.service.ts b/src/money-request/services/money-requests.service.ts index bd4109b..7e9d7d9 100644 --- a/src/money-request/services/money-requests.service.ts +++ b/src/money-request/services/money-requests.service.ts @@ -20,6 +20,12 @@ export class MoneyRequestsService { if (body.frequency === MoneyRequestFrequency.ONE_TIME) { this.logger.log(`Setting end date to null for one time money request`); delete body.endDate; + delete body.startDate; + } + + if (body.startDate && new Date(body.startDate) < new Date()) { + this.logger.error(`Start date ${body.startDate} is in the past`); + throw new BadRequestException('MONEY_REQUEST.START_DATE_IN_THE_PAST'); } if (body.endDate && new Date(body.endDate) < new Date()) { @@ -27,6 +33,11 @@ export class MoneyRequestsService { throw new BadRequestException('MONEY_REQUEST.END_DATE_IN_THE_PAST'); } + if (body.endDate && body.startDate && new Date(body.endDate) < new Date(body.startDate)) { + this.logger.error(`End date ${body.endDate} is before start date ${body.startDate}`); + throw new BadRequestException('MONEY_REQUEST.END_DATE_BEFORE_START_DATE'); + } + const junior = await this.juniorService.findJuniorById(userId, true); const moneyRequest = await this.moneyRequestsRepository.createMoneyRequest(junior.id, junior.guardianId, body); @@ -86,6 +97,11 @@ export class MoneyRequestsService { //@TODO send notification } + findMoneyRequestsChunks(chunkSize: number) { + this.logger.log(`Finding money requests chunks`); + return this.moneyRequestsRepository.findMoneyRequestsChunks(chunkSize); + } + private async prepareMoneyRequestDocument(moneyRequests: MoneyRequest[]) { this.logger.log(`Preparing document for money requests`); await Promise.all( @@ -107,6 +123,12 @@ export class MoneyRequestsService { this.logger.error(`Money request ${moneyRequestId} not found`); throw new BadRequestException('MONEY_REQUEST.NOT_FOUND'); } + + if (moneyRequest.endDate && new Date(moneyRequest.endDate) < new Date()) { + this.logger.error(`Money request ${moneyRequestId} has already ended`); + throw new BadRequestException('MONEY_REQUEST.ENDED'); + } + if (moneyRequest.status !== MoneyRequestStatus.PENDING) { this.logger.error(`Money request ${moneyRequestId} already reviewed`); throw new BadRequestException('MONEY_REQUEST.ALREADY_REVIEWED');