diff --git a/backend/src/modules/investment-planning/investment-planning.service.ts b/backend/src/modules/investment-planning/investment-planning.service.ts index 5fc01ab..9f7d5ab 100644 --- a/backend/src/modules/investment-planning/investment-planning.service.ts +++ b/backend/src/modules/investment-planning/investment-planning.service.ts @@ -169,9 +169,10 @@ export class InvestmentPlanningService { async getAIRecommendations(): Promise { this.debug('getAIRecommendations', 'Starting AI recommendation flow'); - const [snapshot, cdRates] = await Promise.all([ + const [snapshot, cdRates, monthlyForecast] = await Promise.all([ this.getFinancialSnapshot(), this.getCdRates(), + this.getMonthlyForecast(), ]); this.debug('snapshot_summary', { @@ -182,9 +183,10 @@ export class InvestmentPlanningService { budgets: snapshot.budgets.length, projects: snapshot.projects.length, cd_rates: cdRates.length, + forecast_months: monthlyForecast.datapoints.length, }); - const messages = this.buildPromptMessages(snapshot, cdRates); + const messages = this.buildPromptMessages(snapshot, cdRates, monthlyForecast); const aiResponse = await this.callAI(messages); this.debug('final_response', { @@ -315,9 +317,215 @@ export class InvestmentPlanningService { }; } + /** + * Build a 12-month forward cash flow forecast for the AI. + * Mirrors the logic from ReportsService.getCashFlowForecast() but streamlined + * for AI context. Includes: assessment income schedule (regular + special), + * monthly budget income/expenses, investment maturities, and capital project costs. + */ + private async getMonthlyForecast() { + const now = new Date(); + const currentYear = now.getFullYear(); + const currentMonth = now.getMonth() + 1; + const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt']; + const monthLabels = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + const forecastMonths = 12; + + // ── 1) Current cash positions ── + const [opCashRows, resCashRows, opInvRows, resInvRows] = await Promise.all([ + this.tenant.query(` + SELECT COALESCE(SUM(sub.bal), 0) as total FROM ( + SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal + FROM accounts a + JOIN journal_entry_lines jel ON jel.account_id = a.id + JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false + WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true + GROUP BY a.id + ) sub + `), + this.tenant.query(` + SELECT COALESCE(SUM(sub.bal), 0) as total FROM ( + SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal + FROM accounts a + JOIN journal_entry_lines jel ON jel.account_id = a.id + JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false + WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true + GROUP BY a.id + ) sub + `), + this.tenant.query(` + SELECT COALESCE(SUM(current_value), 0) as total + FROM investment_accounts WHERE fund_type = 'operating' AND is_active = true + `), + this.tenant.query(` + SELECT COALESCE(SUM(current_value), 0) as total + FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true + `), + ]); + + let runOpCash = parseFloat(opCashRows[0]?.total || '0'); + let runResCash = parseFloat(resCashRows[0]?.total || '0'); + let runOpInv = parseFloat(opInvRows[0]?.total || '0'); + let runResInv = parseFloat(resInvRows[0]?.total || '0'); + + // ── 2) Assessment income schedule (regular + special assessments) ── + const assessmentGroups = await this.tenant.query(` + SELECT ag.frequency, ag.regular_assessment, ag.special_assessment, + (SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id AND u.status = 'active') as unit_count + FROM assessment_groups ag WHERE ag.is_active = true + `); + + const getAssessmentIncome = (month: number): { operating: number; reserve: number } => { + let operating = 0; + let reserve = 0; + for (const g of assessmentGroups) { + const units = parseInt(g.unit_count) || 0; + const regular = parseFloat(g.regular_assessment) || 0; + const special = parseFloat(g.special_assessment) || 0; + const freq = g.frequency || 'monthly'; + let applies = false; + if (freq === 'monthly') applies = true; + else if (freq === 'quarterly') applies = [1,4,7,10].includes(month); + else if (freq === 'annual') applies = month === 1; + if (applies) { + operating += regular * units; + reserve += special * units; + } + } + return { operating, reserve }; + }; + + // ── 3) Monthly budget data (income & expenses by month) ── + const budgetsByYearMonth: Record = {}; + for (const yr of [currentYear, currentYear + 1]) { + const budgetRows = await this.tenant.query( + `SELECT b.fund_type, a.account_type, + b.jan, b.feb, b.mar, b.apr, b.may, b.jun, + b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt + FROM budgets b + JOIN accounts a ON a.id = b.account_id + WHERE b.fiscal_year = $1`, [yr], + ); + for (let m = 0; m < 12; m++) { + const key = `${yr}-${m + 1}`; + if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 }; + for (const row of budgetRows) { + const amt = parseFloat(row[monthNames[m]]) || 0; + if (amt === 0) continue; + const isOp = row.fund_type === 'operating'; + if (row.account_type === 'income') { + if (isOp) budgetsByYearMonth[key].opIncome += amt; + else budgetsByYearMonth[key].resIncome += amt; + } else if (row.account_type === 'expense') { + if (isOp) budgetsByYearMonth[key].opExpense += amt; + else budgetsByYearMonth[key].resExpense += amt; + } + } + } + } + + // ── 4) Investment maturities ── + const maturities = await this.tenant.query(` + SELECT fund_type, current_value, maturity_date, interest_rate, purchase_date + FROM investment_accounts + WHERE is_active = true AND maturity_date IS NOT NULL AND maturity_date > CURRENT_DATE + `); + const maturityIndex: Record = {}; + for (const inv of maturities) { + const d = new Date(inv.maturity_date); + const key = `${d.getFullYear()}-${d.getMonth() + 1}`; + if (!maturityIndex[key]) maturityIndex[key] = { operating: 0, reserve: 0 }; + const val = parseFloat(inv.current_value) || 0; + const rate = parseFloat(inv.interest_rate) || 0; + const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date(); + const matDate = new Date(inv.maturity_date); + const daysHeld = Math.max((matDate.getTime() - purchaseDate.getTime()) / 86400000, 1); + const interestEarned = val * (rate / 100) * (daysHeld / 365); + const maturityTotal = val + interestEarned; + if (inv.fund_type === 'operating') maturityIndex[key].operating += maturityTotal; + else maturityIndex[key].reserve += maturityTotal; + } + + // ── 5) Capital project expenses ── + const projectExpenses = await this.tenant.query(` + SELECT estimated_cost, target_year, target_month, fund_source + FROM projects + WHERE is_active = true AND status IN ('planned', 'in_progress') + AND target_year IS NOT NULL AND estimated_cost > 0 + `); + const projectIndex: Record = {}; + for (const p of projectExpenses) { + const yr = parseInt(p.target_year); + const mo = parseInt(p.target_month) || 6; + const key = `${yr}-${mo}`; + if (!projectIndex[key]) projectIndex[key] = { operating: 0, reserve: 0 }; + const cost = parseFloat(p.estimated_cost) || 0; + if (p.fund_source === 'operating') projectIndex[key].operating += cost; + else projectIndex[key].reserve += cost; + } + + // ── 6) Build 12-month forward datapoints ── + const datapoints: any[] = []; + for (let i = 0; i < forecastMonths; i++) { + const year = currentYear + Math.floor((currentMonth - 1 + i) / 12); + const month = ((currentMonth - 1 + i) % 12) + 1; + const key = `${year}-${month}`; + const label = `${monthLabels[month - 1]} ${year}`; + + const assessments = getAssessmentIncome(month); + const budget = budgetsByYearMonth[key] || { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 }; + const maturity = maturityIndex[key] || { operating: 0, reserve: 0 }; + const project = projectIndex[key] || { operating: 0, reserve: 0 }; + + // Use budget income if available, else assessment income + const opIncomeMonth = budget.opIncome > 0 ? budget.opIncome : assessments.operating; + const resIncomeMonth = budget.resIncome > 0 ? budget.resIncome : assessments.reserve; + + // Net change: income - expenses - project costs + maturity returns + runOpCash += opIncomeMonth - budget.opExpense - project.operating + maturity.operating; + runResCash += resIncomeMonth - budget.resExpense - project.reserve + maturity.reserve; + + // Subtract maturing investment values from investment balances + if (maturity.operating > 0) runOpInv = Math.max(0, runOpInv - (maturity.operating * 0.96)); + if (maturity.reserve > 0) runResInv = Math.max(0, runResInv - (maturity.reserve * 0.96)); + + datapoints.push({ + month: label, + operating_cash: Math.round(runOpCash * 100) / 100, + operating_investments: Math.round(runOpInv * 100) / 100, + reserve_cash: Math.round(runResCash * 100) / 100, + reserve_investments: Math.round(runResInv * 100) / 100, + // Include drivers for transparency + op_income: Math.round(opIncomeMonth * 100) / 100, + op_expense: Math.round(budget.opExpense * 100) / 100, + res_income: Math.round(resIncomeMonth * 100) / 100, + res_expense: Math.round(budget.resExpense * 100) / 100, + project_cost_op: Math.round(project.operating * 100) / 100, + project_cost_res: Math.round(project.reserve * 100) / 100, + maturity_op: Math.round(maturity.operating * 100) / 100, + maturity_res: Math.round(maturity.reserve * 100) / 100, + }); + } + + // Build assessment schedule summary for the AI + const assessmentSchedule = assessmentGroups.map((g: any) => ({ + frequency: g.frequency || 'monthly', + regular_per_unit: parseFloat(g.regular_assessment) || 0, + special_per_unit: parseFloat(g.special_assessment) || 0, + units: parseInt(g.unit_count) || 0, + total_regular: (parseFloat(g.regular_assessment) || 0) * (parseInt(g.unit_count) || 0), + total_special: (parseFloat(g.special_assessment) || 0) * (parseInt(g.unit_count) || 0), + })); + + return { + datapoints, + assessment_schedule: assessmentSchedule, + }; + } + // ── Private: AI Prompt Construction ── - private buildPromptMessages(snapshot: any, cdRates: CdRate[]) { + private buildPromptMessages(snapshot: any, cdRates: CdRate[], monthlyForecast: any) { const { summary, investment_accounts, budgets, projects, cash_flow_context } = snapshot; const today = new Date().toISOString().split('T')[0]; @@ -331,6 +539,7 @@ CRITICAL RULES: 5. Operating funds should remain highly liquid (money market or high-yield savings only). 6. Respect the separation between operating funds and reserve funds. Never suggest commingling. 7. Base your recommendations ONLY on the available CD rates and instruments provided. Do not reference rates or banks not in the provided data. +8. CRITICAL: Use the 12-MONTH CASH FLOW FORECAST to understand future liquidity. The forecast includes projected income (regular assessments AND special assessments collected from homeowners), budgeted expenses, investment maturities, and capital project costs. Do NOT flag liquidity shortfalls if the forecast shows sufficient income arriving before the expense is due. RESPONSE FORMAT: Respond with ONLY valid JSON (no markdown, no code fences) matching this exact schema: @@ -387,6 +596,27 @@ IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority item `- ${r.bank_name} | APY: ${parseFloat(String(r.apy)).toFixed(2)}% | Term: ${r.term} | Min Deposit: ${r.min_deposit ? '$' + parseFloat(String(r.min_deposit)).toLocaleString() : 'N/A'}`, ).join('\n'); + // Format assessment schedule showing regular + special + const assessmentScheduleLines = (monthlyForecast.assessment_schedule || []).length === 0 + ? 'No assessment schedule available.' + : monthlyForecast.assessment_schedule.map((a: any) => + `- ${a.frequency} collection | ${a.units} units | Regular: $${a.regular_per_unit.toFixed(2)}/unit ($${a.total_regular.toFixed(2)} total) → Operating | Special: $${a.special_per_unit.toFixed(2)}/unit ($${a.total_special.toFixed(2)} total) → Reserve`, + ).join('\n'); + + // Format 12-month forecast table + const forecastLines = (monthlyForecast.datapoints || []).map((dp: any) => { + const drivers: string[] = []; + if (dp.op_income > 0) drivers.push(`OpInc:$${dp.op_income.toFixed(0)}`); + if (dp.op_expense > 0) drivers.push(`OpExp:$${dp.op_expense.toFixed(0)}`); + if (dp.res_income > 0) drivers.push(`ResInc:$${dp.res_income.toFixed(0)}`); + if (dp.res_expense > 0) drivers.push(`ResExp:$${dp.res_expense.toFixed(0)}`); + if (dp.project_cost_res > 0) drivers.push(`ResProjCost:$${dp.project_cost_res.toFixed(0)}`); + if (dp.project_cost_op > 0) drivers.push(`OpProjCost:$${dp.project_cost_op.toFixed(0)}`); + if (dp.maturity_op > 0) drivers.push(`OpMaturity:$${dp.maturity_op.toFixed(0)}`); + if (dp.maturity_res > 0) drivers.push(`ResMaturity:$${dp.maturity_res.toFixed(0)}`); + return `- ${dp.month} | OpCash: $${dp.operating_cash.toFixed(0)} | ResCash: $${dp.reserve_cash.toFixed(0)} | OpInv: $${dp.operating_investments.toFixed(0)} | ResInv: $${dp.reserve_investments.toFixed(0)} | Drivers: ${drivers.join(', ') || 'none'}`; + }).join('\n'); + const userPrompt = `Analyze this HOA's financial position and provide investment recommendations. TODAY'S DATE: ${today} @@ -403,6 +633,10 @@ Grand Total: $${summary.total_all.toFixed(2)} === CURRENT INVESTMENTS === ${investmentsList} +=== ASSESSMENT INCOME SCHEDULE === +${assessmentScheduleLines} +Note: "Regular" assessments fund Operating. "Special" assessments fund Reserve. Both are collected from homeowners per the frequency above. + === ANNUAL BUDGET (${new Date().getFullYear()}) === ${budgetLines} @@ -410,18 +644,22 @@ ${budgetLines} ${budgetSummaryLines} === MONTHLY ASSESSMENT INCOME === -Recurring monthly assessment income: $${cash_flow_context.monthly_assessment_income.toFixed(2)}/month +Recurring monthly regular assessment income: $${cash_flow_context.monthly_assessment_income.toFixed(2)}/month (operating fund) === UPCOMING CAPITAL PROJECTS === ${projectLines} +=== 12-MONTH CASH FLOW FORECAST (Projected) === +This forecast shows month-by-month projected balances factoring in ALL income (regular assessments, special assessments, budgeted income), ALL expenses (budgeted expenses, capital project costs), and investment maturities. +${forecastLines} + === AVAILABLE CD RATES (Market Data) === ${cdRateLines} -Based on this complete financial picture, provide your investment recommendations. Consider: +Based on this complete financial picture INCLUDING the 12-month cash flow forecast, provide your investment recommendations. Consider: 1. Is there excess cash that could earn better returns in CDs? 2. Are any current investments maturing soon that need reinvestment planning? -3. Is the liquidity position adequate for upcoming expenses and projects? +3. Is the liquidity position adequate for upcoming expenses and projects? USE THE FORECAST to check — if income (including special assessments) arrives before expenses are due, the position may be adequate even if current cash seems low. 4. Would a CD ladder strategy improve the yield while maintaining access to funds? 5. Are operating and reserve funds properly separated in the investment strategy?`;