feat: add money request cronjob, and edit money request entity

This commit is contained in:
Abdalhamid Alhamad
2025-01-02 11:22:33 +03:00
parent 557ef4cd33
commit aefa866ae7
12 changed files with 187 additions and 9 deletions

View File

@ -84,7 +84,13 @@ export class Allowance {
const calculateNextDate = (unit: moment.unitOfTime.Diff) => { const calculateNextDate = (unit: moment.unitOfTime.Diff) => {
const diff = now.diff(startDate, unit); const diff = now.diff(startDate, unit);
const nextDate = startDate.clone().add(diff, 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) { switch (this.frequency) {

View File

@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AllowanceModule } from '~/allowance/allowance.module'; import { AllowanceModule } from '~/allowance/allowance.module';
import { MoneyRequestModule } from '~/money-request/money-request.module';
import { BaseCronService } from './services'; import { BaseCronService } from './services';
import { AllowanceTask } from './tasks'; import { AllowanceTask, MoneyRequestTask } from './tasks';
@Module({ @Module({
imports: [AllowanceModule], imports: [AllowanceModule, MoneyRequestModule],
providers: [AllowanceTask, BaseCronService], providers: [AllowanceTask, MoneyRequestTask, BaseCronService],
}) })
export class CronModule {} export class CronModule {}

View File

@ -15,7 +15,7 @@ export class AllowanceTask extends BaseCronService {
private readonly logger = new Logger(AllowanceTask.name); private readonly logger = new Logger(AllowanceTask.name);
private readonly cronLockKey = `${AllowanceTask.name}-lock`; private readonly cronLockKey = `${AllowanceTask.name}-lock`;
private readonly cronLockTtl = TEN_MINUTES; private readonly cronLockTtl = TEN_MINUTES;
constructor(cacheService: CacheService, private allowanceService: AllowancesService) { constructor(cacheService: CacheService, private readonly allowanceService: AllowancesService) {
super(cacheService); super(cacheService);
} }

View File

@ -1 +1,2 @@
export * from './allowance.task'; export * from './allowance.task';
export * from './money-request.task';

View File

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

View File

@ -12,6 +12,7 @@ export class CreateMoneyRequestEntity1734503895302 implements MigrationInterface
"frequency" character varying NOT NULL DEFAULT 'ONE_TIME', "frequency" character varying NOT NULL DEFAULT 'ONE_TIME',
"status" character varying NOT NULL DEFAULT 'PENDING', "status" character varying NOT NULL DEFAULT 'PENDING',
"reviewed_at" TIMESTAMP WITH TIME ZONE, "reviewed_at" TIMESTAMP WITH TIME ZONE,
"start_date" TIMESTAMP WITH TIME ZONE,
"end_date" TIMESTAMP WITH TIME ZONE, "end_date" TIMESTAMP WITH TIME ZONE,
"requester_id" uuid NOT NULL, "requester_id" uuid NOT NULL,
"reviewer_id" uuid NOT NULL, "reviewer_id" uuid NOT NULL,

View File

@ -1,5 +1,15 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 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 { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { MoneyRequestFrequency } from '~/money-request/enums'; import { MoneyRequestFrequency } from '~/money-request/enums';
const MIN_REQUESTED_AMOUNT = 0.01; const MIN_REQUESTED_AMOUNT = 0.01;
@ -30,6 +40,14 @@ export class CreateMoneyRequestRequestDto {
@IsOptional() @IsOptional()
frequency: MoneyRequestFrequency = MoneyRequestFrequency.ONE_TIME; 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' }) @ApiPropertyOptional({ example: '2021-01-01' })
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'moneyRequest.endDate' }) }) @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'moneyRequest.endDate' }) })
@IsOptional() @IsOptional()

View File

@ -22,6 +22,9 @@ export class MoneyRequestResponseDto {
@ApiProperty({ example: MoneyRequestFrequency.ONE_TIME }) @ApiProperty({ example: MoneyRequestFrequency.ONE_TIME })
frequency!: MoneyRequestFrequency; frequency!: MoneyRequestFrequency;
@ApiProperty({ example: '2021-01-01' })
startDate!: Date | null;
@ApiProperty({ example: '2021-01-01' }) @ApiProperty({ example: '2021-01-01' })
endDate!: Date | null; endDate!: Date | null;
@ -41,6 +44,7 @@ export class MoneyRequestResponseDto {
this.requestedAmount = moneyRequest.requestedAmount; this.requestedAmount = moneyRequest.requestedAmount;
this.message = moneyRequest.message; this.message = moneyRequest.message;
this.frequency = moneyRequest.frequency; this.frequency = moneyRequest.frequency;
this.startDate = moneyRequest.startDate || null;
this.endDate = moneyRequest.endDate || null; this.endDate = moneyRequest.endDate || null;
this.status = moneyRequest.status; this.status = moneyRequest.status;
this.reviewedAt = moneyRequest.reviewedAt || null; this.reviewedAt = moneyRequest.reviewedAt || null;

View File

@ -1,3 +1,4 @@
import moment from 'moment';
import { import {
Column, Column,
CreateDateColumn, CreateDateColumn,
@ -31,6 +32,9 @@ export class MoneyRequest {
@Column({ type: 'timestamp with time zone', name: 'reviewed_at', nullable: true }) @Column({ type: 'timestamp with time zone', name: 'reviewed_at', nullable: true })
reviewedAt!: Date; 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 }) @Column({ type: 'timestamp with time zone', name: 'end_date', nullable: true })
endDate!: Date | null; endDate!: Date | null;
@ -53,4 +57,41 @@ export class MoneyRequest {
@UpdateDateColumn({ type: 'timestamp with time zone', name: 'updated_at' }) @UpdateDateColumn({ type: 'timestamp with time zone', name: 'updated_at' })
updatedAt!: Date; 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;
}
}
} }

