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:
2026-02-17 19:58:04 -05:00
commit 243770cea5
118 changed files with 8569 additions and 0 deletions

View 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();
}
}

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

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