Sprint 6: Monthly actuals input, reconciliation, and file attachments
Add spreadsheet-style Monthly Actuals page for entering monthly actuals against budget with auto-generated journal entries and reconciliation flag. Add file attachment support (PDF, images, spreadsheets) on journal entries for receipts and invoices. Enhance Budget vs Actual report with month filter dropdown. Add reconciled badge to Transactions page. Replace bcrypt with bcryptjs to fix Docker cross-platform native binding issues. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
55
backend/src/modules/attachments/attachments.controller.ts
Normal file
55
backend/src/modules/attachments/attachments.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Controller, Get, Post, Delete, Param, UseGuards, Request, Res,
|
||||
UseInterceptors, UploadedFile,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { AttachmentsService } from './attachments.service';
|
||||
|
||||
@ApiTags('attachments')
|
||||
@Controller('attachments')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AttachmentsController {
|
||||
constructor(private attachmentsService: AttachmentsService) {}
|
||||
|
||||
@Post('journal-entry/:journalEntryId')
|
||||
@ApiOperation({ summary: 'Upload an attachment for a journal entry' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
||||
}))
|
||||
async upload(
|
||||
@Param('journalEntryId') journalEntryId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Request() req: any,
|
||||
) {
|
||||
return this.attachmentsService.upload(journalEntryId, file, req.user.sub);
|
||||
}
|
||||
|
||||
@Get('journal-entry/:journalEntryId')
|
||||
@ApiOperation({ summary: 'List attachments for a journal entry' })
|
||||
async listByJournalEntry(@Param('journalEntryId') journalEntryId: string) {
|
||||
return this.attachmentsService.findByJournalEntry(journalEntryId);
|
||||
}
|
||||
|
||||
@Get(':id/download')
|
||||
@ApiOperation({ summary: 'Download an attachment' })
|
||||
async download(@Param('id') id: string, @Res() res: Response) {
|
||||
const attachment = await this.attachmentsService.download(id);
|
||||
res.set({
|
||||
'Content-Type': attachment.mime_type,
|
||||
'Content-Disposition': `attachment; filename="${attachment.filename}"`,
|
||||
'Content-Length': attachment.file_data.length,
|
||||
});
|
||||
res.send(attachment.file_data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete an attachment' })
|
||||
async delete(@Param('id') id: string) {
|
||||
return this.attachmentsService.delete(id);
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/attachments/attachments.module.ts
Normal file
10
backend/src/modules/attachments/attachments.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AttachmentsController } from './attachments.controller';
|
||||
import { AttachmentsService } from './attachments.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AttachmentsController],
|
||||
providers: [AttachmentsService],
|
||||
exports: [AttachmentsService],
|
||||
})
|
||||
export class AttachmentsModule {}
|
||||
78
backend/src/modules/attachments/attachments.service.ts
Normal file
78
backend/src/modules/attachments/attachments.service.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
const ALLOWED_MIMES = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
];
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
@Injectable()
|
||||
export class AttachmentsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async upload(journalEntryId: string, file: Express.Multer.File, userId: string) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file provided');
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new BadRequestException('File size exceeds 10MB limit');
|
||||
}
|
||||
if (!ALLOWED_MIMES.includes(file.mimetype)) {
|
||||
throw new BadRequestException(
|
||||
`File type "${file.mimetype}" not allowed. Allowed: PDF, JPEG, PNG, WebP, GIF, XLS, XLSX`,
|
||||
);
|
||||
}
|
||||
|
||||
// Verify journal entry exists
|
||||
const jeRows = await this.tenant.query(
|
||||
`SELECT id FROM journal_entries WHERE id = $1`,
|
||||
[journalEntryId],
|
||||
);
|
||||
if (!jeRows.length) {
|
||||
throw new NotFoundException('Journal entry not found');
|
||||
}
|
||||
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO attachments (journal_entry_id, filename, mime_type, file_size, file_data, uploaded_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, filename, mime_type, file_size, created_at`,
|
||||
[journalEntryId, file.originalname, file.mimetype, file.size, file.buffer, userId],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async findByJournalEntry(journalEntryId: string) {
|
||||
return this.tenant.query(
|
||||
`SELECT id, journal_entry_id, filename, mime_type, file_size, created_at
|
||||
FROM attachments
|
||||
WHERE journal_entry_id = $1
|
||||
ORDER BY created_at`,
|
||||
[journalEntryId],
|
||||
);
|
||||
}
|
||||
|
||||
async download(id: string) {
|
||||
const rows = await this.tenant.query(
|
||||
`SELECT filename, mime_type, file_data FROM attachments WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
if (!rows.length) throw new NotFoundException('Attachment not found');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const result = await this.tenant.query(
|
||||
`DELETE FROM attachments WHERE id = $1 RETURNING id`,
|
||||
[id],
|
||||
);
|
||||
if (!result.length) throw new NotFoundException('Attachment not found');
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user