Phase 5: AI investment planning - CD rate fetcher and AI recommendation engine
- Add shared.cd_rates table for cross-tenant market data (CD rates from Bankrate) - Create standalone Puppeteer scraper script (scripts/fetch-cd-rates.ts) for cron-based rate fetching - Add investment-planning backend module with 3 endpoints: snapshot, cd-rates, recommendations - AI service gathers tenant financial data (accounts, investments, budgets, projects, cash flow) and calls OpenAI-compatible API (NVIDIA endpoint) for structured investment recommendations - Create InvestmentPlanningPage with summary cards, current investments table, market CD rates table, and AI recommendation accordion - Add Investment Planning to sidebar under Planning menu - Configure AI_API_URL, AI_API_KEY, AI_MODEL environment variables Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,482 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
// ── Interfaces ──
|
||||
|
||||
interface AccountBalance {
|
||||
id: string;
|
||||
account_number: string;
|
||||
name: string;
|
||||
account_type: string;
|
||||
fund_type: string;
|
||||
interest_rate: string | null;
|
||||
balance: string;
|
||||
}
|
||||
|
||||
interface InvestmentAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
institution: string;
|
||||
investment_type: string;
|
||||
fund_type: string;
|
||||
principal: string;
|
||||
interest_rate: string;
|
||||
maturity_date: string | null;
|
||||
purchase_date: string | null;
|
||||
current_value: string;
|
||||
}
|
||||
|
||||
interface CdRate {
|
||||
bank_name: string;
|
||||
apy: string;
|
||||
min_deposit: string | null;
|
||||
term: string;
|
||||
term_months: number | null;
|
||||
fetched_at: string;
|
||||
}
|
||||
|
||||
interface Recommendation {
|
||||
type: 'cd_ladder' | 'new_investment' | 'reallocation' | 'maturity_action' | 'liquidity_warning' | 'general';
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
title: string;
|
||||
summary: string;
|
||||
details: string;
|
||||
fund_type: 'operating' | 'reserve' | 'both';
|
||||
suggested_amount?: number;
|
||||
suggested_term?: string;
|
||||
suggested_rate?: number;
|
||||
bank_name?: string;
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
interface AIResponse {
|
||||
recommendations: Recommendation[];
|
||||
overall_assessment: string;
|
||||
risk_notes: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InvestmentPlanningService {
|
||||
private readonly logger = new Logger(InvestmentPlanningService.name);
|
||||
|
||||
constructor(
|
||||
private tenant: TenantService,
|
||||
private configService: ConfigService,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
// ── Public API Methods ──
|
||||
|
||||
/**
|
||||
* Build a comprehensive financial snapshot for the investment planning page.
|
||||
* All financial data is tenant-scoped via TenantService.
|
||||
*/
|
||||
async getFinancialSnapshot() {
|
||||
const [
|
||||
accountBalances,
|
||||
investmentAccounts,
|
||||
budgets,
|
||||
projects,
|
||||
cashFlowContext,
|
||||
] = await Promise.all([
|
||||
this.getAccountBalances(),
|
||||
this.getInvestmentAccounts(),
|
||||
this.getBudgets(),
|
||||
this.getProjects(),
|
||||
this.getCashFlowContext(),
|
||||
]);
|
||||
|
||||
// Compute summary totals
|
||||
const operatingCash = accountBalances
|
||||
.filter((a) => a.fund_type === 'operating' && a.account_type === 'asset')
|
||||
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
|
||||
|
||||
const reserveCash = accountBalances
|
||||
.filter((a) => a.fund_type === 'reserve' && a.account_type === 'asset')
|
||||
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
|
||||
|
||||
const operatingInvestments = investmentAccounts
|
||||
.filter((i) => i.fund_type === 'operating')
|
||||
.reduce((sum, i) => sum + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||
|
||||
const reserveInvestments = investmentAccounts
|
||||
.filter((i) => i.fund_type === 'reserve')
|
||||
.reduce((sum, i) => sum + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||
|
||||
return {
|
||||
summary: {
|
||||
operating_cash: operatingCash,
|
||||
reserve_cash: reserveCash,
|
||||
operating_investments: operatingInvestments,
|
||||
reserve_investments: reserveInvestments,
|
||||
total_operating: operatingCash + operatingInvestments,
|
||||
total_reserve: reserveCash + reserveInvestments,
|
||||
total_all: operatingCash + reserveCash + operatingInvestments + reserveInvestments,
|
||||
},
|
||||
account_balances: accountBalances,
|
||||
investment_accounts: investmentAccounts,
|
||||
budgets,
|
||||
projects,
|
||||
cash_flow_context: cashFlowContext,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch latest CD rates from the shared schema (cross-tenant market data).
|
||||
* Uses DataSource directly since this queries the shared schema, not tenant.
|
||||
*/
|
||||
async getCdRates(): Promise<CdRate[]> {
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
try {
|
||||
await queryRunner.connect();
|
||||
const rates = await queryRunner.query(
|
||||
`SELECT bank_name, apy, min_deposit, term, term_months, fetched_at
|
||||
FROM shared.cd_rates
|
||||
ORDER BY apy DESC
|
||||
LIMIT 25`,
|
||||
);
|
||||
return rates;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrate the AI recommendation flow:
|
||||
* 1. Gather all financial data (tenant-scoped)
|
||||
* 2. Fetch CD rates (shared schema)
|
||||
* 3. Build the prompt with all context
|
||||
* 4. Call the AI API
|
||||
* 5. Parse and return structured recommendations
|
||||
*/
|
||||
async getAIRecommendations(): Promise<AIResponse> {
|
||||
const [snapshot, cdRates] = await Promise.all([
|
||||
this.getFinancialSnapshot(),
|
||||
this.getCdRates(),
|
||||
]);
|
||||
|
||||
const messages = this.buildPromptMessages(snapshot, cdRates);
|
||||
const aiResponse = await this.callAI(messages);
|
||||
return aiResponse;
|
||||
}
|
||||
|
||||
// ── Private: Tenant-Scoped Data Queries ──
|
||||
|
||||
private async getAccountBalances(): Promise<AccountBalance[]> {
|
||||
return this.tenant.query(`
|
||||
SELECT
|
||||
a.id, a.account_number, a.name, a.account_type, a.fund_type,
|
||||
a.interest_rate,
|
||||
CASE
|
||||
WHEN a.account_type IN ('asset', '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
|
||||
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.is_active = true
|
||||
AND a.account_type IN ('asset', 'liability', 'equity')
|
||||
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type, a.interest_rate
|
||||
ORDER BY a.account_number
|
||||
`);
|
||||
}
|
||||
|
||||
private async getInvestmentAccounts(): Promise<InvestmentAccount[]> {
|
||||
return this.tenant.query(`
|
||||
SELECT
|
||||
id, name, institution, investment_type, fund_type,
|
||||
principal, interest_rate, maturity_date, purchase_date, current_value
|
||||
FROM investment_accounts
|
||||
WHERE is_active = true
|
||||
ORDER BY maturity_date NULLS LAST
|
||||
`);
|
||||
}
|
||||
|
||||
private async getBudgets() {
|
||||
const year = new Date().getFullYear();
|
||||
return this.tenant.query(
|
||||
`SELECT
|
||||
b.fund_type, a.account_type, a.name, a.account_number,
|
||||
(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) as annual_total
|
||||
FROM budgets b
|
||||
JOIN accounts a ON a.id = b.account_id
|
||||
WHERE b.fiscal_year = $1
|
||||
ORDER BY a.account_type, a.account_number`,
|
||||
[year],
|
||||
);
|
||||
}
|
||||
|
||||
private async getProjects() {
|
||||
return this.tenant.query(`
|
||||
SELECT
|
||||
name, estimated_cost, target_year, target_month, fund_source,
|
||||
status, priority, current_fund_balance, funded_percentage
|
||||
FROM projects
|
||||
WHERE is_active = true
|
||||
AND status IN ('planned', 'approved', 'in_progress')
|
||||
ORDER BY target_year, target_month NULLS LAST, priority
|
||||
`);
|
||||
}
|
||||
|
||||
private async getCashFlowContext() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
// Current operating cash position
|
||||
const opCashResult = await 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
|
||||
`);
|
||||
|
||||
// Current reserve cash position
|
||||
const resCashResult = await 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
|
||||
`);
|
||||
|
||||
// Annual budget summary by fund_type and account_type
|
||||
const budgetSummary = await this.tenant.query(
|
||||
`SELECT
|
||||
b.fund_type, a.account_type,
|
||||
SUM(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) as annual_total
|
||||
FROM budgets b
|
||||
JOIN accounts a ON a.id = b.account_id
|
||||
WHERE b.fiscal_year = $1
|
||||
GROUP BY b.fund_type, a.account_type`,
|
||||
[year],
|
||||
);
|
||||
|
||||
// Assessment income (monthly recurring revenue)
|
||||
const assessmentIncome = await this.tenant.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(ag.regular_assessment * (SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id AND u.is_active = true)), 0) as monthly_assessment_income
|
||||
FROM assessment_groups ag
|
||||
WHERE ag.is_active = true
|
||||
`);
|
||||
|
||||
return {
|
||||
current_operating_cash: parseFloat(opCashResult[0]?.total || '0'),
|
||||
current_reserve_cash: parseFloat(resCashResult[0]?.total || '0'),
|
||||
budget_summary: budgetSummary,
|
||||
monthly_assessment_income: parseFloat(assessmentIncome[0]?.monthly_assessment_income || '0'),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Private: AI Prompt Construction ──
|
||||
|
||||
private buildPromptMessages(snapshot: any, cdRates: CdRate[]) {
|
||||
const { summary, investment_accounts, budgets, projects, cash_flow_context } = snapshot;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const systemPrompt = `You are a financial advisor specializing in HOA (Homeowners Association) reserve fund management and conservative investment strategy. You provide fiduciary-grade investment recommendations.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. HOAs are legally required to maintain adequate reserves. NEVER recommend depleting reserve funds below safe levels.
|
||||
2. HOA investments must be conservative ONLY: CDs, money market accounts, treasury bills, and high-yield savings. NO stocks, bonds, mutual funds, or speculative instruments.
|
||||
3. Liquidity is paramount: always ensure enough cash to cover at least 3 months of operating expenses AND any capital project expenses due within the next 12 months.
|
||||
4. CD laddering is the preferred strategy for reserve funds — it balances yield with regular liquidity access.
|
||||
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.
|
||||
|
||||
RESPONSE FORMAT:
|
||||
Respond with ONLY valid JSON (no markdown, no code fences) matching this exact schema:
|
||||
{
|
||||
"recommendations": [
|
||||
{
|
||||
"type": "cd_ladder" | "new_investment" | "reallocation" | "maturity_action" | "liquidity_warning" | "general",
|
||||
"priority": "high" | "medium" | "low",
|
||||
"title": "Short action title (under 60 chars)",
|
||||
"summary": "One sentence summary of the recommendation",
|
||||
"details": "Detailed explanation with specific dollar amounts and timeframes",
|
||||
"fund_type": "operating" | "reserve" | "both",
|
||||
"suggested_amount": 50000.00,
|
||||
"suggested_term": "12 months",
|
||||
"suggested_rate": 4.50,
|
||||
"bank_name": "Bank name from CD rates (if applicable)",
|
||||
"rationale": "Financial reasoning for why this makes sense"
|
||||
}
|
||||
],
|
||||
"overall_assessment": "2-3 sentence overview of the HOA's current investment position and opportunities",
|
||||
"risk_notes": ["Array of risk items or concerns to flag for the board"]
|
||||
}
|
||||
|
||||
IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible.`;
|
||||
|
||||
// Build the data context for the user prompt
|
||||
const investmentsList = investment_accounts.length === 0
|
||||
? 'No current investments.'
|
||||
: investment_accounts.map((i: any) =>
|
||||
`- ${i.name} | Type: ${i.investment_type} | Fund: ${i.fund_type} | Principal: $${parseFloat(i.principal).toFixed(2)} | Rate: ${parseFloat(i.interest_rate || '0').toFixed(2)}% | Maturity: ${i.maturity_date ? new Date(i.maturity_date).toLocaleDateString() : 'N/A'}`,
|
||||
).join('\n');
|
||||
|
||||
const budgetLines = budgets.length === 0
|
||||
? 'No budget data available.'
|
||||
: budgets.map((b: any) =>
|
||||
`- ${b.name} (${b.account_number}) | ${b.account_type}/${b.fund_type}: $${parseFloat(b.annual_total).toFixed(2)}/yr`,
|
||||
).join('\n');
|
||||
|
||||
const projectLines = projects.length === 0
|
||||
? 'No upcoming capital projects.'
|
||||
: projects.map((p: any) =>
|
||||
`- ${p.name} | Cost: $${parseFloat(p.estimated_cost).toFixed(2)} | Target: ${p.target_year || '?'}/${p.target_month || '?'} | Fund: ${p.fund_source} | Status: ${p.status} | Funded: ${parseFloat(p.funded_percentage || '0').toFixed(1)}%`,
|
||||
).join('\n');
|
||||
|
||||
const budgetSummaryLines = (cash_flow_context.budget_summary || []).length === 0
|
||||
? 'No budget summary available.'
|
||||
: cash_flow_context.budget_summary.map((b: any) =>
|
||||
`- ${b.fund_type} ${b.account_type}: $${parseFloat(b.annual_total).toFixed(2)}/yr (~$${(parseFloat(b.annual_total) / 12).toFixed(2)}/mo)`,
|
||||
).join('\n');
|
||||
|
||||
const cdRateLines = cdRates.length === 0
|
||||
? 'No CD rate data available. Rate fetcher may not have been run yet.'
|
||||
: cdRates.map((r: CdRate) =>
|
||||
`- ${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');
|
||||
|
||||
const userPrompt = `Analyze this HOA's financial position and provide investment recommendations.
|
||||
|
||||
TODAY'S DATE: ${today}
|
||||
|
||||
=== CURRENT CASH POSITIONS ===
|
||||
Operating Cash (bank accounts): $${summary.operating_cash.toFixed(2)}
|
||||
Reserve Cash (bank accounts): $${summary.reserve_cash.toFixed(2)}
|
||||
Operating Investments: $${summary.operating_investments.toFixed(2)}
|
||||
Reserve Investments: $${summary.reserve_investments.toFixed(2)}
|
||||
Total Operating Fund: $${summary.total_operating.toFixed(2)}
|
||||
Total Reserve Fund: $${summary.total_reserve.toFixed(2)}
|
||||
Grand Total: $${summary.total_all.toFixed(2)}
|
||||
|
||||
=== CURRENT INVESTMENTS ===
|
||||
${investmentsList}
|
||||
|
||||
=== ANNUAL BUDGET (${new Date().getFullYear()}) ===
|
||||
${budgetLines}
|
||||
|
||||
=== BUDGET SUMMARY (Annual Totals by Category) ===
|
||||
${budgetSummaryLines}
|
||||
|
||||
=== MONTHLY ASSESSMENT INCOME ===
|
||||
Recurring monthly assessment income: $${cash_flow_context.monthly_assessment_income.toFixed(2)}/month
|
||||
|
||||
=== UPCOMING CAPITAL PROJECTS ===
|
||||
${projectLines}
|
||||
|
||||
=== AVAILABLE CD RATES (Market Data) ===
|
||||
${cdRateLines}
|
||||
|
||||
Based on this complete financial picture, 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?
|
||||
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?`;
|
||||
|
||||
return [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
];
|
||||
}
|
||||
|
||||
// ── Private: AI API Call ──
|
||||
|
||||
private async callAI(messages: Array<{ role: string; content: string }>): Promise<AIResponse> {
|
||||
const apiUrl = this.configService.get<string>('AI_API_URL') || 'https://integrate.api.nvidia.com/v1';
|
||||
const apiKey = this.configService.get<string>('AI_API_KEY');
|
||||
const model = this.configService.get<string>('AI_MODEL') || 'moonshotai/kimi-k2.5';
|
||||
|
||||
if (!apiKey) {
|
||||
this.logger.error('AI_API_KEY not configured');
|
||||
return {
|
||||
recommendations: [],
|
||||
overall_assessment: 'AI recommendations are not available. The AI_API_KEY has not been configured in the environment.',
|
||||
risk_notes: ['Configure AI_API_KEY in .env to enable investment recommendations.'],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.log(`Calling AI API: ${apiUrl} with model ${model}`);
|
||||
|
||||
const response = await fetch(`${apiUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
temperature: 0.3,
|
||||
max_tokens: 4096,
|
||||
}),
|
||||
signal: AbortSignal.timeout(90000), // 90 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
this.logger.error(`AI API error ${response.status}: ${errorBody}`);
|
||||
throw new Error(`AI API returned ${response.status}: ${errorBody}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as any;
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
|
||||
if (!content) {
|
||||
throw new Error('Empty response from AI API');
|
||||
}
|
||||
|
||||
// Parse the JSON response — handle potential markdown code fences
|
||||
let cleaned = content.trim();
|
||||
if (cleaned.startsWith('```')) {
|
||||
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(cleaned) as AIResponse;
|
||||
|
||||
// Validate the response structure
|
||||
if (!parsed.recommendations || !Array.isArray(parsed.recommendations)) {
|
||||
throw new Error('Invalid AI response: missing recommendations array');
|
||||
}
|
||||
|
||||
this.logger.log(`AI returned ${parsed.recommendations.length} recommendations`);
|
||||
return parsed;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`AI recommendation failed: ${error.message}`);
|
||||
|
||||
// For JSON parse errors, return what we can
|
||||
if (error instanceof SyntaxError) {
|
||||
return {
|
||||
recommendations: [],
|
||||
overall_assessment: 'The AI service returned an invalid response format. Please try again.',
|
||||
risk_notes: [`Response parsing error: ${error.message}`],
|
||||
};
|
||||
}
|
||||
|
||||
// For network/timeout errors, return a graceful fallback
|
||||
return {
|
||||
recommendations: [],
|
||||
overall_assessment: 'Unable to generate AI recommendations at this time. Please try again later.',
|
||||
risk_notes: [`AI service error: ${error.message}`],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user