feat: money requests

This commit is contained in:
Abdalhameed Ahmad
2025-09-08 21:38:11 +03:00
parent e6642b5a15
commit e1f50decfa
22 changed files with 449 additions and 10 deletions

View File

@ -28,6 +28,7 @@ import { HealthModule } from './health/health.module';
import { JuniorModule } from './junior/junior.module'; import { JuniorModule } from './junior/junior.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
import { WebhookModule } from './webhook/webhook.module'; import { WebhookModule } from './webhook/webhook.module';
import { MoneyRequestModule } from './money-request/money-request.module';
@Module({ @Module({
controllers: [], controllers: [],
@ -74,6 +75,7 @@ import { WebhookModule } from './webhook/webhook.module';
CronModule, CronModule,
NeoLeapModule, NeoLeapModule,
WebhookModule, WebhookModule,
MoneyRequestModule,
], ],
providers: [ providers: [
// Global Pipes // Global Pipes

View File

@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateMoneyRequestsTable1757349525708 implements MigrationInterface {
name = 'CreateMoneyRequestsTable1757349525708';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "money_requests" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "amount" numeric(10,2) NOT NULL, "reason" character varying NOT NULL, "status" character varying NOT NULL DEFAULT 'PENDING', "rejection_reason" text, "junior_id" uuid NOT NULL, "guardian_id" uuid NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_28cff23e9fb06cd5dbf73cd53e7" PRIMARY KEY ("id"))`,
);
await queryRunner.query(`ALTER TABLE "cards" ALTER COLUMN "color" SET DEFAULT 'DEEP_MAGENTA'`);
await queryRunner.query(
`ALTER TABLE "money_requests" ADD CONSTRAINT "FK_f7084c83efe7efaca37297d57ae" FOREIGN KEY ("junior_id") REFERENCES "juniors"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "money_requests" ADD CONSTRAINT "FK_09eadf4c4133b323f467ffc90f3" FOREIGN KEY ("guardian_id") REFERENCES "guardians"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "money_requests" DROP CONSTRAINT "FK_09eadf4c4133b323f467ffc90f3"`);
await queryRunner.query(`ALTER TABLE "money_requests" DROP CONSTRAINT "FK_f7084c83efe7efaca37297d57ae"`);
await queryRunner.query(`ALTER TABLE "cards" ALTER COLUMN "color" SET DEFAULT 'BLUE'`);
await queryRunner.query(`DROP TABLE "money_requests"`);
}
}

View File

@ -1,3 +1,4 @@
export * from './1754913378460-initial-migration'; export * from './1754913378460-initial-migration';
export * from './1754915164809-create-neoleap-related-entities'; export * from './1754915164809-create-neoleap-related-entities';
export * from './1754915164810-seed-default-avatar'; export * from './1754915164810-seed-default-avatar';
export * from './1757349525708-create-money-requests-table';

View File

@ -11,6 +11,7 @@ import {
} from 'typeorm'; } from 'typeorm';
import { Customer } from '~/customer/entities'; import { Customer } from '~/customer/entities';
import { Junior } from '~/junior/entities'; import { Junior } from '~/junior/entities';
import { MoneyRequest } from '~/money-request/entities/money-request.entity';
@Entity('guardians') @Entity('guardians')
export class Guardian extends BaseEntity { export class Guardian extends BaseEntity {
@ -27,6 +28,9 @@ export class Guardian extends BaseEntity {
@OneToMany(() => Junior, (junior) => junior.guardian) @OneToMany(() => Junior, (junior) => junior.guardian)
juniors!: Junior[]; juniors!: Junior[];
@OneToMany(() => MoneyRequest, (moneyRequest) => moneyRequest.guardian)
moneyRequests!: MoneyRequest[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date; createdAt!: Date;

View File

@ -72,12 +72,9 @@
}, },
"MONEY_REQUEST": { "MONEY_REQUEST": {
"START_DATE_IN_THE_PAST": "لا يمكن أن يكون تاريخ البدء في الماضي.",
"END_DATE_IN_THE_PAST": "لا يمكن أن يكون تاريخ النهاية في الماضي.",
"END_DATE_BEFORE_START_DATE": "لا يمكن أن يكون تاريخ النهاية قبل تاريخ البدء.",
"NOT_FOUND": "لم يتم العثور على طلب المال.", "NOT_FOUND": "لم يتم العثور على طلب المال.",
"ENDED": "تم انتهاء طلب المال.", "ALREADY_APPROVED": "تمت الموافقة على طلب المال بالفعل.",
"ALREADY_REVIEWED": "تمت مراجعة طلب المال بالفعل." "ALREADY_REJECTED": "تم رفض طلب المال بالفعل."
}, },
"GOAL": { "GOAL": {

View File

@ -71,12 +71,9 @@
}, },
"MONEY_REQUEST": { "MONEY_REQUEST": {
"START_DATE_IN_THE_PAST": "The start date cannot be in the past.",
"END_DATE_IN_THE_PAST": "The end date cannot be in the past.",
"END_DATE_BEFORE_START_DATE": "The end date cannot be before the start date.",
"NOT_FOUND": "The money request was not found.", "NOT_FOUND": "The money request was not found.",
"ENDED": "The money request has ended.", "ALREADY_APPROVED": "The money request has already been approved.",
"ALREADY_REVIEWED": "The money request has already been reviewed." "ALREADY_REJECTED": "The money request has already been rejected."
}, },
"GOAL": { "GOAL": {

View File

@ -5,12 +5,14 @@ import {
Entity, Entity,
JoinColumn, JoinColumn,
ManyToOne, ManyToOne,
OneToMany,
OneToOne, OneToOne,
PrimaryColumn, PrimaryColumn,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { Customer } from '~/customer/entities'; import { Customer } from '~/customer/entities';
import { Guardian } from '~/guardian/entities/guradian.entity'; import { Guardian } from '~/guardian/entities/guradian.entity';
import { MoneyRequest } from '~/money-request/entities/money-request.entity';
import { Relationship } from '../enums'; import { Relationship } from '../enums';
import { Theme } from './theme.entity'; import { Theme } from './theme.entity';
@ -39,6 +41,9 @@ export class Junior extends BaseEntity {
@JoinColumn({ name: 'guardian_id' }) @JoinColumn({ name: 'guardian_id' })
guardian!: Guardian; guardian!: Guardian;
@OneToMany(() => MoneyRequest, (moneyRequest) => moneyRequest.junior)
moneyRequests!: MoneyRequest[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date; createdAt!: Date;

View File

@ -0,0 +1 @@
export * from './money-requests.controller';

View File

@ -0,0 +1,81 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, 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 } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import { CreateMoneyRequestDto, MoneyRequestsFiltersRequestDto, RejectionMoneyRequestDto } from '../dtos/request';
import { MoneyRequestResponseDto } from '../dtos/response/money-request.response.dto';
import { MoneyRequestsService } from '../services/money-requests.service';
@Controller('money-requests')
@ApiTags('Money Requests')
@UseGuards(AccessTokenGuard, RolesGuard)
@ApiBearerAuth()
export class MoneyRequestsController {
constructor(private readonly moneyRequestsService: MoneyRequestsService) {}
@Post()
@AllowedRoles(Roles.JUNIOR)
@ApiDataResponse(MoneyRequestResponseDto)
async createMoneyRequest(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateMoneyRequestDto) {
const moneyRequest = await this.moneyRequestsService.createMoneyRequest(sub, body);
return ResponseFactory.data(new MoneyRequestResponseDto(moneyRequest));
}
@Get()
@AllowedRoles(Roles.JUNIOR, Roles.GUARDIAN)
@ApiDataResponse(MoneyRequestResponseDto)
async getMoneyRequests(
@AuthenticatedUser() { sub, roles }: IJwtPayload,
@Query() filters: MoneyRequestsFiltersRequestDto,
) {
const [moneyRequests, count] = await this.moneyRequestsService.findMoneyRequests(
sub,
roles.includes(Roles.GUARDIAN) ? Roles.GUARDIAN : Roles.JUNIOR,
filters,
);
return ResponseFactory.dataPage(
moneyRequests.map((mr) => new MoneyRequestResponseDto(mr)),
{
page: filters.page,
size: filters.size,
itemCount: count,
},
);
}
@Get(':id')
@AllowedRoles(Roles.JUNIOR, Roles.GUARDIAN)
@ApiDataResponse(MoneyRequestResponseDto)
async getMoneyRequest(@Param('id') id: string, @AuthenticatedUser() { sub, roles }: IJwtPayload) {
const moneyRequest = await this.moneyRequestsService.findById(
id,
sub,
roles.includes(Roles.GUARDIAN) ? Roles.GUARDIAN : Roles.JUNIOR,
);
return ResponseFactory.data(new MoneyRequestResponseDto(moneyRequest));
}
@Patch(':id/approve')
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(MoneyRequestResponseDto)
@HttpCode(HttpStatus.NO_CONTENT)
async approveMoneyRequest(@Param('id') id: string, @AuthenticatedUser() { sub }: IJwtPayload) {
await this.moneyRequestsService.approveMoneyRequest(id, sub);
}
@Patch(':id/reject')
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(MoneyRequestResponseDto)
@HttpCode(HttpStatus.NO_CONTENT)
async rejectMoneyRequest(
@Param('id') id: string,
@AuthenticatedUser() { sub }: IJwtPayload,
@Body() rejectionReasondto: RejectionMoneyRequestDto,
) {
await this.moneyRequestsService.rejectMoneyRequest(id, sub, rejectionReasondto);
}
}

View File

@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class CreateMoneyRequestDto {
@ApiProperty({ example: 300.42 })
@IsNumber(
{ maxDecimalPlaces: 3 },
{ message: i18n('validation.IsNumber', { path: 'general', property: 'moneyRequest.amount' }) },
)
amount!: number;
@ApiProperty({ example: 'For school supplies' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'moneyRequest.reason' }) })
reason!: string;
}

View File

@ -0,0 +1,3 @@
export * from './create-money-request.request.dto';
export * from './money-requests-filters.request.dto';
export * from './rejection.request.dto';

View File

@ -0,0 +1,13 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { PageOptionsRequestDto } from '~/core/dtos';
import { MoneyRequestStatus } from '~/money-request/enums';
export class MoneyRequestsFiltersRequestDto extends PageOptionsRequestDto {
@ApiPropertyOptional({ example: MoneyRequestStatus.APPROVED, enum: MoneyRequestStatus })
@IsEnum(MoneyRequestStatus, {
message: i18n('validation.enum', { property: 'moneyRequest.status', enum: MoneyRequestStatus }),
})
@IsOptional()
status?: MoneyRequestStatus;
}

View File

@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class RejectionMoneyRequestDto {
@ApiProperty({ example: 'You are spending too much' })
@IsString({ message: i18n('validation.string', { property: 'rejectionReason' }) })
@IsOptional()
rejectionReason!: string;
}

View File

@ -0,0 +1,41 @@
import { ApiProperty } from '@nestjs/swagger';
import { JuniorResponseDto } from '~/junior/dtos/response';
import { MoneyRequest } from '~/money-request/entities/money-request.entity';
import { MoneyRequestStatus } from '~/money-request/enums';
export class MoneyRequestResponseDto {
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' })
id!: string;
@ApiProperty({ example: 300.42 })
amount!: number;
@ApiProperty({ example: 'For school supplies' })
reason!: string;
@ApiProperty({ enum: MoneyRequestStatus, example: MoneyRequestStatus.PENDING })
status!: MoneyRequestStatus;
@ApiProperty({ example: null })
rejectionReason!: string | null;
@ApiProperty({ type: JuniorResponseDto })
junior!: JuniorResponseDto;
@ApiProperty({ example: '2024-01-01T00:00:00.000Z' })
createdAt!: Date;
@ApiProperty({ example: '2024-01-02T00:00:00.000Z' })
updatedAt!: Date;
constructor(data: MoneyRequest) {
this.id = data.id;
this.amount = Number(data.amount);
this.reason = data.reason;
this.status = data.status;
this.rejectionReason = data.rejectionReason;
this.junior = new JuniorResponseDto(data.junior);
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
}
}

View File

@ -0,0 +1,51 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Guardian } from '~/guardian/entities/guradian.entity';
import { Junior } from '~/junior/entities';
import { MoneyRequestStatus } from '../enums';
@Entity('money_requests')
export class MoneyRequest extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ precision: 10, scale: 2, type: 'decimal', name: 'amount' })
amount!: number;
@Column({ type: 'varchar', name: 'reason' })
reason!: string;
@Column({ type: 'varchar', name: 'status', default: MoneyRequestStatus.PENDING })
status!: MoneyRequestStatus;
@Column({ type: 'text', name: 'rejection_reason', nullable: true })
rejectionReason!: string | null;
@Column({ type: 'uuid', name: 'junior_id' })
juniorId!: string;
@Column({ type: 'uuid', name: 'guardian_id' })
guardianId!: string;
@ManyToOne(() => Junior, (junior) => junior.moneyRequests, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'junior_id' })
junior!: Junior;
@ManyToOne(() => Guardian, (guardian) => guardian.moneyRequests, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'guardian_id' })
guardian!: Guardian;
@CreateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', name: 'updated_at' })
updatedAt!: Date;
}

