From cd73015e306b3518d17f813cf051930d510df12a Mon Sep 17 00:00:00 2001 From: shaidshark Date: Thu, 2 Apr 2026 19:16:49 +0200 Subject: [PATCH] feat: add savings opportunity detection engine - Add GET /savings-opportunities endpoint to analyze spending patterns - Detect increasing trends in category spending - Identify high-frequency expenses - Detect potential subscriptions - Flag unusual spending spikes - Include confidence scores for recommendations - Add comprehensive test suite - Update OpenAPI documentation - Update README with feature documentation Fixes #119 --- README.md | 19 + packages/backend/app/openapi.yaml | 763 +++++++++++++----- packages/backend/app/routes/__init__.py | 2 + .../app/routes/savings_opportunities.py | 296 +++++++ .../tests/test_savings_opportunities.py | 273 +++++++ 5 files changed, 1156 insertions(+), 197 deletions(-) create mode 100644 packages/backend/app/routes/savings_opportunities.py create mode 100644 packages/backend/tests/test_savings_opportunities.py diff --git a/README.md b/README.md index 49592bffc..056862ef5 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,25 @@ OpenAPI: `backend/app/openapi.yaml` - Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay` - Reminders: CRUD `/reminders`, trigger `/reminders/run` - Insights: `/insights/monthly`, `/insights/budget-suggestion` +- **Savings Opportunities**: `/savings-opportunities` (analyze spending patterns, detect trends, identify savings potential) + +## Savings Opportunity Detection + +The `/savings-opportunities` endpoint analyzes user spending to identify areas where they can reduce expenses: + +**Detection Types:** +- **Increasing Trends**: Categories where spending is consistently increasing month-over-month +- **High-Frequency Expenses**: Small recurring purchases that add up over time +- **Potential Subscriptions**: Detected recurring charges that may be subscriptions +- **Spending Spikes**: Unusual high-spending days that may warrant review + +**Query Parameters:** +- `months`: Number of months to analyze (1-12, default: 6) +- `min_confidence`: Filter by minimum confidence score (0-100, default: 50) + +**Response:** +- `opportunities`: List of detected savings opportunities with type, category, description, potential savings amount, and confidence score +- `summary`: Total analyzed transactions, total potential savings, analysis period ## MVP UI/UX Plan - Auth screens: register/login. diff --git a/packages/backend/app/openapi.yaml b/packages/backend/app/openapi.yaml index 3f8ec3f0f..b18d11171 100644 --- a/packages/backend/app/openapi.yaml +++ b/packages/backend/app/openapi.yaml @@ -4,54 +4,72 @@ info: version: 0.1.0 description: API for budgeting, bills, reminders and insights servers: - - url: http://localhost:8000 +- url: http://localhost:8000 tags: - - name: Auth - - name: Categories - - name: Expenses - - name: Bills - - name: Reminders - - name: Insights +- name: Auth +- name: Categories +- name: Expenses +- name: Bills +- name: Reminders +- name: Insights +- name: Savings Opportunities paths: /auth/register: post: summary: Register user - tags: [Auth] + tags: + - Auth requestBody: required: true content: application/json: schema: type: object - required: [email, password] + required: + - email + - password properties: - email: { type: string, format: email } - password: { type: string, format: password } + email: + type: string + format: email + password: + type: string + format: password example: email: user@example.com password: secret123 responses: - '201': { description: Created } + '201': + description: Created '400': description: Email already used or missing fields content: application/json: - schema: { $ref: '#/components/schemas/Error' } - example: { error: "email already used" } + schema: + $ref: '#/components/schemas/Error' + example: + error: email already used /auth/login: post: summary: Login - tags: [Auth] + tags: + - Auth requestBody: required: true content: application/json: schema: type: object - required: [email, password] + required: + - email + - password properties: - email: { type: string, format: email } - password: { type: string, format: password } + email: + type: string + format: email + password: + type: string + format: password example: email: user@example.com password: secret123 @@ -69,13 +87,17 @@ paths: description: Invalid credentials content: application/json: - schema: { $ref: '#/components/schemas/Error' } - example: { error: "invalid credentials" } + schema: + $ref: '#/components/schemas/Error' + example: + error: invalid credentials /auth/refresh: post: summary: Refresh access token - tags: [Auth] - security: [{ bearerAuth: [] }] + tags: + - Auth + security: + - bearerAuth: [] responses: '200': description: New access token @@ -84,21 +106,25 @@ paths: schema: type: object properties: - access_token: { type: string } + access_token: + type: string example: access_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... '401': description: Missing/invalid refresh token content: application/json: - schema: { $ref: '#/components/schemas/Error' } - example: { error: "Missing Authorization Header" } - + schema: + $ref: '#/components/schemas/Error' + example: + error: Missing Authorization Header /categories: get: summary: List categories - tags: [Categories] - security: [{ bearerAuth: [] }] + tags: + - Categories + security: + - bearerAuth: [] responses: '200': description: List of categories @@ -109,46 +135,59 @@ paths: items: $ref: '#/components/schemas/Category' example: - - { id: 1, name: Groceries } - - { id: 2, name: Utilities } + - id: 1 + name: Groceries + - id: 2 + name: Utilities '401': description: Unauthorized content: application/json: - schema: { $ref: '#/components/schemas/Error' } + schema: + $ref: '#/components/schemas/Error' post: summary: Create category - tags: [Categories] - security: [{ bearerAuth: [] }] + tags: + - Categories + security: + - bearerAuth: [] requestBody: required: true content: application/json: schema: type: object - required: [name] + required: + - name properties: - name: { type: string } - example: { name: Subscriptions } + name: + type: string + example: + name: Subscriptions responses: - '201': { description: Created } + '201': + description: Created '400': description: Validation error content: application/json: - schema: { $ref: '#/components/schemas/Error' } + schema: + $ref: '#/components/schemas/Error' '409': description: Already exists content: application/json: - schema: { $ref: '#/components/schemas/Error' } - example: { error: "category already exists" } - + schema: + $ref: '#/components/schemas/Error' + example: + error: category already exists /expenses: get: summary: List expenses - tags: [Expenses] - security: [{ bearerAuth: [] }] + tags: + - Expenses + security: + - bearerAuth: [] responses: '200': description: List of expenses @@ -159,17 +198,30 @@ paths: items: $ref: '#/components/schemas/Expense' example: - - { id: 10, amount: 12.5, currency: USD, category_id: 1, notes: Lunch, spent_at: 2025-08-10 } - - { id: 11, amount: 49.0, currency: USD, category_id: 2, notes: "", spent_at: 2025-08-09 } + - id: 10 + amount: 12.5 + currency: USD + category_id: 1 + notes: Lunch + spent_at: 2025-08-10 + - id: 11 + amount: 49.0 + currency: USD + category_id: 2 + notes: '' + spent_at: 2025-08-09 '401': description: Unauthorized content: application/json: - schema: { $ref: '#/components/schemas/Error' } + schema: + $ref: '#/components/schemas/Error' post: summary: Create expense - tags: [Expenses] - security: [{ bearerAuth: [] }] + tags: + - Expenses + security: + - bearerAuth: [] requestBody: required: true content: @@ -183,18 +235,21 @@ paths: description: Lunch date: 2025-08-10 responses: - '201': { description: Created } + '201': + description: Created '400': description: Validation error content: application/json: - schema: { $ref: '#/components/schemas/Error' } - + schema: + $ref: '#/components/schemas/Error' /expenses/recurring: get: summary: List recurring expenses - tags: [Expenses] - security: [{ bearerAuth: [] }] + tags: + - Expenses + security: + - bearerAuth: [] responses: '200': description: List of recurring expenses @@ -206,8 +261,10 @@ paths: $ref: '#/components/schemas/RecurringExpense' post: summary: Create recurring expense - tags: [Expenses] - security: [{ bearerAuth: [] }] + tags: + - Expenses + security: + - bearerAuth: [] requestBody: required: true content: @@ -215,32 +272,39 @@ paths: schema: $ref: '#/components/schemas/NewRecurringExpense' responses: - '201': { description: Created } + '201': + description: Created '400': description: Validation error content: application/json: - schema: { $ref: '#/components/schemas/Error' } - + schema: + $ref: '#/components/schemas/Error' /expenses/recurring/{recurringId}/generate: post: summary: Generate concrete expenses from recurring definition - tags: [Expenses] - security: [{ bearerAuth: [] }] + tags: + - Expenses + security: + - bearerAuth: [] parameters: - - in: path - name: recurringId - required: true - schema: { type: integer } + - in: path + name: recurringId + required: true + schema: + type: integer requestBody: required: true content: application/json: schema: type: object - required: [through_date] + required: + - through_date properties: - through_date: { type: string, format: date } + through_date: + type: string + format: date responses: '200': description: Generated count @@ -249,13 +313,15 @@ paths: schema: type: object properties: - inserted: { type: integer } - + inserted: + type: integer /bills: get: summary: List bills - tags: [Bills] - security: [{ bearerAuth: [] }] + tags: + - Bills + security: + - bearerAuth: [] responses: '200': description: List of bills @@ -266,16 +332,26 @@ paths: items: $ref: '#/components/schemas/Bill' example: - - { id: 3, name: Internet, amount: 49.99, currency: USD, next_due_date: 2025-08-15, cadence: MONTHLY, channel_email: true, channel_whatsapp: false } + - id: 3 + name: Internet + amount: 49.99 + currency: USD + next_due_date: 2025-08-15 + cadence: MONTHLY + channel_email: true + channel_whatsapp: false '401': description: Unauthorized content: application/json: - schema: { $ref: '#/components/schemas/Error' } + schema: + $ref: '#/components/schemas/Error' post: summary: Create bill - tags: [Bills] - security: [{ bearerAuth: [] }] + tags: + - Bills + security: + - bearerAuth: [] requestBody: required: true content: @@ -291,23 +367,27 @@ paths: channel_email: true channel_whatsapp: false responses: - '201': { description: Created } + '201': + description: Created '400': description: Validation error content: application/json: - schema: { $ref: '#/components/schemas/Error' } - + schema: + $ref: '#/components/schemas/Error' /bills/{billId}/pay: post: summary: Mark bill paid - tags: [Bills] - security: [{ bearerAuth: [] }] + tags: + - Bills + security: + - bearerAuth: [] parameters: - - in: path - name: billId - required: true - schema: { type: integer } + - in: path + name: billId + required: true + schema: + type: integer responses: '200': description: Updated @@ -316,24 +396,29 @@ paths: schema: type: object properties: - message: { type: string } - example: { message: updated } + message: + type: string + example: + message: updated '401': description: Unauthorized content: application/json: - schema: { $ref: '#/components/schemas/Error' } + schema: + $ref: '#/components/schemas/Error' '404': description: Not found content: application/json: - schema: { $ref: '#/components/schemas/Error' } - + schema: + $ref: '#/components/schemas/Error' /reminders: get: summary: List reminders - tags: [Reminders] - security: [{ bearerAuth: [] }] + tags: + - Reminders + security: + - bearerAuth: [] responses: '200': description: List of reminders @@ -344,16 +429,23 @@ paths: items: $ref: '#/components/schemas/Reminder' example: - - { id: 6, message: Pay credit card, send_at: 2025-08-11T10:00:00Z, sent: false, channel: email } + - id: 6 + message: Pay credit card + send_at: 2025-08-11 10:00:00+00:00 + sent: false + channel: email '401': description: Unauthorized content: application/json: - schema: { $ref: '#/components/schemas/Error' } + schema: + $ref: '#/components/schemas/Error' post: summary: Create reminder - tags: [Reminders] - security: [{ bearerAuth: [] }] + tags: + - Reminders + security: + - bearerAuth: [] requestBody: required: true content: @@ -362,39 +454,46 @@ paths: $ref: '#/components/schemas/NewReminder' example: message: Pay credit card - send_at: 2025-08-11T10:00:00Z + send_at: 2025-08-11 10:00:00+00:00 channel: email responses: - '201': { description: Created } + '201': + description: Created '400': description: Validation error content: application/json: - schema: { $ref: '#/components/schemas/Error' } - + schema: + $ref: '#/components/schemas/Error' /reminders/run: post: summary: Process due reminders - tags: [Reminders] - security: [{ bearerAuth: [] }] + tags: + - Reminders + security: + - bearerAuth: [] responses: - '200': { description: Processed count } + '200': + description: Processed count '401': description: Unauthorized content: application/json: - schema: { $ref: '#/components/schemas/Error' } - + schema: + $ref: '#/components/schemas/Error' /reminders/bills/{billId}/schedule: post: summary: Create bill reminders with default/override offsets - tags: [Reminders] - security: [{ bearerAuth: [] }] + tags: + - Reminders + security: + - bearerAuth: [] parameters: - - in: path - name: billId - required: true - schema: { type: integer } + - in: path + name: billId + required: true + schema: + type: integer requestBody: required: false content: @@ -404,7 +503,8 @@ paths: properties: offsets_days: type: array - items: { type: integer } + items: + type: integer responses: '200': description: Created reminders count @@ -413,27 +513,35 @@ paths: schema: type: object properties: - created: { type: integer } - + created: + type: integer /reminders/bills/{billId}/autopay-result: post: summary: Emit autopay outcome follow-up reminders - tags: [Reminders] - security: [{ bearerAuth: [] }] + tags: + - Reminders + security: + - bearerAuth: [] parameters: - - in: path - name: billId - required: true - schema: { type: integer } + - in: path + name: billId + required: true + schema: + type: integer requestBody: required: true content: application/json: schema: type: object - required: [status] + required: + - status properties: - status: { type: string, enum: [SUCCESS, FAILED] } + status: + type: string + enum: + - SUCCESS + - FAILED responses: '200': description: Created reminders count @@ -442,18 +550,22 @@ paths: schema: type: object properties: - created: { type: integer } - + created: + type: integer /insights/budget-suggestion: get: summary: Get monthly budget suggestion - tags: [Insights] - security: [{ bearerAuth: [] }] + tags: + - Insights + security: + - bearerAuth: [] parameters: - - in: query - name: month - required: false - schema: { type: string, pattern: '^\d{4}-\d{2}$' } + - in: query + name: month + required: false + schema: + type: string + pattern: ^\d{4}-\d{2}$ responses: '200': description: Suggestion @@ -474,13 +586,79 @@ paths: needs: 600 wants: 360 savings: 240 - tips: ["Cap dining spend to 80% of last month", "Automate weekly transfer to savings"] + tips: + - Cap dining spend to 80% of last month + - Automate weekly transfer to savings + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /savings-opportunities: + get: + summary: Get savings opportunities + description: Analyze spending patterns and identify areas where users can reduce + spending. Detects increasing trends, high-frequency expenses, potential subscriptions, + and spending spikes. + tags: + - Savings Opportunities + security: + - bearerAuth: [] + parameters: + - name: months + in: query + description: Number of months to analyze (1-12) + required: false + schema: + type: integer + default: 6 + minimum: 1 + maximum: 12 + - name: min_confidence + in: query + description: Minimum confidence score filter (0-100) + required: false + schema: + type: integer + default: 50 + minimum: 0 + maximum: 100 + responses: + '200': + description: Savings opportunities analysis + content: + application/json: + schema: + $ref: '#/components/schemas/SavingsOpportunitiesResponse' + example: + opportunities: + - type: increasing_trend + category: Food + description: Spending in Food has increased by 25% per month on + average + potential_savings: 150.0 + confidence: 75 + details: + average_monthly_increase_pct: 25.0 + recent_2_months_total: 600.0 + previous_period_total: 300.0 + summary: + total_analyzed: 50 + potential_savings: 450.0 + analysis_period_months: 6 + '400': + description: Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '401': description: Unauthorized content: application/json: - schema: { $ref: '#/components/schemas/Error' } - + schema: + $ref: '#/components/schemas/Error' components: securitySchemes: bearerAuth: @@ -491,99 +669,290 @@ components: Error: type: object properties: - error: { type: string } - example: { error: "invalid credentials" } + error: + type: string + example: + error: invalid credentials TokenPair: type: object properties: - access_token: { type: string } - refresh_token: { type: string } + access_token: + type: string + refresh_token: + type: string Category: type: object properties: - id: { type: integer } - name: { type: string } + id: + type: integer + name: + type: string Expense: type: object properties: - id: { type: integer } - amount: { type: number, format: float } - currency: { type: string } - category_id: { type: integer } - expense_type: { type: string } - description: { type: string, nullable: true } - date: { type: string, format: date } + id: + type: integer + amount: + type: number + format: float + currency: + type: string + category_id: + type: integer + expense_type: + type: string + description: + type: string + nullable: true + date: + type: string + format: date NewExpense: type: object - required: [amount] + required: + - amount properties: - amount: { type: number, format: float } - currency: { type: string, default: USD } - category_id: { type: integer, nullable: true } - expense_type: { type: string, default: EXPENSE } - description: { type: string, nullable: true } - date: { type: string, format: date, nullable: true } + amount: + type: number + format: float + currency: + type: string + default: USD + category_id: + type: integer + nullable: true + expense_type: + type: string + default: EXPENSE + description: + type: string + nullable: true + date: + type: string + format: date + nullable: true RecurringExpense: type: object properties: - id: { type: integer } - amount: { type: number, format: float } - currency: { type: string } - expense_type: { type: string } - category_id: { type: integer, nullable: true } - description: { type: string } - cadence: { type: string, enum: [DAILY, WEEKLY, MONTHLY, YEARLY] } - start_date: { type: string, format: date } - end_date: { type: string, format: date, nullable: true } - active: { type: boolean } + id: + type: integer + amount: + type: number + format: float + currency: + type: string + expense_type: + type: string + category_id: + type: integer + nullable: true + description: + type: string + cadence: + type: string + enum: + - DAILY + - WEEKLY + - MONTHLY + - YEARLY + start_date: + type: string + format: date + end_date: + type: string + format: date + nullable: true + active: + type: boolean NewRecurringExpense: type: object - required: [amount, description, cadence, start_date] + required: + - amount + - description + - cadence + - start_date properties: - amount: { type: number, format: float } - currency: { type: string, default: INR } - expense_type: { type: string, default: EXPENSE } - category_id: { type: integer, nullable: true } - description: { type: string } - cadence: { type: string, enum: [DAILY, WEEKLY, MONTHLY, YEARLY] } - start_date: { type: string, format: date } - end_date: { type: string, format: date, nullable: true } + amount: + type: number + format: float + currency: + type: string + default: INR + expense_type: + type: string + default: EXPENSE + category_id: + type: integer + nullable: true + description: + type: string + cadence: + type: string + enum: + - DAILY + - WEEKLY + - MONTHLY + - YEARLY + start_date: + type: string + format: date + end_date: + type: string + format: date + nullable: true Bill: type: object properties: - id: { type: integer } - name: { type: string } - amount: { type: number, format: float } - currency: { type: string } - next_due_date: { type: string, format: date } - cadence: { type: string, enum: [WEEKLY, MONTHLY, YEARLY, ONCE] } - autopay_enabled: { type: boolean } - channel_email: { type: boolean } - channel_whatsapp: { type: boolean } + id: + type: integer + name: + type: string + amount: + type: number + format: float + currency: + type: string + next_due_date: + type: string + format: date + cadence: + type: string + enum: + - WEEKLY + - MONTHLY + - YEARLY + - ONCE + autopay_enabled: + type: boolean + channel_email: + type: boolean + channel_whatsapp: + type: boolean NewBill: type: object - required: [name, amount, next_due_date] + required: + - name + - amount + - next_due_date properties: - name: { type: string } - amount: { type: number, format: float } - currency: { type: string, default: USD } - next_due_date: { type: string, format: date } - cadence: { type: string, enum: [WEEKLY, MONTHLY, YEARLY, ONCE], default: MONTHLY } - autopay_enabled: { type: boolean, default: false } - channel_email: { type: boolean, default: true } - channel_whatsapp: { type: boolean, default: false } + name: + type: string + amount: + type: number + format: float + currency: + type: string + default: USD + next_due_date: + type: string + format: date + cadence: + type: string + enum: + - WEEKLY + - MONTHLY + - YEARLY + - ONCE + default: MONTHLY + autopay_enabled: + type: boolean + default: false + channel_email: + type: boolean + default: true + channel_whatsapp: + type: boolean + default: false Reminder: type: object properties: - id: { type: integer } - message: { type: string } - send_at: { type: string, format: date-time } - sent: { type: boolean } - channel: { type: string, enum: [email, whatsapp] } + id: + type: integer + message: + type: string + send_at: + type: string + format: date-time + sent: + type: boolean + channel: + type: string + enum: + - email + - whatsapp NewReminder: type: object - required: [message, send_at] + required: + - message + - send_at + properties: + message: + type: string + send_at: + type: string + format: date-time + channel: + type: string + enum: + - email + - whatsapp + default: email + SavingsOpportunitiesResponse: + type: object + required: + - opportunities + - summary + properties: + opportunities: + type: array + items: + $ref: '#/components/schemas/SavingsOpportunity' + summary: + type: object + required: + - total_analyzed + - potential_savings + - analysis_period_months + properties: + total_analyzed: + type: integer + potential_savings: + type: number + analysis_period_months: + type: integer + categories_analyzed: + type: integer + generated_at: + type: string + format: date + SavingsOpportunity: + type: object + required: + - type + - category + - description + - potential_savings + - confidence + - details properties: - message: { type: string } - send_at: { type: string, format: date-time } - channel: { type: string, enum: [email, whatsapp], default: email } + type: + type: string + enum: + - increasing_trend + - high_frequency + - potential_subscription + - spending_spike + category: + type: string + category_id: + type: integer + description: + type: string + potential_savings: + type: number + confidence: + type: integer + minimum: 0 + maximum: 100 + details: + type: object diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f897..8beecbad3 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -7,6 +7,7 @@ from .categories import bp as categories_bp from .docs import bp as docs_bp from .dashboard import bp as dashboard_bp +from .savings_opportunities import bp as savings_opportunities_bp def register_routes(app: Flask): @@ -18,3 +19,4 @@ def register_routes(app: Flask): app.register_blueprint(categories_bp, url_prefix="/categories") app.register_blueprint(docs_bp, url_prefix="/docs") app.register_blueprint(dashboard_bp, url_prefix="/dashboard") + app.register_blueprint(savings_opportunities_bp, url_prefix="/savings-opportunities") diff --git a/packages/backend/app/routes/savings_opportunities.py b/packages/backend/app/routes/savings_opportunities.py new file mode 100644 index 000000000..cdb301171 --- /dev/null +++ b/packages/backend/app/routes/savings_opportunities.py @@ -0,0 +1,296 @@ +from datetime import date, timedelta +from decimal import Decimal +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from sqlalchemy import func, extract, desc +from collections import defaultdict +import logging +from ..extensions import db +from ..models import Expense, Category +from ..services.cache import cache_get, cache_set + +bp = Blueprint('savings_opportunities', __name__) +logger = logging.getLogger('finmind.savings_opportunities') + + +def _savings_cache_key(user_id: int, months: int) -> str: + return f'user:{user_id}:savings_opportunities:{months}' + + +@bp.get('') +@jwt_required() +def get_savings_opportunities(): + """Analyze spending patterns and identify savings opportunities.""" + uid = int(get_jwt_identity()) + + try: + months = min(12, max(1, int(request.args.get('months', 6)))) + min_confidence = min(100, max(0, int(request.args.get('min_confidence', 50)))) + except ValueError: + return jsonify(error='invalid parameters'), 400 + + cache_key = _savings_cache_key(uid, months) + cached = cache_get(cache_key) + if cached: + logger.info('Savings opportunities cache hit user=%s', uid) + return jsonify(cached) + + try: + end_date = date.today() + start_date = end_date - timedelta(days=months * 30) + + expenses = ( + db.session.query(Expense) + .filter( + Expense.user_id == uid, + Expense.spent_at >= start_date, + Expense.spent_at <= end_date, + Expense.expense_type != 'INCOME', + ) + .all() + ) + + if not expenses: + return jsonify({ + 'opportunities': [], + 'summary': { + 'total_analyzed': 0, + 'potential_savings': 0, + 'analysis_period_months': months, + }, + }) + + opportunities = [] + + category_opportunities = _analyze_category_trends(uid, expenses, months) + opportunities.extend(category_opportunities) + + frequency_opportunities = _analyze_high_frequency_expenses(uid, expenses, months) + opportunities.extend(frequency_opportunities) + + recurring_opportunities = _detect_potential_subscriptions(uid, expenses, months) + opportunities.extend(recurring_opportunities) + + spike_opportunities = _detect_spending_spikes(uid, expenses, months) + opportunities.extend(spike_opportunities) + + opportunities = [o for o in opportunities if o['confidence'] >= min_confidence] + opportunities.sort(key=lambda x: x['potential_savings'], reverse=True) + + total_potential = sum(o['potential_savings'] for o in opportunities) + + response = { + 'opportunities': opportunities, + 'summary': { + 'total_analyzed': len(expenses), + 'potential_savings': round(total_potential, 2), + 'analysis_period_months': months, + 'categories_analyzed': len(set(e.category_id for e in expenses if e.category_id)), + }, + 'generated_at': date.today().isoformat(), + } + + cache_set(cache_key, response, ttl_seconds=3600) + + logger.info( + 'Savings opportunities generated user=%s opportunities=%d potential_savings=%.2f', + uid, len(opportunities), total_potential, + ) + + return jsonify(response) + + except Exception as e: + logger.exception('Savings opportunities failed user=%s', uid) + return jsonify(error='failed to analyze savings opportunities', details=str(e)), 500 + + +def _analyze_category_trends(uid: int, expenses: list, months: int) -> list: + """Identify categories with increasing spending trends.""" + opportunities = [] + category_monthly = defaultdict(lambda: defaultdict(Decimal)) + category_names = {} + + for exp in expenses: + if exp.category_id: + month_key = exp.spent_at.strftime('%Y-%m') + category_monthly[exp.category_id][month_key] += exp.amount + + if category_monthly: + cat_ids = list(category_monthly.keys()) + cats = db.session.query(Category).filter(Category.id.in_(cat_ids)).all() + category_names = {c.id: c.name for c in cats} + + for cat_id, monthly_data in category_monthly.items(): + months_list = sorted(monthly_data.keys()) + if len(months_list) < 2: + continue + + values = [float(monthly_data[m]) for m in months_list] + n = len(values) + if n < 2: + continue + + changes = [] + for i in range(1, n): + if values[i-1] > 0: + change_pct = ((values[i] - values[i-1]) / values[i-1]) * 100 + changes.append(change_pct) + + if not changes: + continue + + avg_change = sum(changes) / len(changes) + recent_total = sum(values[-2:]) + older_total = sum(values[:-2]) if len(values) > 2 else values[0] + + if avg_change > 10 and len([c for c in changes if c > 0]) >= len(changes) * 0.6: + if older_total > 0: + potential_savings = recent_total - older_total + if potential_savings > 0: + confidence = min(95, 50 + int(avg_change)) + opportunities.append({ + 'type': 'increasing_trend', + 'category': category_names.get(cat_id, f'Category {cat_id}'), + 'category_id': cat_id, + 'description': f'Spending in {category_names.get(cat_id, "this category")} has increased by {avg_change:.1f}% per month on average', + 'potential_savings': round(potential_savings / 2, 2), + 'confidence': confidence, + 'details': { + 'average_monthly_increase_pct': round(avg_change, 2), + 'recent_2_months_total': round(recent_total, 2), + 'previous_period_total': round(older_total, 2), + 'trend_months': n, + }, + }) + + return opportunities + + +def _analyze_high_frequency_expenses(uid: int, expenses: list, months: int) -> list: + """Identify high-frequency small expenses that add up.""" + opportunities = [] + expense_freq = defaultdict(list) + + for exp in expenses: + if exp.notes: + normalized = exp.notes.lower().strip() + expense_freq[normalized].append(exp) + + for description, exp_list in expense_freq.items(): + if len(exp_list) >= 4: + total = sum(float(e.amount) for e in exp_list) + avg_amount = total / len(exp_list) + + if len(exp_list) >= months: + frequency = 'high' + confidence = 85 + elif len(exp_list) >= months / 2: + frequency = 'moderate' + confidence = 70 + else: + continue + + annual_impact = total * (12 / months) + + if annual_impact > 100: + opportunities.append({ + 'type': 'high_frequency', + 'category': 'Frequent Expense', + 'description': f'Frequent expense "{description[:50]}" occurs {len(exp_list)} times - consider if all are necessary', + 'potential_savings': round(annual_impact * 0.3, 2), + 'confidence': confidence, + 'details': { + 'frequency': frequency, + 'occurrences': len(exp_list), + 'total_spent': round(total, 2), + 'average_amount': round(avg_amount, 2), + 'estimated_annual_impact': round(annual_impact, 2), + 'sample_descriptions': [e.notes for e in exp_list[:3]], + }, + }) + + return opportunities + + +def _detect_potential_subscriptions(uid: int, expenses: list, months: int) -> list: + """Detect recurring charges that might be subscriptions.""" + opportunities = [] + subscription_candidates = defaultdict(list) + + for exp in expenses: + rounded_amount = round(float(exp.amount) / 5) * 5 + key = (rounded_amount, (exp.notes or '')[:20].lower()) + subscription_candidates[key].append(exp) + + for (amount, desc_prefix), exp_list in subscription_candidates.items(): + if len(exp_list) >= 2 and len(exp_list) <= months + 1: + dates = sorted([e.spent_at for e in exp_list]) + intervals = [] + for i in range(1, len(dates)): + interval = (dates[i] - dates[i-1]).days + intervals.append(interval) + + if intervals: + avg_interval = sum(intervals) / len(intervals) + if 25 <= avg_interval <= 38: + total = sum(float(e.amount) for e in exp_list) + annual_cost = (total / len(exp_list)) * 12 + + opportunities.append({ + 'type': 'potential_subscription', + 'category': 'Subscription', + 'description': f'Potential subscription detected: "{exp_list[0].notes[:50]}" - review if still needed', + 'potential_savings': round(annual_cost / 12, 2), + 'confidence': 75, + 'details': { + 'average_amount': round(amount, 2), + 'occurrences': len(exp_list), + 'average_interval_days': round(avg_interval, 1), + 'estimated_annual_cost': round(annual_cost, 2), + 'recent_dates': [d.isoformat() for d in dates[-3:]], + }, + }) + + return opportunities + + +def _detect_spending_spikes(uid: int, expenses: list, months: int) -> list: + """Identify unusual spending spikes.""" + opportunities = [] + daily_spending = defaultdict(Decimal) + + for exp in expenses: + daily_spending[exp.spent_at] += exp.amount + + if len(daily_spending) < 7: + return opportunities + + amounts = [float(v) for v in daily_spending.values()] + avg_daily = sum(amounts) / len(amounts) + variance = sum((x - avg_daily) ** 2 for x in amounts) / len(amounts) + std_dev = variance ** 0.5 + threshold = avg_daily + (2 * std_dev) + + spike_days = [(day, float(amount)) for day, amount in daily_spending.items() if float(amount) > threshold] + + if spike_days: + total_spike_amount = sum(amount for _, amount in spike_days) + excess = sum(amount - avg_daily for _, amount in spike_days) + + opportunities.append({ + 'type': 'spending_spike', + 'category': 'Unusual Spending', + 'description': f'{len(spike_days)} days with unusually high spending detected - review these for potential savings', + 'potential_savings': round(excess * 0.5, 2), + 'confidence': 60, + 'details': { + 'spike_count': len(spike_days), + 'average_daily': round(avg_daily, 2), + 'spike_threshold': round(threshold, 2), + 'total_spike_amount': round(total_spike_amount, 2), + 'excess_amount': round(excess, 2), + 'spike_dates': [day.isoformat() for day, _ in sorted(spike_days, reverse=True)[:5]], + }, + }) + + return opportunities diff --git a/packages/backend/tests/test_savings_opportunities.py b/packages/backend/tests/test_savings_opportunities.py new file mode 100644 index 000000000..14e61f698 --- /dev/null +++ b/packages/backend/tests/test_savings_opportunities.py @@ -0,0 +1,273 @@ +from datetime import date, timedelta +from decimal import Decimal + + +def test_savings_opportunities_empty(client, auth_header): + """Test that empty expense history returns empty opportunities.""" + r = client.get("/savings-opportunities", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + assert "opportunities" in payload + assert "summary" in payload + assert payload["opportunities"] == [] + assert payload["summary"]["total_analyzed"] == 0 + + +def test_savings_opportunities_with_expenses(client, auth_header): + """Test that expenses are analyzed correctly.""" + # Create some expenses + for i in range(5): + r = client.post( + "/expenses", + json={ + "amount": 100 + i * 10, + "description": f"Coffee shop visit {i+1}", + "date": (date.today() - timedelta(days=i*7)).isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.get("/savings-opportunities", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + assert "opportunities" in payload + assert "summary" in payload + assert payload["summary"]["total_analyzed"] == 5 + + +def test_savings_opportunities_high_frequency_detection(client, auth_header): + """Test detection of high-frequency expenses.""" + # Create many similar expenses + for i in range(8): + r = client.post( + "/expenses", + json={ + "amount": 15.00, + "description": "Daily coffee", + "date": (date.today() - timedelta(days=i)).isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.get("/savings-opportunities?months=1", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + assert len(payload["opportunities"]) > 0 + + # Should detect high frequency expense + high_freq = [o for o in payload["opportunities"] if o["type"] == "high_frequency"] + assert len(high_freq) > 0 + assert high_freq[0]["confidence"] >= 70 + + +def test_savings_opportunities_subscription_detection(client, auth_header): + """Test detection of potential subscriptions.""" + # Create monthly recurring expenses + for i in range(3): + r = client.post( + "/expenses", + json={ + "amount": 9.99, + "description": "Netflix subscription", + "date": (date.today() - timedelta(days=i*30)).isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.get("/savings-opportunities?months=3", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + + # Should detect potential subscription + subscriptions = [o for o in payload["opportunities"] if o["type"] == "potential_subscription"] + assert len(subscriptions) > 0 + assert subscriptions[0]["confidence"] == 75 + + +def test_savings_opportunities_category_trend(client, auth_header): + """Test detection of increasing category trends.""" + # Create a category first + r = client.post("/categories", json={"name": "Food"}, headers=auth_header) + assert r.status_code in (200, 201) + cat_id = r.get_json()["id"] + + # Create increasing expenses in category + for i in range(4): + r = client.post( + "/expenses", + json={ + "amount": 100 + i * 50, # Increasing amounts + "description": f"Grocery shopping week {i+1}", + "category_id": cat_id, + "date": (date.today() - timedelta(days=i*30)).isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.get("/savings-opportunities?months=4", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + + # Should detect increasing trend + trends = [o for o in payload["opportunities"] if o["type"] == "increasing_trend"] + # Note: May or may not detect depending on trend calculation + if len(trends) > 0: + assert trends[0]["category"] == "Food" + assert trends[0]["confidence"] >= 50 + + +def test_savings_opportunities_min_confidence_filter(client, auth_header): + """Test filtering by minimum confidence.""" + # Create various expenses + for i in range(10): + r = client.post( + "/expenses", + json={ + "amount": 20 + i * 5, + "description": f"Expense {i+1}", + "date": (date.today() - timedelta(days=i*3)).isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + # Get all opportunities + r = client.get("/savings-opportunities", headers=auth_header) + assert r.status_code == 200 + all_opportunities = r.get_json()["opportunities"] + + # Get only high confidence + r = client.get("/savings-opportunities?min_confidence=80", headers=auth_header) + assert r.status_code == 200 + high_conf_opportunities = r.get_json()["opportunities"] + + # High confidence should be subset of all + assert len(high_conf_opportunities) <= len(all_opportunities) + for opp in high_conf_opportunities: + assert opp["confidence"] >= 80 + + +def test_savings_opportunities_months_parameter(client, auth_header): + """Test the months parameter.""" + # Create expenses + for i in range(3): + r = client.post( + "/expenses", + json={ + "amount": 50, + "description": f"Expense {i+1}", + "date": (date.today() - timedelta(days=i*30)).isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + # Test different month ranges + r = client.get("/savings-opportunities?months=1", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["summary"]["analysis_period_months"] == 1 + + r = client.get("/savings-opportunities?months=6", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["summary"]["analysis_period_months"] == 6 + + +def test_savings_opportunities_invalid_parameters(client, auth_header): + """Test invalid parameter handling.""" + # Invalid months + r = client.get("/savings-opportunities?months=invalid", headers=auth_header) + assert r.status_code == 400 + + # Invalid min_confidence + r = client.get("/savings-opportunities?min_confidence=invalid", headers=auth_header) + assert r.status_code == 400 + + +def test_savings_opportunities_response_structure(client, auth_header): + """Test that response has correct structure.""" + # Create an expense + r = client.post( + "/expenses", + json={ + "amount": 100, + "description": "Test expense", + "date": date.today().isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.get("/savings-opportunities", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + + # Check summary structure + assert "total_analyzed" in payload["summary"] + assert "potential_savings" in payload["summary"] + assert "analysis_period_months" in payload["summary"] + assert "generated_at" in payload + + # If opportunities exist, check their structure + if payload["opportunities"]: + opp = payload["opportunities"][0] + assert "type" in opp + assert "category" in opp + assert "description" in opp + assert "potential_savings" in opp + assert "confidence" in opp + assert "details" in opp + assert isinstance(opp["confidence"], int) + assert 0 <= opp["confidence"] <= 100 + + +def test_savings_opportunities_requires_auth(client): + """Test that endpoint requires authentication.""" + r = client.get("/savings-opportunities") + assert r.status_code == 401 + + +def test_savings_opportunities_excludes_income(client, auth_header): + """Test that income expenses are excluded from analysis.""" + # Create regular expense + r = client.post( + "/expenses", + json={ + "amount": 100, + "description": "Regular expense", + "date": date.today().isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + # Create income (should be excluded) + r = client.post( + "/expenses", + json={ + "amount": 1000, + "description": "Salary", + "date": date.today().isoformat(), + "expense_type": "INCOME", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.get("/savings-opportunities", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + + # Should only analyze 1 expense (the EXPENSE type) + assert payload["summary"]["total_analyzed"] == 1