diff --git a/apps/backend/db/db_setup.sql b/apps/backend/db/db_setup.sql index 5b82e7f..b2075d5 100644 --- a/apps/backend/db/db_setup.sql +++ b/apps/backend/db/db_setup.sql @@ -57,6 +57,8 @@ CREATE TABLE expenditures ( amount NUMERIC(12,2) NOT NULL CHECK (amount >= 0), category VARCHAR(50), description TEXT, + status VARCHAR(15) NOT NULL DEFAULT 'pending' CHECK (status IN ('approved', 'pending', 'denied', 'needs_more_info')), + receipt_url TEXT, spent_on DATE NOT NULL DEFAULT CURRENT_DATE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); diff --git a/apps/backend/lambdas/expenditures/db-types.d.ts b/apps/backend/lambdas/expenditures/db-types.d.ts index 5ca734e..18fcb70 100644 --- a/apps/backend/lambdas/expenditures/db-types.d.ts +++ b/apps/backend/lambdas/expenditures/db-types.d.ts @@ -29,7 +29,9 @@ export interface BranchExpenditures { entered_by: number | null; expenditure_id: Generated; project_id: number; + receipt_url: string | null; spent_on: Generated; + status: Generated; } export interface BranchProjectDonations { diff --git a/apps/backend/lambdas/expenditures/handler.ts b/apps/backend/lambdas/expenditures/handler.ts index b7be0fd..53c2a85 100644 --- a/apps/backend/lambdas/expenditures/handler.ts +++ b/apps/backend/lambdas/expenditures/handler.ts @@ -104,7 +104,7 @@ export const handler = async (event: any): Promise => { return json(400, { message: validationResult.message }); } - const { projectID, amount, category, description, spentOn } = validationResult; + const { projectID, amount, category, description, status, receiptUrl, spentOn } = validationResult; // Authorize: must be global admin, or PI/Accountant/Admin on this project if (!user.isAdmin) { @@ -141,6 +141,8 @@ export const handler = async (event: any): Promise => { amount, category: category ?? null, description: description ?? null, + status, + receipt_url: receiptUrl ?? null, spent_on: spentOn ? new Date(spentOn) : new Date(), }) .executeTakeFirst(); @@ -158,6 +160,8 @@ export const handler = async (event: any): Promise => { amount, category: category ?? null, description: description ?? null, + status, + receiptUrl: receiptUrl ?? null, spentOn: spentOn ?? new Date().toISOString().split('T')[0], }, }); diff --git a/apps/backend/lambdas/expenditures/openapi.yaml b/apps/backend/lambdas/expenditures/openapi.yaml index 6f2b582..a42f5b9 100644 --- a/apps/backend/lambdas/expenditures/openapi.yaml +++ b/apps/backend/lambdas/expenditures/openapi.yaml @@ -88,6 +88,13 @@ paths: type: string description: type: string + status: + type: string + enum: [approved, pending, denied, needs_more_info] + default: pending + receipt_url: + type: string + nullable: true spentOn: type: string format: date diff --git a/apps/backend/lambdas/expenditures/test/expenditures.e2e.test.ts b/apps/backend/lambdas/expenditures/test/expenditures.e2e.test.ts index cd9b031..ed41f43 100644 --- a/apps/backend/lambdas/expenditures/test/expenditures.e2e.test.ts +++ b/apps/backend/lambdas/expenditures/test/expenditures.e2e.test.ts @@ -210,6 +210,53 @@ describe('Expenditures integration tests', () => { expect(body.body.category).toBeNull(); }); + test('201: creates expenditure with status and receipt_url', async () => { + const res = await handler( + postEvent({ + projectID: 1, + amount: 300, + status: 'approved', + receipt_url: 'https://s3.amazonaws.com/branch-receipts/receipt.pdf', + }) + ); + + expect(res.statusCode).toBe(201); + const body = JSON.parse(res.body); + expect(body.body.status).toBe('approved'); + expect(body.body.receiptUrl).toBe('https://s3.amazonaws.com/branch-receipts/receipt.pdf'); + + const client = await pool.connect(); + try { + const result = await client.query( + "SELECT * FROM branch.expenditures WHERE amount = 300 AND project_id = 1" + ); + expect(result.rows.length).toBe(1); + expect(result.rows[0].status).toBe('approved'); + expect(result.rows[0].receipt_url).toBe('https://s3.amazonaws.com/branch-receipts/receipt.pdf'); + } finally { + client.release(); + } + }); + + test('201: status defaults to pending when omitted', async () => { + const res = await handler(postEvent({ projectID: 1, amount: 100 })); + + expect(res.statusCode).toBe(201); + expect(JSON.parse(res.body).body.status).toBe('pending'); + + const client = await pool.connect(); + try { + const result = await client.query( + "SELECT * FROM branch.expenditures WHERE amount = 100 AND project_id = 1" + ); + expect(result.rows.length).toBe(1); + expect(result.rows[0].status).toBe('pending'); + expect(result.rows[0].receipt_url).toBeNull(); + } finally { + client.release(); + } + }); + test('404: project not found', async () => { const res = await handler(postEvent({ projectID: 999, amount: 1000 })); expect(res.statusCode).toBe(404); @@ -217,6 +264,20 @@ describe('Expenditures integration tests', () => { }); }); + describe('Input validation', () => { + test('400: invalid status value returns 400', async () => { + const res = await handler(postEvent({ projectID: 1, amount: 500, status: 'unknown' })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toContain('status must be one of'); + }); + + test('400: empty string receipt_url returns 400', async () => { + const res = await handler(postEvent({ projectID: 1, amount: 500, receipt_url: '' })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toContain('receipt_url'); + }); + }); + describe('GET /expenditures — list and pagination', () => { test('200: returns all expenditures with data envelope', async () => { mockAuthenticateRequest.mockResolvedValue(adminUser); diff --git a/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts b/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts index 00fe296..1ec35a8 100644 --- a/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts +++ b/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts @@ -237,6 +237,34 @@ describe('POST /expenditures unit tests', () => { const json = JSON.parse(res.body); expect(json.message).toBeDefined(); }); + + test('400: invalid status value', async () => { + const res = await handler( + postEvent({ + projectID: 1, + amount: 1000, + status: 'unknown', + }) + ); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.message).toContain('status must be one of'); + }); + + test('400: empty string receipt_url', async () => { + const res = await handler( + postEvent({ + projectID: 1, + amount: 1000, + receipt_url: '', + }) + ); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.message).toContain('receipt_url'); + }); }); describe('Response Format', () => { @@ -333,6 +361,41 @@ describe('POST /expenditures unit tests', () => { expect(json.body).toHaveProperty('projectID'); expect(json.body).toHaveProperty('amount'); expect(json.body.enteredBy).toBe(1); // authenticated user's ID + expect(json.body.status).toBe('pending'); // default status + expect(json.body.receiptUrl).toBeNull(); + }); + + test('201: accepts explicit status and receipt_url', async () => { + mockDb.selectFrom.mockReturnValue({ + where: jest.fn().mockReturnValue({ + selectAll: jest.fn().mockReturnValue({ + executeTakeFirst: jest.fn().mockReturnValue({ + project_id: 1, + name: 'Test Project', + } as any), + }), + }), + }); + + mockDb.insertInto.mockReturnValue({ + values: jest.fn().mockReturnValue({ + executeTakeFirst: jest.fn().mockReturnValue(undefined as any), + }), + }); + + const res = await handler( + postEvent({ + projectID: 1, + amount: 800, + status: 'approved', + receipt_url: 'https://s3.amazonaws.com/branch-receipts/receipt.pdf', + }) + ); + + expect(res.statusCode).toBe(201); + const json = JSON.parse(res.body); + expect(json.body.status).toBe('approved'); + expect(json.body.receiptUrl).toBe('https://s3.amazonaws.com/branch-receipts/receipt.pdf'); }); test('404: returns 404 when project not found', async () => { diff --git a/apps/backend/lambdas/expenditures/validation-utils.ts b/apps/backend/lambdas/expenditures/validation-utils.ts index 4078c31..f80c622 100644 --- a/apps/backend/lambdas/expenditures/validation-utils.ts +++ b/apps/backend/lambdas/expenditures/validation-utils.ts @@ -1,8 +1,13 @@ +export const EXPENDITURE_STATUSES = ['approved', 'pending', 'denied', 'needs_more_info'] as const; +export type ExpenditureStatus = typeof EXPENDITURE_STATUSES[number]; + export interface ExpenditureInput { projectID: number; amount: number; category?: string; description?: string; + status: ExpenditureStatus; + receiptUrl?: string; spentOn?: string; } @@ -59,6 +64,30 @@ export class ExpenditureValidationUtils { return description; } + static validateStatus(status: unknown): ExpenditureStatus | Error { + if (status === undefined || status === null) { + return 'pending'; + } + + if (typeof status !== 'string' || !EXPENDITURE_STATUSES.includes(status as ExpenditureStatus)) { + return new Error(`status must be one of: ${EXPENDITURE_STATUSES.join(', ')}`); + } + + return status as ExpenditureStatus; + } + + static validateReceiptUrl(receiptUrl: unknown): string | undefined | Error { + if (receiptUrl === undefined || receiptUrl === null) { + return undefined; + } + + if (typeof receiptUrl !== 'string' || receiptUrl.trim() === '') { + return new Error('receipt_url must be a non-empty string'); + } + + return receiptUrl; + } + static validateSpentOn(spentOn: unknown): string | undefined | Error { if (spentOn === undefined || spentOn === null) { return undefined; @@ -94,6 +123,16 @@ export class ExpenditureValidationUtils { return description; } + const status = this.validateStatus(body.status); + if (status instanceof Error) { + return status; + } + + const receiptUrl = this.validateReceiptUrl(body.receipt_url); + if (receiptUrl instanceof Error) { + return receiptUrl; + } + const spentOn = this.validateSpentOn(body.spentOn); if (spentOn instanceof Error) { return spentOn; @@ -104,6 +143,8 @@ export class ExpenditureValidationUtils { amount, category, description, + status, + receiptUrl, spentOn, }; }