Rename to HOA LedgerIQ and implement remaining report pages
- Rename app from "HOA Financial Platform" to "HOA LedgerIQ" across all frontend pages, backend API docs, package.json files, and seed data - Add Cash Flow Statement report (GET /reports/cash-flow) with operating and reserve fund activity breakdown, beginning/ending cash balances - Add Aging Report (GET /reports/aging) with per-unit aging buckets (current, 1-30, 31-60, 61-90, 90+ days), expandable invoice details - Add Year-End Package (GET /reports/year-end) with income statement summary, collection stats, 1099-NEC vendor report, reserve fund status - Add Settings page showing org info, user profile, and system details - Replace all PlaceholderPage references with real implementations - Bump auth store version to 3 for localStorage migration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,8 +28,8 @@ async function bootstrap() {
|
||||
});
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('HOA Financial Platform API')
|
||||
.setDescription('API for the HOA Financial Intelligence Platform')
|
||||
.setTitle('HOA LedgerIQ API')
|
||||
.setDescription('API for the HOA LedgerIQ')
|
||||
.setVersion('0.1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
|
||||
@@ -28,6 +28,24 @@ export class ReportsController {
|
||||
return this.reportsService.getCashFlowSankey(parseInt(year || '') || new Date().getFullYear());
|
||||
}
|
||||
|
||||
@Get('cash-flow')
|
||||
getCashFlowStatement(@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.getCashFlowStatement(from || defaultFrom, to || defaultTo);
|
||||
}
|
||||
|
||||
@Get('aging')
|
||||
getAgingReport() {
|
||||
return this.reportsService.getAgingReport();
|
||||
}
|
||||
|
||||
@Get('year-end')
|
||||
getYearEndSummary(@Query('year') year?: string) {
|
||||
return this.reportsService.getYearEndSummary(parseInt(year || '') || new Date().getFullYear());
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
getDashboardKPIs() {
|
||||
return this.reportsService.getDashboardKPIs();
|
||||
|
||||
@@ -178,6 +178,221 @@ export class ReportsService {
|
||||
return { nodes, links, total_income: totalIncome, total_expenses: totalExpenses, net_cash_flow: netFlow };
|
||||
}
|
||||
|
||||
async getCashFlowStatement(from: string, to: string) {
|
||||
// Operating activities: income minus expenses from journal entries
|
||||
const operating = await this.tenant.query(`
|
||||
SELECT a.name, a.account_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
|
||||
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 je.entry_date BETWEEN $1 AND $2
|
||||
WHERE a.account_type IN ('income', 'expense') AND a.is_active = true
|
||||
AND a.fund_type = 'operating'
|
||||
GROUP BY a.id, a.name, a.account_type
|
||||
HAVING ABS(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
|
||||
ORDER BY a.account_type, a.name
|
||||
`, [from, to]);
|
||||
|
||||
// Reserve fund activities
|
||||
const reserve = await this.tenant.query(`
|
||||
SELECT a.name,
|
||||
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
|
||||
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 je.entry_date BETWEEN $1 AND $2
|
||||
WHERE a.is_active = true AND a.fund_type = 'reserve'
|
||||
GROUP BY a.id, a.name, a.account_type
|
||||
HAVING ABS(COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)) > 0
|
||||
ORDER BY a.name
|
||||
`, [from, to]);
|
||||
|
||||
// Cash beginning and ending balances
|
||||
const beginCash = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
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.account_type = 'asset' AND a.name LIKE '%Cash%' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`, [from]);
|
||||
|
||||
const endCash = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
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.account_type = 'asset' AND a.name LIKE '%Cash%' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`, [to]);
|
||||
|
||||
const operatingItems = operating.map((r: any) => ({
|
||||
name: r.name, type: r.account_type, amount: parseFloat(r.amount),
|
||||
}));
|
||||
const reserveItems = reserve.map((r: any) => ({
|
||||
name: r.name, amount: parseFloat(r.amount),
|
||||
}));
|
||||
|
||||
const totalOperating = operatingItems.reduce((s: number, r: any) => s + r.amount, 0);
|
||||
const totalReserve = reserveItems.reduce((s: number, r: any) => s + r.amount, 0);
|
||||
const beginningBalance = parseFloat(beginCash[0]?.balance || '0');
|
||||
const endingBalance = parseFloat(endCash[0]?.balance || '0');
|
||||
|
||||
return {
|
||||
from, to,
|
||||
operating_activities: operatingItems,
|
||||
reserve_activities: reserveItems,
|
||||
total_operating: totalOperating.toFixed(2),
|
||||
total_reserve: totalReserve.toFixed(2),
|
||||
net_cash_change: (totalOperating + totalReserve).toFixed(2),
|
||||
beginning_cash: beginningBalance.toFixed(2),
|
||||
ending_cash: endingBalance.toFixed(2),
|
||||
};
|
||||
}
|
||||
|
||||
async getAgingReport() {
|
||||
const sql = `
|
||||
SELECT
|
||||
u.id as unit_id, u.unit_number, u.owner_name,
|
||||
i.id as invoice_id, i.invoice_number, i.description,
|
||||
i.due_date, i.amount, i.amount_paid,
|
||||
i.amount - i.amount_paid as balance_due,
|
||||
CASE
|
||||
WHEN i.due_date >= CURRENT_DATE THEN 'current'
|
||||
WHEN CURRENT_DATE - i.due_date BETWEEN 1 AND 30 THEN '1-30'
|
||||
WHEN CURRENT_DATE - i.due_date BETWEEN 31 AND 60 THEN '31-60'
|
||||
WHEN CURRENT_DATE - i.due_date BETWEEN 61 AND 90 THEN '61-90'
|
||||
ELSE '90+'
|
||||
END as aging_bucket
|
||||
FROM invoices i
|
||||
JOIN units u ON u.id = i.unit_id
|
||||
WHERE i.status NOT IN ('paid', 'void', 'written_off')
|
||||
AND i.amount - i.amount_paid > 0
|
||||
ORDER BY u.unit_number, i.due_date
|
||||
`;
|
||||
const rows = await this.tenant.query(sql);
|
||||
|
||||
// Aggregate by unit
|
||||
const unitMap = new Map<string, any>();
|
||||
rows.forEach((r: any) => {
|
||||
if (!unitMap.has(r.unit_id)) {
|
||||
unitMap.set(r.unit_id, {
|
||||
unit_id: r.unit_id, unit_number: r.unit_number, owner_name: r.owner_name,
|
||||
current: 0, days_1_30: 0, days_31_60: 0, days_61_90: 0, days_90_plus: 0,
|
||||
total: 0, invoices: [],
|
||||
});
|
||||
}
|
||||
const unit = unitMap.get(r.unit_id);
|
||||
const bal = parseFloat(r.balance_due);
|
||||
unit.total += bal;
|
||||
switch (r.aging_bucket) {
|
||||
case 'current': unit.current += bal; break;
|
||||
case '1-30': unit.days_1_30 += bal; break;
|
||||
case '31-60': unit.days_31_60 += bal; break;
|
||||
case '61-90': unit.days_61_90 += bal; break;
|
||||
case '90+': unit.days_90_plus += bal; break;
|
||||
}
|
||||
unit.invoices.push({
|
||||
invoice_id: r.invoice_id, invoice_number: r.invoice_number,
|
||||
description: r.description, due_date: r.due_date,
|
||||
amount: parseFloat(r.amount), amount_paid: parseFloat(r.amount_paid),
|
||||
balance_due: bal, aging_bucket: r.aging_bucket,
|
||||
});
|
||||
});
|
||||
|
||||
const units = Array.from(unitMap.values());
|
||||
const summary = {
|
||||
current: units.reduce((s, u) => s + u.current, 0),
|
||||
days_1_30: units.reduce((s, u) => s + u.days_1_30, 0),
|
||||
days_31_60: units.reduce((s, u) => s + u.days_31_60, 0),
|
||||
days_61_90: units.reduce((s, u) => s + u.days_61_90, 0),
|
||||
days_90_plus: units.reduce((s, u) => s + u.days_90_plus, 0),
|
||||
total: units.reduce((s, u) => s + u.total, 0),
|
||||
unit_count: units.length,
|
||||
};
|
||||
|
||||
return { units, summary };
|
||||
}
|
||||
|
||||
async getYearEndSummary(year: number) {
|
||||
// Income statement for the full year
|
||||
const from = `${year}-01-01`;
|
||||
const to = `${year}-12-31`;
|
||||
const incomeStmt = await this.getIncomeStatement(from, to);
|
||||
const balanceSheet = await this.getBalanceSheet(to);
|
||||
|
||||
// 1099 vendor data
|
||||
const vendors1099 = await this.tenant.query(`
|
||||
SELECT v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code,
|
||||
COALESCE(SUM(p.amount), 0) as total_paid
|
||||
FROM vendors v
|
||||
JOIN (
|
||||
SELECT vendor_id, amount FROM invoices
|
||||
WHERE EXTRACT(YEAR FROM invoice_date) = $1
|
||||
AND status IN ('paid', 'partial')
|
||||
) p ON p.vendor_id = v.id
|
||||
WHERE v.is_1099_eligible = true
|
||||
GROUP BY v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code
|
||||
HAVING COALESCE(SUM(p.amount), 0) >= 600
|
||||
ORDER BY v.name
|
||||
`, [year]);
|
||||
|
||||
// Collection stats
|
||||
const collections = await this.tenant.query(`
|
||||
SELECT
|
||||
COUNT(*) as total_invoices,
|
||||
COUNT(*) FILTER (WHERE status = 'paid') as paid_invoices,
|
||||
COUNT(*) FILTER (WHERE status IN ('overdue', 'sent')) as outstanding_invoices,
|
||||
COALESCE(SUM(amount), 0) as total_assessed,
|
||||
COALESCE(SUM(amount_paid), 0) as total_collected,
|
||||
COALESCE(SUM(amount - amount_paid) FILTER (WHERE status NOT IN ('paid', 'void', 'written_off')), 0) as total_outstanding
|
||||
FROM invoices
|
||||
WHERE EXTRACT(YEAR FROM invoice_date) = $1
|
||||
`, [year]);
|
||||
|
||||
// Reserve fund status
|
||||
const reserveStatus = await this.tenant.query(`
|
||||
SELECT name, current_fund_balance, replacement_cost,
|
||||
CASE WHEN replacement_cost > 0
|
||||
THEN ROUND((current_fund_balance / replacement_cost * 100)::numeric, 1)
|
||||
ELSE 0 END as percent_funded
|
||||
FROM reserve_components
|
||||
ORDER BY name
|
||||
`);
|
||||
|
||||
return {
|
||||
year,
|
||||
income_statement: incomeStmt,
|
||||
balance_sheet: balanceSheet,
|
||||
vendors_1099: vendors1099,
|
||||
collections: collections[0] || {},
|
||||
reserve_status: reserveStatus,
|
||||
};
|
||||
}
|
||||
|
||||
async getDashboardKPIs() {
|
||||
// Total cash (all asset accounts with 'Cash' in name)
|
||||
const cash = await this.tenant.query(`
|
||||
|
||||
Reference in New Issue
Block a user