diff --git a/backend/src/database/tenant-schema.service.ts b/backend/src/database/tenant-schema.service.ts index ac4d4f4..2291645 100644 --- a/backend/src/database/tenant-schema.service.ts +++ b/backend/src/database/tenant-schema.service.ts @@ -311,6 +311,7 @@ export class TenantSchemaService { account_id UUID REFERENCES "${s}".accounts(id), notes TEXT, is_active BOOLEAN DEFAULT TRUE, + is_funding_locked BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() )`, diff --git a/backend/src/modules/monthly-actuals/monthly-actuals.service.ts b/backend/src/modules/monthly-actuals/monthly-actuals.service.ts index 7179444..00baeb9 100644 --- a/backend/src/modules/monthly-actuals/monthly-actuals.service.ts +++ b/backend/src/modules/monthly-actuals/monthly-actuals.service.ts @@ -28,8 +28,10 @@ export class MonthlyActualsService { a.fund_type, COALESCE(b.${budgetCol}, 0) as budget_amount, COALESCE(SUM( - CASE WHEN a.account_type IN ('expense', 'asset') THEN jel.debit - jel.credit - ELSE jel.credit - jel.debit END + CASE WHEN je.id IS NOT NULL THEN + CASE WHEN a.account_type IN ('expense', 'asset') THEN jel.debit - jel.credit + ELSE jel.credit - jel.debit END + END ), 0) as actual_amount FROM accounts a LEFT JOIN budgets b ON b.account_id = a.id AND b.fiscal_year = $1 diff --git a/backend/src/modules/projects/projects.service.ts b/backend/src/modules/projects/projects.service.ts index f90fb61..52783c7 100644 --- a/backend/src/modules/projects/projects.service.ts +++ b/backend/src/modules/projects/projects.service.ts @@ -6,50 +6,10 @@ export class ProjectsService { constructor(private tenant: TenantService) {} async findAll() { - // 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_equity 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_investments AS ( - SELECT COALESCE(SUM(current_value), 0) as total - FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true - ), - reserve_balance AS ( - SELECT re.total + ri.total as total - FROM reserve_equity re, reserve_investments ri - ), - 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 - `); + const projects = await this.tenant.query( + 'SELECT * FROM projects WHERE is_active = true ORDER BY name', + ); + return this.computeFunding(projects); } async findOne(id: string) { @@ -59,49 +19,103 @@ 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(` - WITH reserve_equity 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_investments AS ( - SELECT COALESCE(SUM(current_value), 0) as total - FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true - ), - reserve_balance AS ( - SELECT re.total + ri.total as total - FROM reserve_equity re, reserve_investments ri - ), - 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 + const projects = await 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', + ); + return this.computeFunding(projects); + } + + /** + * Priority-based funding allocation for reserve projects. + * + * 1. Projects with is_funding_locked = true keep their stored funded_percentage + * and current_fund_balance values as-is. + * 2. Remaining reserve balance (after deducting locked amounts) is allocated + * sequentially to unlocked reserve projects sorted by target_year, target_month, + * priority — near-term items get fully funded first. + */ + private async computeFunding(projects: any[]): Promise { + // Get total reserve balance (equity + investments) + const [balanceRow] = await this.tenant.query(` + SELECT + COALESCE(( + SELECT SUM(sub.balance) 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 + ), 0) + + COALESCE(( + SELECT SUM(current_value) FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true + ), 0) as total `); + const totalReserve = parseFloat(balanceRow?.total || '0'); + + // Separate locked and unlocked reserve projects + const lockedReserve: any[] = []; + const unlockedReserve: any[] = []; + + for (const p of projects) { + if (p.fund_source === 'reserve' && !p.is_funding_locked) { + unlockedReserve.push(p); + } else if (p.fund_source === 'reserve' && p.is_funding_locked) { + lockedReserve.push(p); + } + } + + // Deduct locked amounts from available reserve balance + const lockedTotal = lockedReserve.reduce((sum, p) => sum + parseFloat(p.current_fund_balance || '0'), 0); + let remaining = Math.max(totalReserve - lockedTotal, 0); + + // Sort unlocked by target_year, target_month, priority for sequential allocation + unlockedReserve.sort((a, b) => { + const ya = a.target_year || 9999; + const yb = b.target_year || 9999; + if (ya !== yb) return ya - yb; + const ma = a.target_month || 13; + const mb = b.target_month || 13; + if (ma !== mb) return ma - mb; + return (a.priority || 3) - (b.priority || 3); + }); + + // Allocate remaining balance sequentially: near-term items first + const fundingMap = new Map(); + + // Locked projects keep their stored values + for (const p of lockedReserve) { + fundingMap.set(p.id, { + funded_percentage: parseFloat(p.funded_percentage || '0'), + current_fund_balance: parseFloat(p.current_fund_balance || '0'), + }); + } + + // Unlocked projects get sequential allocation + for (const p of unlockedReserve) { + const cost = parseFloat(p.estimated_cost || '0'); + if (cost <= 0) { + fundingMap.set(p.id, { funded_percentage: 0, current_fund_balance: 0 }); + continue; + } + const allocated = Math.min(cost, remaining); + remaining -= allocated; + const pct = Math.min((allocated / cost) * 100, 100); + fundingMap.set(p.id, { + funded_percentage: Math.round(pct * 100) / 100, + current_fund_balance: Math.round(allocated * 100) / 100, + }); + } + + // Apply computed funding to all projects + return projects.map((p) => { + const funding = fundingMap.get(p.id); + if (funding) { + return { ...p, funded_percentage: funding.funded_percentage, current_fund_balance: funding.current_fund_balance }; + } + return p; // non-reserve projects keep stored values + }); } async create(dto: any) { @@ -116,8 +130,8 @@ export class ProjectsService { current_fund_balance, annual_contribution, fund_source, funded_percentage, useful_life_years, remaining_life_years, condition_rating, last_replacement_date, next_replacement_date, planned_date, - target_year, target_month, status, priority, account_id, notes - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21) + target_year, target_month, status, priority, account_id, notes, is_funding_locked + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22) RETURNING *`, [ dto.name, dto.description || null, dto.category || null, @@ -131,6 +145,7 @@ export class ProjectsService { dto.target_year || null, dto.target_month || null, dto.status || 'planned', dto.priority || 3, dto.account_id || null, dto.notes || null, + dto.is_funding_locked || false, ], ); return rows[0]; @@ -154,7 +169,7 @@ export class ProjectsService { ['planned_date', 'planned_date'], ['target_year', 'target_year'], ['target_month', 'target_month'], ['status', 'status'], ['priority', 'priority'], - ['account_id', 'account_id'], ['notes', 'notes'], ['is_active', 'is_active'], + ['account_id', 'account_id'], ['notes', 'notes'], ['is_active', 'is_active'], ['is_funding_locked', 'is_funding_locked'], ]; for (const [dtoKey, dbCol] of fields) { diff --git a/backend/src/modules/units/units.service.ts b/backend/src/modules/units/units.service.ts index 842d9df..04e34aa 100644 --- a/backend/src/modules/units/units.service.ts +++ b/backend/src/modules/units/units.service.ts @@ -10,6 +10,7 @@ export class UnitsService { SELECT u.*, ag.name as assessment_group_name, ag.regular_assessment as group_regular_assessment, + ag.special_assessment as group_special_assessment, ag.frequency as group_frequency, COALESCE(( SELECT SUM(i.amount - i.amount_paid) diff --git a/frontend/src/pages/accounts/AccountsPage.tsx b/frontend/src/pages/accounts/AccountsPage.tsx index c2a8321..5f6a0e3 100644 --- a/frontend/src/pages/accounts/AccountsPage.tsx +++ b/frontend/src/pages/accounts/AccountsPage.tsx @@ -37,7 +37,6 @@ import { IconStarFilled, IconAdjustments, IconInfoCircle, - IconCurrencyDollar, } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; @@ -125,7 +124,6 @@ export function AccountsPage() { const [adjustingAccount, setAdjustingAccount] = useState(null); const [search, setSearch] = useState(''); const [filterType, setFilterType] = useState(null); - const [filterFund, setFilterFund] = useState(null); const [showArchived, setShowArchived] = useState(false); const queryClient = useQueryClient(); @@ -283,63 +281,6 @@ export function AccountsPage() { }, }); - // ── Opening balance state + mutations ── - const [obOpened, { open: openOB, close: closeOB }] = useDisclosure(false); - const [bulkOBOpened, { open: openBulkOB, close: closeBulkOB }] = useDisclosure(false); - const [obAccount, setOBAccount] = useState(null); - const [bulkOBDate, setBulkOBDate] = useState(new Date()); - const [bulkOBEntries, setBulkOBEntries] = useState>({}); - - const obForm = useForm({ - initialValues: { - targetBalance: 0, - asOfDate: new Date() as Date | null, - memo: '', - }, - validate: { - targetBalance: (v) => (v !== undefined && v !== null ? null : 'Required'), - asOfDate: (v) => (v ? null : 'Required'), - }, - }); - - const openingBalanceMutation = useMutation({ - mutationFn: (values: { accountId: string; targetBalance: number; asOfDate: string; memo: string }) => - api.post(`/accounts/${values.accountId}/opening-balance`, { - targetBalance: values.targetBalance, - asOfDate: values.asOfDate, - memo: values.memo, - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['accounts'] }); - queryClient.invalidateQueries({ queryKey: ['trial-balance'] }); - notifications.show({ message: 'Opening balance set successfully', color: 'green' }); - closeOB(); - setOBAccount(null); - obForm.reset(); - }, - onError: (err: any) => { - notifications.show({ message: err.response?.data?.message || 'Error setting opening balance', color: 'red' }); - }, - }); - - const bulkOBMutation = useMutation({ - mutationFn: (dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] }) => - api.post('/accounts/bulk-opening-balances', dto), - onSuccess: (res: any) => { - queryClient.invalidateQueries({ queryKey: ['accounts'] }); - queryClient.invalidateQueries({ queryKey: ['trial-balance'] }); - const d = res.data; - let msg = `Opening balances: ${d.processed} updated, ${d.skipped} unchanged`; - if (d.errors?.length) msg += `. ${d.errors.length} error(s)`; - notifications.show({ message: msg, color: d.errors?.length ? 'yellow' : 'green', autoClose: 10000 }); - closeBulkOB(); - setBulkOBEntries({}); - }, - onError: (err: any) => { - notifications.show({ message: err.response?.data?.message || 'Error setting opening balances', color: 'red' }); - }, - }); - // ── Investment edit form ── const invForm = useForm({ initialValues: { @@ -449,51 +390,6 @@ export function AccountsPage() { }); }; - // ── Opening balance handlers ── - const handleSetOpeningBalance = (account: Account) => { - setOBAccount(account); - const tbEntry = trialBalance.find((tb) => tb.id === account.id); - obForm.setValues({ - targetBalance: parseFloat(tbEntry?.balance || account.balance || '0'), - asOfDate: new Date(), - memo: '', - }); - openOB(); - }; - - const handleOBSubmit = (values: { targetBalance: number; asOfDate: Date | null; memo: string }) => { - if (!obAccount || !values.asOfDate) return; - openingBalanceMutation.mutate({ - accountId: obAccount.id, - targetBalance: values.targetBalance, - asOfDate: values.asOfDate.toISOString().split('T')[0], - memo: values.memo, - }); - }; - - const handleOpenBulkOB = () => { - const entries: Record = {}; - for (const a of accounts.filter((acc) => ['asset', 'liability'].includes(acc.account_type) && acc.is_active && !acc.is_system)) { - const tb = trialBalance.find((t) => t.id === a.id); - entries[a.id] = parseFloat(tb?.balance || a.balance || '0'); - } - setBulkOBEntries(entries); - setBulkOBDate(new Date()); - openBulkOB(); - }; - - const handleBulkOBSubmit = () => { - if (!bulkOBDate) return; - const entries = Object.entries(bulkOBEntries).map(([accountId, targetBalance]) => ({ - accountId, - targetBalance, - })); - bulkOBMutation.mutate({ - asOfDate: bulkOBDate.toISOString().split('T')[0], - entries, - }); - }; - // ── Filtering ── // Only show asset and liability accounts — these represent real cash positions. // Income, expense, and equity accounts are internal bookkeeping managed via @@ -504,7 +400,6 @@ export function AccountsPage() { if (!VISIBLE_ACCOUNT_TYPES.includes(a.account_type)) return false; if (search && !a.name.toLowerCase().includes(search.toLowerCase()) && !String(a.account_number).includes(search)) return false; if (filterType && a.account_type !== filterType) return false; - if (filterFund && a.fund_type !== filterFund) return false; return true; }); @@ -548,12 +443,6 @@ export function AccountsPage() { return sum + (bal * (rate / 100) / 12); }, 0); - // ── Opening balance modal: current balance ── - const obCurrentBalance = obAccount - ? parseFloat(trialBalance.find((tb) => tb.id === obAccount.id)?.balance || obAccount.balance || '0') - : 0; - const obAdjustmentAmount = (obForm.values.targetBalance || 0) - obCurrentBalance; - // ── Adjust modal: current balance from trial balance ── const adjustCurrentBalance = adjustingAccount ? parseFloat( @@ -583,9 +472,6 @@ export function AccountsPage() { onChange={(e) => setShowArchived(e.currentTarget.checked)} size="sm" /> - @@ -640,14 +526,6 @@ export function AccountsPage() { onChange={setFilterType} w={150} /> -