Phase 6: Expand market rates and enhance AI investment recommendations

- Rate fetcher now scrapes CD, Money Market, and High Yield Savings rates
  from Bankrate.com with pauses between fetches to avoid rate limiting
- Historical rate data is preserved (no longer deleted on each fetch)
- Database migration adds rate_type column and tenant ai_recommendations table
- Backend returns market rates grouped by type with latest-batch-only queries
- AI prompt now includes all three rate types for comprehensive analysis
- AI recommendations are saved per-tenant for retrieval on page load
- Frontend: "Market CD Rates" replaced with "Today's Market Rates" tabbed view
- Rates section is collapsible (expanded by default) to save screen space
- Saved recommendations load automatically with "Last Updated" timestamp

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 13:39:19 -05:00
parent d9bb9363dd
commit 2fed5d6ce1
7 changed files with 686 additions and 317 deletions

View File

@@ -316,6 +316,17 @@ export class TenantSchemaService {
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// AI Investment Recommendations (saved per tenant)
`CREATE TABLE "${s}".ai_recommendations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
recommendations_json JSONB NOT NULL,
overall_assessment TEXT,
risk_notes JSONB,
requested_by UUID,
response_time_ms INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Attachments (file storage for receipts/invoices)
`CREATE TABLE "${s}".attachments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),

View File

