From f7e9c98bd90dd63ee21d674c53a9eab417143721 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Wed, 25 Feb 2026 15:31:32 -0500 Subject: [PATCH] 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 --- .env.example | 5 + backend/src/app.module.ts | 2 + .../investment-planning.controller.ts | 30 + .../investment-planning.module.ts | 9 + .../investment-planning.service.ts | 482 +++++++++++++++ db/init/00-init.sql | 15 + db/migrations/005-cd-rates.sql | 17 + docker-compose.yml | 3 + frontend/src/App.tsx | 2 + frontend/src/components/layout/Sidebar.tsx | 2 + .../InvestmentPlanningPage.tsx | 565 ++++++++++++++++++ scripts/README.md | 47 ++ scripts/fetch-cd-rates.ts | 403 +++++++++++++ scripts/package.json | 19 + 14 files changed, 1601 insertions(+) create mode 100644 backend/src/modules/investment-planning/investment-planning.controller.ts create mode 100644 backend/src/modules/investment-planning/investment-planning.module.ts create mode 100644 backend/src/modules/investment-planning/investment-planning.service.ts create mode 100644 db/migrations/005-cd-rates.sql create mode 100644 frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx create mode 100644 scripts/README.md create mode 100644 scripts/fetch-cd-rates.ts create mode 100644 scripts/package.json diff --git a/.env.example b/.env.example index 3232c6c..76a3e53 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index adef66a..0b42f8b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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], }) diff --git a/backend/src/modules/investment-planning/investment-planning.controller.ts b/backend/src/modules/investment-planning/investment-planning.controller.ts new file mode 100644 index 0000000..58c57c0 --- /dev/null +++ b/backend/src/modules/investment-planning/investment-planning.controller.ts @@ -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(); + } +} diff --git a/backend/src/modules/investment-planning/investment-planning.module.ts b/backend/src/modules/investment-planning/investment-planning.module.ts new file mode 100644 index 0000000..ce1d164 --- /dev/null +++ b/backend/src/modules/investment-planning/investment-planning.module.ts @@ -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 {} diff --git a/backend/src/modules/investment-planning/investment-planning.service.ts b/backend/src/modules/investment-planning/investment-planning.service.ts new file mode 100644 index 0000000..dc2b54d --- /dev/null +++ b/backend/src/modules/investment-planning/investment-planning.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + const apiUrl = this.configService.get('AI_API_URL') || 'https://integrate.api.nvidia.com/v1'; + const apiKey = this.configService.get('AI_API_KEY'); + const model = this.configService.get('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}`], + }; + } + } +} diff --git a/db/init/00-init.sql b/db/init/00-init.sql index 9a20cd9..894266f 100644 --- a/db/init/00-init.sql +++ b/db/init/00-init.sql @@ -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); diff --git a/db/migrations/005-cd-rates.sql b/db/migrations/005-cd-rates.sql new file mode 100644 index 0000000..d9fb6b3 --- /dev/null +++ b/db/migrations/005-cd-rates.sql @@ -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); diff --git a/docker-compose.yml b/docker-compose.yml index 1287af6..90a1945 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f58851f..3eec4f1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 830ef43..0f44879 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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' }, ], }, diff --git a/frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx b/frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx new file mode 100644 index 0000000..39d02a9 --- /dev/null +++ b/frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx @@ -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 = { + high: 'red', + medium: 'yellow', + low: 'blue', +}; + +const typeIcons: Record = { + cd_ladder: IconChartAreaLine, + new_investment: IconBuildingBank, + reallocation: IconRefresh, + maturity_action: IconCash, + liquidity_warning: IconAlertTriangle, + general: IconBulb, +}; + +const typeLabels: Record = { + 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(null); + + // Load financial snapshot on mount + const { data: snapshot, isLoading: snapshotLoading } = useQuery({ + 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({ + 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 ( +
+ +
+ ); + } + + const s = snapshot?.summary; + + return ( + + {/* Page Header */} + +
+ Investment Planning + + Account overview, market rates, and AI-powered investment recommendations + +
+
+ + {/* ── Section 1: Financial Snapshot Cards ── */} + {s && ( + + + + + + + + Operating Cash + + + + {fmt(s.operating_cash)} + + + Investments: {fmt(s.operating_investments)} + + + + + + + + + + Reserve Cash + + + + {fmt(s.reserve_cash)} + + + Investments: {fmt(s.reserve_investments)} + + + + + + + + + + Total All Funds + + + + {fmt(s.total_all)} + + + Operating: {fmt(s.total_operating)} | Reserve: {fmt(s.total_reserve)} + + + + + + + + + + Total Invested + + + + {fmt(s.operating_investments + s.reserve_investments)} + + + Earning interest across all accounts + + + + )} + + {/* ── Section 2: Current Investments Table ── */} + {snapshot?.investment_accounts && snapshot.investment_accounts.length > 0 && ( + + + Current Investments + + + + + Name + Institution + Type + Fund + Principal + Rate + Maturity + + + + {snapshot.investment_accounts.map((inv) => ( + + {inv.name} + {inv.institution || '-'} + + + {inv.investment_type} + + + + + {inv.fund_type} + + + + {fmt(parseFloat(inv.principal))} + + + {parseFloat(inv.interest_rate || '0').toFixed(2)}% + + + {inv.maturity_date + ? new Date(inv.maturity_date).toLocaleDateString() + : '-'} + + + ))} + +
+
+ )} + + {/* ── Section 3: Market CD Rates ── */} + + + Market CD Rates + {cdRates.length > 0 && ( + + Last fetched: {new Date(cdRates[0].fetched_at).toLocaleString()} + + )} + + {ratesLoading ? ( +
+ +
+ ) : ( + + + + Bank + APY + Term + Min Deposit + + + + {cdRates.map((r, i) => ( + + {r.bank_name} + + {parseFloat(r.apy).toFixed(2)}% + + {r.term} + + {r.min_deposit + ? `$${parseFloat(r.min_deposit).toLocaleString()}` + : '-'} + + + ))} + {cdRates.length === 0 && ( + + + + No CD rates available. Run the fetch-cd-rates script to populate market data. + + + + )} + +
+ )} +
+ + + + {/* ── Section 4: AI Investment Recommendations ── */} + + + + + + +
+ AI Investment Recommendations + + Powered by AI analysis of your complete financial picture + +
+
+ +
+ + {/* Loading State */} + {aiMutation.isPending && ( +
+ + + + Analyzing your financial data and market rates... + + + This may take up to 30 seconds + + +
+ )} + + {/* Results */} + {aiResult && !aiMutation.isPending && ( + + {/* Overall Assessment */} + + {aiResult.overall_assessment} + + + {/* Risk Notes */} + {aiResult.risk_notes && aiResult.risk_notes.length > 0 && ( + } + > + + {aiResult.risk_notes.map((note, i) => ( + + {note} + + ))} + + + )} + + {/* Recommendation Cards */} + {aiResult.recommendations.length > 0 ? ( + + {aiResult.recommendations.map((rec, i) => { + const Icon = typeIcons[rec.type] || IconBulb; + return ( + + + + + + +
+ + {rec.title} + + {rec.priority} + + + {typeLabels[rec.type] || rec.type} + + + {rec.fund_type} + + + + {rec.summary} + +
+ {rec.suggested_amount != null && ( + + {fmt(rec.suggested_amount)} + + )} +
+
+ + + {rec.details} + + {(rec.suggested_term || + rec.suggested_rate != null || + rec.bank_name) && ( + + + {rec.suggested_term && ( +
+ + Suggested Term + + {rec.suggested_term} +
+ )} + {rec.suggested_rate != null && ( +
+ + Target Rate + + + {rec.suggested_rate}% APY + +
+ )} + {rec.bank_name && ( +
+ + Bank + + {rec.bank_name} +
+ )} +
+
+ )} + + + {rec.rationale} + +
+
+
+ ); + })} +
+ ) : ( + + No specific recommendations at this time. + + )} +
+ )} + + {/* Empty State */} + {!aiResult && !aiMutation.isPending && ( + + + + + + AI-Powered Investment Analysis + + + 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. + + + )} +
+
+ ); +} diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..d12f851 --- /dev/null +++ b/scripts/README.md @@ -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` diff --git a/scripts/fetch-cd-rates.ts b/scripts/fetch-cd-rates.ts new file mode 100644 index 0000000..5f57dc4 --- /dev/null +++ b/scripts/fetch-cd-rates.ts @@ -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 { + 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(); + 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 { + 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(); diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..e1c3406 --- /dev/null +++ b/scripts/package.json @@ -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" + } +}