From 112578672e1b6e8fb5431220270c9e9e6e0de393 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Fri, 20 Feb 2026 08:22:31 -0500 Subject: [PATCH] Fix reserve fund balance, dynamic project funding, year-end report, and unit form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dashboard reserve fund KPI now uses reserve equity accounts (fund balance position) instead of asset accounts, correctly showing the total reserve fund balance regardless of how users categorize their reserve accounts - Projects findAll() and findForPlanning() dynamically compute funded_percentage and current_fund_balance from reserve equity account balances via CTE, distributing the total reserve balance proportionally across projects - Year-end summary reserve status now queries unified projects table instead of deprecated reserve_components table - Remove standalone Monthly Assessment field from Units form — assessment amount is now inherited from the selected assessment group Co-Authored-By: Claude Opus 4.6 --- .../src/modules/projects/projects.service.ts | 73 +++++++++++++++++-- .../src/modules/reports/reports.service.ts | 58 ++++++++++----- frontend/src/pages/units/UnitsPage.tsx | 22 ++---- 3 files changed, 112 insertions(+), 41 deletions(-) diff --git a/backend/src/modules/projects/projects.service.ts b/backend/src/modules/projects/projects.service.ts index 1abb763..c74d1ee 100644 --- a/backend/src/modules/projects/projects.service.ts +++ b/backend/src/modules/projects/projects.service.ts @@ -6,8 +6,42 @@ export class ProjectsService { constructor(private tenant: TenantService) {} async findAll() { - // Return all active projects ordered by name - return this.tenant.query('SELECT * FROM projects WHERE is_active = true ORDER BY name'); + // Return all active projects with dynamically computed reserve fund balance + // The total reserve fund balance (from reserve EQUITY accounts = fund balance) is distributed + // proportionally across reserve projects based on their estimated_cost + return this.tenant.query(` + WITH reserve_balance AS ( + SELECT COALESCE(SUM(sub.balance), 0) as total FROM ( + SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 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.fund_type = 'reserve' AND a.account_type = 'equity' AND a.is_active = true + GROUP BY a.id + ) sub + ), + reserve_total_cost AS ( + SELECT COALESCE(SUM(estimated_cost), 0) as total + FROM projects + WHERE is_active = true AND fund_source = 'reserve' AND estimated_cost > 0 + ) + SELECT p.*, + CASE + WHEN p.fund_source = 'reserve' AND p.estimated_cost > 0 AND rtc.total > 0 THEN + LEAST(ROUND((rb.total * (p.estimated_cost / rtc.total)) / p.estimated_cost * 100, 2), 100) + ELSE p.funded_percentage + END as funded_percentage, + CASE + WHEN p.fund_source = 'reserve' AND rtc.total > 0 THEN + ROUND(rb.total * (p.estimated_cost / rtc.total), 2) + ELSE p.current_fund_balance + END as current_fund_balance + FROM projects p + CROSS JOIN reserve_balance rb + CROSS JOIN reserve_total_cost rtc + WHERE p.is_active = true + ORDER BY p.name + `); } async findOne(id: string) { @@ -18,10 +52,39 @@ export class ProjectsService { async findForPlanning() { // Only return projects that have target_year set (for the Capital Planning kanban) + // Uses the same dynamic reserve fund balance computation as findAll() return this.tenant.query(` - SELECT * FROM projects - WHERE is_active = true AND target_year IS NOT NULL - ORDER BY target_year, target_month NULLS LAST, priority + WITH reserve_balance AS ( + SELECT COALESCE(SUM(sub.balance), 0) as total FROM ( + SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 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.fund_type = 'reserve' AND a.account_type = 'equity' AND a.is_active = true + GROUP BY a.id + ) sub + ), + reserve_total_cost AS ( + SELECT COALESCE(SUM(estimated_cost), 0) as total + FROM projects + WHERE is_active = true AND fund_source = 'reserve' AND estimated_cost > 0 + ) + SELECT p.*, + CASE + WHEN p.fund_source = 'reserve' AND p.estimated_cost > 0 AND rtc.total > 0 THEN + LEAST(ROUND((rb.total * (p.estimated_cost / rtc.total)) / p.estimated_cost * 100, 2), 100) + ELSE p.funded_percentage + END as funded_percentage, + CASE + WHEN p.fund_source = 'reserve' AND rtc.total > 0 THEN + ROUND(rb.total * (p.estimated_cost / rtc.total), 2) + ELSE p.current_fund_balance + END as current_fund_balance + FROM projects p + CROSS JOIN reserve_balance rb + CROSS JOIN reserve_total_cost rtc + WHERE p.is_active = true AND p.target_year IS NOT NULL + ORDER BY p.target_year, p.target_month NULLS LAST, p.priority `); } diff --git a/backend/src/modules/reports/reports.service.ts b/backend/src/modules/reports/reports.service.ts index e0d921d..c04d672 100644 --- a/backend/src/modules/reports/reports.service.ts +++ b/backend/src/modules/reports/reports.service.ts @@ -373,14 +373,40 @@ export class ReportsService { WHERE EXTRACT(YEAR FROM invoice_date) = $1 `, [year]); - // Reserve fund status + // Reserve fund status from unified projects table + // Uses dynamic reserve balance computation (reserve equity accounts = fund balance) 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 + WITH reserve_balance AS ( + SELECT COALESCE(SUM(sub.balance), 0) as total FROM ( + SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 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.fund_type = 'reserve' AND a.account_type = 'equity' AND a.is_active = true + GROUP BY a.id + ) sub + ), + reserve_total_cost AS ( + SELECT COALESCE(SUM(estimated_cost), 0) as total + FROM projects + WHERE is_active = true AND fund_source = 'reserve' AND estimated_cost > 0 + ) + SELECT p.name, + CASE + WHEN rtc.total > 0 THEN ROUND(rb.total * (p.estimated_cost / rtc.total), 2) + ELSE 0 + END as current_fund_balance, + p.estimated_cost as replacement_cost, + CASE + WHEN p.estimated_cost > 0 AND rtc.total > 0 THEN + LEAST(ROUND((rb.total * (p.estimated_cost / rtc.total)) / p.estimated_cost * 100, 1), 100) + ELSE 0 + END as percent_funded + FROM projects p + CROSS JOIN reserve_balance rb + CROSS JOIN reserve_total_cost rtc + WHERE p.is_active = true AND p.fund_source = 'reserve' + ORDER BY p.name `); return { @@ -414,23 +440,17 @@ export class ReportsService { FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off') `); - // Reserve fund balance: computed from journal entries on reserve fund_type accounts - // credit - debit for equity/liability/income accounts (reserve equity + reserve income - reserve expenses) + // Reserve fund balance: use the reserve equity accounts (fund balance accounts like 3100) + // The equity accounts track the total reserve fund position via double-entry bookkeeping + // This is the standard HOA approach — every reserve contribution/expenditure flows through equity const reserves = await this.tenant.query(` SELECT COALESCE(SUM(sub.balance), 0) as total FROM ( - SELECT - CASE - WHEN a.account_type IN ('asset') - THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) - WHEN a.account_type IN ('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 + SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 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.fund_type = 'reserve' AND a.account_type IN ('asset') AND a.is_active = true - GROUP BY a.id, a.account_type + WHERE a.fund_type = 'reserve' AND a.account_type = 'equity' AND a.is_active = true + GROUP BY a.id ) sub `); diff --git a/frontend/src/pages/units/UnitsPage.tsx b/frontend/src/pages/units/UnitsPage.tsx index d38c0c2..36f7a3f 100644 --- a/frontend/src/pages/units/UnitsPage.tsx +++ b/frontend/src/pages/units/UnitsPage.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Title, Table, Group, Button, Stack, TextInput, Modal, - NumberInput, Select, Badge, ActionIcon, Text, Loader, Center, Tooltip, Alert, + Select, Badge, ActionIcon, Text, Loader, Center, Tooltip, Alert, } from '@mantine/core'; import { useForm } from '@mantine/form'; import { useDisclosure } from '@mantine/hooks'; @@ -56,7 +56,7 @@ export function UnitsPage() { const form = useForm({ initialValues: { unit_number: '', address_line1: '', city: '', state: '', zip_code: '', - owner_name: '', owner_email: '', owner_phone: '', monthly_assessment: 0, + owner_name: '', owner_email: '', owner_phone: '', assessment_group_id: '' as string | null, }, validate: { @@ -96,7 +96,7 @@ export function UnitsPage() { form.setValues({ unit_number: u.unit_number, address_line1: u.address_line1 || '', city: '', state: '', zip_code: '', owner_name: u.owner_name || '', - owner_email: u.owner_email || '', owner_phone: '', monthly_assessment: parseFloat(u.monthly_assessment || '0'), + owner_email: u.owner_email || '', owner_phone: '', assessment_group_id: u.assessment_group_id || '', }); open(); @@ -108,21 +108,10 @@ export function UnitsPage() { // Pre-populate with default group if (defaultGroup) { form.setFieldValue('assessment_group_id', defaultGroup.id); - form.setFieldValue('monthly_assessment', parseFloat(defaultGroup.regular_assessment || '0')); } open(); }; - const handleGroupChange = (groupId: string | null) => { - form.setFieldValue('assessment_group_id', groupId); - if (groupId) { - const group = assessmentGroups.find(g => g.id === groupId); - if (group) { - form.setFieldValue('monthly_assessment', parseFloat(group.regular_assessment || '0')); - } - } - }; - const filtered = units.filter((u) => !search || u.unit_number.toLowerCase().includes(search.toLowerCase()) || (u.owner_name || '').toLowerCase().includes(search.toLowerCase()) @@ -213,17 +202,16 @@ export function UnitsPage() {