Phase 6: Expand market rates and enhance AI investment recommendations
- Rate fetcher now scrapes CD, Money Market, and High Yield Savings rates from Bankrate.com with pauses between fetches to avoid rate limiting - Historical rate data is preserved (no longer deleted on each fetch) - Database migration adds rate_type column and tenant ai_recommendations table - Backend returns market rates grouped by type with latest-batch-only queries - AI prompt now includes all three rate types for comprehensive analysis - AI recommendations are saved per-tenant for retrieval on page load - Frontend: "Market CD Rates" replaced with "Today's Market Rates" tabbed view - Rates section is collapsible (expanded by default) to save screen space - Saved recommendations load automatically with "Last Updated" timestamp Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
Divider,
|
||||
Accordion,
|
||||
Paper,
|
||||
Tabs,
|
||||
Collapse,
|
||||
ActionIcon,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBulb,
|
||||
@@ -27,6 +30,8 @@ import {
|
||||
IconRefresh,
|
||||
IconCoin,
|
||||
IconPigMoney,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
@@ -59,15 +64,22 @@ interface FinancialSnapshot {
|
||||
}>;
|
||||
}
|
||||
|
||||
interface CdRate {
|
||||
interface MarketRate {
|
||||
bank_name: string;
|
||||
apy: string;
|
||||
min_deposit: string | null;
|
||||
term: string;
|
||||
term_months: number | null;
|
||||
rate_type: string;
|
||||
fetched_at: string;
|
||||
}
|
||||
|
||||
interface MarketRatesResponse {
|
||||
cd: MarketRate[];
|
||||
money_market: MarketRate[];
|
||||
high_yield_savings: MarketRate[];
|
||||
}
|
||||
|
||||
interface Recommendation {
|
||||
type: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
@@ -88,6 +100,15 @@ interface AIResponse {
|
||||
risk_notes: string[];
|
||||
}
|
||||
|
||||
interface SavedRecommendation {
|
||||
id: string;
|
||||
recommendations: Recommendation[];
|
||||
overall_assessment: string;
|
||||
risk_notes: string[];
|
||||
response_time_ms: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
const fmt = (v: number) =>
|
||||
@@ -117,10 +138,198 @@ const typeLabels: Record<string, string> = {
|
||||
general: 'General',
|
||||
};
|
||||
|
||||
// ── Component ──
|
||||
// ── Rate Table Component ──
|
||||
|
||||
function RateTable({ rates, showTerm }: { rates: MarketRate[]; showTerm: boolean }) {
|
||||
if (rates.length === 0) {
|
||||
return (
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
No rates available. Run the market rate fetcher to populate data.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Bank</Table.Th>
|
||||
<Table.Th ta="right">APY</Table.Th>
|
||||
{showTerm && <Table.Th>Term</Table.Th>}
|
||||
<Table.Th ta="right">Min Deposit</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rates.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>
|
||||
{showTerm && <Table.Td>{r.term}</Table.Td>}
|
||||
<Table.Td ta="right" ff="monospace">
|
||||
{r.min_deposit
|
||||
? `$${parseFloat(r.min_deposit).toLocaleString()}`
|
||||
: '-'}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Recommendations Display Component ──
|
||||
|
||||
function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIResponse; lastUpdated?: string }) {
|
||||
return (
|
||||
<Stack>
|
||||
{/* Last Updated timestamp */}
|
||||
{lastUpdated && (
|
||||
<Text size="xs" c="dimmed" ta="right">
|
||||
Last updated: {new Date(lastUpdated).toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ──
|
||||
|
||||
export function InvestmentPlanningPage() {
|
||||
const [aiResult, setAiResult] = useState<AIResponse | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
|
||||
const [ratesExpanded, setRatesExpanded] = useState(true);
|
||||
|
||||
// Load financial snapshot on mount
|
||||
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
|
||||
@@ -131,15 +340,36 @@ export function InvestmentPlanningPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Load CD rates on mount
|
||||
const { data: cdRates = [], isLoading: ratesLoading } = useQuery<CdRate[]>({
|
||||
queryKey: ['investment-planning-cd-rates'],
|
||||
// Load market rates (all types) on mount
|
||||
const { data: marketRates, isLoading: ratesLoading } = useQuery<MarketRatesResponse>({
|
||||
queryKey: ['investment-planning-market-rates'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/investment-planning/cd-rates');
|
||||
const { data } = await api.get('/investment-planning/market-rates');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Load saved recommendation on mount
|
||||
const { data: savedRec } = useQuery<SavedRecommendation | null>({
|
||||
queryKey: ['investment-planning-saved-recommendation'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/investment-planning/saved-recommendation');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Populate AI results from saved recommendation on load
|
||||
useEffect(() => {
|
||||
if (savedRec && !aiResult) {
|
||||
setAiResult({
|
||||
recommendations: savedRec.recommendations,
|
||||
overall_assessment: savedRec.overall_assessment,
|
||||
risk_notes: savedRec.risk_notes,
|
||||
});
|
||||
setLastUpdated(savedRec.created_at);
|
||||
}
|
||||
}, [savedRec]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// AI recommendation (on-demand)
|
||||
const aiMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -148,6 +378,7 @@ export function InvestmentPlanningPage() {
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setAiResult(data);
|
||||
setLastUpdated(new Date().toISOString());
|
||||
if (data.recommendations.length > 0) {
|
||||
notifications.show({
|
||||
message: `Generated ${data.recommendations.length} investment recommendations`,
|
||||
@@ -173,6 +404,23 @@ export function InvestmentPlanningPage() {
|
||||
|
||||
const s = snapshot?.summary;
|
||||
|
||||
// Determine the latest fetched_at timestamp across all rate types
|
||||
const allRatesList = [
|
||||
...(marketRates?.cd || []),
|
||||
...(marketRates?.money_market || []),
|
||||
...(marketRates?.high_yield_savings || []),
|
||||
];
|
||||
const latestFetchedAt = allRatesList.length > 0
|
||||
? allRatesList.reduce((latest, r) =>
|
||||
new Date(r.fetched_at) > new Date(latest.fetched_at) ? r : latest,
|
||||
).fetched_at
|
||||
: null;
|
||||
|
||||
const totalRateCount =
|
||||
(marketRates?.cd?.length || 0) +
|
||||
(marketRates?.money_market?.length || 0) +
|
||||
(marketRates?.high_yield_savings?.length || 0);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{/* Page Header */}
|
||||
@@ -312,57 +560,71 @@ export function InvestmentPlanningPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Section 3: Market CD Rates ── */}
|
||||
{/* ── Section 3: Today's Market Rates (Collapsible with Tabs) ── */}
|
||||
<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 justify="space-between" mb={ratesExpanded ? 'md' : 0}>
|
||||
<Group gap="xs">
|
||||
<Title order={4}>Today's Market Rates</Title>
|
||||
{totalRateCount > 0 && (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{totalRateCount} rates
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
{latestFetchedAt && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Last fetched: {new Date(latestFetchedAt).toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setRatesExpanded((v) => !v)}
|
||||
title={ratesExpanded ? 'Collapse rates' : 'Expand rates'}
|
||||
>
|
||||
{ratesExpanded ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<Collapse in={ratesExpanded}>
|
||||
{ratesLoading ? (
|
||||
<Center py="lg">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
<Tabs defaultValue="cd">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="cd">
|
||||
CDs {(marketRates?.cd?.length || 0) > 0 && (
|
||||
<Badge size="xs" variant="light" ml={4}>{marketRates?.cd?.length}</Badge>
|
||||
)}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="money_market">
|
||||
Money Market {(marketRates?.money_market?.length || 0) > 0 && (
|
||||
<Badge size="xs" variant="light" ml={4}>{marketRates?.money_market?.length}</Badge>
|
||||
)}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="high_yield_savings">
|
||||
High Yield Savings {(marketRates?.high_yield_savings?.length || 0) > 0 && (
|
||||
<Badge size="xs" variant="light" ml={4}>{marketRates?.high_yield_savings?.length}</Badge>
|
||||
)}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="cd" pt="sm">
|
||||
<RateTable rates={marketRates?.cd || []} showTerm={true} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="money_market" pt="sm">
|
||||
<RateTable rates={marketRates?.money_market || []} showTerm={false} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="high_yield_savings" pt="sm">
|
||||
<RateTable rates={marketRates?.high_yield_savings || []} showTerm={false} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
)}
|
||||
</Collapse>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
@@ -409,137 +671,7 @@ export function InvestmentPlanningPage() {
|
||||
|
||||
{/* 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>
|
||||
<RecommendationsDisplay aiResult={aiResult} lastUpdated={lastUpdated || undefined} />
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
@@ -552,7 +684,7 @@ export function InvestmentPlanningPage() {
|
||||
AI-Powered Investment Analysis
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm" maw={500} mx="auto">
|
||||
Click "Get AI Recommendations" to analyze your accounts, cash flow,
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user