Fix reserve fund balance, dynamic project funding, year-end report, and unit form

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 08:22:31 -05:00
parent 739ccaeed4
commit 112578672e
3 changed files with 112 additions and 41 deletions

View File

@@ -6,8 +6,42 @@ export class ProjectsService {
constructor(private tenant: TenantService) {} constructor(private tenant: TenantService) {}
async findAll() { async findAll() {
// Return all active projects ordered by name // Return all active projects with dynamically computed reserve fund balance
return this.tenant.query('SELECT * FROM projects WHERE is_active = true ORDER BY name'); // 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) { async findOne(id: string) {
@@ -18,10 +52,39 @@ export class ProjectsService {
async findForPlanning() { async findForPlanning() {
// Only return projects that have target_year set (for the Capital Planning kanban) // 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(` return this.tenant.query(`
SELECT * FROM projects WITH reserve_balance AS (
WHERE is_active = true AND target_year IS NOT NULL SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
ORDER BY target_year, target_month NULLS LAST, priority 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
`); `);
} }

View File

@@ -373,14 +373,40 @@ export class ReportsService {
WHERE EXTRACT(YEAR FROM invoice_date) = $1 WHERE EXTRACT(YEAR FROM invoice_date) = $1
`, [year]); `, [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(` const reserveStatus = await this.tenant.query(`
SELECT name, current_fund_balance, replacement_cost, WITH reserve_balance AS (
CASE WHEN replacement_cost > 0 SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
THEN ROUND((current_fund_balance / replacement_cost * 100)::numeric, 1) SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
ELSE 0 END as percent_funded FROM accounts a
FROM reserve_components LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
ORDER BY name 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 { return {
@@ -414,23 +440,17 @@ export class ReportsService {
FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off') FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off')
`); `);
// Reserve fund balance: computed from journal entries on reserve fund_type accounts // Reserve fund balance: use the reserve equity accounts (fund balance accounts like 3100)
// credit - debit for equity/liability/income accounts (reserve equity + reserve income - reserve expenses) // 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(` const reserves = await this.tenant.query(`
SELECT COALESCE(SUM(sub.balance), 0) as total FROM ( SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
SELECT SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
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
FROM accounts a FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id 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 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 WHERE a.fund_type = 'reserve' AND a.account_type = 'equity' AND a.is_active = true
GROUP BY a.id, a.account_type GROUP BY a.id
) sub ) sub
`); `);

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Title, Table, Group, Button, Stack, TextInput, Modal, 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'; } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
@@ -56,7 +56,7 @@ export function UnitsPage() {
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
unit_number: '', address_line1: '', city: '', state: '', zip_code: '', 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, assessment_group_id: '' as string | null,
}, },
validate: { validate: {
@@ -96,7 +96,7 @@ export function UnitsPage() {
form.setValues({ form.setValues({
unit_number: u.unit_number, address_line1: u.address_line1 || '', unit_number: u.unit_number, address_line1: u.address_line1 || '',
city: '', state: '', zip_code: '', owner_name: u.owner_name || '', 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 || '', assessment_group_id: u.assessment_group_id || '',
}); });
open(); open();
@@ -108,21 +108,10 @@ export function UnitsPage() {
// Pre-populate with default group // Pre-populate with default group
if (defaultGroup) { if (defaultGroup) {
form.setFieldValue('assessment_group_id', defaultGroup.id); form.setFieldValue('assessment_group_id', defaultGroup.id);
form.setFieldValue('monthly_assessment', parseFloat(defaultGroup.regular_assessment || '0'));
} }
open(); 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) => const filtered = units.filter((u) =>
!search || u.unit_number.toLowerCase().includes(search.toLowerCase()) || !search || u.unit_number.toLowerCase().includes(search.toLowerCase()) ||
(u.owner_name || '').toLowerCase().includes(search.toLowerCase()) (u.owner_name || '').toLowerCase().includes(search.toLowerCase())
@@ -213,17 +202,16 @@ export function UnitsPage() {
<TextInput label="Owner Phone" {...form.getInputProps('owner_phone')} /> <TextInput label="Owner Phone" {...form.getInputProps('owner_phone')} />
<Select <Select
label="Assessment Group" label="Assessment Group"
description="Required — all units must belong to an assessment group" description="Required — the unit's monthly assessment is inherited from the selected group"
required required
data={assessmentGroups.map(g => ({ data={assessmentGroups.map(g => ({
value: g.id, value: g.id,
label: `${g.name}${g.is_default ? ' (Default)' : ''}$${parseFloat(g.regular_assessment || '0').toFixed(2)}/${g.frequency || 'mo'}`, label: `${g.name}${g.is_default ? ' (Default)' : ''}$${parseFloat(g.regular_assessment || '0').toFixed(2)}/${g.frequency || 'mo'}`,
}))} }))}
value={form.values.assessment_group_id} value={form.values.assessment_group_id}
onChange={handleGroupChange} onChange={(v) => form.setFieldValue('assessment_group_id', v)}
error={form.errors.assessment_group_id} error={form.errors.assessment_group_id}
/> />
<NumberInput label="Monthly Assessment" prefix="$" decimalScale={2} min={0} {...form.getInputProps('monthly_assessment')} />
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button> <Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
</Stack> </Stack>
</form> </form>