feat: spending forecast endpoint — project income & expenses for next N months#2
feat: spending forecast endpoint — project income & expenses for next N months#2biswajeetdev wants to merge 2 commits into
Conversation
Adds a spending forecast endpoint that uses the last N months of transaction history to project income, expenses, and net balance for the next N months (default 3, configurable via ?months=). Also returns per-category monthly averages for a breakdown view.
biswajeetdev
left a comment
There was a problem hiding this comment.
Code review pass — a few things worth addressing.
| const lookback = Math.max(3, horizon); | ||
|
|
||
| const cutoff = db.raw( | ||
| `NOW() - INTERVAL '${lookback} months'` |
There was a problem hiding this comment.
Template literal in db.raw. lookback is clamped so this is currently safe, but interpolating variables into db.raw() is a pattern that gets copy-pasted into unsafe places. Prefer computing a JS date to avoid raw SQL strings entirely:
const cutoffDate = new Date();
cutoffDate.setMonth(cutoffDate.getMonth() - lookback);
// .where('date', '>=', cutoffDate.toISOString().slice(0, 10))| for (const row of history) { | ||
| if (byType[row.type]) byType[row.type].push(Number(row.total)); | ||
| } | ||
| const avg = (arr) => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0; |
There was a problem hiding this comment.
Sparse-month averaging overstates forecast. avg(byType.income) averages only months with at least one record. If a user had income in 2 of the last 6 months, the average reflects those 2 months — overstating projected monthly income by 3x compared to what a calendar-aware window would show.
Fix: divide total by lookback (the calendar window) instead of byType.income.length. The category breakdown already handles this correctly via months_active — same logic should apply at the aggregate level.
| ), | ||
| })); | ||
|
|
||
| const forecast = nextMonths(horizon).map((month) => ({ |
There was a problem hiding this comment.
Flat projection — document the method. Every forecast month gets identical values (rolling mean, no trend). Fine for v1, but API consumers have no way to know whether numbers reflect a trend or a flat mean. Add a method field so a future regression-based implementation can change it without breaking clients:
res.json({
method: 'rolling_average',
lookback_months: lookback,
// ...
});|
|
||
| router.get('/summary', auth, rbac('read:dashboard'), ctrl.getSummary); | ||
| router.get('/summary', auth, rbac('read:dashboard'), ctrl.getSummary); | ||
| router.get('/forecast', auth, rbac('read:dashboard'), ctrl.getForecast); |
There was a problem hiding this comment.
Missing newline at end of file — causes \ No newline at end of file noise in every future diff on this file.
…g, method field - Replace db.raw template literal with a JS Date cutoff (no raw SQL interpolation) - Divide income/expense totals by the calendar lookback window rather than months-with-data to avoid inflating averages when activity is sparse - Add method:'rolling_average' to response so clients can distinguish from future trend-based implementations
|
Addressed all three review points in follow-up commit (a4e42c1):
|
Summary
GET /api/dashboard/forecastbehind existingauth+rbac('read:dashboard')guards?months=1-12)Response shape
{ "lookback_months": 3, "forecast_horizon": 3, "avg_monthly_income": 4200.00, "avg_monthly_expenses": 3150.00, "forecast": [ { "month": "2026-07", "projected_income": 4200.00, "projected_expenses": 3150.00, "projected_net": 1050.00 }, { "month": "2026-08", "projected_income": 4200.00, "projected_expenses": 3150.00, "projected_net": 1050.00 }, { "month": "2026-09", "projected_income": 4200.00, "projected_expenses": 3150.00, "projected_net": 1050.00 } ], "category_forecast": [ { "category": "Rent", "projected_monthly": 1200.00 }, { "category": "Food", "projected_monthly": 450.00 } ] }Implementation notes
max(3, horizon)— enforces minimum 3 months of data for stable averagesmonths_active(months where the category had any record) to avoid underestimating sparse categoriesTest plan
GET /api/dashboard/forecastreturns 401 without token?months=6returns 6-month horizon with 6-month lookbackprojected_net > 0when income > expenses in history?months=0and?months=99are clamped to valid range (1–12)