feat: savings opportunity detection engine (#119)#470
Open
qiridigital wants to merge 1 commit intorohitdash08:mainfrom
Open
feat: savings opportunity detection engine (#119)#470qiridigital wants to merge 1 commit intorohitdash08:mainfrom
qiridigital wants to merge 1 commit intorohitdash08:mainfrom
Conversation
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
There was a problem hiding this comment.
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_opportunitiesservice to compute underspend, subscription-like, and irregular-spend opportunities. - Adds
/insights/savings-opportunitiesJWT-protected route with amonthsquery 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.pyFour independent detectors run in parallel:
consistent_underspendrecurring_subscriptionirregular_big_spendtop_spender_categoriesEndpoint:
GET /insights/savings-opportunities?months=N— look-back window (default 3, max 12)opportunities[]sorted by impact +top_spender_categories[]+summary+total_estimated_monthly_savingFrontend Changes
SavingsOpportunities.tsx— Full dashboard with:api/savings.ts— TypeScript client with complete type definitions/savingsadded toApp.tsx+ nav link inNavbar.tsxTests
13 pytest tests:
monthsparameter handling