@@ -17,11 +17,23 @@ export class InvestmentPlanningController {
}
@Get('cd-rates')
@ApiOperation({ summary: 'Get latest CD rates from market data' })
@ApiOperation({ summary: 'Get latest CD rates from market data (backward compat)' })
getCdRates() {
return this.service.getCdRates();
}
@Get('market-rates')
@ApiOperation({ summary: 'Get all market rates grouped by type (CD, Money Market, High Yield Savings)' })
getMarketRates() {
return this.service.getMarketRates();
}
@Get('saved-recommendation')
@ApiOperation({ summary: 'Get the latest saved AI recommendation for this tenant' })
getSavedRecommendation() {
return this.service.getSavedRecommendation();
}
@Post('recommendations')
@ApiOperation({ summary: 'Get AI-powered investment recommendations' })
getRecommendations(@Req() req: any) {

View File

@@ -28,12 +28,13 @@ export interface InvestmentAccount {
current_value: string;
}
export interface CdRate {
export interface MarketRate {
bank_name: string;
apy: string;
min_deposit: string | null;
term: string;
term_months: number | null;
rate_type: string;
fetched_at: string;
}
@@ -57,6 +58,15 @@ export interface AIResponse {
risk_notes: string[];
}
export interface SavedRecommendation {
id: string;
recommendations: Recommendation[];
overall_assessment: string;
risk_notes: string[];
response_time_ms: number;
created_at: string;
}
@Injectable()
export class InvestmentPlanningService {
private readonly logger = new Logger(InvestmentPlanningService.name);
@@ -139,40 +149,124 @@ export class InvestmentPlanningService {
}
/**
* Fetch latest CD rates from the shared schema (cross-tenant market data).
* Uses DataSource directly since this queries the shared schema, not tenant.
* Fetch latest market rates from the shared schema (cross-tenant market data).
* Returns rates grouped by type, each showing only the most recent fetch batch.
*/
async getCdRates(): Promise<CdRate[]> {
async getMarketRates(): Promise<{ cd: MarketRate[]; money_market: MarketRate[]; high_yield_savings: MarketRate[] }> {
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;
// For each rate type, get the latest batch (same fetched_at timestamp)
const fetchLatest = async (rateType: string): Promise<MarketRate[]> => {
return queryRunner.query(
`SELECT bank_name, apy, min_deposit, term, term_months, rate_type, fetched_at
FROM shared.cd_rates
WHERE rate_type = $1
AND fetched_at = (
SELECT MAX(fetched_at) FROM shared.cd_rates WHERE rate_type = $1
)
ORDER BY apy DESC
LIMIT 25`,
[rateType],
);
};
const [cd, moneyMarket, highYieldSavings] = await Promise.all([
fetchLatest('cd'),
fetchLatest('money_market'),
fetchLatest('high_yield_savings'),
]);
return {
cd,
money_market: moneyMarket,
high_yield_savings: highYieldSavings,
};
} finally {
await queryRunner.release();
}
}
/**
* Backward-compatible: get only CD rates.
*/
async getCdRates(): Promise<MarketRate[]> {
const rates = await this.getMarketRates();
return rates.cd;
}
/**
* Get the latest saved AI recommendation for this tenant.
*/
async getSavedRecommendation(): Promise<SavedRecommendation | null> {
try {
const rows = await this.tenant.query(
`SELECT id, recommendations_json, overall_assessment, risk_notes,
response_time_ms, created_at
FROM ai_recommendations
ORDER BY created_at DESC
LIMIT 1`,
);
if (!rows || rows.length === 0) return null;
const row = rows[0];
const recData = row.recommendations_json || {};
return {
id: row.id,
recommendations: recData.recommendations || [],
overall_assessment: row.overall_assessment || recData.overall_assessment || '',
risk_notes: row.risk_notes || recData.risk_notes || [],
response_time_ms: row.response_time_ms || 0,
created_at: row.created_at,
};
} catch (err: any) {
// Table might not exist yet (pre-migration tenants)
this.logger.warn(`Could not load saved recommendations: ${err.message}`);
return null;
}
}
/**
* Save AI recommendation result to tenant schema.
*/
private async saveRecommendation(aiResponse: AIResponse, userId: string | undefined, elapsed: number): Promise<void> {
try {
await this.tenant.query(
`INSERT INTO ai_recommendations
(recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms)
VALUES ($1, $2, $3, $4, $5)`,
[
JSON.stringify(aiResponse),
aiResponse.overall_assessment || '',
JSON.stringify(aiResponse.risk_notes || []),
userId || null,
elapsed,
],
);
} catch (err: any) {
// Non-critical — don't let storage failure break recommendations
this.logger.warn(`Could not save recommendation: ${err.message}`);
}
}
/**
* Orchestrate the AI recommendation flow:
* 1. Gather all financial data (tenant-scoped)
* 2. Fetch CD rates (shared schema)
* 2. Fetch all market rates (shared schema)
* 3. Build the prompt with all context
* 4. Call the AI API
* 5. Parse and return structured recommendations
* 6. Save to tenant storage for future retrieval
*/
async getAIRecommendations(userId?: string, orgId?: string): Promise<AIResponse> {
this.debug('getAIRecommendations', 'Starting AI recommendation flow');
const startTime = Date.now();
const [snapshot, cdRates, monthlyForecast] = await Promise.all([
const [snapshot, allRates, monthlyForecast] = await Promise.all([
this.getFinancialSnapshot(),
this.getCdRates(),
this.getMarketRates(),
this.getMonthlyForecast(),
]);
@@ -183,11 +277,13 @@ export class InvestmentPlanningService {
investment_accounts: snapshot.investment_accounts.length,
budgets: snapshot.budgets.length,
projects: snapshot.projects.length,
cd_rates: cdRates.length,
cd_rates: allRates.cd.length,
money_market_rates: allRates.money_market.length,
savings_rates: allRates.high_yield_savings.length,
forecast_months: monthlyForecast.datapoints.length,
});
const messages = this.buildPromptMessages(snapshot, cdRates, monthlyForecast);
const messages = this.buildPromptMessages(snapshot, allRates, monthlyForecast);
const aiResponse = await this.callAI(messages);
const elapsed = Date.now() - startTime;
@@ -197,6 +293,9 @@ export class InvestmentPlanningService {
risk_notes_count: aiResponse.risk_notes?.length || 0,
});
// Save recommendation to tenant storage (fire-and-forget)
this.saveRecommendation(aiResponse, userId, elapsed).catch(() => {});
// Log AI usage to shared.ai_recommendation_log (fire-and-forget)
this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {});
@@ -345,9 +444,6 @@ 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();
@@ -551,7 +647,11 @@ export class InvestmentPlanningService {
// ── Private: AI Prompt Construction ──
private buildPromptMessages(snapshot: any, cdRates: CdRate[], monthlyForecast: any) {
private buildPromptMessages(
snapshot: any,
allRates: { cd: MarketRate[]; money_market: MarketRate[]; high_yield_savings: MarketRate[] },
monthlyForecast: any,
) {
const { summary, investment_accounts, budgets, projects, cash_flow_context } = snapshot;
const today = new Date().toISOString().split('T')[0];
@@ -564,8 +664,10 @@ CRITICAL RULES:
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.
7. Base your recommendations ONLY on the available market rates (CDs, Money Market, High Yield Savings) 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.
9. When recommending money market or high yield savings accounts, focus on their liquidity advantages for operating funds. When recommending CDs, focus on their higher yields for longer-term reserve fund placement.
10. Compare current account rates against available market rates. If better rates are available, suggest specific moves with the potential additional interest income that could be earned.
RESPONSE FORMAT:
Respond with ONLY valid JSON (no markdown, no code fences) matching this exact schema:
@@ -581,7 +683,7 @@ Respond with ONLY valid JSON (no markdown, no code fences) matching this exact s
"suggested_amount": 50000.00,
"suggested_term": "12 months",
"suggested_rate": 4.50,
"bank_name": "Bank name from CD rates (if applicable)",
"bank_name": "Bank name from market rates (if applicable)",
"rationale": "Financial reasoning for why this makes sense"
}
],
@@ -589,7 +691,7 @@ Respond with ONLY valid JSON (no markdown, no code fences) matching this exact s
"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.`;
IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible. When there are opportunities for better rates on existing positions, quantify the additional annual interest that could be earned.`;
// Build the data context for the user prompt
const investmentsList = investment_accounts.length === 0
@@ -616,11 +718,18 @@ IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority item
`- ${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');
// Format market rates by type
const formatRates = (rates: MarketRate[], typeLabel: string): string => {
if (rates.length === 0) return `No ${typeLabel} rate data available. Rate fetcher may not have been run yet.`;
return rates.map((r: MarketRate) => {
const termStr = r.term !== 'N/A' ? ` | Term: ${r.term}` : '';
return `- ${r.bank_name} | APY: ${parseFloat(String(r.apy)).toFixed(2)}%${termStr} | Min Deposit: ${r.min_deposit ? '$' + parseFloat(String(r.min_deposit)).toLocaleString() : 'N/A'}`;
}).join('\n');
};
const cdRateLines = formatRates(allRates.cd, 'CD');
const moneyMarketLines = formatRates(allRates.money_market, 'Money Market');
const savingsRateLines = formatRates(allRates.high_yield_savings, 'High Yield Savings');
// Format assessment schedule showing regular + special
const assessmentScheduleLines = (monthlyForecast.assessment_schedule || []).length === 0
@@ -679,15 +788,25 @@ ${projectLines}
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) ===
=== AVAILABLE MARKET RATES ===
--- CD Rates ---
${cdRateLines}
--- Money Market Rates ---
${moneyMarketLines}
--- High Yield Savings Rates ---
${savingsRateLines}
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?
1. Is there excess cash that could earn better returns in CDs, money market accounts, or high-yield savings?
2. Are any current investments maturing soon that need reinvestment planning?
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?`;
5. Are operating and reserve funds properly separated in the investment strategy?
6. Could any current money market or savings accounts earn better rates at a different bank? Quantify the potential additional annual interest.
7. For operating funds that need to stay liquid, are money market or high-yield savings accounts being used optimally?`;
return [
{ role: 'system', content: systemPrompt },