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