diff --git a/backend/app/api/v1/predictions.py b/backend/app/api/v1/predictions.py index 7084629..6accb3a 100644 --- a/backend/app/api/v1/predictions.py +++ b/backend/app/api/v1/predictions.py @@ -178,7 +178,9 @@ async def cashflow_forecast( ): await _check_prediction_rate(redis, str(user.id)) from datetime import timedelta + import math import numpy as np + from app.core.security import decrypt_field # Historical daily cash flows (last 90 days) hist_df = await get_daily_cash_flow(db, user.id, days=90) @@ -203,28 +205,95 @@ async def cashflow_forecast( avg_inflow = float(hist_df["inflow"].mean()) avg_outflow = float(hist_df["outflow"].mean()) std_net = float((hist_df["inflow"] - hist_df["outflow"]).std()) + if math.isnan(std_net): + std_net = 0.0 else: avg_inflow = 0.0 avg_outflow = 0.0 std_net = 0.0 - # Project 30 days forward + # Fetch recurring transactions (expenses) + rec_result = await db.execute(text(""" + SELECT description_enc, amount::float, recurring_rule, date + FROM transactions + WHERE user_id = CAST(:uid AS uuid) + AND is_recurring = TRUE + AND type = 'expense' + AND deleted_at IS NULL + AND status != 'void' + ORDER BY date DESC + """), {"uid": str(user.id)}) + rec_rows = rec_result.fetchall() + + _FREQ_DAYS = {"daily": 1, "weekly": 7, "fortnightly": 14, "monthly": 30, "quarterly": 91, "yearly": 365} + + # Build a map of date_str -> extra outflow from recurring payments today = date.today() + horizon = today + timedelta(days=30) + recurring_by_date: dict[str, float] = {} + upcoming_payments: list[dict] = [] + + # Track which (name, freq) pairs we've already scheduled to avoid duplicates + seen_recurring: set[tuple[str, str]] = set() + + for desc_enc, amount, rule, last_date in rec_rows: + rule = rule or {} + freq = rule.get("frequency", "monthly") + interval_days = _FREQ_DAYS.get(freq, 30) + try: + name = decrypt_field(desc_enc) or "Recurring payment" + except Exception: + name = "Recurring payment" + + dedup_key = (name.lower()[:30], freq) + if dedup_key in seen_recurring: + continue + seen_recurring.add(dedup_key) + + # Walk forward from last_date until past the horizon + next_d = last_date + timedelta(days=interval_days) + while next_d <= horizon: + if next_d > today: + ds = next_d.strftime("%Y-%m-%d") + recurring_by_date[ds] = recurring_by_date.get(ds, 0.0) + abs(amount) + upcoming_payments.append({ + "name": name, + "date": ds, + "amount": round(abs(amount), 2), + }) + next_d += timedelta(days=interval_days) + + upcoming_payments.sort(key=lambda x: x["date"]) + + # Project 30 days forward: baseline (avg) + known recurring hits + confidence bands daily = [] running_balance = current_balance for i in range(1, 31): d = today + timedelta(days=i) - net = avg_inflow - avg_outflow - running_balance += net + ds = d.strftime("%Y-%m-%d") + base_net = avg_inflow - avg_outflow + known_outflow = recurring_by_date.get(ds, 0.0) + running_balance += base_net - known_outflow + + # Confidence band widens with sqrt of days elapsed + band = std_net * math.sqrt(i) if std_net > 0 else 0.0 daily.append({ - "date": d.strftime("%Y-%m-%d"), + "date": ds, "balance": round(running_balance, 2), + "upper": round(running_balance + band, 2), + "lower": round(running_balance - band, 2), "avg_inflow": round(avg_inflow, 2), - "avg_outflow": round(avg_outflow, 2), + "avg_outflow": round(avg_outflow + known_outflow, 2), "negative_risk": running_balance < 0, }) negative_days = [d["date"] for d in daily if d["negative_risk"]] + negative_day_set = set(negative_days) + + # Flag upcoming payments that fall on or after the first negative-risk day + first_negative = min(negative_day_set) if negative_day_set else None + for p in upcoming_payments: + p["at_risk"] = first_negative is not None and p["date"] >= first_negative return { "current_balance": round(current_balance, 2), @@ -232,5 +301,6 @@ async def cashflow_forecast( "avg_daily_outflow": round(avg_outflow, 2), "forecast": daily, "negative_risk_days": negative_days, + "upcoming_payments": upcoming_payments, "history_days": len(hist_df), } diff --git a/frontend/src/api/predictions.ts b/frontend/src/api/predictions.ts index 6bb62e8..425c1e3 100644 --- a/frontend/src/api/predictions.ts +++ b/frontend/src/api/predictions.ts @@ -61,17 +61,27 @@ export interface BudgetForecastResponse { export interface CashFlowDay { date: string; balance: number; + upper: number; + lower: number; avg_inflow: number; avg_outflow: number; negative_risk: boolean; } +export interface UpcomingPayment { + name: string; + date: string; + amount: number; + at_risk: boolean; +} + export interface CashFlowResponse { current_balance: number; avg_daily_inflow: number; avg_daily_outflow: number; forecast: CashFlowDay[]; negative_risk_days: string[]; + upcoming_payments: UpcomingPayment[]; history_days: number; } diff --git a/frontend/src/pages/predictions/PredictionsPage.tsx b/frontend/src/pages/predictions/PredictionsPage.tsx index bc659ab..d7bc471 100644 --- a/frontend/src/pages/predictions/PredictionsPage.tsx +++ b/frontend/src/pages/predictions/PredictionsPage.tsx @@ -6,7 +6,7 @@ import { } from "@/api/predictions"; import { formatCurrency } from "@/utils/currency"; import { cn } from "@/utils/cn"; -import { Sparkles, TrendingUp, BarChart3, Wallet, RefreshCw, Loader2 } from "lucide-react"; +import { Sparkles, TrendingUp, BarChart3, Wallet, RefreshCw, Loader2, PiggyBank, AlertTriangle, CheckCircle2 } from "lucide-react"; import { AreaChart, Area, BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, ReferenceLine, @@ -16,16 +16,17 @@ import { cssVar } from "@/utils/cssVar"; import { TOOLTIP_STYLE, ACTIVE_DOT } from "@/utils/chartTheme"; const TABS = [ + { id: "budget", label: "Budget", icon: PiggyBank }, + { id: "cashflow", label: "Cash Flow", icon: Wallet }, { id: "spending", label: "Spending", icon: BarChart3 }, { id: "networth", label: "Net Worth", icon: TrendingUp }, { id: "montecarlo", label: "Monte Carlo", icon: Sparkles }, - { id: "cashflow", label: "Cash Flow", icon: Wallet }, ] as const; type Tab = (typeof TABS)[number]["id"]; export default function PredictionsPage() { - const [tab, setTab] = useState("spending"); + const [tab, setTab] = useState("budget"); return (
@@ -51,10 +52,153 @@ export default function PredictionsPage() { ))}
+ {tab === "budget" && } + {tab === "cashflow" && } {tab === "spending" && } {tab === "networth" && } {tab === "montecarlo" && } - {tab === "cashflow" && } + + ); +} + +// ─── Budget Forecast ───────────────────────────────────────────────────────── + +function BudgetForecastTab() { + const { data, isLoading } = useQuery({ queryKey: ["pred-budget"], queryFn: getBudgetForecast }); + + if (isLoading) return ; + + if (!data || data.forecasts.length === 0) { + return ( +
+ +

