diff --git a/src/controllers/dashboard.controller.js b/src/controllers/dashboard.controller.js index feba13f..ce83809 100644 --- a/src/controllers/dashboard.controller.js +++ b/src/controllers/dashboard.controller.js @@ -1,5 +1,85 @@ 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 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 }) + .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')"); + + // 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 (totals[row.type] !== undefined) totals[row.type] += Number(row.total); + } + const avgIncome = totals.income / lookback; + const avgExpense = totals.expense / lookback; + + // 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({ + method: 'rolling_average', + 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