Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions backend/scripts/seed-revenue-types.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
const AWS = require('aws-sdk');

const tableName =
process.env.DYNAMODB_REVENUE_TYPE_TABLE_NAME ||
process.env.DYNAMODB_REVENUE_TABLE_NAME ||
process.env.DYNAMODB_GRANT_TABLE_NAME;

if (!tableName) {
console.error('Missing table');
process.exit(1);
}

AWS.config.update({
region: process.env.AWS_REGION,
accessKeyId: process.env.OPEN_HATCH,
secretAccessKey: process.env.CLOSED_HATCH,
});

const docClient = new AWS.DynamoDB.DocumentClient();

const seedRows = [
{ revenueTypeId: 1000, name: 'Grants' },
{ revenueTypeId: 1001, name: 'Individual Donations' },
{ revenueTypeId: 1002, name: 'Corporate Sponsorships' },
{ revenueTypeId: 1003, name: 'Fundraising Events' },
{ revenueTypeId: 1004, name: 'Other Revenue' },
];

async function run() {
const now = new Date().toISOString();

for (const row of seedRows) {
const params = {
TableName: tableName,
Item: {
revenueTypeId: row.revenueTypeId,
name: row.name,
description: `Seeded revenue type: ${row.name}`,
isActive: true,
createdAt: now,
updatedAt: now,
},
};

await docClient.put(params).promise();
console.log(`Seeded ${row.revenueTypeId} - ${row.name}`);
}

console.log('Revenue type seed done');
}

run().catch((error) => {
console.error('Revenue seed failed:', error);
process.exit(1);
});
3 changes: 2 additions & 1 deletion backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ 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/revenue.module';

@Module({
imports: [AuthModule, UserModule, GrantModule, NotificationsModule,CashflowModule],
imports: [AuthModule, UserModule, GrantModule, NotificationsModule, CashflowModule, RevenueModule],
})
export class AppModule {}
150 changes: 150 additions & 0 deletions backend/src/revenue/__test__/revenue.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { RevenueTypeValue } from '../types/revenue.types';
import { RevenueService } from '../revenue.service';

const mockPromise = vi.fn();
const mockScan = vi.fn(() => ({ promise: mockPromise }));
const mockGet = vi.fn(() => ({ promise: mockPromise }));
const mockDelete = vi.fn(() => ({ promise: mockPromise }));
const mockUpdate = vi.fn(() => ({ promise: mockPromise }));
const mockPut = vi.fn(() => ({ promise: mockPromise }));

const mockDocumentClient = {
scan: mockScan,
get: mockGet,
delete: mockDelete,
update: mockUpdate,
put: mockPut,
};

vi.mock('aws-sdk', () => ({
DynamoDB: {
DocumentClient: vi.fn(function () {
return mockDocumentClient;
}),
},
}));

