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:
2026-02-18 09:09:50 -05:00
parent 243770cea5
commit e0272f9d8a
17 changed files with 1200 additions and 18 deletions

View File

@@ -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();

View File

@@ -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();

View File

@@ -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(`