Skip to content

feat: savings opportunity detection engine (#119)#470

Open
qiridigital wants to merge 1 commit intorohitdash08:mainfrom
qiridigital:feat/savings-opportunity
Open

feat: savings opportunity detection engine (#119)#470
qiridigital wants to merge 1 commit intorohitdash08:mainfrom
qiridigital:feat/savings-opportunity

Conversation

@qiridigital
Copy link

Savings Opportunity Detection Engine

closes #119 | /claim #119

Summary

Implements a production-ready savings opportunity detection engine that analyses a user's expense history and surfaces actionable, personalised insights for reducing spending.

Backend Changes

New service: services/savings.py
Four independent detectors run in parallel:

Detector Trigger Output
consistent_underspend Recent spend ≥20% below own 3-month average Estimated monthly saving if lower budget locked in
recurring_subscription Same exact amount appears in ≥2 different months Annual cost estimate + months detected
irregular_big_spend Single expense ≥2× category average Comparison to mean, date and notes
top_spender_categories Ranked by average monthly spend % of total spend, 10%-reduction estimate

Endpoint: GET /insights/savings-opportunities

  • ?months=N — look-back window (default 3, max 12)
  • INCOME transactions excluded from all analysis
  • Response: opportunities[] sorted by impact + top_spender_categories[] + summary + total_estimated_monthly_saving

Frontend Changes

  • SavingsOpportunities.tsx — Full dashboard with:
    • Potential saving banner (monthly + annualised)
    • Summary pills (cut budget / subscriptions / big spends)
    • Filter tabs by opportunity type
    • Colour-coded opportunity cards with metric details
    • "Where your money goes" top-spender bar chart
    • Time window selector (1/3/6/12 months)
  • api/savings.ts — TypeScript client with complete type definitions
  • Route /savings added to App.tsx + nav link in Navbar.tsx

Tests

13 pytest tests:

  • No-data smoke test (empty state)
  • Auth protection
  • Invalid/extreme months parameter handling
  • Consistent underspend detection and structure
  • Recurring subscription detection (same-amount in 2+ months)
  • Non-recurring amounts not falsely flagged
  • Top spender categories returned
  • Irregular big spend detection (>2× avg)
  • Income exclusion from all detectors
  • Full response structure validation

Analyses expense history to surface personalised savings opportunities:

1. Consistent underspend: categories where recent spend is 20%+ below
   the 3-month average — user has proven they can spend less
2. Recurring subscriptions: same-amount expenses in 2+ months flagged as
   subscriptions worth reviewing, with estimated annual cost
3. Irregular big spends: single expenses 2x+ the category average,
   annotated with category average for comparison
4. Top spender breakdown: % of total spend per category with estimated
   10%-reduction saving

- GET /insights/savings-opportunities?months=N (default 3, max 12)
- Service: services/savings.py with 4 independent detectors
- Response: opportunities list + top spenders + summary counts +
  total_estimated_monthly_saving
- React SavingsOpportunities page: potential saving banner, summary pills,
  filter tabs by opportunity type, top spenders bar chart
- TypeScript API client with full typed interfaces
- Route /savings added to App.tsx + nav link in Navbar
- 13 pytest tests: smoke, auth, underspend, subscription, top-spenders,
  big-spend, income exclusion, response structure

/claim rohitdash08#119
Copilot AI review requested due to automatic review settings March 16, 2026 21:20
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds an end-to-end “Savings Opportunities” feature: a backend detection service + authenticated insights endpoint, plus a new frontend page to display opportunities and top spenders.

Changes:

  • Introduces detect_savings_opportunities service to compute underspend, subscription-like, and irregular-spend opportunities.
  • Adds /insights/savings-opportunities JWT-protected route with a months query param (capped at 12).
  • Adds frontend page + API client + navbar/route wiring for a new “Savings” section, and adds backend tests.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
packages/backend/app/services/savings.py New detection engine and consolidated report builder.
packages/backend/app/routes/insights.py New authenticated endpoint to serve savings opportunities.
packages/backend/tests/test_savings.py New tests covering endpoint behavior and some detectors.
app/src/api/savings.ts Typed client for the new savings opportunities API.
app/src/pages/SavingsOpportunities.tsx UI to view summary, filter opportunities, and show top spenders.
app/src/components/layout/Navbar.tsx Adds “Savings” navigation item.
app/src/App.tsx Adds protected /savings route.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +48 to +52
db.session.query(
Expense.category_id,
func.strftime("%Y-%m", Expense.spent_at).label("month"),
func.sum(Expense.amount).label("total"),
)
Comment on lines +158 to +162
db.session.query(
Expense.category_id,
Expense.amount,
func.strftime("%Y-%m", Expense.spent_at).label("month"),
func.count(Expense.id).label("cnt"),
Comment on lines +68 to +70
def _category_name(cat_id: int) -> str:
cat = Category.query.get(cat_id)
return cat.name if cat else f"Category #{cat_id}"
Comment on lines +237 to +241
seen_cats: set[int] = set()
for e in big:
avg = avg_map.get(e.category_id, 0)
if avg > 0 and float(e.amount) > 2 * avg and e.category_id not in seen_cats:
seen_cats.add(e.category_id)
"""
uid = int(get_jwt_identity())
try:
months = min(int(request.args.get("months", 3)), 12)
Comment on lines +72 to +79
r = client.get("/insights/savings-opportunities?months=3", headers=auth_header)
data = r.get_json()
underspend_ops = [o for o in data["opportunities"] if o["type"] == "consistent_underspend"]
# May or may not trigger depending on month boundaries, just check structure
for op in underspend_ops:
assert op["category_id"] == cid
assert op["estimated_monthly_saving"] > 0
assert "message" in op
assert "total_opportunities" in summary


import pytest
Comment on lines +180 to +193
const load = async () => {
setLoading(true);
try {
const data = await getSavingsOpportunities(months);
setReport(data);
} finally {
setLoading(false);
}
};

useEffect(() => {
load();
}, [months]);

Comment on lines +17 to +27
def _add_expense(client, auth_header, category_id, amount, spent_at=None, expense_type="EXPENSE", notes=None):
payload = {
"category_id": category_id,
"amount": amount,
"expense_type": expense_type,
"spent_at": spent_at or TODAY,
}
if notes:
payload["notes"] = notes
r = client.post("/expenses", json=payload, headers=auth_header)
assert r.status_code in (200, 201)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Savings opportunity detection engine

2 participants