Initial commit: HOA Financial Intelligence Platform MVP
Multi-tenant financial management platform for homeowner associations featuring: - NestJS backend with 16 modules (auth, accounts, transactions, budgets, units, invoices, payments, vendors, reserves, investments, capital projects, reports) - React + Mantine frontend with dashboard, CRUD pages, and financial reports - Schema-per-tenant PostgreSQL isolation with JWT-based tenant resolution - Docker Compose infrastructure (nginx, backend, frontend, postgres, redis) - Comprehensive seed data for Sunrise Valley HOA demo - 39 API endpoints with Swagger documentation - Double-entry bookkeeping with journal entries - Budget vs actual reporting and Sankey cash flow visualization Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
35
backend/src/modules/reports/reports.controller.ts
Normal file
35
backend/src/modules/reports/reports.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
@ApiTags('reports')
|
||||
@Controller('reports')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ReportsController {
|
||||
constructor(private reportsService: ReportsService) {}
|
||||
|
||||
@Get('balance-sheet')
|
||||
getBalanceSheet(@Query('as_of') asOf?: string) {
|
||||
return this.reportsService.getBalanceSheet(asOf || new Date().toISOString().split('T')[0]);
|
||||
}
|
||||
|
||||
@Get('income-statement')
|
||||
getIncomeStatement(@Query('from') from?: string, @Query('to') to?: string) {
|
||||
const now = new Date();
|
||||
const defaultFrom = `${now.getFullYear()}-01-01`;
|
||||
const defaultTo = now.toISOString().split('T')[0];
|
||||
return this.reportsService.getIncomeStatement(from || defaultFrom, to || defaultTo);
|
||||
}
|
||||
|
||||
@Get('cash-flow-sankey')
|
||||
getCashFlowSankey(@Query('year') year?: string) {
|
||||
return this.reportsService.getCashFlowSankey(parseInt(year || '') || new Date().getFullYear());
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
getDashboardKPIs() {
|
||||
return this.reportsService.getDashboardKPIs();
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/reports/reports.module.ts
Normal file
10
backend/src/modules/reports/reports.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReportsController } from './reports.controller';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportsService],
|
||||
exports: [ReportsService],
|
||||
})
|
||||
export class ReportsModule {}
|
||||
227
backend/src/modules/reports/reports.service.ts
Normal file
227
backend/src/modules/reports/reports.service.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async getBalanceSheet(asOf: string) {
|
||||
const sql = `
|
||||
SELECT a.id, a.account_number, a.name, a.account_type, a.fund_type,
|
||||
CASE
|
||||
WHEN a.account_type IN ('asset', 'expense')
|
||||
THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
||||
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
|
||||
END as balance
|
||||
FROM accounts a
|
||||
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 je.entry_date <= $1
|
||||
WHERE a.is_active = true AND a.account_type IN ('asset', 'liability', 'equity')
|
||||
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
|
||||
HAVING CASE
|
||||
WHEN a.account_type IN ('asset') THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
||||
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
|
||||
END <> 0 OR a.is_system = true
|
||||
ORDER BY a.account_number
|
||||
`;
|
||||
const rows = await this.tenant.query(sql, [asOf]);
|
||||
|
||||
const assets = rows.filter((r: any) => r.account_type === 'asset');
|
||||
const liabilities = rows.filter((r: any) => r.account_type === 'liability');
|
||||
const equity = rows.filter((r: any) => r.account_type === 'equity');
|
||||
|
||||
const totalAssets = assets.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
|
||||
const totalLiabilities = liabilities.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
|
||||
const totalEquity = equity.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
|
||||
|
||||
return {
|
||||
as_of: asOf,
|
||||
assets, liabilities, equity,
|
||||
total_assets: totalAssets.toFixed(2),
|
||||
total_liabilities: totalLiabilities.toFixed(2),
|
||||
total_equity: totalEquity.toFixed(2),
|
||||
};
|
||||
}
|
||||
|
||||
async getIncomeStatement(from: string, to: string) {
|
||||
const sql = `
|
||||
SELECT a.id, a.account_number, a.name, a.account_type, a.fund_type,
|
||||
CASE
|
||||
WHEN a.account_type = 'income'
|
||||
THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
|
||||
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
||||
END as amount
|
||||
FROM accounts a
|
||||
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 je.entry_date BETWEEN $1 AND $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
|
||||
HAVING CASE
|
||||
WHEN a.account_type = 'income' THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
|
||||
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
||||
END <> 0 OR a.is_system = true
|
||||
ORDER BY a.account_number
|
||||
`;
|
||||
const rows = await this.tenant.query(sql, [from, to]);
|
||||
|
||||
const income = rows.filter((r: any) => r.account_type === 'income');
|
||||
const expenses = rows.filter((r: any) => r.account_type === 'expense');
|
||||
|
||||
const totalIncome = income.reduce((s: number, r: any) => s + parseFloat(r.amount), 0);
|
||||
const totalExpenses = expenses.reduce((s: number, r: any) => s + parseFloat(r.amount), 0);
|
||||
|
||||
return {
|
||||
from, to,
|
||||
income, expenses,
|
||||
total_income: totalIncome.toFixed(2),
|
||||
total_expenses: totalExpenses.toFixed(2),
|
||||
net_income: (totalIncome - totalExpenses).toFixed(2),
|
||||
};
|
||||
}
|
||||
|
||||
async getCashFlowSankey(year: number) {
|
||||
// Get income accounts with amounts
|
||||
const income = await this.tenant.query(`
|
||||
SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount
|
||||
FROM accounts a
|
||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
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
|
||||
WHERE a.account_type = 'income' AND a.is_active = true
|
||||
GROUP BY a.id, a.name
|
||||
HAVING COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) > 0
|
||||
ORDER BY amount DESC
|
||||
`, [year]);
|
||||
|
||||
const expenses = await this.tenant.query(`
|
||||
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount
|
||||
FROM accounts a
|
||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
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
|
||||
WHERE a.account_type = 'expense' AND a.is_active = true
|
||||
GROUP BY a.id, a.name, a.fund_type
|
||||
HAVING COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) > 0
|
||||
ORDER BY amount DESC
|
||||
`, [year]);
|
||||
|
||||
if (!income.length && !expenses.length) {
|
||||
return { nodes: [], links: [], total_income: 0, total_expenses: 0, net_cash_flow: 0 };
|
||||
}
|
||||
|
||||
// Build Sankey nodes and links
|
||||
// Structure: Income Sources → HOA Fund → Expense Categories
|
||||
const nodes: { name: string; category: string }[] = [];
|
||||
const links: { source: number; target: number; value: number }[] = [];
|
||||
|
||||
// Income source nodes
|
||||
income.forEach((i: any) => nodes.push({ name: i.name, category: 'income' }));
|
||||
|
||||
// Central HOA Fund node
|
||||
const fundIdx = nodes.length;
|
||||
nodes.push({ name: 'HOA Fund', category: 'operating' });
|
||||
|
||||
// Operating expense nodes
|
||||
const opExpenses = expenses.filter((e: any) => e.fund_type === 'operating');
|
||||
const resExpenses = expenses.filter((e: any) => e.fund_type === 'reserve');
|
||||
|
||||
if (opExpenses.length) {
|
||||
const opIdx = nodes.length;
|
||||
nodes.push({ name: 'Operating Expenses', category: 'expense' });
|
||||
opExpenses.forEach((e: any) => nodes.push({ name: e.name, category: 'expense' }));
|
||||
|
||||
// Link fund → operating
|
||||
const opTotal = opExpenses.reduce((s: number, e: any) => s + parseFloat(e.amount), 0);
|
||||
links.push({ source: fundIdx, target: opIdx, value: opTotal });
|
||||
|
||||
// Link operating → each expense
|
||||
opExpenses.forEach((e: any, i: number) => {
|
||||
links.push({ source: opIdx, target: opIdx + 1 + i, value: parseFloat(e.amount) });
|
||||
});
|
||||
}
|
||||
|
||||
if (resExpenses.length) {
|
||||
const resIdx = nodes.length;
|
||||
nodes.push({ name: 'Reserve Expenses', category: 'reserve' });
|
||||
resExpenses.forEach((e: any) => nodes.push({ name: e.name, category: 'reserve' }));
|
||||
|
||||
const resTotal = resExpenses.reduce((s: number, e: any) => s + parseFloat(e.amount), 0);
|
||||
links.push({ source: fundIdx, target: resIdx, value: resTotal });
|
||||
|
||||
resExpenses.forEach((e: any, i: number) => {
|
||||
links.push({ source: resIdx, target: resIdx + 1 + i, value: parseFloat(e.amount) });
|
||||
});
|
||||
}
|
||||
|
||||
// Link income sources → fund
|
||||
income.forEach((i: any, idx: number) => {
|
||||
links.push({ source: idx, target: fundIdx, value: parseFloat(i.amount) });
|
||||
});
|
||||
|
||||
// Net surplus node
|
||||
const totalIncome = income.reduce((s: number, i: any) => s + parseFloat(i.amount), 0);
|
||||
const totalExpenses = expenses.reduce((s: number, e: any) => s + parseFloat(e.amount), 0);
|
||||
const netFlow = totalIncome - totalExpenses;
|
||||
|
||||
if (netFlow > 0) {
|
||||
const surplusIdx = nodes.length;
|
||||
nodes.push({ name: 'Surplus / Savings', category: 'net' });
|
||||
links.push({ source: fundIdx, target: surplusIdx, value: netFlow });
|
||||
}
|
||||
|
||||
return { nodes, links, total_income: totalIncome, total_expenses: totalExpenses, net_cash_flow: netFlow };
|
||||
}
|
||||
|
||||
async getDashboardKPIs() {
|
||||
// Total cash (all asset accounts with 'Cash' in name)
|
||||
const cash = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance
|
||||
FROM accounts a
|
||||
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
|
||||
WHERE a.account_type = 'asset' AND a.name LIKE '%Cash%' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`);
|
||||
const totalCash = parseFloat(cash[0]?.total || '0');
|
||||
|
||||
// Receivables
|
||||
const ar = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(amount - amount_paid), 0) as total
|
||||
FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off')
|
||||
`);
|
||||
|
||||
// Reserve fund balance
|
||||
const reserves = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(current_fund_balance), 0) as total FROM reserve_components
|
||||
`);
|
||||
|
||||
// Delinquent count (overdue invoices)
|
||||
const delinquent = await this.tenant.query(`
|
||||
SELECT COUNT(DISTINCT unit_id) as count FROM invoices WHERE status = 'overdue'
|
||||
`);
|
||||
|
||||
// Recent transactions
|
||||
const recentTx = await this.tenant.query(`
|
||||
SELECT je.id, je.entry_date, je.description, je.entry_type,
|
||||
(SELECT COALESCE(SUM(debit), 0) FROM journal_entry_lines WHERE journal_entry_id = je.id) as amount
|
||||
FROM journal_entries je WHERE je.is_posted = true AND je.is_void = false
|
||||
ORDER BY je.entry_date DESC, je.created_at DESC LIMIT 10
|
||||
`);
|
||||
|
||||
return {
|
||||
total_cash: totalCash.toFixed(2),
|
||||
total_receivables: ar[0]?.total || '0.00',
|
||||
reserve_fund_balance: reserves[0]?.total || '0.00',
|
||||
delinquent_units: parseInt(delinquent[0]?.count || '0'),
|
||||
recent_transactions: recentTx,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user