Phase 5: AI investment planning - CD rate fetcher and AI recommendation engine

- Add shared.cd_rates table for cross-tenant market data (CD rates from Bankrate)
- Create standalone Puppeteer scraper script (scripts/fetch-cd-rates.ts) for cron-based rate fetching
- Add investment-planning backend module with 3 endpoints: snapshot, cd-rates, recommendations
- AI service gathers tenant financial data (accounts, investments, budgets, projects, cash flow) and calls OpenAI-compatible API (NVIDIA endpoint) for structured investment recommendations
- Create InvestmentPlanningPage with summary cards, current investments table, market CD rates table, and AI recommendation accordion
- Add Investment Planning to sidebar under Planning menu
- Configure AI_API_URL, AI_API_KEY, AI_MODEL environment variables

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 15:31:32 -05:00
parent e0c956859b
commit f7e9c98bd9
14 changed files with 1601 additions and 0 deletions

View File

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

View File

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

View File

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