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:
2026-02-23 11:48:57 -05:00
parent ea49b91bb3
commit 84822474f8
20 changed files with 9868 additions and 22 deletions

View File

@@ -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,
);
}
}

View File

@@ -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 {}

View 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,
};
}
}