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:
@@ -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
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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}`],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
17
db/migrations/005-cd-rates.sql
Normal file
17
db/migrations/005-cd-rates.sql
Normal 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);
|
||||
@@ -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
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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
47
scripts/README.md
Normal 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
403
scripts/fetch-cd-rates.ts
Normal 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
19
scripts/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user