View File

@ -0,0 +1 @@
export * from './money-request-status.enum';

View File

@ -0,0 +1,5 @@
export enum MoneyRequestStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JuniorModule } from '~/junior/junior.module';
import { MoneyRequestsController } from './controllers';
import { MoneyRequest } from './entities/money-request.entity';
import { MoneyRequestsRepository } from './repositories';
import { MoneyRequestsService } from './services';
@Module({
imports: [TypeOrmModule.forFeature([MoneyRequest]), JuniorModule],
controllers: [MoneyRequestsController],
providers: [MoneyRequestsService, MoneyRequestsRepository],
})
export class MoneyRequestModule {}

View File

@ -0,0 +1 @@
export * from './money-requests.repository';

View File

@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Roles } from '~/auth/enums/roles.enum';
import { CreateMoneyRequestDto, MoneyRequestsFiltersRequestDto } from '../dtos/request';
import { MoneyRequest } from '../entities/money-request.entity';
import { MoneyRequestStatus } from '../enums';
const FIRST_PAGE = 1;
@Injectable()
export class MoneyRequestsRepository {
constructor(@InjectRepository(MoneyRequest) private readonly moneyRequestRepository: Repository<MoneyRequest>) {}
findAll(userId: string, role: Roles, filters: MoneyRequestsFiltersRequestDto): Promise<[MoneyRequest[], number]> {
const queryBuilder = this.moneyRequestRepository.createQueryBuilder('moneyRequest');
if (role === Roles.JUNIOR) {
queryBuilder.where('moneyRequest.juniorId = :userId', { userId });
} else if (role === Roles.GUARDIAN) {
queryBuilder.where('moneyRequest.guardianId = :userId', { userId });
}
queryBuilder.leftJoinAndSelect('moneyRequest.junior', 'junior');
queryBuilder.leftJoinAndSelect('junior.customer', 'customer');
queryBuilder.leftJoinAndSelect('customer.user', 'user');
queryBuilder.leftJoinAndSelect('user.profilePicture', 'profilePicture');
if (filters.status) {
queryBuilder.andWhere('moneyRequest.status = :status', { status: filters.status });
}
queryBuilder.skip((filters.page - FIRST_PAGE) * filters.size);
queryBuilder.take(filters.size);
queryBuilder.orderBy('moneyRequest.createdAt', 'DESC');
return queryBuilder.getManyAndCount();
}
createMoneyRequest(juniorId: string, guardianId: string, body: CreateMoneyRequestDto): Promise<MoneyRequest> {
return this.moneyRequestRepository.save(
this.moneyRequestRepository.create({
amount: body.amount,
reason: body.reason,
status: MoneyRequestStatus.PENDING,
juniorId,
guardianId,
}),
);
}
findById(id: string, userId?: string, role?: Roles): Promise<MoneyRequest | null> {
const whereCondition: any = { id };
if (role === Roles.JUNIOR) {
whereCondition.juniorId = userId;
} else {
whereCondition.guardianId = userId;
}
return this.moneyRequestRepository.findOne({
where: whereCondition,
relations: ['junior', 'junior.customer', 'junior.customer.user', 'junior.customer.user.profilePicture'],
});
}
approveMoneyRequest(id: string) {
return this.moneyRequestRepository.update({ id }, { status: MoneyRequestStatus.APPROVED });
}
rejectMoneyRequest(id: string, rejectionReason?: string) {
return this.moneyRequestRepository.update({ id }, { status: MoneyRequestStatus.REJECTED, rejectionReason });
}
}