View File

@ -9,7 +9,7 @@ import { MoneyRequestsService } from './services';
@Module({ @Module({
controllers: [MoneyRequestsController], controllers: [MoneyRequestsController],
providers: [MoneyRequestsService, MoneyRequestsRepository], providers: [MoneyRequestsService, MoneyRequestsRepository],
exports: [], exports: [MoneyRequestsService],
imports: [TypeOrmModule.forFeature([MoneyRequest]), JuniorModule], imports: [TypeOrmModule.forFeature([MoneyRequest]), JuniorModule],
}) })
export class MoneyRequestModule {} export class MoneyRequestModule {}

View File

@ -1,9 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { IsNull, MoreThan, Not, Repository } from 'typeorm';
import { CreateMoneyRequestRequestDto, MoneyRequestsFiltersRequestDto } from '../dtos/request'; import { CreateMoneyRequestRequestDto, MoneyRequestsFiltersRequestDto } from '../dtos/request';
import { MoneyRequest } from '../entities'; import { MoneyRequest } from '../entities';
import { MoneyRequestStatus } from '../enums'; import { MoneyRequestFrequency, MoneyRequestStatus } from '../enums';
const ONE = 1; const ONE = 1;
@Injectable() @Injectable()
export class MoneyRequestsRepository { export class MoneyRequestsRepository {
@ -16,6 +16,7 @@ export class MoneyRequestsRepository {
reviewerId, reviewerId,
requestedAmount: body.requestedAmount, requestedAmount: body.requestedAmount,
message: body.message, message: body.message,
startDate: body.startDate,
endDate: body.endDate, endDate: body.endDate,
frequency: body.frequency, frequency: body.frequency,
}), }),
@ -46,4 +47,33 @@ export class MoneyRequestsRepository {
updateMoneyRequestStatus(moneyRequestId: string, reviewerId: string, status: MoneyRequestStatus) { updateMoneyRequestStatus(moneyRequestId: string, reviewerId: string, status: MoneyRequestStatus) {
return this.moneyRequestRepository.update({ id: moneyRequestId, reviewerId }, { status, reviewedAt: new Date() }); 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;
}
}
} }

View File

@ -20,6 +20,12 @@ export class MoneyRequestsService {
if (body.frequency === MoneyRequestFrequency.ONE_TIME) { if (body.frequency === MoneyRequestFrequency.ONE_TIME) {
this.logger.log(`Setting end date to null for one time money request`); this.logger.log(`Setting end date to null for one time money request`);
delete body.endDate; 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()) { 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'); 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 junior = await this.juniorService.findJuniorById(userId, true);
const moneyRequest = await this.moneyRequestsRepository.createMoneyRequest(junior.id, junior.guardianId, body); const moneyRequest = await this.moneyRequestsRepository.createMoneyRequest(junior.id, junior.guardianId, body);
@ -86,6 +97,11 @@ export class MoneyRequestsService {
//@TODO send notification //@TODO send notification
} }
findMoneyRequestsChunks(chunkSize: number) {
this.logger.log(`Finding money requests chunks`);
return this.moneyRequestsRepository.findMoneyRequestsChunks(chunkSize);
}
private async prepareMoneyRequestDocument(moneyRequests: MoneyRequest[]) { private async prepareMoneyRequestDocument(moneyRequests: MoneyRequest[]) {
this.logger.log(`Preparing document for money requests`); this.logger.log(`Preparing document for money requests`);
await Promise.all( await Promise.all(
@ -107,6 +123,12 @@ export class MoneyRequestsService {
this.logger.error(`Money request ${moneyRequestId} not found`); this.logger.error(`Money request ${moneyRequestId} not found`);
throw new BadRequestException('MONEY_REQUEST.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) { if (moneyRequest.status !== MoneyRequestStatus.PENDING) {
this.logger.error(`Money request ${moneyRequestId} already reviewed`); this.logger.error(`Money request ${moneyRequestId} already reviewed`);
throw new BadRequestException('MONEY_REQUEST.ALREADY_REVIEWED'); throw new BadRequestException('MONEY_REQUEST.ALREADY_REVIEWED');