Skip to content
Merged
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
2 changes: 2 additions & 0 deletions apps/backend/db/db_setup.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/lambdas/expenditures/db-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ export interface BranchExpenditures {
entered_by: number | null;
expenditure_id: Generated<number>;
project_id: number;
receipt_url: string | null;
spent_on: Generated<Timestamp>;
status: Generated<string>;
}

export interface BranchProjectDonations {
Expand Down
6 changes: 5 additions & 1 deletion apps/backend/lambdas/expenditures/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
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) {
Expand Down Expand Up @@ -141,6 +141,8 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
amount,
category: category ?? null,
description: description ?? null,
status,
receipt_url: receiptUrl ?? null,
spent_on: spentOn ? new Date(spentOn) : new Date(),
})
.executeTakeFirst();
Expand All @@ -158,6 +160,8 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
amount,
category: category ?? null,
description: description ?? null,
status,
receiptUrl: receiptUrl ?? null,
spentOn: spentOn ?? new Date().toISOString().split('T')[0],
},
});
Expand Down
7 changes: 7 additions & 0 deletions apps/backend/lambdas/expenditures/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions apps/backend/lambdas/expenditures/test/expenditures.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,13 +210,74 @@ 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);
expect(JSON.parse(res.body).message).toBe('Project not found');
});
});

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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
41 changes: 41 additions & 0 deletions apps/backend/lambdas/expenditures/validation-utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -104,6 +143,8 @@ export class ExpenditureValidationUtils {
amount,
category,
description,
status,
receiptUrl,
spentOn,
};
}
Expand Down
Loading