No monthly budgets set

+

Set up budgets on the Budgets page to see forecasts here.

+
+ ); + } + + const forecasts = data.forecasts; + const atRisk = forecasts.filter(f => f.probability_overspend > 0.5); + const onTrack = forecasts.filter(f => f.probability_overspend <= 0.5); + const totalForecastOverspend = atRisk.reduce((sum, f) => sum + Math.max(0, f.forecast_month_total - f.budget_amount), 0); + + return ( +
+ {/* Summary bar */} +
+
+

Budgets tracked

+

{forecasts.length}

+
+
0 ? "border-destructive/40" : "border-border")}> +

At risk this month

+

0 ? "text-destructive" : "text-success")}> + {atRisk.length} +

+
+
+

Forecast overspend

+

0 ? "text-destructive" : "text-success")}> + {totalForecastOverspend > 0 ? formatCurrency(totalForecastOverspend, "GBP") : "—"} +

+
+
+ + {atRisk.length === 0 && ( +
+ +

All budgets on track for this month

+
+ )} + + {/* At-risk budgets */} + {atRisk.length > 0 && ( +
+
+ +

At Risk

+
+ {atRisk.map(f => )} +
+ )} + + {/* On-track budgets */} + {onTrack.length > 0 && ( +
+
+ +

On Track

+
+ {onTrack.map(f => )} +
+ )} +
+ ); +} + +function BudgetForecastCard({ forecast: f }: { forecast: import("@/api/predictions").BudgetForecastItem }) { + const spentPct = Math.min(100, (f.spent_so_far / f.budget_amount) * 100); + const forecastPct = Math.min(110, (f.forecast_month_total / f.budget_amount) * 100); + const isOverBudget = f.forecast_month_total > f.budget_amount; + + const riskColor = + f.probability_overspend > 0.7 + ? "text-destructive" + : f.probability_overspend > 0.3 + ? "text-warning" + : "text-success"; + + const barColor = + f.probability_overspend > 0.7 + ? "bg-destructive" + : f.probability_overspend > 0.3 + ? "bg-warning" + : "bg-success"; + + return ( +
+
+
+

{f.category_name}

+

+ {f.days_remaining} days remaining · {formatCurrency(f.daily_velocity, "GBP")}/day +

+
+ 0.7 ? "border-destructive/40" : + f.probability_overspend > 0.3 ? "border-warning/40" : "border-success/40" + )}> + {(f.probability_overspend * 100).toFixed(0)}% risk + +
+ + {/* Progress bar: spent so far */} +
+
+ Spent so far + {formatCurrency(f.spent_so_far, "GBP")} / {formatCurrency(f.budget_amount, "GBP")} +
+
+
+
+
+ + {/* Forecast bar */} +
+
+ Forecast month-end + + {formatCurrency(f.forecast_month_total, "GBP")} + {isOverBudget && (+{formatCurrency(f.forecast_month_total - f.budget_amount, "GBP")})} + +
+
+
+ {/* Budget marker at 100% */} +
+
+
); } @@ -125,48 +269,6 @@ function SpendingTab() { )}
- {/* Budget forecast alert cards */} - -
- ); -} - -function BudgetAlerts() { - const { data } = useQuery({ queryKey: ["pred-budget"], queryFn: getBudgetForecast }); - if (!data?.forecasts.length) return null; - - const atRisk = data.forecasts.filter(f => f.probability_overspend > 0.5); - if (!atRisk.length) return null; - - return ( -
-

Budget Overspend Risk

-
- {atRisk.slice(0, 5).map(f => { - const forecastPct = Math.min(140, (f.forecast_month_total / f.budget_amount) * 100); - return ( -
-
- {f.category_name} - 0.75 ? "text-destructive" : "text-warning")}> - {(f.probability_overspend * 100).toFixed(0)}% overspend risk - -
-
-
0.75 ? "bg-destructive" : "bg-warning")} - style={{ width: `${Math.min(100, forecastPct)}%` }} - /> -
-
-
- Spent: {formatCurrency(f.spent_so_far, "GBP")} - Forecast: {formatCurrency(f.forecast_month_total, "GBP")} / {formatCurrency(f.budget_amount, "GBP")} -
-
- ); - })} -
); } @@ -425,6 +527,7 @@ function CashFlowTab() { if (!data) return ; const hasRisk = data.negative_risk_days.length > 0; + const hasBands = data.forecast.some(d => d.upper !== d.balance); return (
@@ -448,16 +551,23 @@ function CashFlowTab() { {hasRisk && (
- ⚠ Negative balance risk -

- Balance may go negative on: {data.negative_risk_days.slice(0, 5).join(", ")} - {data.negative_risk_days.length > 5 && ` +${data.negative_risk_days.length - 5} more`} -

+ +
+

Negative balance risk

+

+ Balance may go negative on: {data.negative_risk_days.slice(0, 5).join(", ")} + {data.negative_risk_days.length > 5 && ` +${data.negative_risk_days.length - 5} more`} +

+
)} + {/* Chart */}
-

30-Day Balance Forecast

+
+

30-Day Balance Forecast

+ {hasBands &&

Shaded = uncertainty band

} +

Based on {data.history_days} days of transaction history

@@ -469,12 +579,45 @@ function CashFlowTab() { v.slice(5)} /> `£${(v / 1000).toFixed(1)}k`} width={55} /> - formatCurrency(v, "GBP")} /> + [formatCurrency(v, "GBP"), name]} /> + {/* Confidence band: upper then lower fills between them */} + {hasBands && ( + <> + + + + )}
+ + {/* Upcoming recurring payments */} + {data.upcoming_payments.length > 0 && ( +
+

Upcoming Recurring Payments

+
+ {data.upcoming_payments.map((p, i) => ( +
+
+ {p.at_risk && } + {p.name} +
+
+ {p.date} + + -{formatCurrency(p.amount, "GBP")} + +
+
+ ))} +
+
+ )}
); }