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() {