From 1b6988db6f70a5a200c9e5fd29c481c8a32cbb0f Mon Sep 17 00:00:00 2001 From: "Yumiko (Yumi) Chow" <75456756+yumi520@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:16:04 -0400 Subject: [PATCH] added revenue endpoints, seeded dynamo table, added swagger doc --- backend/scripts/seed-revenue-types.js | 55 ++++ backend/src/app.module.ts | 3 +- .../revenue/__test__/revenue.service.spec.ts | 150 +++++++++++ backend/src/revenue/revenue.controller.ts | 101 ++++++++ backend/src/revenue/revenue.module.ts | 10 + backend/src/revenue/revenue.service.ts | 244 ++++++++++++++++++ backend/src/revenue/types/revenue.types.ts | 80 ++++++ 7 files changed, 642 insertions(+), 1 deletion(-) create mode 100644 backend/scripts/seed-revenue-types.js create mode 100644 backend/src/revenue/__test__/revenue.service.spec.ts create mode 100644 backend/src/revenue/revenue.controller.ts create mode 100644 backend/src/revenue/revenue.module.ts create mode 100644 backend/src/revenue/revenue.service.ts create mode 100644 backend/src/revenue/types/revenue.types.ts diff --git a/backend/scripts/seed-revenue-types.js b/backend/scripts/seed-revenue-types.js new file mode 100644 index 00000000..6cde5313 --- /dev/null +++ b/backend/scripts/seed-revenue-types.js @@ -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); +}); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 25449e13..19d6b880 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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 {} \ No newline at end of file diff --git a/backend/src/revenue/__test__/revenue.service.spec.ts b/backend/src/revenue/__test__/revenue.service.spec.ts new file mode 100644 index 00000000..7f62c888 --- /dev/null +++ b/backend/src/revenue/__test__/revenue.service.spec.ts @@ -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); + }); + + 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); + }); +}); diff --git a/backend/src/revenue/revenue.controller.ts b/backend/src/revenue/revenue.controller.ts new file mode 100644 index 00000000..ab936f46 --- /dev/null +++ b/backend/src/revenue/revenue.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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); + } +} \ No newline at end of file diff --git a/backend/src/revenue/revenue.module.ts b/backend/src/revenue/revenue.module.ts new file mode 100644 index 00000000..9c129d8f --- /dev/null +++ b/backend/src/revenue/revenue.module.ts @@ -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 { } \ No newline at end of file diff --git a/backend/src/revenue/revenue.service.ts b/backend/src/revenue/revenue.service.ts new file mode 100644 index 00000000..2a8a418d --- /dev/null +++ b/backend/src/revenue/revenue.service.ts @@ -0,0 +1,244 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, +} from '@nestjs/common'; +import * as AWS from 'aws-sdk'; +import { CreateRevenueTypeBody, RevenueTypeValue, UpdateRevenueTypeBody } from './types/revenue.types'; + +interface RevenueTypeRecord { + revenueTypeId: number; + name: RevenueTypeValue; + description?: string; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +interface AWSError extends Error { + code?: string; + statusCode?: number; + requestId?: string; + retryable?: boolean; +} + +@Injectable() +export class RevenueService { + private readonly logger = new Logger(RevenueService.name); + private readonly dynamoDb = new AWS.DynamoDB.DocumentClient(); + + private get tableName(): string { + return ( + process.env.DYNAMODB_REVENUE_TYPE_TABLE_NAME || + process.env.DYNAMODB_REVENUE_TABLE_NAME || + process.env.DYNAMODB_GRANT_TABLE_NAME || + 'TABLE_FAILURE' + ); + } + + private assertTableConfigured(): void { + if (this.tableName === 'TABLE_FAILURE') { + this.logger.error('Revenue type table environment variable is not set'); + throw new InternalServerErrorException( + 'Server configuration error: Revenue type DynamoDB table name not configured', + ); + } + } + + private isAWSError(error: unknown): error is AWSError { + return ( + typeof error === 'object' && + error !== null && + ('code' in error || 'statusCode' in error || 'requestId' in error) + ); + } + + private handleAWSError(error: AWSError, operation: string): never { + const message = error.message || 'Unknown AWS error'; + this.logger.error(`AWS error during ${operation}: ${message}`); + + switch (error.code) { + case 'ResourceNotFoundException': + throw new BadRequestException(`AWS DynamoDB Error: Table or resource not found. ${message}`); + case 'ValidationException': + throw new BadRequestException(`AWS DynamoDB Validation Error: Invalid request parameters. ${message}`); + case 'ConditionalCheckFailedException': + throw new BadRequestException(`AWS DynamoDB Error: Conditional check failed. ${message}`); + case 'ProvisionedThroughputExceededException': + case 'ThrottlingException': + throw new InternalServerErrorException(`AWS DynamoDB Error: Request throttled, please retry. ${message}`); + default: + throw new InternalServerErrorException(`AWS DynamoDB Error during ${operation}: ${message}`); + } + } + + private validateRevenueTypeId(revenueTypeId: number): void { + if (!Number.isInteger(revenueTypeId) || revenueTypeId <= 0) { + throw new BadRequestException( + `Invalid revenue type ID: ${revenueTypeId}. ID must be a positive integer.`, + ); + } + } + + async createRevenueType(body: CreateRevenueTypeBody): Promise { + this.assertTableConfigured(); + + const now = new Date().toISOString(); + const revenueTypeId = Date.now(); + const item: RevenueTypeRecord = { + revenueTypeId, + name: body.name, + description: body.description, + isActive: true, + createdAt: now, + updatedAt: now, + }; + + const params: AWS.DynamoDB.DocumentClient.PutItemInput = { + TableName: this.tableName, + Item: item, + ConditionExpression: 'attribute_not_exists(revenueTypeId)', + }; + + try { + await this.dynamoDb.put(params).promise(); + return item; + } catch (error) { + if (this.isAWSError(error)) { + this.handleAWSError(error, 'createRevenueType'); + } + throw new InternalServerErrorException('Failed to create revenue type.'); + } + } + + async getAllRevenueTypes(): Promise { + this.assertTableConfigured(); + + try { + const data = await this.dynamoDb.scan({ TableName: this.tableName }).promise(); + return (data.Items as RevenueTypeRecord[]) || []; + } catch (error) { + if (this.isAWSError(error)) { + this.handleAWSError(error, 'getAllRevenueTypes'); + } + throw new InternalServerErrorException('Failed to retrieve revenue types.'); + } + } + + async getRevenueTypeById(revenueTypeId: number): Promise { + this.assertTableConfigured(); + this.validateRevenueTypeId(revenueTypeId); + + const params: AWS.DynamoDB.DocumentClient.GetItemInput = { + TableName: this.tableName, + Key: { revenueTypeId }, + }; + + try { + const data = await this.dynamoDb.get(params).promise(); + if (!data.Item) { + throw new NotFoundException(`Revenue type ${revenueTypeId} not found.`); + } + return data.Item as RevenueTypeRecord; + } catch (error) { + if (error instanceof NotFoundException || error instanceof BadRequestException) { + throw error; + } + if (this.isAWSError(error)) { + this.handleAWSError(error, 'getRevenueTypeById'); + } + throw new InternalServerErrorException(`Failed to retrieve revenue type ${revenueTypeId}.`); + } + } + + async updateRevenueType( + revenueTypeId: number, + body: UpdateRevenueTypeBody, + ): Promise { + this.assertTableConfigured(); + this.validateRevenueTypeId(revenueTypeId); + + const entries = Object.entries(body).filter(([, value]) => value !== undefined); + if (entries.length === 0) { + throw new BadRequestException('No fields provided to update.'); + } + + const UpdateExpression = + 'SET ' + + entries + .map(([_, __], idx) => `#k${idx} = :v${idx}`) + .concat('#updatedAt = :updatedAt') + .join(', '); + + const ExpressionAttributeNames = entries.reduce>( + (acc, [key], idx) => { + acc[`#k${idx}`] = key; + return acc; + }, + { '#updatedAt': 'updatedAt' }, + ); + + const ExpressionAttributeValues = entries.reduce>( + (acc, [_, value], idx) => { + acc[`:v${idx}`] = value; + return acc; + }, + { ':updatedAt': new Date().toISOString() }, + ); + + const params: AWS.DynamoDB.DocumentClient.UpdateItemInput = { + TableName: this.tableName, + Key: { revenueTypeId }, + ConditionExpression: 'attribute_exists(revenueTypeId)', + UpdateExpression, + ExpressionAttributeNames, + ExpressionAttributeValues, + ReturnValues: 'ALL_NEW', + }; + + try { + const data = await this.dynamoDb.update(params).promise(); + if (!data.Attributes) { + throw new NotFoundException(`Revenue type ${revenueTypeId} not found.`); + } + return data.Attributes as RevenueTypeRecord; + } catch (error) { + if (error instanceof NotFoundException || error instanceof BadRequestException) { + throw error; + } + if (this.isAWSError(error) && error.code === 'ConditionalCheckFailedException') { + throw new NotFoundException(`Revenue type ${revenueTypeId} not found.`); + } + if (this.isAWSError(error)) { + this.handleAWSError(error, 'updateRevenueType'); + } + throw new InternalServerErrorException(`Failed to update revenue type ${revenueTypeId}.`); + } + } + + async deleteRevenueTypeById(revenueTypeId: number): Promise<{ message: string }> { + this.assertTableConfigured(); + this.validateRevenueTypeId(revenueTypeId); + + const params: AWS.DynamoDB.DocumentClient.DeleteItemInput = { + TableName: this.tableName, + Key: { revenueTypeId }, + ConditionExpression: 'attribute_exists(revenueTypeId)', + }; + + try { + await this.dynamoDb.delete(params).promise(); + return { message: `Revenue type ${revenueTypeId} deleted successfully` }; + } catch (error) { + if (this.isAWSError(error) && error.code === 'ConditionalCheckFailedException') { + throw new NotFoundException(`Revenue type ${revenueTypeId} not found.`); + } + if (this.isAWSError(error)) { + this.handleAWSError(error, 'deleteRevenueTypeById'); + } + throw new InternalServerErrorException(`Failed to delete revenue type ${revenueTypeId}.`); + } + } +} \ No newline at end of file diff --git a/backend/src/revenue/types/revenue.types.ts b/backend/src/revenue/types/revenue.types.ts new file mode 100644 index 00000000..723615fc --- /dev/null +++ b/backend/src/revenue/types/revenue.types.ts @@ -0,0 +1,80 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; + +export enum RevenueTypeValue { + Grants = 'Grants', + Donation = 'Individual Donations', + Sponsorship = 'Corporate Sponsorships', + Fundraising = 'Fundraising Events', + Other = 'Other Revenue', +} + +export class RevenueTypeResponseDto { + @ApiProperty({ description: 'Unique ID for the revenue type', example: 1234567890 }) + revenueTypeId!: number; + + @ApiProperty({ + description: 'Revenue type category', + enum: RevenueTypeValue, + example: RevenueTypeValue.Grants, + }) + name!: RevenueTypeValue; + + @ApiProperty({ + description: 'Additional details about this revenue type', + required: false, + example: 'Used for recurring and one-time funding streams.', + }) + description?: string; + + @ApiProperty({ description: 'Whether the revenue type is active', example: true }) + isActive!: boolean; + + @ApiProperty({ description: 'ISO timestamp for when the revenue type was created', example: '2026-03-19T00:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ description: 'ISO timestamp for when the revenue type was last updated', example: '2026-03-19T00:00:00.000Z' }) + updatedAt!: string; +} + +export class CreateRevenueTypeBody { + @ApiProperty({ + description: 'Revenue type category', + enum: RevenueTypeValue, + example: RevenueTypeValue.Fundraising, + }) + @IsEnum(RevenueTypeValue) + name!: RevenueTypeValue; + + @ApiProperty({ + description: 'Optional context for this revenue type', + required: false, + example: 'Annual events and community campaigns.', + }) + @IsOptional() + @IsString() + @MaxLength(300) + description?: string; +} + +export class UpdateRevenueTypeBody { + @ApiProperty({ + description: 'Revenue type category', + enum: RevenueTypeValue, + required: false, + }) + @IsOptional() + @IsEnum(RevenueTypeValue) + name?: RevenueTypeValue; + + @ApiProperty({ description: 'Optional context for this revenue type', required: false }) + @IsOptional() + @IsString() + @MaxLength(300) + description?: string; + + @ApiProperty({ description: 'Whether the revenue type is active', required: false }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} \ No newline at end of file