describe('RevenueService', () => {
let service: RevenueService;

beforeEach(async () => {
vi.clearAllMocks();
process.env.DYNAMODB_REVENUE_TYPE_TABLE_NAME = 'RevenueTypes';

const module: TestingModule = await Test.createTestingModule({
providers: [RevenueService],
}).compile();

service = module.get<RevenueService>(RevenueService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});

it('creates a revenue type', async () => {
mockPromise.mockResolvedValueOnce({});

const result = await service.createRevenueType({
name: RevenueTypeValue.Grants,
description: 'Test description',
});

expect(result.name).toBe(RevenueTypeValue.Grants);
expect(result.isActive).toBe(true);
expect(mockPut).toHaveBeenCalledWith(
expect.objectContaining({
TableName: 'RevenueTypes',
}),
);
});

it('gets all revenue types', async () => {
mockPromise.mockResolvedValueOnce({
Items: [
{
revenueTypeId: 1,
name: RevenueTypeValue.Donation,
isActive: true,
createdAt: '2026-03-19T00:00:00.000Z',
updatedAt: '2026-03-19T00:00:00.000Z',
},
],
});

const result = await service.getAllRevenueTypes();

expect(result).toHaveLength(1);
expect(result[0].name).toBe(RevenueTypeValue.Donation);
});

it('gets one revenue type by id', async () => {
mockPromise.mockResolvedValueOnce({
Item: {
revenueTypeId: 10,
name: RevenueTypeValue.Fundraising,
isActive: true,
createdAt: '2026-03-19T00:00:00.000Z',
updatedAt: '2026-03-19T00:00:00.000Z',
},
});

const result = await service.getRevenueTypeById(10);

expect(result.revenueTypeId).toBe(10);
expect(result.name).toBe(RevenueTypeValue.Fundraising);
});

it('throws not found when revenue type is missing', async () => {
mockPromise.mockResolvedValueOnce({ Item: undefined });

await expect(service.getRevenueTypeById(404)).rejects.toThrow(NotFoundException);
});

it('throws bad request for invalid id input', async () => {
await expect(service.getRevenueTypeById(0)).rejects.toThrow(BadRequestException);
});

it('updates revenue type', async () => {
mockPromise.mockResolvedValueOnce({
Attributes: {
revenueTypeId: 1,
name: RevenueTypeValue.Sponsorship,
isActive: false,
createdAt: '2026-03-19T00:00:00.000Z',
updatedAt: '2026-03-19T01:00:00.000Z',
},
});

const result = await service.updateRevenueType(1, { isActive: false });

expect(result.isActive).toBe(false);
expect(mockUpdate).toHaveBeenCalled();
});

it('deletes revenue type', async () => {
mockPromise.mockResolvedValueOnce({});

const result = await service.deleteRevenueTypeById(5);

expect(result.message).toContain('deleted successfully');
expect(mockDelete).toHaveBeenCalled();
});

it('maps aws validation exception to bad request', async () => {
const awsError = new Error('bad params');
(awsError as any).code = 'ValidationException';
mockPromise.mockRejectedValueOnce(awsError);

await expect(service.getAllRevenueTypes()).rejects.toThrow(BadRequestException);
});

it('throws internal server error for unexpected errors', async () => {
mockPromise.mockRejectedValueOnce(new Error('unexpected'));

await expect(service.getAllRevenueTypes()).rejects.toThrow(InternalServerErrorException);
});
});
101 changes: 101 additions & 0 deletions backend/src/revenue/revenue.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Body, Controller, Delete, Get, Logger, Param, ParseIntPipe, Post, Put, UseGuards, ValidationPipe } from '@nestjs/common';
import { RevenueService } from './revenue.service';
import { VerifyUserGuard } from '../guards/auth.guard';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiParam, ApiBody, ApiTags } from '@nestjs/swagger';
import { CreateRevenueTypeBody, RevenueTypeResponseDto, UpdateRevenueTypeBody } from './types/revenue.types';

@ApiTags('revenue-types')
@Controller('revenue-types')
export class RevenueController {
private readonly logger = new Logger(RevenueController.name);

constructor(private readonly revenueService: RevenueService) {}

@Post()
@UseGuards(VerifyUserGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Create revenue type', description: 'Creates a new revenue type record.' })
@ApiBody({ type: CreateRevenueTypeBody })
@ApiResponse({ status: 201, description: 'Revenue type created successfully', type: RevenueTypeResponseDto })
@ApiResponse({ status: 400, description: 'Bad Request - Invalid request payload' })
@ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing authentication token' })
@ApiResponse({ status: 403, description: 'Forbidden - User does not have access to this resource' })
@ApiResponse({ status: 500, description: 'Internal Server Error - AWS or server error' })
async createRevenueType(
@Body(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
body: CreateRevenueTypeBody,
): Promise<RevenueTypeResponseDto> {
this.logger.log(`POST /revenue-types - Creating revenue type: ${body.name}`);
return this.revenueService.createRevenueType(body);
}

@Get()
@UseGuards(VerifyUserGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get all revenue types', description: 'Retrieves all revenue type records.' })
@ApiResponse({ status: 200, description: 'Revenue types retrieved successfully', type: [RevenueTypeResponseDto] })
@ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing authentication token' })
@ApiResponse({ status: 403, description: 'Forbidden - User does not have access to this resource' })
@ApiResponse({ status: 500, description: 'Internal Server Error - AWS or server error' })
async getAllRevenueTypes(): Promise<RevenueTypeResponseDto[]> {
this.logger.log('GET /revenue-types - Retrieving all revenue types');
return this.revenueService.getAllRevenueTypes();
}

@Get(':revenueTypeId')
@UseGuards(VerifyUserGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get revenue type by ID', description: 'Retrieves a revenue type record by ID.' })
@ApiParam({ name: 'revenueTypeId', type: Number, description: 'Revenue type ID' })
@ApiResponse({ status: 200, description: 'Revenue type retrieved successfully', type: RevenueTypeResponseDto })
@ApiResponse({ status: 400, description: 'Bad Request - Invalid revenue type ID' })
@ApiResponse({ status: 404, description: 'Not Found - Revenue type does not exist' })
@ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing authentication token' })
@ApiResponse({ status: 403, description: 'Forbidden - User does not have access to this resource' })
@ApiResponse({ status: 500, description: 'Internal Server Error - AWS or server error' })
async getRevenueTypeById(
@Param('revenueTypeId', ParseIntPipe) revenueTypeId: number,
): Promise<RevenueTypeResponseDto> {
this.logger.log(`GET /revenue-types/${revenueTypeId} - Retrieving revenue type`);
return this.revenueService.getRevenueTypeById(revenueTypeId);
}

@Put(':revenueTypeId')
@UseGuards(VerifyUserGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Update revenue type', description: 'Updates a revenue type record by ID.' })
@ApiParam({ name: 'revenueTypeId', type: Number, description: 'Revenue type ID' })
@ApiBody({ type: UpdateRevenueTypeBody })
@ApiResponse({ status: 200, description: 'Revenue type updated successfully', type: RevenueTypeResponseDto })
@ApiResponse({ status: 400, description: 'Bad Request - Invalid payload or revenue type ID' })
@ApiResponse({ status: 404, description: 'Not Found - Revenue type does not exist' })
@ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing authentication token' })
@ApiResponse({ status: 403, description: 'Forbidden - User does not have access to this resource' })
@ApiResponse({ status: 500, description: 'Internal Server Error - AWS or server error' })
async updateRevenueType(
@Param('revenueTypeId', ParseIntPipe) revenueTypeId: number,
@Body(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
body: UpdateRevenueTypeBody,
): Promise<RevenueTypeResponseDto> {
this.logger.log(`PUT /revenue-types/${revenueTypeId} - Updating revenue type`);
return this.revenueService.updateRevenueType(revenueTypeId, body);
}

@Delete(':revenueTypeId')
@UseGuards(VerifyUserGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Delete revenue type', description: 'Deletes a revenue type record by ID.' })
@ApiParam({ name: 'revenueTypeId', type: Number, description: 'Revenue type ID' })
@ApiResponse({ status: 200, description: 'Revenue type deleted successfully' })
@ApiResponse({ status: 400, description: 'Bad Request - Invalid revenue type ID' })
@ApiResponse({ status: 404, description: 'Not Found - Revenue type does not exist' })
@ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing authentication token' })
@ApiResponse({ status: 403, description: 'Forbidden - User does not have access to this resource' })
@ApiResponse({ status: 500, description: 'Internal Server Error - AWS or server error' })
async deleteRevenueTypeById(
@Param('revenueTypeId', ParseIntPipe) revenueTypeId: number,
): Promise<{ message: string }> {
this.logger.log(`DELETE /revenue-types/${revenueTypeId} - Deleting revenue type`);
return this.revenueService.deleteRevenueTypeById(revenueTypeId);
}
}
10 changes: 10 additions & 0 deletions backend/src/revenue/revenue.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { RevenueService } from './revenue.service';
import { RevenueController } from './revenue.controller';
import { NotificationsModule } from '../notifications/notification.module';
@Module({
imports: [NotificationsModule],
controllers: [RevenueController],
providers: [RevenueService],
})
export class RevenueModule { }
Loading
Loading