View File

@ -0,0 +1 @@
export * from './money-requests.service';

View File

@ -0,0 +1,102 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { Transactional } from 'typeorm-transactional';
import { Roles } from '~/auth/enums';
import { OciService } from '~/document/services';
import { Junior } from '~/junior/entities/junior.entity';
import { JuniorService } from '~/junior/services';
import { CreateMoneyRequestDto, MoneyRequestsFiltersRequestDto, RejectionMoneyRequestDto } from '../dtos/request';
import { MoneyRequest } from '../entities/money-request.entity';
import { MoneyRequestStatus } from '../enums';
import { MoneyRequestsRepository } from '../repositories';
@Injectable()
export class MoneyRequestsService {
private readonly logger = new Logger(MoneyRequestsService.name);
constructor(
private readonly moneyRequestsRepository: MoneyRequestsRepository,
private readonly juniorService: JuniorService,
private readonly ociService: OciService,
) {}
async createMoneyRequest(juniorId: string, body: CreateMoneyRequestDto) {
const junior = await this.juniorService.findJuniorById(juniorId);
const moneyRequest = await this.moneyRequestsRepository.createMoneyRequest(junior.id, junior.guardianId, body);
return this.findById(moneyRequest.id);
}
async findById(id: string, userId?: string, role?: Roles): Promise<MoneyRequest> {
const moneyRequest = await this.moneyRequestsRepository.findById(id, userId, role);
if (!moneyRequest) {
throw new BadRequestException('MONEY_REQUEST.NOT_FOUND');
}
await this.prepareJuniorImages([moneyRequest.junior]);
return moneyRequest;
}
async findMoneyRequests(
userId: string,
role: Roles,
filters: MoneyRequestsFiltersRequestDto,
): Promise<[MoneyRequest[], number]> {
const [moneyRequests, count] = await this.moneyRequestsRepository.findAll(userId, role, filters);
const juniors = moneyRequests.map((moneyRequest) => moneyRequest.junior);
await this.prepareJuniorImages(juniors);
return [moneyRequests, count];
}
@Transactional()
async approveMoneyRequest(id: string, guardianId: string): Promise<void> {
const moneyRequest = await this.moneyRequestsRepository.findById(id, guardianId, Roles.GUARDIAN);
if (!moneyRequest) {
throw new BadRequestException('MONEY_REQUEST.NOT_FOUND');
}
if (moneyRequest.status == MoneyRequestStatus.APPROVED) {
throw new BadRequestException('MONEY_REQUEST.ALREADY_APPROVED');
}
await Promise.all([
this.moneyRequestsRepository.approveMoneyRequest(id),
this.juniorService.transferToJunior(
moneyRequest.juniorId,
{ amount: moneyRequest.amount },
moneyRequest.guardianId,
),
]);
}
async rejectMoneyRequest(
id: string,
guardianId: string,
rejectionReasondto: RejectionMoneyRequestDto,
): Promise<void> {
const moneyRequest = await this.moneyRequestsRepository.findById(id, guardianId, Roles.GUARDIAN);
if (!moneyRequest) {
throw new BadRequestException('MONEY_REQUEST.NOT_FOUND');
}
if (moneyRequest.status == MoneyRequestStatus.APPROVED) {
throw new BadRequestException('MONEY_REQUEST.ALREADY_APPROVED');
}
if (moneyRequest.status == MoneyRequestStatus.REJECTED) {
throw new BadRequestException('MONEY_REQUEST.ALREADY_REJECTED');
}
await this.moneyRequestsRepository.rejectMoneyRequest(id, rejectionReasondto?.rejectionReason);
}
private async prepareJuniorImages(juniors: Junior[]) {
this.logger.log(`Preparing junior images`);
await Promise.all(
juniors.map(async (junior) => {
const profilePicture = junior.customer.user.profilePicture;
if (profilePicture) {
profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture);
}
}),
);
}
}