Skip to content

feat: spending forecast endpoint — project income & expenses for next N months#2

Open
biswajeetdev wants to merge 2 commits into
mainfrom
feat/spending-forecast
Open

feat: spending forecast endpoint — project income & expenses for next N months#2
biswajeetdev wants to merge 2 commits into
mainfrom
feat/spending-forecast

Conversation

@biswajeetdev
Copy link
Copy Markdown
Owner

Summary

  • Adds GET /api/dashboard/forecast behind existing auth + rbac('read:dashboard') guards
  • Uses the last N months of transaction data to project the next N months (default 3, configurable via ?months=1-12)
  • Returns both an aggregate monthly forecast and a per-category breakdown

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

  • Lookback window is max(3, horizon) — enforces minimum 3 months of data for stable averages
  • Category forecast divides total spend by months_active (months where the category had any record) to avoid underestimating sparse categories
  • Forecast is a simple rolling average — intentionally not using regression to keep the output explainable to end users
  • No new DB migrations required

Test plan

  • GET /api/dashboard/forecast returns 401 without token
  • ?months=6 returns 6-month horizon with 6-month lookback
  • Returns projected_net > 0 when income > expenses in history
  • Empty DB returns zeros gracefully (no division by zero crash)
  • ?months=0 and ?months=99 are clamped to valid range (1–12)

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.
Copy link
Copy Markdown
Owner Author

@biswajeetdev biswajeetdev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review pass — a few things worth addressing.

Comment thread src/controllers/dashboard.controller.js Outdated
const lookback = Math.max(3, horizon);

const cutoff = db.raw(
`NOW() - INTERVAL '${lookback} months'`
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Comment thread src/controllers/dashboard.controller.js Outdated
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;
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) => ({
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@biswajeetdev
Copy link
Copy Markdown
Owner Author

Addressed all three review points in follow-up commit (a4e42c1):

  • Template literal: replaced db.raw with a JS Date cutoff — no raw SQL strings
  • Sparse-month averaging: switched to calendar-window division (total / lookback) so sparse months don't inflate the average
  • Method field: added method: 'rolling_average' to response for forward-compatibility
  • Trailing newline: already present in routes file — no change needed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant