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:
@@ -0,0 +1,34 @@
|
||||
import { Controller, Get, Post, Param, Body, UseGuards, Request } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { MonthlyActualsService } from './monthly-actuals.service';
|
||||
|
||||
@ApiTags('monthly-actuals')
|
||||
@Controller('monthly-actuals')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MonthlyActualsController {
|
||||
constructor(private monthlyActualsService: MonthlyActualsService) {}
|
||||
|
||||
@Get(':year/:month')
|
||||
@ApiOperation({ summary: 'Get monthly actuals grid for a specific month' })
|
||||
async getGrid(@Param('year') year: string, @Param('month') month: string) {
|
||||
return this.monthlyActualsService.getActualsGrid(parseInt(year), parseInt(month));
|
||||
}
|
||||
|
||||
@Post(':year/:month')
|
||||
@ApiOperation({ summary: 'Save monthly actuals (creates reconciled journal entry)' })
|
||||
async save(
|
||||
@Param('year') year: string,
|
||||
@Param('month') month: string,
|
||||
@Body() body: { lines: { accountId: string; amount: number }[] },
|
||||
@Request() req: any,
|
||||
) {
|
||||
return this.monthlyActualsService.saveActuals(
|
||||
parseInt(year),
|
||||
parseInt(month),
|
||||
body.lines,
|
||||
req.user.sub,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MonthlyActualsController } from './monthly-actuals.controller';
|
||||
import { MonthlyActualsService } from './monthly-actuals.service';
|
||||
import { JournalEntriesModule } from '../journal-entries/journal-entries.module';
|
||||
|
||||
@Module({
|
||||
imports: [JournalEntriesModule],
|
||||
controllers: [MonthlyActualsController],
|
||||
providers: [MonthlyActualsService],
|
||||
})
|
||||
export class MonthlyActualsModule {}
|
||||
191
backend/src/modules/monthly-actuals/monthly-actuals.service.ts
Normal file
191
backend/src/modules/monthly-actuals/monthly-actuals.service.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
import { JournalEntriesService } from '../journal-entries/journal-entries.service';
|
||||
|
||||
const MONTH_COLS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
|
||||
const MONTH_LABELS = ['January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
@Injectable()
|
||||
export class MonthlyActualsService {
|
||||
constructor(
|
||||
private tenant: TenantService,
|
||||
private journalEntriesService: JournalEntriesService,
|
||||
) {}
|
||||
|
||||
async getActualsGrid(year: number, month: number) {
|
||||
if (month < 1 || month > 12) throw new BadRequestException('Month must be 1-12');
|
||||
|
||||
const budgetCol = MONTH_COLS[month - 1];
|
||||
|
||||
// Get all income/expense accounts with budget amounts and existing actuals
|
||||
const rows = await this.tenant.query(
|
||||
`SELECT
|
||||
a.id as account_id,
|
||||
a.account_number,
|
||||
a.name as account_name,
|
||||
a.account_type,
|
||||
a.fund_type,
|
||||
COALESCE(b.${budgetCol}, 0) as budget_amount,
|
||||
COALESCE(SUM(
|
||||
CASE WHEN a.account_type IN ('expense', 'asset') THEN jel.debit - jel.credit
|
||||
ELSE jel.credit - jel.debit END
|
||||
), 0) as actual_amount
|
||||
FROM accounts a
|
||||
LEFT JOIN budgets b ON b.account_id = a.id AND b.fiscal_year = $1
|
||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
||||
AND EXTRACT(MONTH FROM je.entry_date) = $2
|
||||
WHERE a.is_active = true
|
||||
AND a.account_type IN ('income', 'expense')
|
||||
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type,
|
||||
b.${budgetCol}
|
||||
ORDER BY a.account_number`,
|
||||
[year, month],
|
||||
);
|
||||
|
||||
// Check if there's an existing monthly_actual journal entry for this month
|
||||
const existingJE = await this.tenant.query(
|
||||
`SELECT id FROM journal_entries
|
||||
WHERE entry_type = 'monthly_actual'
|
||||
AND EXTRACT(YEAR FROM entry_date) = $1
|
||||
AND EXTRACT(MONTH FROM entry_date) = $2
|
||||
AND is_void = false
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[year, month],
|
||||
);
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
month_label: MONTH_LABELS[month - 1],
|
||||
existing_journal_entry_id: existingJE[0]?.id || null,
|
||||
lines: rows.map((r: any) => ({
|
||||
...r,
|
||||
budget_amount: parseFloat(r.budget_amount || '0'),
|
||||
actual_amount: parseFloat(r.actual_amount || '0'),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async saveActuals(year: number, month: number, lines: { accountId: string; amount: number }[], userId: string) {
|
||||
if (month < 1 || month > 12) throw new BadRequestException('Month must be 1-12');
|
||||
|
||||
const monthLabel = MONTH_LABELS[month - 1];
|
||||
|
||||
// 1. Void any existing monthly_actual entries for this year/month
|
||||
const existingEntries = await this.tenant.query(
|
||||
`SELECT id FROM journal_entries
|
||||
WHERE entry_type = 'monthly_actual'
|
||||
AND EXTRACT(YEAR FROM entry_date) = $1
|
||||
AND EXTRACT(MONTH FROM entry_date) = $2
|
||||
AND is_void = false`,
|
||||
[year, month],
|
||||
);
|
||||
|
||||
for (const entry of existingEntries) {
|
||||
await this.journalEntriesService.void(entry.id, userId, `Replaced by updated monthly actuals for ${monthLabel} ${year}`);
|
||||
}
|
||||
|
||||
// 2. Find primary operating cash account (offset account for double-entry)
|
||||
let cashAccounts = await this.tenant.query(
|
||||
`SELECT id FROM accounts WHERE is_primary = true AND fund_type = 'operating' AND account_type = 'asset' LIMIT 1`,
|
||||
);
|
||||
if (!cashAccounts.length) {
|
||||
cashAccounts = await this.tenant.query(
|
||||
`SELECT id FROM accounts WHERE account_number = '1000' AND account_type = 'asset' LIMIT 1`,
|
||||
);
|
||||
}
|
||||
if (!cashAccounts.length) {
|
||||
throw new BadRequestException(
|
||||
'No primary cash account found. Please set a primary operating account on the Accounts page.',
|
||||
);
|
||||
}
|
||||
const cashAccountId = cashAccounts[0].id;
|
||||
|
||||
// 3. Filter to lines with actual amounts
|
||||
const filteredLines = lines.filter((l) => l.amount !== 0);
|
||||
if (filteredLines.length === 0) {
|
||||
return { message: 'No actuals to save', journal_entry_id: null };
|
||||
}
|
||||
|
||||
// 4. Look up account types for each line
|
||||
const accountIds = filteredLines.map((l) => l.accountId);
|
||||
const accountRows = await this.tenant.query(
|
||||
`SELECT id, account_type FROM accounts WHERE id = ANY($1)`,
|
||||
[accountIds],
|
||||
);
|
||||
const accountTypeMap = new Map(accountRows.map((a: any) => [a.id, a.account_type]));
|
||||
|
||||
// 5. Build journal entry lines
|
||||
const jeLines: any[] = [];
|
||||
let totalCashDebit = 0;
|
||||
let totalCashCredit = 0;
|
||||
|
||||
for (const line of filteredLines) {
|
||||
const acctType = accountTypeMap.get(line.accountId);
|
||||
if (!acctType) continue;
|
||||
|
||||
if (acctType === 'expense') {
|
||||
// Expense: debit expense account, credit cash
|
||||
jeLines.push({
|
||||
accountId: line.accountId,
|
||||
debit: Math.abs(line.amount),
|
||||
credit: 0,
|
||||
memo: `${monthLabel} actual`,
|
||||
});
|
||||
totalCashCredit += Math.abs(line.amount);
|
||||
} else if (acctType === 'income') {
|
||||
// Income: credit income account, debit cash
|
||||
jeLines.push({
|
||||
accountId: line.accountId,
|
||||
debit: 0,
|
||||
credit: Math.abs(line.amount),
|
||||
memo: `${monthLabel} actual`,
|
||||
});
|
||||
totalCashDebit += Math.abs(line.amount);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Add offsetting cash line(s) to balance the entry
|
||||
const netCash = totalCashDebit - totalCashCredit;
|
||||
if (netCash > 0) {
|
||||
jeLines.push({ accountId: cashAccountId, debit: netCash, credit: 0, memo: `${monthLabel} actuals offset` });
|
||||
} else if (netCash < 0) {
|
||||
jeLines.push({ accountId: cashAccountId, debit: 0, credit: Math.abs(netCash), memo: `${monthLabel} actuals offset` });
|
||||
}
|
||||
|
||||
// 7. Set entry_date to last day of the month
|
||||
const lastDay = new Date(year, month, 0); // month is 1-indexed; new Date(2026, 1, 0) = Jan 31
|
||||
const dateStr = lastDay.toISOString().split('T')[0];
|
||||
|
||||
// 8. Create journal entry via existing service
|
||||
const je = await this.journalEntriesService.create(
|
||||
{
|
||||
entryDate: dateStr,
|
||||
description: `Monthly actuals for ${monthLabel} ${year}`,
|
||||
referenceNumber: `ACTUAL-${year}-${String(month).padStart(2, '0')}`,
|
||||
entryType: 'monthly_actual',
|
||||
lines: jeLines,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
|
||||
// 9. Auto-post the entry
|
||||
await this.journalEntriesService.post(je.id, userId);
|
||||
|
||||
// 10. Set is_reconciled flag
|
||||
await this.tenant.query(
|
||||
`UPDATE journal_entries SET is_reconciled = true WHERE id = $1`,
|
||||
[je.id],
|
||||
);
|
||||
|
||||
return {
|
||||
message: `Saved ${filteredLines.length} actuals for ${monthLabel} ${year}`,
|
||||
journal_entry_id: je.id,
|
||||
lines_count: filteredLines.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user