From 59ec4e0ecc2a465946348b94fec7c636d1a6be0c Mon Sep 17 00:00:00 2001 From: Biswajeet Kumar Date: Sat, 6 Jun 2026 14:58:32 +0530 Subject: [PATCH 1/2] feat: add GET /api/dashboard/forecast endpoint 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. --- src/controllers/dashboard.controller.js | 79 +++++++++++++++++++++++++ src/routes/dashboard.routes.js | 3 +- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/controllers/dashboard.controller.js b/src/controllers/dashboard.controller.js index feba13f..327e95b 100644 --- a/src/controllers/dashboard.controller.js +++ b/src/controllers/dashboard.controller.js @@ -1,5 +1,84 @@ const db = require('../config/database'); +// Build the next N month labels from today (e.g. ["2026-07", "2026-08", "2026-09"]) +function nextMonths(n) { + const months = []; + const d = new Date(); + for (let i = 1; i <= n; i++) { + const m = new Date(d.getFullYear(), d.getMonth() + i, 1); + months.push(`${m.getFullYear()}-${String(m.getMonth() + 1).padStart(2, '0')}`); + } + return months; +} + +exports.getForecast = async (req, res, next) => { + try { + const horizon = Math.min(12, Math.max(1, parseInt(req.query.months, 10) || 3)); + // lookback window mirrors the forecast horizon (min 3 months for stability) + const lookback = Math.max(3, horizon); + + const cutoff = db.raw( + `NOW() - INTERVAL '${lookback} months'` + ); + + const history = await db('financial_records') + .where({ is_deleted: false }) + .where('date', '>=', cutoff) + .select( + db.raw("TO_CHAR(date, 'YYYY-MM') AS month"), + 'type', + db.raw('SUM(amount) AS total') + ) + .groupByRaw("TO_CHAR(date, 'YYYY-MM'), type") + .orderByRaw("TO_CHAR(date, 'YYYY-MM')"); + + // Compute monthly averages per type + const byType = { income: [], expense: [] }; + 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; + const avgIncome = avg(byType.income); + const avgExpense = avg(byType.expense); + + // Category-level averages for expense breakdown + const catHistory = await db('financial_records as r') + .join('categories as c', 'r.category_id', 'c.id') + .where({ 'r.is_deleted': false, 'r.type': 'expense' }) + .where('r.date', '>=', cutoff) + .select( + 'c.name as category', + db.raw('SUM(r.amount) AS total'), + db.raw(`COUNT(DISTINCT TO_CHAR(r.date, 'YYYY-MM')) AS months_active`) + ) + .groupBy('c.name') + .orderBy('total', 'desc'); + + const categoryForecast = catHistory.map((row) => ({ + category: row.category, + projected_monthly: Number( + (Number(row.total) / Math.max(1, Number(row.months_active))).toFixed(2) + ), + })); + + const forecast = nextMonths(horizon).map((month) => ({ + month, + projected_income: Number(avgIncome.toFixed(2)), + projected_expenses: Number(avgExpense.toFixed(2)), + projected_net: Number((avgIncome - avgExpense).toFixed(2)), + })); + + res.json({ + lookback_months: lookback, + forecast_horizon: horizon, + avg_monthly_income: Number(avgIncome.toFixed(2)), + avg_monthly_expenses: Number(avgExpense.toFixed(2)), + forecast, + category_forecast: categoryForecast, + }); + } catch (err) { next(err); } +}; + exports.getSummary = async (req, res, next) => { try { const base = () => db('financial_records').where({ is_deleted: false }); diff --git a/src/routes/dashboard.routes.js b/src/routes/dashboard.routes.js index 151e849..7f2bd78 100644 --- a/src/routes/dashboard.routes.js +++ b/src/routes/dashboard.routes.js @@ -3,6 +3,7 @@ const auth = require('../middleware/auth'); const rbac = require('../middleware/rbac'); const ctrl = require('../controllers/dashboard.controller'); -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); module.exports = router; \ No newline at end of file From a4e42c1f3487f4102cb9a77364b1417d72952a8f Mon Sep 17 00:00:00 2001 From: Biswajeet Kumar Date: Sat, 6 Jun 2026 15:08:04 +0530 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20code=20review=20=E2=80=94?= =?UTF-8?q?=20safe=20cutoff=20date,=20calendar-window=20averaging,=20metho?= =?UTF-8?q?d=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/controllers/dashboard.controller.js | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/controllers/dashboard.controller.js b/src/controllers/dashboard.controller.js index 327e95b..ce83809 100644 --- a/src/controllers/dashboard.controller.js +++ b/src/controllers/dashboard.controller.js @@ -17,9 +17,9 @@ exports.getForecast = async (req, res, next) => { // lookback window mirrors the forecast horizon (min 3 months for stability) const lookback = Math.max(3, horizon); - const cutoff = db.raw( - `NOW() - INTERVAL '${lookback} months'` - ); + const cutoffDate = new Date(); + cutoffDate.setMonth(cutoffDate.getMonth() - lookback); + const cutoff = cutoffDate.toISOString().slice(0, 10); const history = await db('financial_records') .where({ is_deleted: false }) @@ -32,14 +32,14 @@ exports.getForecast = async (req, res, next) => { .groupByRaw("TO_CHAR(date, 'YYYY-MM'), type") .orderByRaw("TO_CHAR(date, 'YYYY-MM')"); - // Compute monthly averages per type - const byType = { income: [], expense: [] }; + // Divide by calendar window (lookback) not by months-with-data to avoid + // overstating averages when activity is sparse (e.g. income in 2 of 6 months). + const totals = { income: 0, expense: 0 }; for (const row of history) { - if (byType[row.type]) byType[row.type].push(Number(row.total)); + if (totals[row.type] !== undefined) totals[row.type] += Number(row.total); } - const avg = (arr) => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0; - const avgIncome = avg(byType.income); - const avgExpense = avg(byType.expense); + const avgIncome = totals.income / lookback; + const avgExpense = totals.expense / lookback; // Category-level averages for expense breakdown const catHistory = await db('financial_records as r') @@ -69,8 +69,9 @@ exports.getForecast = async (req, res, next) => { })); res.json({ - lookback_months: lookback, - forecast_horizon: horizon, + method: 'rolling_average', + lookback_months: lookback, + forecast_horizon: horizon, avg_monthly_income: Number(avgIncome.toFixed(2)), avg_monthly_expenses: Number(avgExpense.toFixed(2)), forecast,