diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 1c241e77..ed4c948c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -3,11 +3,11 @@ import { AuthModule } from './auth/auth.module'; import { UserModule } from './user/user.module'; import { GrantModule } from './grant/grant.module'; import { NotificationsModule } from './notifications/notification.module'; -import { CashflowModule } from './cashflow/cashflow.module'; +import { RevenueModule } from './revenue/cashflow-revenue.module'; import { CostModule } from './cost/cashflow-cost.module'; import { DefaultValuesModule } from './default-values/default-values.module'; @Module({ - imports: [AuthModule, UserModule, GrantModule, NotificationsModule, CashflowModule, CostModule, DefaultValuesModule], + imports: [AuthModule, UserModule, GrantModule, NotificationsModule, RevenueModule, CostModule, DefaultValuesModule], }) export class AppModule {} \ No newline at end of file diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 03b951ea..f19b010b 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -4,6 +4,7 @@ import { Response } from 'express'; import { VerifyUserGuard } from "../guards/auth.guard"; import { LoginBody, RegisterBody, SetPasswordBody, UpdateProfileBody, ChangePasswordBody } from './types/auth.types'; import { ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; +import { User } from '../../../middle-layer/types/User'; @Controller('auth') export class AuthController { @@ -145,7 +146,7 @@ export class AuthController { async login( @Res({ passthrough: true }) response: Response, @Body() body:LoginBody - ): Promise<{ message: string }> { + ): Promise<{ message: string; user: User }> { const result = await this.authService.login(body.email, body.password); // Set cookie with access token @@ -183,7 +184,10 @@ export class AuthController { } - return { message: 'User logged in successfully' }; + return { + message: 'User logged in successfully', + user: result.user, + }; } /** diff --git a/backend/src/cashflow/cashflow.controller.ts b/backend/src/cashflow/cashflow.controller.ts deleted file mode 100644 index f62af84c..00000000 --- a/backend/src/cashflow/cashflow.controller.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Controller, Get, Logger } from "@nestjs/common"; -import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger"; - - - -@ApiTags('cashflow') -@Controller('cashflow') -export class CashflowController { - private readonly logger = new Logger(CashflowController.name); - @Get('hello') - @ApiOperation({ summary: 'Hello endpoint', description: 'Returns a simple hello message' }) - @ApiResponse({ status: 200, description: 'Returns hello message' }) - getHello(): string { - return 'hello'; - } -} \ No newline at end of file diff --git a/backend/src/cashflow/cashflow.module.ts b/backend/src/cashflow/cashflow.module.ts deleted file mode 100644 index 1c310533..00000000 --- a/backend/src/cashflow/cashflow.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { CashflowService } from './cashflow.service'; -import { CashflowController } from './cashflow.controller'; - - -@Module({ - controllers: [CashflowController], - providers: [CashflowService], -}) -export class CashflowModule {} \ No newline at end of file diff --git a/backend/src/cashflow/cashflow.service.ts b/backend/src/cashflow/cashflow.service.ts deleted file mode 100644 index 19202c89..00000000 --- a/backend/src/cashflow/cashflow.service.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import * as AWS from 'aws-sdk'; - -@Injectable() -export class CashflowService { - private readonly logger = new Logger(CashflowService.name); - private dynamoDb = new AWS.DynamoDB.DocumentClient(); - -} \ No newline at end of file diff --git a/backend/src/cost/__test__/cashflow-cost.service.spec.ts b/backend/src/cost/__test__/cashflow-cost.service.spec.ts index b07a5c43..8e9b33a5 100644 --- a/backend/src/cost/__test__/cashflow-cost.service.spec.ts +++ b/backend/src/cost/__test__/cashflow-cost.service.spec.ts @@ -163,46 +163,6 @@ describe('CostService', () => { }); }); - describe('getCostsByType()', () => { - it('returns costs filtered by type', async () => { - const items = [{ name: 'Food', amount: 200, type: CostType.MealsFood }]; - mockScanPromise.mockResolvedValue({ Items: items }); - - const result = await service.getCostsByType(CostType.MealsFood); - - expect(result).toEqual(items); - expect(mockScan).toHaveBeenCalledWith({ - TableName: 'Costs', - FilterExpression: '#type = :type', - ExpressionAttributeNames: { - '#type': 'type', - }, - ExpressionAttributeValues: { - ':type': CostType.MealsFood, - }, - }); - }); - - it('throws InternalServerErrorException when table name is missing', async () => { - delete process.env.CASHFLOW_COST_TABLE_NAME; - - await expect(service.getCostsByType(CostType.MealsFood)).rejects.toThrow( - InternalServerErrorException, - ); - }); - - it('throws InternalServerErrorException on DynamoDB error', async () => { - mockScanPromise.mockRejectedValue(new Error('scan failed')); - - await expect(service.getCostsByType(CostType.MealsFood)).rejects.toThrow( - InternalServerErrorException, - ); - await expect(service.getCostsByType(CostType.MealsFood)).rejects.toThrow( - `Failed to retrieve costs with type ${CostType.MealsFood}`, - ); - }); - }); - describe('createCost()', () => { it('creates a cost and trims the name', async () => { mockPutPromise.mockResolvedValue({}); @@ -342,144 +302,152 @@ describe('CostService', () => { }); describe('updateCost()', () => { + beforeEach(() => { + mockGetPromise.mockResolvedValue({ + Item: { + name: 'Food', + amount: 200, + type: CostType.MealsFood, + date: '2026-03-22', + }, + }); + }); + it('updates non-key fields for an existing cost', async () => { const updatedItem = { name: 'Food', amount: 300, type: CostType.Services, + date: '2026-03-22' as TDateISO, }; - mockUpdatePromise.mockResolvedValue({ Attributes: updatedItem }); + mockPutPromise.mockResolvedValue({}); const result = await service.updateCost('Food', { + name: 'Food', amount: 300, type: CostType.Services, + date: '2026-03-22' as TDateISO, }); expect(result).toEqual(updatedItem); - expect(mockUpdate).toHaveBeenCalledWith({ + expect(mockGet).toHaveBeenCalledWith({ TableName: 'Costs', Key: { name: 'Food' }, - UpdateExpression: 'SET #amount = :amount, #type = :type', - ExpressionAttributeNames: { - '#amount': 'amount', - '#type': 'type', - '#name': 'name', - }, - ExpressionAttributeValues: { - ':amount': 300, - ':type': CostType.Services, - }, - ConditionExpression: 'attribute_exists(#name)', - ReturnValues: 'ALL_NEW', }); - }); - - it('updates only amount when type is not provided', async () => { - const updatedItem = { - name: 'Food', - amount: 275, - type: CostType.MealsFood, - }; - mockUpdatePromise.mockResolvedValue({ Attributes: updatedItem }); - - const result = await service.updateCost('Food', { amount: 275 }); - - expect(result).toEqual(updatedItem); - expect(mockUpdate).toHaveBeenCalledWith({ + expect(mockPut).toHaveBeenCalledWith({ TableName: 'Costs', - Key: { name: 'Food' }, - UpdateExpression: 'SET #amount = :amount', + Item: { + name: 'Food', + amount: 300, + type: CostType.Services, + date: '2026-03-22', + }, + ConditionExpression: 'attribute_exists(#name)', ExpressionAttributeNames: { - '#amount': 'amount', '#name': 'name', }, - ExpressionAttributeValues: { - ':amount': 275, - }, - ConditionExpression: 'attribute_exists(#name)', - ReturnValues: 'ALL_NEW', }); }); - it('updates only type when amount is not provided', async () => { - const updatedItem = { + it('does nothing when incoming payload matches existing cost', async () => { + const result = await service.updateCost('Food', { name: 'Food', amount: 200, - type: CostType.Services, - }; - mockUpdatePromise.mockResolvedValue({ Attributes: updatedItem }); - - const result = await service.updateCost('Food', { - type: CostType.Services, + type: CostType.MealsFood, + date: '2026-03-22' as TDateISO, }); - expect(result).toEqual(updatedItem); - expect(mockUpdate).toHaveBeenCalledWith({ - TableName: 'Costs', - Key: { name: 'Food' }, - UpdateExpression: 'SET #type = :type', - ExpressionAttributeNames: { - '#type': 'type', - '#name': 'name', - }, - ExpressionAttributeValues: { - ':type': CostType.Services, - }, - ConditionExpression: 'attribute_exists(#name)', - ReturnValues: 'ALL_NEW', + expect(result).toEqual({ + name: 'Food', + amount: 200, + type: CostType.MealsFood, + date: '2026-03-22', }); - }); - - it('throws BadRequestException when update payload is empty', async () => { - await expect(service.updateCost('Food', {})).rejects.toThrow( - BadRequestException, - ); - await expect(service.updateCost('Food', {})).rejects.toThrow( - 'At least one field is required for update', - ); + expect(mockPut).not.toHaveBeenCalled(); + expect(mockTransactWrite).not.toHaveBeenCalled(); }); it('throws BadRequestException for invalid amount', async () => { await expect( - service.updateCost('Food', { amount: Number.NaN }), + service.updateCost('Food', { + name: 'Food', + amount: Number.NaN, + type: CostType.MealsFood, + date: '2026-03-22' as TDateISO, + }), ).rejects.toThrow(BadRequestException); }); it('throws BadRequestException for invalid type', async () => { await expect( - service.updateCost('Food', { type: 'INVALID' as unknown as CostType }), + service.updateCost('Food', { + name: 'Food', + amount: 250, + type: 'INVALID' as unknown as CostType, + date: '2026-03-22' as TDateISO, + }), ).rejects.toThrow(BadRequestException); }); it('throws BadRequestException for invalid date format', async () => { await expect( - service.updateCost('Food', { date: 'not-a-date' as unknown as TDateISO }), + service.updateCost('Food', { + name: 'Food', + amount: 250, + type: CostType.MealsFood, + date: 'not-a-date' as unknown as TDateISO, + }), ).rejects.toThrow(BadRequestException); await expect( - service.updateCost('Food', { date: 'not-a-date' as unknown as TDateISO }), + service.updateCost('Food', { + name: 'Food', + amount: 250, + type: CostType.MealsFood, + date: 'not-a-date' as unknown as TDateISO, + }), ).rejects.toThrow('date must be a valid ISO 8601 format string'); }); it('throws NotFoundException when non-rename update target does not exist', async () => { - const err = { code: 'ConditionalCheckFailedException' }; - mockUpdatePromise.mockRejectedValue(err); + mockGetPromise.mockResolvedValue({ Item: undefined }); await expect( - service.updateCost('Food', { amount: 250 }), + service.updateCost('Food', { + name: 'Food', + amount: 250, + type: CostType.MealsFood, + date: '2026-03-22' as TDateISO, + }), ).rejects.toThrow(NotFoundException); await expect( - service.updateCost('Food', { amount: 250 }), + service.updateCost('Food', { + name: 'Food', + amount: 250, + type: CostType.MealsFood, + date: '2026-03-22' as TDateISO, + }), ).rejects.toThrow('Cost with name Food not found'); + expect(mockPut).not.toHaveBeenCalled(); }); it('throws InternalServerErrorException on non-rename DynamoDB error', async () => { - mockUpdatePromise.mockRejectedValue(new Error('update failed')); + mockPutPromise.mockRejectedValue(new Error('update failed')); await expect( - service.updateCost('Food', { amount: 250 }), + service.updateCost('Food', { + name: 'Food', + amount: 250, + type: CostType.MealsFood, + date: '2026-03-22' as TDateISO, + }), ).rejects.toThrow(InternalServerErrorException); await expect( - service.updateCost('Food', { amount: 250 }), + service.updateCost('Food', { + name: 'Food', + amount: 250, + type: CostType.MealsFood, + date: '2026-03-22' as TDateISO, + }), ).rejects.toThrow('Failed to update cost Food'); }); @@ -492,13 +460,15 @@ describe('CostService', () => { const result = await service.updateCost('Food', { name: 'Meals', amount: 300, + type: CostType.MealsFood, + date: '2026-03-22' as TDateISO, }); expect(result).toEqual({ name: 'Meals', amount: 300, type: CostType.MealsFood, - date: undefined, + date: '2026-03-22', }); expect(mockGet).toHaveBeenCalledWith({ TableName: 'Costs', @@ -513,7 +483,7 @@ describe('CostService', () => { name: 'Meals', amount: 300, type: CostType.MealsFood, - date: undefined, + date: '2026-03-22', }, ConditionExpression: 'attribute_not_exists(#name)', ExpressionAttributeNames: { @@ -544,6 +514,7 @@ describe('CostService', () => { const result = await service.updateCost('Food', { name: 'Meals', amount: 300, + type: CostType.MealsFood, date: '2026-03-22' as TDateISO, }); @@ -559,10 +530,20 @@ describe('CostService', () => { mockGetPromise.mockResolvedValue({ Item: undefined }); await expect( - service.updateCost('Food', { name: 'Meals' }), + service.updateCost('Food', { + name: 'Meals', + amount: 300, + type: CostType.MealsFood, + date: '2026-03-22' as TDateISO, + }), ).rejects.toThrow(NotFoundException); await expect( - service.updateCost('Food', { name: 'Meals' }), + service.updateCost('Food', { + name: 'Meals', + amount: 300, + type: CostType.MealsFood, + date: '2026-03-22' as TDateISO, + }), ).rejects.toThrow('Cost with name Food not found'); }); @@ -575,10 +556,20 @@ describe('CostService', () => { }); await expect( - service.updateCost('Food', { name: 'Meals' }), + service.updateCost('Food', { + name: 'Meals', + amount: 300, + type: CostType.MealsFood, + date: '2026-03-22' as TDateISO, + }), ).rejects.toThrow(ConflictException); await expect( - service.updateCost('Food', { name: 'Meals' }), + service.updateCost('Food', { + name: 'Meals', + amount: 300, + type: CostType.MealsFood, + date: '2026-03-22' as TDateISO, + }), ).rejects.toThrow('Cost with name Meals already exists'); }); @@ -589,19 +580,34 @@ describe('CostService', () => { mockTransactWritePromise.mockRejectedValue(new Error('txn failed')); await expect( - service.updateCost('Food', { name: 'Meals' }), + service.updateCost('Food', { + name: 'Meals', + amount: 300, + type: CostType.MealsFood, + date: '2026-03-22' as TDateISO, + }), ).rejects.toThrow(InternalServerErrorException); await expect( - service.updateCost('Food', { name: 'Meals' }), + service.updateCost('Food', { + name: 'Meals', + amount: 300, + type: CostType.MealsFood, + date: '2026-03-22' as TDateISO, + }), ).rejects.toThrow('Failed to update cost Food'); }); it('throws InternalServerErrorException when table name is missing', async () => { delete process.env.CASHFLOW_COST_TABLE_NAME; - await expect(service.updateCost('Food', { amount: 200 })).rejects.toThrow( - InternalServerErrorException, - ); + await expect( + service.updateCost('Food', { + name: 'Food', + amount: 200, + type: CostType.MealsFood, + date: '2026-03-22' as TDateISO, + }), + ).rejects.toThrow(InternalServerErrorException); }); }); diff --git a/backend/src/cost/cashflow-cost.controller.ts b/backend/src/cost/cashflow-cost.controller.ts index 2de8a533..eec525de 100644 --- a/backend/src/cost/cashflow-cost.controller.ts +++ b/backend/src/cost/cashflow-cost.controller.ts @@ -1,11 +1,10 @@ import { - BadRequestException, Body, Controller, Delete, Get, Param, - Patch, + Put, Post, UseGuards } from '@nestjs/common'; @@ -18,9 +17,9 @@ import { ApiBearerAuth } from '@nestjs/swagger'; import { CostService } from './cashflow-cost.service'; -import { CostType } from '../../../middle-layer/types/CostType'; import { VerifyAdminRoleGuard } from '../guards/auth.guard'; import { CashflowCost } from '../types/CashflowCost'; +import { CashflowCostDTO } from './types/cost.types'; // interface CreateCostBody { // cost : CashflowCost; @@ -69,22 +68,6 @@ export class CostController { return await this.costService.getCostByName(costName); } - /** - * gets costs by type (e.g. Personal Salary, Personal Benefits, etc.) - * @param costType type of cost you are trying to get (e.g. all Salary costs) - * @returns array of costs of the specified type, if any exist - */ - @Get('type/:costType') - @UseGuards(VerifyAdminRoleGuard) - @ApiBearerAuth() - @ApiOperation({ summary: 'Get costs by type' }) - @ApiParam({ name: 'costType', type: String, description: 'Cost Type' }) - @ApiResponse({ status: 200, description: 'Successfully retrieved costs' }) - @ApiResponse({ status: 500, description: 'Internal Server Error' }) - async getCostsByType(@Param('costType') costType: CostType) { - return await this.costService.getCostsByType(costType); - } - /** * creates a new cost with the specified fields in the request body * @param body must include amount, type, and name of the cost to be created @@ -94,46 +77,21 @@ export class CostController { @UseGuards(VerifyAdminRoleGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Create a cost' }) - @ApiBody({ - schema: { - type: 'object', - required: ['name', 'amount', 'type','date'], - properties: { - name: { type: 'string', example: 'PM Salary' }, - amount: { type: 'number', example: 12000 }, - type: { type: 'string', enum: Object.values(CostType), example: CostType.Salary }, - date: { type: 'string', example: '2026-03-14T00:00:00.000Z' }, - }, - }, -}) + @ApiBody({ type: CashflowCostDTO, description: 'Full cost payload' }) @ApiResponse({ status: 201, description: 'Successfully created cost' }) @ApiResponse({ status: 400, description: 'Bad Request - Invalid cost payload' }) @ApiResponse({ status: 409, description: 'Conflict - Cost with the same name already exists' }) @ApiResponse({ status: 500, description: 'Internal Server Error' }) - async createCost(@Body() body: CashflowCost) { + async createCost(@Body() body: CashflowCostDTO) { return await this.costService.createCost(body); } - @Patch(':costName') + @Put(':costName') @UseGuards(VerifyAdminRoleGuard) @ApiBearerAuth() - @ApiOperation({ summary: 'Update cost fields by name' }) + @ApiOperation({ summary: 'Replace cost by name' }) @ApiParam({ name: 'costName', type: String, description: 'Cost Name' }) - @ApiBody({ - schema: { - type: 'object', - properties: { - amount: { type: 'number', example: 13000 }, - type: { - type: 'string', - enum: Object.values(CostType), - example: CostType.Benefits, - }, - name: { type: 'string', example: 'Updated Cost Name' }, - date: {type: 'string', example: "2026-03-22T16:09:52Z"} - }, - }, - }) + @ApiBody({ type: CashflowCostDTO, description: 'Full replacement payload (all fields required)' }) @ApiResponse({ status: 200, description: 'Successfully updated cost' }) @ApiResponse({ status: 400, description: 'Bad Request - Invalid update payload' }) @ApiResponse({ status: 404, description: 'Cost not found' }) @@ -141,13 +99,9 @@ export class CostController { @ApiResponse({ status: 500, description: 'Internal Server Error' }) async updateCost( @Param('costName') costName: string, - @Body() body: CashflowCost, + @Body() body: CashflowCostDTO, ) { - if (Object.keys(body).length === 0) { - throw new BadRequestException('At least one field is required for update'); - } - - return await this.costService.updateCost(costName, body); + return await this.costService.updateCost(decodeURIComponent(costName), body); } @Delete(':costName') @@ -159,6 +113,6 @@ export class CostController { @ApiResponse({ status: 404, description: 'Cost not found' }) @ApiResponse({ status: 500, description: 'Internal Server Error' }) async deleteCost(@Param('costName') costName: string) { - return await this.costService.deleteCost(costName); + return await this.costService.deleteCost(decodeURIComponent(costName)); } } diff --git a/backend/src/cost/cashflow-cost.service.ts b/backend/src/cost/cashflow-cost.service.ts index ebbc94af..c988bb19 100644 --- a/backend/src/cost/cashflow-cost.service.ts +++ b/backend/src/cost/cashflow-cost.service.ts @@ -23,7 +23,7 @@ export class CostService { // Validation helper methods private validateCostType(type: string) { - if (!Object.values(CostType).includes(type as CostType)) { + if (!Object.values(CostType).includes(type as CostType) || type === null) { throw new BadRequestException( `type must be one of: ${Object.values(CostType).join(', ')}`, ); @@ -31,19 +31,22 @@ export class CostService { } private validateAmount(amount: number) { - if (!Number.isFinite(amount) || amount <= 0) { + if (!Number.isFinite(amount) || amount <= 0 || amount === null) { throw new BadRequestException('amount must be a finite positive number'); } } private validateName(name: string) { - if (name.trim().length === 0) { + if (name === null || name.trim().length === 0) { throw new BadRequestException('name must be a non-empty string'); } } private validateDate(date: string) { + if (date === null) { + throw new BadRequestException('date is required and must be a non-null string'); + } const iso8601Regex = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(.\d{3})?(Z|[+-]\d{2}:\d{2})?)?$/; if (!iso8601Regex.test(date)) { throw new BadRequestException('date must be a valid ISO 8601 format string (e.g., "2026-03-22" or "2026-03-22T16:09:52Z")'); @@ -114,45 +117,6 @@ export class CostService { } } - async getCostsByType(costType: CostType): Promise { - const tableName = process.env.CASHFLOW_COST_TABLE_NAME || ''; - this.logger.log(`Retrieving costs with type ${costType}`); - - if (!tableName) { - this.logger.error('CASHFLOW_COST_TABLE_NAME is not defined'); - throw new InternalServerErrorException('Server configuration error'); - } - - const validCostTypes = Object.values(CostType) as CostType[]; - - if (!validCostTypes.includes(costType)) { - throw new BadRequestException( - `costType must be one of: ${Object.values(CostType).join(', ')}`, - ); - } - - try { - const result = await this.dynamoDb - .scan({ - TableName: tableName, - FilterExpression: '#type = :type', - ExpressionAttributeNames: { - '#type': 'type', - }, - ExpressionAttributeValues: { - ':type': costType, - }, - }) - .promise(); - - this.logger.log(`Retrieved ${result.Items?.length ?? 0} costs with type ${costType}`); - return (result.Items ?? []) as CashflowCost[]; - } catch (error) { - this.logger.error(`Failed to retrieve costs with type ${costType}`, error as Error); - throw new InternalServerErrorException(`Failed to retrieve costs with type ${costType}`); - } - } - async createCost(cost: CashflowCost): Promise { const tableName = process.env.CASHFLOW_COST_TABLE_NAME || ''; this.validateAmount(cost.amount); @@ -202,7 +166,7 @@ export class CostService { } } - async updateCost(costName: string, updates: Partial): Promise { + async updateCost(costName: string, updates: CashflowCost): Promise { const tableName = process.env.CASHFLOW_COST_TABLE_NAME || ''; this.validateName(costName); const normalizedName = costName.trim(); @@ -212,54 +176,59 @@ export class CostService { throw new InternalServerErrorException('Server configuration error'); } - if (updates.amount !== undefined) { - this.validateAmount(updates.amount); - } - if (updates.type !== undefined) { - this.validateCostType(updates.type); - } + this.validateAmount(updates.amount); + this.validateCostType(updates.type); + if (updates.name !== undefined) { this.validateName(updates.name); updates.name = updates.name.trim(); } - if (updates.date !== undefined) { - this.validateDate(updates.date); - } - const shouldRename = - updates.name !== undefined && updates.name.trim() !== normalizedName; + this.validateDate(updates.date); - if (shouldRename) { - const targetName = updates.name as string; - this.logger.log(`Renaming cost ${normalizedName} to ${targetName}`); + const existingResult = await this.dynamoDb + .get({ + TableName: tableName, + Key: { name: normalizedName }, + }) + .promise(); - try { - const existingResult = await this.dynamoDb - .get({ - TableName: tableName, - Key: { name: normalizedName }, - }) - .promise(); + if (!existingResult.Item) { + throw new NotFoundException(`Cost with name ${normalizedName} not found`); + } - if (!existingResult.Item) { - throw new NotFoundException(`Cost with name ${normalizedName} not found`); - } + const existingCost = existingResult.Item as CashflowCost; + const existingDateTime = new Date(existingCost.date).getTime(); + const updatesDateTime = new Date(updates.date).getTime(); + const datesAreEqual = + !Number.isNaN(existingDateTime) && + !Number.isNaN(updatesDateTime) && + existingDateTime === updatesDateTime; + + const isUnchanged = + existingCost.name === updates.name && + existingCost.amount === updates.amount && + existingCost.type === updates.type && + datesAreEqual; + + if (isUnchanged) { + this.logger.log(`No changes detected for cost ${normalizedName}; skipping update`); + return existingCost; + } - const existingCost = existingResult.Item as CashflowCost; - const renamedCost: CashflowCost = { - name: targetName, - amount: updates.amount ?? existingCost.amount, - type: updates.type ?? existingCost.type, - date: updates.date ?? existingCost.date - }; + const shouldRename = updates.name.trim() !== normalizedName; + if (shouldRename) { + this.logger.log(`Renaming cost ${normalizedName} to ${updates.name.trim()}`); + + try { await this.dynamoDb .transactWrite({ TransactItems: [ { Put: { TableName: tableName, - Item: renamedCost, + Item: updates, ConditionExpression: 'attribute_not_exists(#name)', ExpressionAttributeNames: { '#name': 'name', @@ -280,7 +249,8 @@ export class CostService { }) .promise(); - return renamedCost; + this.logger.log(`Successfully renamed cost ${normalizedName} to ${updates.name.trim()}`); + return updates; } catch (error) { if (error instanceof NotFoundException) { throw error; @@ -288,11 +258,11 @@ export class CostService { const awsError = error as { code?: string }; if (awsError.code === 'ConditionalCheckFailedException') { - throw new ConflictException(`Cost with name ${targetName} already exists`); + throw new ConflictException(`Cost with name ${updates.name.trim()} already exists`); } this.logger.error( - `Failed to rename cost ${normalizedName} to ${targetName}`, + `Failed to rename cost ${normalizedName} to ${updates.name.trim()}`, error as Error, ); throw new InternalServerErrorException( @@ -301,58 +271,21 @@ export class CostService { } } - let nonKeyUpdates: Partial= {}; - - if (updates.amount !== undefined) { - nonKeyUpdates.amount = updates.amount; - } - - if (updates.type !== undefined) { - nonKeyUpdates.type = updates.type; - } - - const updateKeys = Object.keys(nonKeyUpdates) as Array; - - if (updateKeys.length === 0) { - throw new BadRequestException('At least one field is required for update'); - } - - const updateExpression = - 'SET ' + updateKeys.map((key) => `#${String(key)} = :${String(key)}`).join(', '); - const expressionAttributeNames = updateKeys.reduce>( - (acc, key) => { - acc[`#${String(key)}`] = String(key); - return acc; - }, - {}, - ); - const expressionAttributeValues = updateKeys.reduce>( - (acc, key) => { - acc[`:${String(key)}`] = nonKeyUpdates[key]; - return acc; - }, - {}, - ); - - this.logger.log(`Updating cost ${normalizedName} with updates: ${JSON.stringify(updates)}`); + this.logger.log(`Replacing cost ${normalizedName} with payload: ${JSON.stringify(updates)}`); try { - const result = await this.dynamoDb - .update({ + await this.dynamoDb + .put({ TableName: tableName, - Key: { name: normalizedName }, - UpdateExpression: updateExpression, + Item: updates, + ConditionExpression: 'attribute_exists(#name)', ExpressionAttributeNames: { - ...expressionAttributeNames, '#name': 'name', }, - ExpressionAttributeValues: expressionAttributeValues, - ConditionExpression: 'attribute_exists(#name)', - ReturnValues: 'ALL_NEW', }) .promise(); - return result.Attributes as CashflowCost; + return updates; } catch (error) { const awsError = error as { code?: string }; if (awsError.code === 'ConditionalCheckFailedException') { diff --git a/backend/src/cost/types/cost.types.ts b/backend/src/cost/types/cost.types.ts new file mode 100644 index 00000000..f8ad075f --- /dev/null +++ b/backend/src/cost/types/cost.types.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsDateString, + IsEnum, + IsNotEmpty, + IsNumber, + IsString, + Min, +} from 'class-validator'; +import { CashflowCost } from '../../../../middle-layer/types/CashflowCost'; +import { CostType } from '../../../../middle-layer/types/CostType'; +import { TDateISO } from '../../utils/date'; + +export class CashflowCostDTO implements CashflowCost { + @ApiProperty({ description: 'The name of the cost item', example: 'PM Salary' }) + @IsString() + @IsNotEmpty() + name!: string; + + @ApiProperty({ description: 'The amount for the cost item', example: 12000 }) + @IsNumber({ allowInfinity: false, allowNaN: false }) + @Min(0.01) + amount!: number; + + @ApiProperty({ description: 'The type of cost', enum: CostType, example: CostType.Salary }) + @IsEnum(CostType) + type!: CostType; + + @ApiProperty({ description: 'Cost date in ISO 8601 format', example: '2026-03-14T00:00:00.000Z' }) + @IsDateString() + date!: TDateISO; +} \ No newline at end of file diff --git a/backend/src/grant/grant.service.ts b/backend/src/grant/grant.service.ts index 89fdad2e..9d75a119 100644 --- a/backend/src/grant/grant.service.ts +++ b/backend/src/grant/grant.service.ts @@ -410,7 +410,7 @@ export class GrantService { const userId = grant.bcan_poc.POC_email; this.logger.debug(`Preparing to create notifications for user: ${userId}`); - //await this.createGrantNotifications({ ...grant, grantId: newGrantId }, userId); + await this.createGrantNotifications({ ...grant, grantId: newGrantId }, userId); this.logger.log(`Successfully created grant ${newGrantId} with all associated data`); } catch (error: unknown) { diff --git a/backend/src/revenue/__test__/cashflow-revenue.service.spec.ts b/backend/src/revenue/__test__/cashflow-revenue.service.spec.ts new file mode 100644 index 00000000..211962e6 --- /dev/null +++ b/backend/src/revenue/__test__/cashflow-revenue.service.spec.ts @@ -0,0 +1,313 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException, InternalServerErrorException } from '@nestjs/common'; +import { RevenueService } from '../cashflow-revenue.service'; +import { RevenueType } from '../../../../middle-layer/types/RevenueType'; +import { CashflowRevenue } from '../../../../middle-layer/types/CashflowRevenue'; +import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest'; + +// ─── Mock function declarations ─────────────────────────────────────────────── +const mockPromise = vi.fn(); + +const mockScan = vi.fn(() => ({ promise: mockPromise })); +const mockPut = vi.fn(() => ({ promise: mockPromise })); +const mockDelete = vi.fn(() => ({ promise: mockPromise })); + +// ─── AWS SDK mock ───────────────────────────────────────────────────────────── +vi.mock('aws-sdk', () => { + const documentClientFactory = vi.fn(function () { + return { scan: mockScan, put: mockPut, delete: mockDelete }; + }); + + const awsMock = { + DynamoDB: { DocumentClient: documentClientFactory }, + }; + + return { ...awsMock, default: awsMock }; +}); + +// ─── Mock data ──────────────────────────────────────────────────────────────── +const mockRevenue: CashflowRevenue = { + name: 'Test Revenue', + amount: 1000, + type: RevenueType.Donation, + installments: [ + { amount: 500, date: new Date('2024-01-01') }, + { amount: 500, date: new Date('2024-06-01') }, + ], +}; + +const mockDatabase: CashflowRevenue[] = [ + { name: 'Revenue One', amount: 1000, type: RevenueType.Donation, installments: [{ amount: 1000, date: new Date('2024-01-01') }] }, + { name: 'Revenue Two', amount: 2000, type: RevenueType.Grants, installments: [{ amount: 2000, date: new Date('2024-02-01') }] }, + { name: 'Revenue Three', amount: 3000, type: RevenueType.Sponsorship, installments: [{ amount: 3000, date: new Date('2024-03-01') }] }, +]; + +// ─── Test suite ─────────────────────────────────────────────────────────────── +describe('RevenueService', () => { + let service: RevenueService; + + beforeAll(() => { + process.env.CASHFLOW_REVENUE_TABLE_NAME = 'test-revenue-table'; + }); + + beforeEach(async () => { + vi.clearAllMocks(); + + mockScan.mockReturnValue({ promise: mockPromise }); + mockPut.mockReturnValue({ promise: mockPromise }); + mockDelete.mockReturnValue({ promise: mockPromise }); + mockPromise.mockResolvedValue({}); + + const module: TestingModule = await Test.createTestingModule({ + providers: [RevenueService], + }).compile(); + + service = module.get(RevenueService); + }); + + // ─── getAllRevenue ─────────────────────────────────────────────────────────── + + describe('getAllRevenue', () => { + it('should return all revenue items', async () => { + mockPromise.mockResolvedValueOnce({ Items: mockDatabase }); + const result = await service.getAllRevenue(); + expect(result).toHaveLength(3); + expect(mockScan).toHaveBeenCalledWith({ TableName: 'test-revenue-table' }); + }); + + it('should return empty array when no items exist', async () => { + mockPromise.mockResolvedValueOnce({ Items: [] }); + const result = await service.getAllRevenue(); + expect(result).toEqual([]); + }); + + it('should throw InternalServerErrorException when data is null', async () => { + mockPromise.mockResolvedValueOnce(null); + await expect(service.getAllRevenue()).rejects.toThrow(InternalServerErrorException); + }); + + it('should throw InternalServerErrorException when Items is undefined', async () => { + mockPromise.mockResolvedValueOnce({}); + await expect(service.getAllRevenue()).rejects.toThrow(InternalServerErrorException); + }); + + it('should throw InternalServerErrorException when table name is not set', async () => { + delete process.env.CASHFLOW_REVENUE_TABLE_NAME; + const module = await Test.createTestingModule({ providers: [RevenueService] }).compile(); + const serviceNoTable = module.get(RevenueService); + await expect(serviceNoTable.getAllRevenue()).rejects.toThrow(InternalServerErrorException); + process.env.CASHFLOW_REVENUE_TABLE_NAME = 'test-revenue-table'; + }); + + it('should throw InternalServerErrorException on unexpected error', async () => { + mockPromise.mockRejectedValueOnce(new Error('Unexpected error')); + await expect(service.getAllRevenue()).rejects.toThrow(InternalServerErrorException); + }); + + it('should handle ResourceNotFoundException', async () => { + mockPromise.mockRejectedValueOnce({ code: 'ResourceNotFoundException', message: 'Table not found' }); + await expect(service.getAllRevenue()).rejects.toThrow(InternalServerErrorException); + }); + }); + + // ─── createRevenue ────────────────────────────────────────────────────────── + + describe('createRevenue', () => { + it('should create and return a revenue item', async () => { + mockPromise.mockResolvedValueOnce({}); + const result = await service.createRevenue(mockRevenue); + expect(result).toEqual({ ...mockRevenue, name: mockRevenue.name.trim() }); + expect(mockPut).toHaveBeenCalledWith(expect.objectContaining({ + TableName: 'test-revenue-table', + ConditionExpression: 'attribute_not_exists(#name)', + })); + }); + + it('should trim the name before saving', async () => { + mockPromise.mockResolvedValueOnce({}); + const result = await service.createRevenue({ ...mockRevenue, name: ' Padded Name ' }); + expect(result.name).toBe('Padded Name'); + }); + + it('should throw BadRequestException when revenue body is missing', async () => { + await expect(service.createRevenue(null as any)).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when amount is null', async () => { + await expect(service.createRevenue({ ...mockRevenue, amount: null as any })).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when amount is zero', async () => { + await expect(service.createRevenue({ ...mockRevenue, amount: 0 })).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when amount is negative', async () => { + await expect(service.createRevenue({ ...mockRevenue, amount: -100 })).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when type is invalid', async () => { + await expect(service.createRevenue({ ...mockRevenue, type: 'InvalidType' as RevenueType })).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when type is missing', async () => { + await expect(service.createRevenue({ ...mockRevenue, type: null as any })).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when name is missing', async () => { + await expect(service.createRevenue({ ...mockRevenue, name: null as any })).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when name is empty string', async () => { + await expect(service.createRevenue({ ...mockRevenue, name: ' ' })).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when installments is missing', async () => { + await expect(service.createRevenue({ ...mockRevenue, installments: null as any })).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when installments is not an array', async () => { + await expect(service.createRevenue({ ...mockRevenue, installments: 'bad' as any })).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when installment amount is invalid', async () => { + await expect(service.createRevenue({ + ...mockRevenue, + installments: [{ amount: -50, date: new Date() }], + })).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when installment date is missing', async () => { + await expect(service.createRevenue({ + ...mockRevenue, + installments: [{ amount: 500, date: null as any }], + })).rejects.toThrow(BadRequestException); + }); + + it('should throw InternalServerErrorException on unexpected error', async () => { + mockPromise.mockRejectedValueOnce(new Error('Unexpected error')); + await expect(service.createRevenue(mockRevenue)).rejects.toThrow(InternalServerErrorException); + }); + + it('should handle ConditionalCheckFailedException when item already exists', async () => { + mockPromise.mockRejectedValueOnce({ code: 'ConditionalCheckFailedException', message: 'Item already exists' }); + await expect(service.createRevenue(mockRevenue)).rejects.toThrow(InternalServerErrorException); + }); + + it('should handle ThrottlingException', async () => { + mockPromise.mockRejectedValueOnce({ code: 'ThrottlingException', message: 'Request throttled' }); + await expect(service.createRevenue(mockRevenue)).rejects.toThrow(InternalServerErrorException); + }); + }); + + // ─── updateRevenue ────────────────────────────────────────────────────────── + + describe('updateRevenue', () => { + it('should update and return the revenue item', async () => { + mockPromise.mockResolvedValueOnce({}); + const result = await service.updateRevenue('Test Revenue', mockRevenue); + expect(result).toEqual({ ...mockRevenue, name: mockRevenue.name.trim() }); + expect(mockPut).toHaveBeenCalledWith(expect.objectContaining({ + TableName: 'test-revenue-table', + ConditionExpression: 'attribute_exists(#name)', + })); + }); + + it('should trim the name before saving', async () => { + mockPromise.mockResolvedValueOnce({}); + const result = await service.updateRevenue('Padded Name', { ...mockRevenue, name: ' Padded Name ' }); + expect(result.name).toBe('Padded Name'); + }); + + it('should throw BadRequestException when revenue body is null', async () => { + await expect(service.updateRevenue('Test Revenue', null as any)).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when amount is invalid', async () => { + await expect(service.updateRevenue('Test Revenue', { ...mockRevenue, amount: -1 })).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when type is invalid', async () => { + await expect(service.updateRevenue('Test Revenue', { ...mockRevenue, type: 'bad' as RevenueType })).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when name is empty', async () => { + await expect(service.updateRevenue('Test Revenue', { ...mockRevenue, name: ' ' })).rejects.toThrow(BadRequestException); + }); + + it('should throw InternalServerErrorException when item does not exist', async () => { + mockPromise.mockRejectedValueOnce({ code: 'ConditionalCheckFailedException', message: 'Item does not exist', requestId: '123' }); + await expect(service.updateRevenue('Nonexistent', mockRevenue)).rejects.toThrow(InternalServerErrorException); + }); + + it('should throw InternalServerErrorException when table name is not set', async () => { + delete process.env.CASHFLOW_REVENUE_TABLE_NAME; + const module = await Test.createTestingModule({ providers: [RevenueService] }).compile(); + const serviceNoTable = module.get(RevenueService); + await expect(serviceNoTable.updateRevenue('Test Revenue', mockRevenue)).rejects.toThrow(InternalServerErrorException); + process.env.CASHFLOW_REVENUE_TABLE_NAME = 'test-revenue-table'; + }); + + it('should throw InternalServerErrorException on unexpected error', async () => { + mockPromise.mockRejectedValueOnce(new Error('Unexpected error')); + await expect(service.updateRevenue('Test Revenue', mockRevenue)).rejects.toThrow(InternalServerErrorException); + }); + + it('should handle ProvisionedThroughputExceededException', async () => { + mockPromise.mockRejectedValueOnce({ code: 'ProvisionedThroughputExceededException', message: 'Throughput exceeded', requestId: '123' }); + await expect(service.updateRevenue('Test Revenue', mockRevenue)).rejects.toThrow(InternalServerErrorException); + }); + }); + + // ─── deleteRevenue ────────────────────────────────────────────────────────── + + describe('deleteRevenue', () => { + it('should delete a revenue item successfully', async () => { + mockPromise.mockResolvedValueOnce({}); + await expect(service.deleteRevenue('Test Revenue')).resolves.toBeUndefined(); + expect(mockDelete).toHaveBeenCalledWith(expect.objectContaining({ + TableName: 'test-revenue-table', + Key: { name: 'Test Revenue' }, + ConditionExpression: 'attribute_exists(#name)', + })); + }); + + it('should trim name before deleting', async () => { + mockPromise.mockResolvedValueOnce({}); + await service.deleteRevenue(' Test Revenue '); + expect(mockDelete).toHaveBeenCalledWith(expect.objectContaining({ + Key: { name: 'Test Revenue' }, + })); + }); + + it('should throw BadRequestException when name is empty', async () => { + await expect(service.deleteRevenue('')).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when name is whitespace', async () => { + await expect(service.deleteRevenue(' ')).rejects.toThrow(BadRequestException); + }); + + it('should throw InternalServerErrorException when item does not exist', async () => { + mockPromise.mockRejectedValueOnce({ code: 'ConditionalCheckFailedException', message: 'Item does not exist', requestId: '123' }); + await expect(service.deleteRevenue('Nonexistent')).rejects.toThrow(InternalServerErrorException); + }); + + it('should throw InternalServerErrorException when table name is not set', async () => { + delete process.env.CASHFLOW_REVENUE_TABLE_NAME; + const module = await Test.createTestingModule({ providers: [RevenueService] }).compile(); + const serviceNoTable = module.get(RevenueService); + await expect(serviceNoTable.deleteRevenue('Test Revenue')).rejects.toThrow(InternalServerErrorException); + process.env.CASHFLOW_REVENUE_TABLE_NAME = 'test-revenue-table'; + }); + + it('should throw InternalServerErrorException on unexpected error', async () => { + mockPromise.mockRejectedValueOnce(new Error('Unexpected error')); + await expect(service.deleteRevenue('Test Revenue')).rejects.toThrow(InternalServerErrorException); + }); + + it('should handle ResourceNotFoundException', async () => { + mockPromise.mockRejectedValueOnce({ code: 'ResourceNotFoundException', message: 'Table not found', requestId: '123' }); + await expect(service.deleteRevenue('Test Revenue')).rejects.toThrow(InternalServerErrorException); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/revenue/cashflow-revenue.controller.ts b/backend/src/revenue/cashflow-revenue.controller.ts new file mode 100644 index 00000000..677034f9 --- /dev/null +++ b/backend/src/revenue/cashflow-revenue.controller.ts @@ -0,0 +1,75 @@ +import { Body, Controller, Delete, Get, Logger, Param, Post, Put, UseGuards } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody, ApiParam } from "@nestjs/swagger"; +import { VerifyAdminRoleGuard } from "../guards/auth.guard"; +import { CashflowRevenue } from "../types/CashflowRevenue"; +import { RevenueService } from "./cashflow-revenue.service"; +import { CashflowRevenueDTO } from "./types/revenue.types"; + +@ApiTags('cashflow-revenue') +@Controller('cashflow-revenue') +export class RevenueController { + + private readonly logger = new Logger(RevenueController.name); + + constructor(private readonly revService: RevenueService) { } + + @Get() + @UseGuards(VerifyAdminRoleGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get all revenue', description: 'Retrieves all cashflow revenue items' }) + @ApiResponse({ status: 200, description: 'Successfully retrieved all revenue items', type: [CashflowRevenueDTO] }) + @ApiResponse({ status: 401, description: 'Unauthorized - invalid or missing token' }) + @ApiResponse({ status: 500, description: 'Internal server error' }) + async getAllRevenue(): Promise { + return this.revService.getAllRevenue(); + } + + @Post() + @UseGuards(VerifyAdminRoleGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Create a revenue item', description: 'Creates a new cashflow revenue item' }) + @ApiBody({ type: CashflowRevenueDTO, description: 'The revenue item to create' }) + @ApiResponse({ status: 201, description: 'Successfully created revenue item', type: CashflowRevenueDTO }) + @ApiResponse({ status: 400, description: 'Bad request - invalid body or missing fields' }) + @ApiResponse({ status: 401, description: 'Unauthorized - invalid or missing token' }) + @ApiResponse({ status: 409, description: 'Conflict - revenue item with this name already exists' }) + @ApiResponse({ status: 500, description: 'Internal server error' }) + async createRevenue( + @Body() body: CashflowRevenue + ): Promise { + return await this.revService.createRevenue(body); + } + + @Put(':name') + @UseGuards(VerifyAdminRoleGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Update a revenue item', description: 'Updates an existing cashflow revenue item by name' }) + @ApiParam({ name: 'name', description: 'The name of the revenue item to update' }) + @ApiBody({ type: CashflowRevenueDTO, description: 'The updated revenue item data' }) + @ApiResponse({ status: 200, description: 'Successfully updated revenue item', type: CashflowRevenueDTO }) + @ApiResponse({ status: 400, description: 'Bad request - invalid body or missing fields' }) + @ApiResponse({ status: 401, description: 'Unauthorized - invalid or missing token' }) + @ApiResponse({ status: 404, description: 'Not found - revenue item with this name does not exist' }) + @ApiResponse({ status: 500, description: 'Internal server error' }) + async updateRevenue( + @Param('name') name: string, + @Body() body: CashflowRevenue + ): Promise { + return await this.revService.updateRevenue(decodeURIComponent(name), body); + } + + @Delete(':name') + @UseGuards(VerifyAdminRoleGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Delete a revenue item', description: 'Deletes an existing cashflow revenue item by name' }) + @ApiParam({ name: 'name', description: 'The name of the revenue item to delete' }) + @ApiResponse({ status: 200, description: 'Successfully deleted revenue item' }) + @ApiResponse({ status: 401, description: 'Unauthorized - invalid or missing token' }) + @ApiResponse({ status: 404, description: 'Not found - revenue item with this name does not exist' }) + @ApiResponse({ status: 500, description: 'Internal server error' }) + async deleteRevenue( + @Param('name') name: string + ): Promise { + return await this.revService.deleteRevenue(decodeURIComponent(name)); + } +} \ No newline at end of file diff --git a/backend/src/revenue/cashflow-revenue.module.ts b/backend/src/revenue/cashflow-revenue.module.ts new file mode 100644 index 00000000..d8e22b53 --- /dev/null +++ b/backend/src/revenue/cashflow-revenue.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { RevenueService } from './cashflow-revenue.service'; +import { RevenueController } from './cashflow-revenue.controller'; + + +@Module({ + controllers: [RevenueController], + providers: [RevenueService], +}) +export class RevenueModule {} \ No newline at end of file diff --git a/backend/src/revenue/cashflow-revenue.service.ts b/backend/src/revenue/cashflow-revenue.service.ts new file mode 100644 index 00000000..17de6e7d --- /dev/null +++ b/backend/src/revenue/cashflow-revenue.service.ts @@ -0,0 +1,432 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, +} from "@nestjs/common"; +import * as AWS from "aws-sdk"; +import { CashflowRevenue } from "../types/CashflowRevenue"; +import { AWSError } from "aws-sdk"; +import { RevenueType } from "../../../middle-layer/types/RevenueType"; +import { Installment } from "../../../middle-layer/types/Installment"; + +@Injectable() +export class RevenueService { + private readonly logger = new Logger(RevenueService.name); + private dynamoDb = new AWS.DynamoDB.DocumentClient(); + private revenueTableName : string = process.env.CASHFLOW_REVENUE_TABLE_NAME || "" + /** + * Helper method to check if an error is an AWS error and extract relevant information + */ + private isAWSError(error: unknown): error is AWSError { + return ( + typeof error === "object" && + error !== null && + ("code" in error || "statusCode" in error || "requestId" in error) + ); + } + + /** + * Helper method to handle AWS errors and throw appropriate NestJS exceptions + */ + private handleAWSError( + error: AWSError, + operation: string, + context?: string, + ): never { + const errorContext = context ? ` (${context})` : ""; + const errorDetails = { + code: error.code, + message: error.message, + requestId: error.requestId, + retryable: error.retryable, + }; + + this.logger.error(`AWS Error during ${operation}${errorContext}:`, { + ...errorDetails, + stack: error.stack, + }); + + // Handle specific AWS error codes + switch (error.code) { + case "ResourceNotFoundException": + throw new BadRequestException( + `AWS DynamoDB Error: Table or resource not found. ${error.message}`, + ); + case "ValidationException": + throw new BadRequestException( + `AWS DynamoDB Validation Error: Invalid request parameters. ${error.message}`, + ); + case "ProvisionedThroughputExceededException": + throw new InternalServerErrorException( + `AWS DynamoDB Error: Request rate too high. Please retry later. ${error.message}`, + ); + case "ThrottlingException": + throw new InternalServerErrorException( + `AWS DynamoDB Error: Request throttled. Please retry later. ${error.message}`, + ); + case "ConditionalCheckFailedException": + throw new BadRequestException( + `AWS DynamoDB Error: Conditional check failed. ${error.message}`, + ); + case "ItemCollectionSizeLimitExceededException": + throw new BadRequestException( + `AWS DynamoDB Error: Item collection size limit exceeded. ${error.message}`, + ); + default: + throw new InternalServerErrorException( + `AWS DynamoDB Error during ${operation}: ${error.message || "Unknown AWS error"}`, + ); + } + } + /** + * Method to validate the money amount of a revenue object is valid + * @param amount Number amount for a revenue source + */ + private validateAmount(amount: number): void { + if (amount === undefined || amount === null) { + this.logger.error('Validation failed: amount is required'); + throw new BadRequestException('amount is required'); + } + if (!Number.isFinite(amount) || amount <= 0) { + this.logger.error(`Validation failed: invalid amount value: ${amount}`); + throw new BadRequestException('amount must be a finite positive number'); + } +} + +/** + * Method to validate a revenue source + * @param type Type of revenue source + */ +private validateType(type: RevenueType): void { + if (type === undefined || type === null) { + this.logger.error('Validation failed: type is required'); + throw new BadRequestException('type is required'); + } + if (!Object.values(RevenueType).includes(type)) { + this.logger.error(`Validation failed: invalid type value: ${type}`); + throw new BadRequestException( + `type must be one of: ${Object.values(RevenueType).join(', ')}`, + ); + } +} + +/** + * Method to validate the name of a revenue source + * @param name Name of a revenue source + */ +private validateName(name: string): void { + if (name === undefined || name === null) { + this.logger.error('Validation failed: name is required'); + throw new BadRequestException('name is required'); + } + if (name.trim().length === 0) { + this.logger.error('Validation failed: name is empty or whitespace'); + throw new BadRequestException('name must be a non-empty string'); + } +} + +/** + * Method to validate the inputted installments are valid + * @param installments Installment array to represent when a revenue would be dispersed + */ +private validateInstallments(installments: Installment[], amount: number): void { + if (installments === undefined || installments === null) { + this.logger.error('Validation failed: installments is required'); + throw new BadRequestException('installments is required'); + } + if (!Array.isArray(installments)) { + this.logger.error(`Validation failed: installments is not an array, received: ${typeof installments}`); + throw new BadRequestException('installments must be an array'); + } + let total = 0; + installments.forEach((installment, index) => { + if (!Number.isFinite(installment.amount) || installment.amount <= 0) { + this.logger.error( + `Validation failed: installments[${index}].amount is invalid: ${installment.amount}`, + ); + throw new BadRequestException( + `installments[${index}].amount must be a finite positive number`, + ); + } + if (!installment.date) { + this.logger.error(`Validation failed: installments[${index}].date is required`); + throw new BadRequestException(`installments[${index}].date is required`); + } + const isoFullRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/; + const dateStr = String(installment.date); + if (!isoFullRegex.test(dateStr) && !isoDateRegex.test(dateStr)) { + this.logger.error(`Validation failed: installments[${index}].date is not a valid ISO date: ${installment.date}`); + throw new BadRequestException(`installments[${index}].date must be a valid ISO date (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS.mmmZ)`); + } + const parsedDate = new Date(installment.date); + if (isNaN(parsedDate.getTime())) { + this.logger.error(`Validation failed: installments[${index}].date is not a valid date: ${installment.date}`); + throw new BadRequestException(`installments[${index}].date must be a valid date`); + } + total += installment.amount; + }); + + if (amount != total) { + this.logger.error(`Validation failed: installments summed up does not equal total amount`); + throw new BadRequestException('Installment summed up does not equal total amount'); + } +} + +/** + * Method to validate a revenue object for the cashflow + * @param revenue Revenue object to represent a cashflow revenue source + */ +private validateRevenueObject(revenue: CashflowRevenue): void { + if (!revenue) { + this.logger.error('Validation failed: revenue body is required'); + throw new BadRequestException('revenue body is required'); + } + this.validateAmount(revenue.amount); + this.validateType(revenue.type); + this.validateName(revenue.name); + this.validateInstallments(revenue.installments,revenue.amount); + + +} + +/** + * Method to validate the dynamo db table name + * @param tableName name of the revenue dynamo db table + */ +private validateTableName(tableName : string){ + if ( + tableName === "" || + tableName === null || + tableName === undefined + ) { + this.logger.error("Revenue table env variable is not filled in"); + throw new InternalServerErrorException("Server Config Error"); + } +} + + /** + * Method to retrieve all of the revenue data + * @returns All the revenue objects in the data base + */ + async getAllRevenue(): Promise { + this.logger.log("Retrieving all the cashflow revenue data"); + + + this.validateTableName(this.revenueTableName) + + const params = { TableName: this.revenueTableName }; + + try { + this.logger.debug(`Scanning Revenue DynamoDB table: ${params.TableName}`); + const data = await this.dynamoDb.scan(params).promise(); + if (!data || !data.Items) { + this.logger.error("There has been an error"); + throw new InternalServerErrorException("Internal Server Error"); + } + + if (data.Items?.length === 0) { + this.logger.warn("There are zero revenue items in the database"); + return [] as CashflowRevenue[]; + } + const revenue = (data.Items as CashflowRevenue[]) || []; + this.logger.log( + `Retrived ${revenue.length} revenue items from the backend`, + ); + return revenue; + } catch (error) { + if (error instanceof InternalServerErrorException) { + throw error; + } + if(this.isAWSError(error)){ + try{ + this.handleAWSError(error, "getAllRevenue", `table ${params.TableName}`) + } catch(error){ + throw new InternalServerErrorException("Internal Server Error") + } + } + + this.logger.error("Uncaught Error fetching all Revenue data: ", error) + throw new InternalServerErrorException("Internal Server Error") + } + // Call to the database + // Handle errors based off errors + } + + /** + * Method to create a new revenue object + * @param revenue Revenue object being created + * @returns Returns the uploaded cashflow revenue + */ +async createRevenue(revenue: CashflowRevenue): Promise { + this.validateRevenueObject(revenue); + this.validateTableName(this.revenueTableName); + + const normalizedRevenue = { + ...revenue, + name: revenue.name.trim(), + }; + + // Check if a revenue item with the same name already exists + const getParams = { + TableName: this.revenueTableName, + Key: { name: normalizedRevenue.name }, + }; + + try { + this.logger.log(`Checking if revenue item with name '${normalizedRevenue.name}' already exists`); + const existing = await this.dynamoDb.get(getParams).promise(); + if (existing.Item) { + this.logger.error(`Revenue item with name '${normalizedRevenue.name}' already exists`); + throw new BadRequestException(`A revenue item with the name '${normalizedRevenue.name}' already exists`); + } + } catch (error) { + if (error instanceof BadRequestException || error instanceof InternalServerErrorException) { + throw error; + } + if (this.isAWSError(error)) { + this.logger.error('AWS error during duplicate check for createRevenue: ', error); + throw new InternalServerErrorException('Internal Server Error'); + } + this.logger.error('Uncaught error checking for duplicate revenue item: ', error); + throw new InternalServerErrorException('Internal Server Error'); + } + + const params = { + TableName: this.revenueTableName, + Item: normalizedRevenue, + ConditionExpression: 'attribute_not_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, + }; + + try { + this.logger.log(`Creating revenue item with name: ${normalizedRevenue.name}`); + await this.dynamoDb.put(params).promise(); + this.logger.log(`Successfully created revenue item with name: ${normalizedRevenue.name}`); + return normalizedRevenue; + } catch (error) { + if (error instanceof BadRequestException || error instanceof InternalServerErrorException) { + throw error; + } + if (this.isAWSError(error)) { + if (error.code === 'ConditionalCheckFailedException') { + this.logger.error(`Revenue item with name '${normalizedRevenue.name}' already exists (race condition)`); + throw new BadRequestException(`A revenue item with the name '${normalizedRevenue.name}' already exists`); + } + this.logger.error('AWS error during createRevenue: ', error); + throw new InternalServerErrorException('Internal Server Error'); + } + this.logger.error('Uncaught error creating revenue item: ', error); + throw new InternalServerErrorException('Internal Server Error'); + } +} + +async updateRevenue(name: string, revenue: CashflowRevenue): Promise { + this.validateRevenueObject(revenue); + this.validateName(name); + this.validateTableName(this.revenueTableName); + + const normalizedRevenue = { + ...revenue, + name: revenue.name.trim(), + }; + + // Check if the revenue item actually exists before updating + const getParams = { + TableName: this.revenueTableName, + Key: { name: name.trim() }, + }; + + try { + this.logger.log(`Checking if revenue item with name '${name}' exists`); + const existing = await this.dynamoDb.get(getParams).promise(); + if (!existing.Item) { + this.logger.error(`Revenue item with name '${name}' does not exist`); + throw new BadRequestException(`A revenue item with the name '${name}' does not exist`); + } + } catch (error) { + if (error instanceof BadRequestException || error instanceof InternalServerErrorException) { + throw error; + } + if (this.isAWSError(error)) { + this.logger.error('AWS error during existence check for updateRevenue: ', error); + throw new InternalServerErrorException('Internal Server Error'); + } + this.logger.error('Uncaught error checking for revenue item existence: ', error); + throw new InternalServerErrorException('Internal Server Error'); + } + + const params = { + TableName: this.revenueTableName, + Item: normalizedRevenue, + ConditionExpression: 'attribute_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, + }; + + try { + this.logger.log(`Updating revenue item with name: ${name}`); + await this.dynamoDb.put(params).promise(); + this.logger.log(`Successfully updated revenue item with name: ${name}`); + return normalizedRevenue; + } catch (error) { + if (error instanceof BadRequestException || error instanceof InternalServerErrorException) { + throw error; + } + if (this.isAWSError(error)) { + if (error.code === 'ConditionalCheckFailedException') { + this.logger.error(`Revenue item with name '${name}' does not exist (race condition)`); + throw new BadRequestException(`A revenue item with the name '${name}' does not exist`); + } + this.logger.error('AWS error during updateRevenue: ', error); + throw new InternalServerErrorException('Internal Server Error'); + } + this.logger.error('Uncaught error updating revenue item: ', error); + throw new InternalServerErrorException('Internal Server Error'); + } +} + + async deleteRevenue(name: string): Promise { + this.validateTableName(this.revenueTableName); + this.validateName(name) + + if (!name || name.trim().length === 0) { + this.logger.error('Validation failed: name param is required for delete'); + throw new BadRequestException('name is required'); + } + + const params = { + TableName: this.revenueTableName, + Key: { name: name.trim() }, + ConditionExpression: 'attribute_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, + }; + + try { + this.logger.log(`Deleting revenue item with name: ${name}`); + await this.dynamoDb.delete(params).promise(); + this.logger.log(`Successfully deleted revenue item with name: ${name}`); + } catch (error) { + if (error instanceof BadRequestException || error instanceof InternalServerErrorException) { + throw error; + } + + if (this.isAWSError(error)) { + try { + this.handleAWSError(error, 'deleteRevenue', `table ${params.TableName}`); + } catch (handledError) { + throw new InternalServerErrorException('Internal Server Error'); + } + } + + this.logger.error('Uncaught error deleting revenue item: ', error); + throw new InternalServerErrorException('Internal Server Error'); + } + } +} diff --git a/backend/src/revenue/types/revenue.types.ts b/backend/src/revenue/types/revenue.types.ts new file mode 100644 index 00000000..f7623ee0 --- /dev/null +++ b/backend/src/revenue/types/revenue.types.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { CashflowRevenue } from "../../../../middle-layer/types/CashflowRevenue"; +import { Installment } from "../../../../middle-layer/types/Installment"; +import { RevenueType } from "../../../../middle-layer/types/RevenueType"; +export class CashflowRevenueDTO implements CashflowRevenue { + @ApiProperty({ description: "The revenue amount", example: 1000 }) + amount!: number; + + @ApiProperty({ description: "The type of revenue", enum: RevenueType }) + type!: RevenueType; + + @ApiProperty({ + description: "The name of the revenue item", + example: "Q1 Sales", + }) + name!: string; + + @ApiProperty({ + description: "List of installments", + example: [{ amount: 30000, date: "2026-03-14T00:00:00.000Z" }], + }) + installments!: Installment[]; +} diff --git a/frontend/src/main-page/MainPage.tsx b/frontend/src/main-page/MainPage.tsx index 8149cb35..e6b694f1 100644 --- a/frontend/src/main-page/MainPage.tsx +++ b/frontend/src/main-page/MainPage.tsx @@ -97,7 +97,7 @@ function MainPage() { +