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:
2026-02-25 15:31:32 -05:00
parent e0c956859b
commit f7e9c98bd9
14 changed files with 1601 additions and 0 deletions

View File

@@ -5,3 +5,8 @@ DATABASE_URL=postgresql://hoafinance:change_me@postgres:5432/hoafinance
REDIS_URL=redis://redis:6379
JWT_SECRET=change_me_to_random_string
NODE_ENV=development
# AI Investment Advisor (OpenAI-compatible API)
AI_API_URL=https://integrate.api.nvidia.com/v1
AI_API_KEY=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
AI_MODEL=moonshotai/kimi-k2.5

View File

@@ -23,6 +23,7 @@ import { AssessmentGroupsModule } from './modules/assessment-groups/assessment-g
import { ProjectsModule } from './modules/projects/projects.module';
import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.module';
import { AttachmentsModule } from './modules/attachments/attachments.module';
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
@Module({
imports: [
@@ -60,6 +61,7 @@ import { AttachmentsModule } from './modules/attachments/attachments.module';
ProjectsModule,
MonthlyActualsModule,
AttachmentsModule,
InvestmentPlanningModule,
],
controllers: [AppController],
})

View File

@@ -0,0 +1,30 @@
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { InvestmentPlanningService } from './investment-planning.service';
@ApiTags('investment-planning')
@Controller('investment-planning')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class InvestmentPlanningController {
constructor(private service: InvestmentPlanningService) {}
@Get('snapshot')
@ApiOperation({ summary: 'Get financial snapshot for investment planning' })
getSnapshot() {
return this.service.getFinancialSnapshot();
}
@Get('cd-rates')
@ApiOperation({ summary: 'Get latest CD rates from market data' })
getCdRates() {
return this.service.getCdRates();
}
@Post('recommendations')
@ApiOperation({ summary: 'Get AI-powered investment recommendations' })
getRecommendations() {
return this.service.getAIRecommendations();
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { InvestmentPlanningController } from './investment-planning.controller';
import { InvestmentPlanningService } from './investment-planning.service';
@Module({
controllers: [InvestmentPlanningController],
providers: [InvestmentPlanningService],
})
export class InvestmentPlanningModule {}

View File

@@ -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}`],
};
}
}
}

View File

@@ -73,6 +73,19 @@ CREATE TABLE shared.invitations (
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- CD Rates (cross-tenant market data for investment recommendations)
CREATE TABLE shared.cd_rates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bank_name VARCHAR(255) NOT NULL,
apy DECIMAL(6,4) NOT NULL,
min_deposit DECIMAL(15,2),
term VARCHAR(100) NOT NULL,
term_months INTEGER,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source_url VARCHAR(500),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_user_orgs_user ON shared.user_organizations(user_id);
CREATE INDEX idx_user_orgs_org ON shared.user_organizations(organization_id);
@@ -80,3 +93,5 @@ CREATE INDEX idx_users_email ON shared.users(email);
CREATE INDEX idx_orgs_schema ON shared.organizations(schema_name);
CREATE INDEX idx_invitations_token ON shared.invitations(token);
CREATE INDEX idx_invitations_email ON shared.invitations(email);
CREATE INDEX idx_cd_rates_fetched ON shared.cd_rates(fetched_at DESC);
CREATE INDEX idx_cd_rates_apy ON shared.cd_rates(apy DESC);

View File

@@ -0,0 +1,17 @@
-- Migration: Add CD rates table to shared schema
-- For existing deployments that already have the shared schema initialized
CREATE TABLE IF NOT EXISTS shared.cd_rates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bank_name VARCHAR(255) NOT NULL,
apy DECIMAL(6,4) NOT NULL,
min_deposit DECIMAL(15,2),
term VARCHAR(100) NOT NULL,
term_months INTEGER,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source_url VARCHAR(500),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cd_rates_fetched ON shared.cd_rates(fetched_at DESC);
CREATE INDEX IF NOT EXISTS idx_cd_rates_apy ON shared.cd_rates(apy DESC);

View File

@@ -22,6 +22,9 @@ services:
- REDIS_URL=${REDIS_URL}
- JWT_SECRET=${JWT_SECRET}
- NODE_ENV=${NODE_ENV}
- AI_API_URL=${AI_API_URL}
- AI_API_KEY=${AI_API_KEY}
- AI_MODEL=${AI_MODEL}
volumes:
- ./backend/src:/app/src
- ./backend/nest-cli.json:/app/nest-cli.json

View File

@@ -29,6 +29,7 @@ import { AdminPage } from './pages/admin/AdminPage';
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
import { InvestmentPlanningPage } from './pages/investment-planning/InvestmentPlanningPage';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
@@ -117,6 +118,7 @@ export function App() {
<Route path="projects" element={<ProjectsPage />} />
<Route path="investments" element={<InvestmentsPage />} />
<Route path="capital-projects" element={<CapitalProjectsPage />} />
<Route path="investment-planning" element={<InvestmentPlanningPage />} />
<Route path="assessment-groups" element={<AssessmentGroupsPage />} />
<Route path="cash-flow" element={<CashFlowForecastPage />} />
<Route path="monthly-actuals" element={<MonthlyActualsPage />} />

View File

@@ -16,6 +16,7 @@ import {
IconCategory,
IconChartAreaLine,
IconClipboardCheck,
IconSparkles,
} from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore';
@@ -54,6 +55,7 @@ const navSections = [
items: [
{ label: 'Projects', icon: IconShieldCheck, path: '/projects' },
{ label: 'Capital Planning', icon: IconBuildingBank, path: '/capital-projects' },
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning' },
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
],
},

View File

@@ -0,0 +1,565 @@
import { useState } from 'react';
import {
Title,
Text,
Stack,
Card,
SimpleGrid,
Group,
Button,
Table,
Badge,
Loader,
Center,
Alert,
ThemeIcon,
Divider,
Accordion,
Paper,
} from '@mantine/core';
import {
IconBulb,
IconCash,
IconBuildingBank,
IconChartAreaLine,
IconAlertTriangle,
IconSparkles,
IconRefresh,
IconCoin,
IconPigMoney,
} from '@tabler/icons-react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import api from '../../services/api';
// ── Types ──
interface FinancialSummary {
operating_cash: number;
reserve_cash: number;
operating_investments: number;
reserve_investments: number;
total_operating: number;
total_reserve: number;
total_all: number;
}
interface FinancialSnapshot {
summary: FinancialSummary;
investment_accounts: Array<{
id: string;
name: string;
institution: string;
investment_type: string;
fund_type: string;
principal: string;
interest_rate: string;
maturity_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: string;
priority: 'high' | 'medium' | 'low';
title: string;
summary: string;
details: string;
fund_type: string;
suggested_amount?: number;
suggested_term?: string;
suggested_rate?: number;
bank_name?: string;
rationale: string;
}
interface AIResponse {
recommendations: Recommendation[];
overall_assessment: string;
risk_notes: string[];
}
// ── Helpers ──
const fmt = (v: number) =>
v.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const priorityColors: Record<string, string> = {
high: 'red',
medium: 'yellow',
low: 'blue',
};
const typeIcons: Record<string, any> = {
cd_ladder: IconChartAreaLine,
new_investment: IconBuildingBank,
reallocation: IconRefresh,
maturity_action: IconCash,
liquidity_warning: IconAlertTriangle,
general: IconBulb,
};
const typeLabels: Record<string, string> = {
cd_ladder: 'CD Ladder',
new_investment: 'New Investment',
reallocation: 'Reallocation',
maturity_action: 'Maturity Action',
liquidity_warning: 'Liquidity',
general: 'General',
};
// ── Component ──
export function InvestmentPlanningPage() {
const [aiResult, setAiResult] = useState<AIResponse | null>(null);
// Load financial snapshot on mount
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
queryKey: ['investment-planning-snapshot'],
queryFn: async () => {
const { data } = await api.get('/investment-planning/snapshot');
return data;
},
});
// Load CD rates on mount
const { data: cdRates = [], isLoading: ratesLoading } = useQuery<CdRate[]>({
queryKey: ['investment-planning-cd-rates'],
queryFn: async () => {
const { data } = await api.get('/investment-planning/cd-rates');
return data;
},
});
// AI recommendation (on-demand)
const aiMutation = useMutation({
mutationFn: async () => {
const { data } = await api.post('/investment-planning/recommendations');
return data as AIResponse;
},
onSuccess: (data) => {
setAiResult(data);
if (data.recommendations.length > 0) {
notifications.show({
message: `Generated ${data.recommendations.length} investment recommendations`,
color: 'green',
});
}
},
onError: (err: any) => {
notifications.show({
message: err.response?.data?.message || 'Failed to get AI recommendations',
color: 'red',
});
},
});
if (snapshotLoading) {
return (
<Center h={400}>
<Loader size="lg" />
</Center>
);
}
const s = snapshot?.summary;
return (
<Stack>
{/* Page Header */}
<Group justify="space-between" align="flex-start">
<div>
<Title order={2}>Investment Planning</Title>
<Text c="dimmed" size="sm">
Account overview, market rates, and AI-powered investment recommendations
</Text>
</div>
</Group>
{/* ── Section 1: Financial Snapshot Cards ── */}
{s && (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="blue" size="sm">
<IconCash size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Operating Cash
</Text>
</Group>
<Text fw={700} size="xl" ff="monospace">
{fmt(s.operating_cash)}
</Text>
<Text size="xs" c="dimmed">
Investments: {fmt(s.operating_investments)}
</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="violet" size="sm">
<IconPigMoney size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Reserve Cash
</Text>
</Group>
<Text fw={700} size="xl" ff="monospace">
{fmt(s.reserve_cash)}
</Text>
<Text size="xs" c="dimmed">
Investments: {fmt(s.reserve_investments)}
</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="teal" size="sm">
<IconChartAreaLine size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Total All Funds
</Text>
</Group>
<Text fw={700} size="xl" ff="monospace">
{fmt(s.total_all)}
</Text>
<Text size="xs" c="dimmed">
Operating: {fmt(s.total_operating)} | Reserve: {fmt(s.total_reserve)}
</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="green" size="sm">
<IconCoin size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Total Invested
</Text>
</Group>
<Text fw={700} size="xl" ff="monospace">
{fmt(s.operating_investments + s.reserve_investments)}
</Text>
<Text size="xs" c="dimmed">
Earning interest across all accounts
</Text>
</Card>
</SimpleGrid>
)}
{/* ── Section 2: Current Investments Table ── */}
{snapshot?.investment_accounts && snapshot.investment_accounts.length > 0 && (
<Card withBorder p="lg">
<Title order={4} mb="md">
Current Investments
</Title>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Institution</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Fund</Table.Th>
<Table.Th ta="right">Principal</Table.Th>
<Table.Th ta="right">Rate</Table.Th>
<Table.Th>Maturity</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{snapshot.investment_accounts.map((inv) => (
<Table.Tr key={inv.id}>
<Table.Td fw={500}>{inv.name}</Table.Td>
<Table.Td>{inv.institution || '-'}</Table.Td>
<Table.Td>
<Badge size="sm" variant="light">
{inv.investment_type}
</Badge>
</Table.Td>
<Table.Td>
<Badge
size="sm"
color={inv.fund_type === 'reserve' ? 'violet' : 'blue'}
>
{inv.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">
{fmt(parseFloat(inv.principal))}
</Table.Td>
<Table.Td ta="right">
{parseFloat(inv.interest_rate || '0').toFixed(2)}%
</Table.Td>
<Table.Td>
{inv.maturity_date
? new Date(inv.maturity_date).toLocaleDateString()
: '-'}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)}
{/* ── Section 3: Market CD Rates ── */}
<Card withBorder p="lg">
<Group justify="space-between" mb="md">
<Title order={4}>Market CD Rates</Title>
{cdRates.length > 0 && (
<Text size="xs" c="dimmed">
Last fetched: {new Date(cdRates[0].fetched_at).toLocaleString()}
</Text>
)}
</Group>
{ratesLoading ? (
<Center py="lg">
<Loader />
</Center>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Bank</Table.Th>
<Table.Th ta="right">APY</Table.Th>
<Table.Th>Term</Table.Th>
<Table.Th ta="right">Min Deposit</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{cdRates.map((r, i) => (
<Table.Tr key={i}>
<Table.Td fw={500}>{r.bank_name}</Table.Td>
<Table.Td ta="right" fw={700} c="green">
{parseFloat(r.apy).toFixed(2)}%
</Table.Td>
<Table.Td>{r.term}</Table.Td>
<Table.Td ta="right" ff="monospace">
{r.min_deposit
? `$${parseFloat(r.min_deposit).toLocaleString()}`
: '-'}
</Table.Td>
</Table.Tr>
))}
{cdRates.length === 0 && (
<Table.Tr>
<Table.Td colSpan={4}>
<Text ta="center" c="dimmed" py="lg">
No CD rates available. Run the fetch-cd-rates script to populate market data.
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
)}
</Card>
<Divider />
{/* ── Section 4: AI Investment Recommendations ── */}
<Card withBorder p="lg">
<Group justify="space-between" mb="md">
<Group gap="xs">
<ThemeIcon variant="light" color="grape" size="md">
<IconSparkles size={18} />
</ThemeIcon>
<div>
<Title order={4}>AI Investment Recommendations</Title>
<Text size="xs" c="dimmed">
Powered by AI analysis of your complete financial picture
</Text>
</div>
</Group>
<Button
leftSection={<IconSparkles size={16} />}
onClick={() => aiMutation.mutate()}
loading={aiMutation.isPending}
variant="gradient"
gradient={{ from: 'grape', to: 'violet' }}
>
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
</Button>
</Group>
{/* Loading State */}
{aiMutation.isPending && (
<Center py="xl">
<Stack align="center" gap="sm">
<Loader size="lg" type="dots" />
<Text c="dimmed" size="sm">
Analyzing your financial data and market rates...
</Text>
<Text c="dimmed" size="xs">
This may take up to 30 seconds
</Text>
</Stack>
</Center>
)}
{/* Results */}
{aiResult && !aiMutation.isPending && (
<Stack>
{/* Overall Assessment */}
<Alert color="blue" variant="light" title="Overall Assessment">
<Text size="sm">{aiResult.overall_assessment}</Text>
</Alert>
{/* Risk Notes */}
{aiResult.risk_notes && aiResult.risk_notes.length > 0 && (
<Alert
color="yellow"
variant="light"
title="Risk Notes"
icon={<IconAlertTriangle />}
>
<Stack gap={4}>
{aiResult.risk_notes.map((note, i) => (
<Text key={i} size="sm">
{note}
</Text>
))}
</Stack>
</Alert>
)}
{/* Recommendation Cards */}
{aiResult.recommendations.length > 0 ? (
<Accordion variant="separated">
{aiResult.recommendations.map((rec, i) => {
const Icon = typeIcons[rec.type] || IconBulb;
return (
<Accordion.Item key={i} value={`rec-${i}`}>
<Accordion.Control>
<Group>
<ThemeIcon
variant="light"
color={priorityColors[rec.priority] || 'gray'}
size="md"
>
<Icon size={16} />
</ThemeIcon>
<div style={{ flex: 1 }}>
<Group gap="xs">
<Text fw={600}>{rec.title}</Text>
<Badge
size="xs"
color={priorityColors[rec.priority]}
>
{rec.priority}
</Badge>
<Badge size="xs" variant="light">
{typeLabels[rec.type] || rec.type}
</Badge>
<Badge
size="xs"
variant="dot"
color={
rec.fund_type === 'reserve'
? 'violet'
: rec.fund_type === 'operating'
? 'blue'
: 'gray'
}
>
{rec.fund_type}
</Badge>
</Group>
<Text size="sm" c="dimmed" mt={2}>
{rec.summary}
</Text>
</div>
{rec.suggested_amount != null && (
<Text fw={700} ff="monospace" c="green" size="lg">
{fmt(rec.suggested_amount)}
</Text>
)}
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<Text size="sm">{rec.details}</Text>
{(rec.suggested_term ||
rec.suggested_rate != null ||
rec.bank_name) && (
<Paper withBorder p="sm" radius="sm">
<SimpleGrid cols={{ base: 1, sm: 3 }}>
{rec.suggested_term && (
<div>
<Text size="xs" c="dimmed">
Suggested Term
</Text>
<Text fw={600}>{rec.suggested_term}</Text>
</div>
)}
{rec.suggested_rate != null && (
<div>
<Text size="xs" c="dimmed">
Target Rate
</Text>
<Text fw={600}>
{rec.suggested_rate}% APY
</Text>
</div>
)}
{rec.bank_name && (
<div>
<Text size="xs" c="dimmed">
Bank
</Text>
<Text fw={600}>{rec.bank_name}</Text>
</div>
)}
</SimpleGrid>
</Paper>
)}
<Alert variant="light" color="gray" title="Rationale">
<Text size="sm">{rec.rationale}</Text>
</Alert>
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
) : (
<Text ta="center" c="dimmed" py="lg">
No specific recommendations at this time.
</Text>
)}
</Stack>
)}
{/* Empty State */}
{!aiResult && !aiMutation.isPending && (
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
<ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md">
<IconSparkles size={28} />
</ThemeIcon>
<Text fw={500} mb={4}>
AI-Powered Investment Analysis
</Text>
<Text c="dimmed" size="sm" maw={500} mx="auto">
Click "Get AI Recommendations" to analyze your accounts, cash flow,
budget, and capital projects against current market rates. The AI will
suggest specific investment moves to maximize interest income while
maintaining adequate liquidity.
</Text>
</Paper>
)}
</Card>
</Stack>
);
}

47
scripts/README.md Normal file
View File

@@ -0,0 +1,47 @@
# HOA LedgerIQ - Scripts
Standalone scripts for data fetching, maintenance, and automation tasks.
## CD Rate Fetcher
Scrapes the top 25 CD rates from [Bankrate.com](https://www.bankrate.com/banking/cds/cd-rates/) and stores them in the `shared.cd_rates` PostgreSQL table.
**Note:** Bankrate renders rate data dynamically via JavaScript, so this script uses Puppeteer (headless Chrome) to fully render the page before extracting data.
### Prerequisites
- Node.js 20+
- PostgreSQL with the `shared.cd_rates` table (created by `db/init/00-init.sql` or `db/migrations/005-cd-rates.sql`)
- A `.env` file at the project root with `DATABASE_URL`
### Manual Execution
```bash
cd scripts
npm install
npx tsx fetch-cd-rates.ts
```
### Cron Setup
To run daily at 6:00 AM:
```bash
# Edit crontab
crontab -e
# Add this line (adjust path to your project directory):
0 6 * * * cd /path/to/HOA_Financial_Platform/scripts && /usr/local/bin/npx tsx fetch-cd-rates.ts >> /var/log/hoa-cd-rates.log 2>&1
```
For Docker-based deployments, you can use a host cron job that executes into the container:
```bash
0 6 * * * docker exec hoa-backend sh -c "cd /app/scripts && npx tsx fetch-cd-rates.ts" >> /var/log/hoa-cd-rates.log 2>&1
```
### Troubleshooting
- **0 rates extracted**: Bankrate likely changed their page structure. Inspect the page DOM in a browser and update the CSS selectors in `fetch-cd-rates.ts`.
- **Database connection error**: Verify `DATABASE_URL` in `.env` points to the correct PostgreSQL instance. For local development (outside Docker), use `localhost:5432` instead of `postgres:5432`.
- **Puppeteer launch error**: Ensure Chromium dependencies are installed. On Ubuntu: `apt-get install -y libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libpango-1.0-0 libasound2`

403
scripts/fetch-cd-rates.ts Normal file
View File

@@ -0,0 +1,403 @@
#!/usr/bin/env tsx
/**
* CD Rate Fetcher Script
*
* Scrapes the top CD rates from Bankrate.com and stores them in the
* shared.cd_rates table in PostgreSQL. Designed to run standalone via cron.
*
* Bankrate renders rate data dynamically via JavaScript, so this script
* uses Puppeteer (headless Chrome) to fully render the page before scraping.
*
* Usage:
* cd scripts
* npm install
* npx tsx fetch-cd-rates.ts
*
* Environment:
* DATABASE_URL - PostgreSQL connection string (reads from ../.env)
*/
import * as dotenv from 'dotenv';
import { resolve } from 'path';
import { Pool } from 'pg';
import puppeteer, { type Browser } from 'puppeteer';
// Load .env from project root
dotenv.config({ path: resolve(__dirname, '..', '.env') });
const BANKRATE_URL = 'https://www.bankrate.com/banking/cds/cd-rates/';
const MAX_RATES = 25;
interface CdRate {
bank_name: string;
apy: number;
min_deposit: number | null;
term: string;
term_months: number | null;
}
/**
* Parse a term string like "3 months", "1 year", "18 months" into a month count.
*/
function parseTermMonths(term: string): number | null {
const lower = term.toLowerCase().trim();
const monthMatch = lower.match(/(\d+)\s*month/);
if (monthMatch) return parseInt(monthMatch[1], 10);
const yearMatch = lower.match(/(\d+)\s*year/);
if (yearMatch) return parseInt(yearMatch[1], 10) * 12;
// Handle fractional years like "1.5 years"
const fracYearMatch = lower.match(/([\d.]+)\s*year/);
if (fracYearMatch) return Math.round(parseFloat(fracYearMatch[1]) * 12);
return null;
}
/**
* Parse a currency string like "$500", "$1,000", "$0", "No minimum" into a number or null.
*/
function parseMinDeposit(raw: string): number | null {
if (!raw) return null;
const cleaned = raw.replace(/[^0-9.]/g, '');
if (!cleaned) return null;
const val = parseFloat(cleaned);
return isNaN(val) ? null : val;
}
/**
* Parse an APY string like "4.50%", "4.50% APY" into a number.
*/
function parseApy(raw: string): number {
const cleaned = raw.replace(/[^0-9.]/g, '');
return parseFloat(cleaned) || 0;
}
/**
* Launch headless Chrome, navigate to Bankrate, and scrape CD rate data.
*/
async function fetchRates(): Promise<CdRate[]> {
let browser: Browser | null = null;
try {
console.log('Launching headless browser...');
browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
],
});
const page = await browser.newPage();
await page.setUserAgent(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
);
console.log(`Navigating to ${BANKRATE_URL}...`);
await page.goto(BANKRATE_URL, {
waitUntil: 'networkidle2',
timeout: 60000,
});
// Wait for rate content to render
// Bankrate uses various table/card patterns; we'll try multiple selectors
console.log('Waiting for rate data to render...');
await page.waitForSelector(
'table, [data-testid*="rate"], .brc-table, [class*="ComparisonTable"], [class*="rate-table"]',
{ timeout: 30000 },
).catch(() => {
console.log('Primary selectors not found, proceeding with page scan...');
});
// Extra wait for dynamic content
await new Promise((resolve) => setTimeout(resolve, 3000));
// Scroll down to load all content (rate tables may be below the fold)
console.log('Scrolling to load all content...');
await page.evaluate(async () => {
for (let i = 0; i < 10; i++) {
window.scrollBy(0, 800);
await new Promise((r) => setTimeout(r, 500));
}
window.scrollTo(0, 0);
});
await new Promise((resolve) => setTimeout(resolve, 2000));
// Extract rate data from the page using multiple strategies
const rates = await page.evaluate((maxRates: number) => {
const results: Array<{
bank_name: string;
apy_raw: string;
min_deposit_raw: string;
term_raw: string;
}> = [];
// Strategy 1: Look for detailed bank comparison tables with named banks
// These typically have 4+ columns: Bank, APY, Min Deposit, Term
const tables = document.querySelectorAll('table');
for (const table of tables) {
const rows = table.querySelectorAll('tbody tr');
if (rows.length < 3) continue; // Skip small tables
for (const row of rows) {
const cells = row.querySelectorAll('td, th');
if (cells.length < 3) continue;
const texts = Array.from(cells).map((c) => c.textContent?.trim() || '');
const apyCell = texts.find((t) => /\d+\.\d+\s*%/.test(t));
if (!apyCell) continue;
// Bank name: look for a cell with a real name (not just number/percent/dollar)
const bankCell = texts.find(
(t) =>
t.length > 3 &&
!/^\d/.test(t) &&
!t.includes('%') &&
!t.startsWith('$') &&
!/^\d+\s*(month|year)/i.test(t),
);
// Also try to find the bank name from links or images in the row
const linkEl = row.querySelector('a[href*="review"], a[href*="bank"], img[alt]');
const linkName = linkEl?.textContent?.trim() || (linkEl as HTMLImageElement)?.alt || '';
const name = linkName.length > 3 ? linkName : bankCell || '';
if (!name) continue;
results.push({
bank_name: name,
apy_raw: apyCell,
min_deposit_raw:
texts.find((t) => t.includes('$') || /no min/i.test(t)) || '',
term_raw: texts.find((t) => /\d+\s*(month|year)/i.test(t)) || '',
});
if (results.length >= maxRates) break;
}
if (results.length >= 5) break; // Found a good table
}
// Strategy 2: Look for card/list layouts with bank names and rates
if (results.length < 5) {
const cardSelectors = [
'[class*="product"]',
'[class*="offer-card"]',
'[class*="rate-card"]',
'[class*="ComparisonRow"]',
'[class*="comparison-row"]',
'[data-testid*="product"]',
'[class*="partner"]',
];
for (const selector of cardSelectors) {
const cards = document.querySelectorAll(selector);
if (cards.length < 3) continue;
for (const card of cards) {
const text = card.textContent || '';
if (text.length < 20 || text.length > 2000) continue;
const apyMatch = text.match(/([\d.]+)\s*%/);
if (!apyMatch) continue;
// Try to find bank name from heading, link, or image alt text
const nameEl =
card.querySelector(
'h2, h3, h4, h5, strong, [class*="name"], [class*="bank"], [class*="title"], a[href*="review"], img[alt]',
);
let bankName = nameEl?.textContent?.trim() || (nameEl as HTMLImageElement)?.alt || '';
// Skip if the "name" is just a rate or term
if (!bankName || bankName.length < 3 || /^\d/.test(bankName) || bankName.includes('%')) continue;
const depositMatch = text.match(/\$[\d,]+/);
const termMatch = text.match(/\d+\s*(?:month|year)s?/i);
results.push({
bank_name: bankName,
apy_raw: apyMatch[0],
min_deposit_raw: depositMatch?.[0] || '',
term_raw: termMatch?.[0] || '',
});
if (results.length >= maxRates) break;
}
if (results.length >= 5) break;
}
}
// Strategy 3: Broad scan for rate-bearing elements
if (results.length < 5) {
const allElements = document.querySelectorAll(
'div, section, article, li',
);
for (const el of allElements) {
if (el.children.length > 20) continue;
const text = el.textContent || '';
if (text.length < 20 || text.length > 500) continue;
const apyMatch = text.match(/([\d.]+)\s*%\s*(?:APY)?/i);
if (!apyMatch) continue;
const bankEl = el.querySelector(
'h2, h3, h4, h5, strong, b, a[href*="review"]',
);
let bankName = bankEl?.textContent?.trim() || '';
if (!bankName || bankName.length < 3 || /^\d/.test(bankName)) continue;
const depositMatch = text.match(/\$[\d,]+/);
const termMatch = text.match(/\d+\s*(?:month|year)s?/i);
results.push({
bank_name: bankName,
apy_raw: apyMatch[0],
min_deposit_raw: depositMatch?.[0] || '',
term_raw: termMatch?.[0] || '',
});
if (results.length >= maxRates) break;
}
}
return results;
}, MAX_RATES);
console.log(`Raw extraction found ${rates.length} rate entries.`);
// Parse and normalize the scraped data
const parsed: CdRate[] = rates
.map((r) => {
let bankName = r.bank_name.replace(/\s+/g, ' ').trim();
const term = r.term_raw || 'N/A';
// If the bank name looks like a term or deposit info, it's a
// summary card — label it more descriptively using the term
const termText = r.term_raw || bankName;
if (
/^\d+\s*(month|year)/i.test(bankName) ||
/no\s*min/i.test(bankName) ||
/^\$/.test(bankName) ||
bankName.length < 4
) {
bankName = `Top CD Rate - ${termText.replace(/^\d+/, (m: string) => m + ' ')}`.replace(/\s+/g, ' ').trim();
}
return {
bank_name: bankName,
apy: parseApy(r.apy_raw),
min_deposit: parseMinDeposit(r.min_deposit_raw),
term,
term_months: parseTermMonths(r.term_raw || bankName),
};
})
.filter((r) => r.bank_name && r.apy > 0);
// Deduplicate by bank name + term (keep highest APY)
const seen = new Map<string, CdRate>();
for (const rate of parsed) {
const key = `${rate.bank_name}|${rate.term}`;
const existing = seen.get(key);
if (!existing || rate.apy > existing.apy) {
seen.set(key, rate);
}
}
return Array.from(seen.values())
.sort((a, b) => b.apy - a.apy)
.slice(0, MAX_RATES);
} finally {
if (browser) {
await browser.close();
}
}
}
/**
* Store scraped rates into shared.cd_rates, replacing all previous data.
*/
async function storeRates(rates: CdRate[]): Promise<void> {
const connectionString =
process.env.DATABASE_URL ||
'postgresql://hoafinance:change_me@localhost:5432/hoafinance';
const pool = new Pool({ connectionString });
const client = await pool.connect();
try {
await client.query('BEGIN');
// Clear previous batch (we only keep the latest fetch)
await client.query('DELETE FROM shared.cd_rates');
const now = new Date().toISOString();
for (const rate of rates) {
await client.query(
`INSERT INTO shared.cd_rates
(bank_name, apy, min_deposit, term, term_months, fetched_at, source_url)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
rate.bank_name,
rate.apy,
rate.min_deposit,
rate.term,
rate.term_months,
now,
BANKRATE_URL,
],
);
}
await client.query('COMMIT');
console.log(`Successfully stored ${rates.length} CD rates at ${now}`);
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
await pool.end();
}
}
/**
* Main entry point.
*/
async function main() {
console.log('=== CD Rate Fetcher ===');
console.log(`Fetching top CD rates from Bankrate.com...`);
console.log(`Time: ${new Date().toISOString()}`);
console.log('');
try {
const rates = await fetchRates();
if (rates.length === 0) {
console.warn('');
console.warn('WARNING: No CD rates were extracted from Bankrate.');
console.warn(
'This likely means Bankrate changed their page structure.',
);
console.warn(
'Review the page DOM and update selectors in fetch-cd-rates.ts.',
);
process.exit(1);
}
console.log(`\nExtracted ${rates.length} rates:`);
console.log('─'.repeat(70));
for (const r of rates) {
console.log(
` ${r.bank_name.padEnd(30)} ${String(r.apy + '%').padEnd(8)} ${r.term.padEnd(15)} ${r.min_deposit != null ? '$' + r.min_deposit.toLocaleString() : 'N/A'}`,
);
}
console.log('─'.repeat(70));
console.log('\nStoring to database...');
await storeRates(rates);
console.log('Done.');
} catch (err) {
console.error('\nFATAL ERROR:', err);
process.exit(1);
}
}
main();

19
scripts/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "hoa-ledgeriq-scripts",
"version": "1.0.0",
"private": true,
"description": "Standalone scripts for HOA LedgerIQ platform (cron jobs, data fetching)",
"scripts": {
"fetch-cd-rates": "tsx fetch-cd-rates.ts"
},
"dependencies": {
"dotenv": "^16.4.7",
"pg": "^8.13.1",
"puppeteer": "^23.0.0"
},
"devDependencies": {
"@types/pg": "^8.11.0",
"tsx": "^4.19.0",
"typescript": "^5.7.3"
}
}