diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189de..26ab181a 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -34,11 +34,11 @@ CREATE INDEX IF NOT EXISTS idx_expenses_user_spent_at ON expenses(user_id, spent ALTER TABLE expenses ADD COLUMN IF NOT EXISTS expense_type VARCHAR(20) NOT NULL DEFAULT 'EXPENSE'; -DO $$ BEGIN +DO ` BEGIN CREATE TYPE recurring_cadence AS ENUM ('DAILY','WEEKLY','MONTHLY','YEARLY'); EXCEPTION WHEN duplicate_object THEN NULL; -END $$; +END `; CREATE TABLE IF NOT EXISTS recurring_expenses ( id SERIAL PRIMARY KEY, @@ -59,11 +59,11 @@ CREATE INDEX IF NOT EXISTS idx_recurring_expenses_user_start ON recurring_expens ALTER TABLE expenses ADD COLUMN IF NOT EXISTS source_recurring_id INT REFERENCES recurring_expenses(id) ON DELETE SET NULL; -DO $$ BEGIN +DO ` BEGIN CREATE TYPE bill_cadence AS ENUM ('MONTHLY','WEEKLY','YEARLY','ONCE'); EXCEPTION WHEN duplicate_object THEN NULL; -END $$; +END `; CREATE TABLE IF NOT EXISTS bills ( id SERIAL PRIMARY KEY, @@ -123,3 +123,24 @@ CREATE TABLE IF NOT EXISTS audit_logs ( action VARCHAR(100) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); + +-- Detected subscriptions table +CREATE TABLE IF NOT EXISTS detected_subscriptions ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + amount NUMERIC(12,2) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'INR', + detected_cadence VARCHAR(20) NOT NULL, + next_expected_date DATE NOT NULL, + confidence NUMERIC(3,2) NOT NULL, + last_occurrence_date DATE NOT NULL, + occurrence_count INT NOT NULL DEFAULT 1, + pattern_description VARCHAR(500), + confirmed BOOLEAN NOT NULL DEFAULT FALSE, + dismissed BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_detected_subscriptions_user ON detected_subscriptions(user_id, dismissed, confirmed); +CREATE INDEX IF NOT EXISTS idx_detected_subscriptions_pattern ON detected_subscriptions(user_id, pattern_description); diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..7c0a2c2a 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -133,3 +133,102 @@ class AuditLog(db.Model): user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) action = db.Column(db.String(100), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +class SavingsGoal(db.Model): + __tablename__ = 'savings_goals' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + name = db.Column(db.String(200), nullable=False) + description = db.Column(db.String(500), nullable=True) + target_amount = db.Column(db.Numeric(12, 2), nullable=False) + current_amount = db.Column(db.Numeric(12, 2), default=0, nullable=False) + currency = db.Column(db.String(10), default='INR', nullable=False) + deadline = db.Column(db.Date, nullable=True) + completed = db.Column(db.Boolean, default=False, nullable=False) + completed_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + milestones = db.relationship('SavingsMilestone', backref='goal', lazy=True, cascade='all, delete-orphan') + +class SavingsMilestone(db.Model): + __tablename__ = 'savings_milestones' + id = db.Column(db.Integer, primary_key=True) + goal_id = db.Column(db.Integer, db.ForeignKey('savings_goals.id'), nullable=False) + name = db.Column(db.String(200), nullable=False) + target_amount = db.Column(db.Numeric(12, 2), nullable=False) + reached = db.Column(db.Boolean, default=False, nullable=False) + reached_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +class RuleField(str, Enum): + PAYEE = 'payee' + AMOUNT = 'amount' + DESCRIPTION = 'description' + NOTES = 'notes' + +class RuleOperator(str, Enum): + CONTAINS = 'contains' + EQUALS = 'equals' + REGEX = 'regex' + GT = 'gt' + LT = 'lt' + GTE = 'gte' + LTE = 'lte' + STARTSWITH = 'startswith' + ENDSWITH = 'endswith' + +class ConditionType(str, Enum): + AND = 'AND' + OR = 'OR' + +class CategorizationRule(db.Model): + __tablename__ = 'categorization_rules' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + name = db.Column(db.String(100), nullable=False) + field = db.Column(SAEnum(RuleField), nullable=False) + operator = db.Column(SAEnum(RuleOperator), nullable=False) + value = db.Column(db.String(500), nullable=False) + category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True) + tag = db.Column(db.String(100), nullable=True) + priority = db.Column(db.Integer, default=0, nullable=False) + condition_type = db.Column(SAEnum(ConditionType), default=ConditionType.AND, nullable=False) + active = db.Column(db.Boolean, default=True, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'field': self.field.value if self.field else None, + 'operator': self.operator.value if self.operator else None, + 'value': self.value, + 'category_id': self.category_id, + 'tag': self.tag, + 'priority': self.priority, + 'condition_type': self.condition_type.value if self.condition_type else None, + 'active': self.active, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + } + +class RuleCondition(db.Model): + __tablename__ = 'rule_conditions' + id = db.Column(db.Integer, primary_key=True) + rule_id = db.Column(db.Integer, db.ForeignKey('categorization_rules.id', ondelete='CASCADE'), nullable=False) + field = db.Column(SAEnum(RuleField), nullable=False) + operator = db.Column(SAEnum(RuleOperator), nullable=False) + value = db.Column(db.String(500), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + def to_dict(self): + return { + 'id': self.id, + 'rule_id': self.rule_id, + 'field': self.field.value if self.field else None, + 'operator': self.operator.value if self.operator else None, + 'value': self.value, + } diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f89..4680cfdd 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -7,14 +7,18 @@ from .categories import bp as categories_bp from .docs import bp as docs_bp from .dashboard import bp as dashboard_bp +from .savings_goals import bp as savings_goals_bp +from .analytics import bp as analytics_bp def register_routes(app: Flask): - app.register_blueprint(auth_bp, url_prefix="/auth") - app.register_blueprint(expenses_bp, url_prefix="/expenses") - app.register_blueprint(bills_bp, url_prefix="/bills") - app.register_blueprint(reminders_bp, url_prefix="/reminders") - app.register_blueprint(insights_bp, url_prefix="/insights") - 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(auth_bp, url_prefix='/auth') + app.register_blueprint(expenses_bp, url_prefix='/expenses') + app.register_blueprint(bills_bp, url_prefix='/bills') + app.register_blueprint(reminders_bp, url_prefix='/reminders') + app.register_blueprint(insights_bp, url_prefix='/insights') + 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_goals_bp, url_prefix='/savings-goals') + app.register_blueprint(analytics_bp, url_prefix='/analytics') diff --git a/packages/backend/app/routes/savings_goals.py b/packages/backend/app/routes/savings_goals.py new file mode 100644 index 00000000..662ddf24 --- /dev/null +++ b/packages/backend/app/routes/savings_goals.py @@ -0,0 +1,267 @@ +import logging +from datetime import date, datetime, timedelta +from decimal import Decimal, InvalidOperation + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity + +from ..extensions import db +from ..models import SavingsGoal, SavingsMilestone, User + +bp = Blueprint('savings_goals', __name__) +logger = logging.getLogger('finmind.savings_goals') + + +@bp.get('') +@jwt_required() +def list_savings_goals(): + uid = int(get_jwt_identity()) + completed = request.args.get('completed') + query = db.session.query(SavingsGoal).filter_by(user_id=uid) + if completed is not None: + query = query.filter_by(completed=(completed.lower() == 'true')) + goals = query.order_by(SavingsGoal.deadline.asc().nulls_last()).all() + result = [] + for goal in goals: + goal_dict = _goal_to_dict(goal) + goal_dict['progress'] = _calculate_progress(goal) + goal_dict['milestones'] = [_milestone_to_dict(m) for m in sorted(goal.milestones, key=lambda m: m.target_amount)] + result.append(goal_dict) + return jsonify(result) + + +@bp.post('') +@jwt_required() +def create_savings_goal(): + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + data = request.get_json() or {} + name = (data.get('name') or '').strip() + if not name: + return jsonify(error='name required'), 400 + target_amount = _parse_amount(data.get('target_amount')) + if target_amount is None or target_amount <= 0: + return jsonify(error='target_amount must be positive'), 400 + current_amount = _parse_amount(data.get('current_amount')) or Decimal('0') + deadline = None + if data.get('deadline'): + try: + deadline = date.fromisoformat(data.get('deadline')) + except ValueError: + return jsonify(error='invalid deadline'), 400 + completed = current_amount >= target_amount + goal = SavingsGoal( + user_id=uid, name=name, description=data.get('description'), + target_amount=target_amount, current_amount=current_amount, + currency=data.get('currency') or (user.preferred_currency if user else 'INR'), + deadline=deadline, completed=completed, + completed_at=datetime.utcnow() if completed else None + ) + db.session.add(goal) + db.session.flush() + milestones_data = data.get('milestones', []) + for m_data in milestones_data: + m_amount = _parse_amount(m_data.get('target_amount')) + if m_amount is None or m_amount <= 0: + continue + m_reached = current_amount >= m_amount + milestone = SavingsMilestone( + goal_id=goal.id, name=(m_data.get('name') or str(float(m_amount))).strip(), + target_amount=m_amount, reached=m_reached, + reached_at=datetime.utcnow() if m_reached else None + ) + db.session.add(milestone) + db.session.commit() + result = _goal_to_dict(goal) + result['progress'] = _calculate_progress(goal) + result['milestones'] = [_milestone_to_dict(m) for m in sorted(goal.milestones, key=lambda m: m.target_amount)] + return jsonify(result), 201 + + +@bp.get('/') +@jwt_required() +def get_savings_goal(goal_id): + uid = int(get_jwt_identity()) + goal = db.session.get(SavingsGoal, goal_id) + if not goal or goal.user_id != uid: + return jsonify(error='not found'), 404 + result = _goal_to_dict(goal) + result['progress'] = _calculate_progress(goal) + result['milestones'] = [_milestone_to_dict(m) for m in sorted(goal.milestones, key=lambda m: m.target_amount)] + return jsonify(result) + +@bp.patch('/') +@jwt_required() +def update_savings_goal(goal_id): + uid = int(get_jwt_identity()) + goal = db.session.get(SavingsGoal, goal_id) + if not goal or goal.user_id != uid: + return jsonify(error='not found'), 404 + data = request.get_json() or {} + previous_amount = goal.current_amount + if 'name' in data: + name = (data.get('name') or '').strip() + if not name: + return jsonify(error='name cannot be empty'), 400 + goal.name = name + if 'description' in data: + goal.description = data.get('description') + if 'target_amount' in data: + target_amount = _parse_amount(data.get('target_amount')) + if target_amount is None or target_amount <= 0: + return jsonify(error='target_amount must be positive'), 400 + goal.target_amount = target_amount + if 'current_amount' in data: + current_amount = _parse_amount(data.get('current_amount')) + if current_amount is None or current_amount < 0: + return jsonify(error='current_amount must be non-negative'), 400 + goal.current_amount = current_amount + if 'currency' in data: + goal.currency = str(data.get('currency') or 'INR')[:10] + if 'deadline' in data: + if data.get('deadline'): + try: + goal.deadline = date.fromisoformat(data.get('deadline')) + except ValueError: + return jsonify(error='invalid deadline'), 400 + else: + goal.deadline = None + _update_completion_status(goal) + _check_milestones(goal, previous_amount) + db.session.commit() + result = _goal_to_dict(goal) + result['progress'] = _calculate_progress(goal) + result['milestones'] = [_milestone_to_dict(m) for m in sorted(goal.milestones, key=lambda m: m.target_amount)] + return jsonify(result) + +@bp.delete('/') +@jwt_required() +def delete_savings_goal(goal_id): + uid = int(get_jwt_identity()) + goal = db.session.get(SavingsGoal, goal_id) + if not goal or goal.user_id != uid: + return jsonify(error='not found'), 404 + db.session.delete(goal) + db.session.commit() + return jsonify(message='deleted') + + +@bp.post('//contribute') +@jwt_required() +def contribute_to_goal(goal_id): + uid = int(get_jwt_identity()) + goal = db.session.get(SavingsGoal, goal_id) + if not goal or goal.user_id != uid: + return jsonify(error='not found'), 404 + if goal.completed: + return jsonify(error='goal already completed'), 400 + data = request.get_json() or {} + amount = _parse_amount(data.get('amount')) + if amount is None or amount <= 0: + return jsonify(error='amount must be positive'), 400 + previous_amount = goal.current_amount + goal.current_amount = goal.current_amount + amount + _update_completion_status(goal) + newly_completed = _check_milestones(goal, previous_amount) + db.session.commit() + result = _goal_to_dict(goal) + result['progress'] = _calculate_progress(goal) + result['milestones'] = [_milestone_to_dict(m) for m in sorted(goal.milestones, key=lambda m: m.target_amount)] + if newly_completed: + result['milestones_completed'] = [{'name': m.name, 'target_amount': float(m.target_amount)} for m in newly_completed] + if goal.completed: + result['goal_completed'] = True + return jsonify(result) + +@bp.post('//milestones') +@jwt_required() +def add_milestone(goal_id): + uid = int(get_jwt_identity()) + goal = db.session.get(SavingsGoal, goal_id) + if not goal or goal.user_id != uid: + return jsonify(error='not found'), 404 + data = request.get_json() or {} + target_amount = _parse_amount(data.get('target_amount')) + if target_amount is None or target_amount <= 0: + return jsonify(error='target_amount must be positive'), 400 + if target_amount > goal.target_amount: + return jsonify(error='milestone cannot exceed goal target'), 400 + name = (data.get('name') or str(float(target_amount))).strip() + reached = goal.current_amount >= target_amount + milestone = SavingsMilestone( + goal_id=goal.id, name=name, target_amount=target_amount, + reached=reached, reached_at=datetime.utcnow() if reached else None + ) + db.session.add(milestone) + db.session.commit() + return jsonify(_milestone_to_dict(milestone)), 201 + +@bp.delete('//milestones/') +@jwt_required() +def delete_milestone(goal_id, milestone_id): + uid = int(get_jwt_identity()) + goal = db.session.get(SavingsGoal, goal_id) + if not goal or goal.user_id != uid: + return jsonify(error='goal not found'), 404 + milestone = db.session.get(SavingsMilestone, milestone_id) + if not milestone or milestone.goal_id != goal_id: + return jsonify(error='milestone not found'), 404 + db.session.delete(milestone) + db.session.commit() + return jsonify(message='deleted') + + +@bp.get('/dashboard') +@jwt_required() +def savings_goals_dashboard(): + uid = int(get_jwt_identity()) + goals = db.session.query(SavingsGoal).filter_by(user_id=uid).all() + total_target = sum(float(g.target_amount) for g in goals) + total_saved = sum(float(g.current_amount) for g in goals) + completed_count = sum(1 for g in goals if g.completed) + active_count = sum(1 for g in goals if not g.completed) + today = date.today() + upcoming = db.session.query(SavingsGoal).filter_by(user_id=uid, completed=False).filter(SavingsGoal.deadline >= today).order_by(SavingsGoal.deadline.asc()).limit(5).all() + overdue = db.session.query(SavingsGoal).filter_by(user_id=uid, completed=False).filter(SavingsGoal.deadline < today).order_by(SavingsGoal.deadline.asc()).all() + week_ago = datetime.utcnow() - timedelta(days=7) + recent_milestones = db.session.query(SavingsMilestone).join(SavingsGoal).filter(SavingsGoal.user_id == uid).filter(SavingsMilestone.reached == True).filter(SavingsMilestone.reached_at >= week_ago).order_by(SavingsMilestone.reached_at.desc()).limit(10).all() + return jsonify({ + 'summary': {'total_goals': len(goals), 'active_goals': active_count, 'completed_goals': completed_count, 'total_target': round(total_target, 2), 'total_saved': round(total_saved, 2), 'overall_progress': round((total_saved / total_target * 100) if total_target > 0 else 0, 2)}, + 'upcoming_deadlines': [{'id': g.id, 'name': g.name, 'deadline': g.deadline.isoformat() if g.deadline else None, 'target_amount': float(g.target_amount), 'current_amount': float(g.current_amount), 'progress': _calculate_progress(g)} for g in upcoming], + 'overdue_goals': [{'id': g.id, 'name': g.name, 'deadline': g.deadline.isoformat() if g.deadline else None, 'target_amount': float(g.target_amount), 'current_amount': float(g.current_amount), 'progress': _calculate_progress(g)} for g in overdue], + 'recent_milestones': [{'id': m.id, 'goal_id': m.goal_id, 'goal_name': m.goal.name, 'name': m.name, 'target_amount': float(m.target_amount), 'reached_at': m.reached_at.isoformat() if m.reached_at else None} for m in recent_milestones] + }) + + +def _goal_to_dict(goal): + return {'id': goal.id, 'name': goal.name, 'description': goal.description, 'target_amount': float(goal.target_amount), 'current_amount': float(goal.current_amount), 'currency': goal.currency, 'deadline': goal.deadline.isoformat() if goal.deadline else None, 'completed': goal.completed, 'completed_at': goal.completed_at.isoformat() if goal.completed_at else None, 'created_at': goal.created_at.isoformat() if goal.created_at else None, 'updated_at': goal.updated_at.isoformat() if goal.updated_at else None} + +def _milestone_to_dict(milestone): + return {'id': milestone.id, 'goal_id': milestone.goal_id, 'name': milestone.name, 'target_amount': float(milestone.target_amount), 'reached': milestone.reached, 'reached_at': milestone.reached_at.isoformat() if milestone.reached_at else None} + +def _calculate_progress(goal): + if goal.target_amount <= 0: + return {'percentage': 0.0, 'remaining': 0.0} + percentage = min(100.0, (float(goal.current_amount) / float(goal.target_amount)) * 100) + remaining = max(0.0, float(goal.target_amount) - float(goal.current_amount)) + return {'percentage': round(percentage, 2), 'remaining': round(remaining, 2)} + +def _parse_amount(raw): + try: + return Decimal(str(raw)).quantize(Decimal('0.01')) + except (InvalidOperation, ValueError, TypeError): + return None + +def _update_completion_status(goal): + if goal.current_amount >= goal.target_amount and not goal.completed: + goal.completed = True + goal.completed_at = datetime.utcnow() + +def _check_milestones(goal, previous_amount): + newly_completed = [] + for milestone in goal.milestones: + if not milestone.reached and goal.current_amount >= milestone.target_amount: + milestone.reached = True + milestone.reached_at = datetime.utcnow() + newly_completed.append(milestone) + return newly_completed diff --git a/packages/backend/tests/test_savings_goals.py b/packages/backend/tests/test_savings_goals.py new file mode 100644 index 00000000..44bddb3d --- /dev/null +++ b/packages/backend/tests/test_savings_goals.py @@ -0,0 +1,117 @@ +from datetime import date, timedelta + +def test_savings_goals_crud(client, auth_header): + # Initially empty + r = client.get('/savings-goals', headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + # Create goal + payload = { + 'name': 'Vacation Fund', + 'target_amount': 1000.00, + 'current_amount': 0, + 'currency': 'USD', + 'deadline': (date.today() + timedelta(days=90)).isoformat() + } + r = client.post('/savings-goals', json=payload, headers=auth_header) + assert r.status_code == 201 + goal_id = r.get_json()['id'] + + # List has 1 + r = client.get('/savings-goals', headers=auth_header) + assert r.status_code == 200 + items = r.get_json() + assert len(items) == 1 + assert items[0]['name'] == 'Vacation Fund' + + # Get single goal + r = client.get(f'/savings-goals/{goal_id}', headers=auth_header) + assert r.status_code == 200 + goal = r.get_json() + assert goal['target_amount'] == 1000.0 + assert goal['progress']['percentage'] == 0.0 + + # Update goal + r = client.patch(f'/savings-goals/{goal_id}', json={'current_amount': 500}, headers=auth_header) + assert r.status_code == 200 + goal = r.get_json() + assert goal['current_amount'] == 500.0 + assert goal['progress']['percentage'] == 50.0 + + # Delete goal + r = client.delete(f'/savings-goals/{goal_id}', headers=auth_header) + assert r.status_code == 200 + assert r.get_json()['message'] == 'deleted' + +def test_savings_goal_with_milestones(client, auth_header): + payload = { + 'name': 'Emergency Fund', + 'target_amount': 10000.00, + 'current_amount': 0, + 'milestones': [ + {'name': 'First 1000', 'target_amount': 1000}, + {'name': 'Halfway', 'target_amount': 5000}, + {'name': 'Almost there', 'target_amount': 7500} + ] + } + r = client.post('/savings-goals', json=payload, headers=auth_header) + assert r.status_code == 201 + goal = r.get_json() + assert len(goal['milestones']) == 3 + assert all(not m['reached'] for m in goal['milestones']) + +def test_contribute_to_goal(client, auth_header): + # Create goal + payload = {'name': 'New Car', 'target_amount': 5000, 'current_amount': 0} + r = client.post('/savings-goals', json=payload, headers=auth_header) + assert r.status_code == 201 + goal_id = r.get_json()['id'] + + # Add milestone + r = client.post(f'/savings-goals/{goal_id}/milestones', json={'name': 'Quarter', 'target_amount': 1250}, headers=auth_header) + assert r.status_code == 201 + + # Contribute + r = client.post(f'/savings-goals/{goal_id}/contribute', json={'amount': 1000}, headers=auth_header) + assert r.status_code == 200 + goal = r.get_json() + assert goal['current_amount'] == 1000.0 + assert goal['progress']['percentage'] == 20.0 + + # Contribute to reach milestone + r = client.post(f'/savings-goals/{goal_id}/contribute', json={'amount': 250}, headers=auth_header) + assert r.status_code == 200 + goal = r.get_json() + assert 'milestones_completed' in goal + assert len(goal['milestones_completed']) == 1 + +def test_goal_completion(client, auth_header): + payload = {'name': 'Small Goal', 'target_amount': 100, 'current_amount': 0} + r = client.post('/savings-goals', json=payload, headers=auth_header) + assert r.status_code == 201 + goal_id = r.get_json()['id'] + + # Contribute to complete + r = client.post(f'/savings-goals/{goal_id}/contribute', json={'amount': 100}, headers=auth_header) + assert r.status_code == 200 + goal = r.get_json() + assert goal['completed'] == True + assert goal['goal_completed'] == True + + # Cannot contribute to completed goal + r = client.post(f'/savings-goals/{goal_id}/contribute', json={'amount': 50}, headers=auth_header) + assert r.status_code == 400 + +def test_savings_dashboard(client, auth_header): + # Create multiple goals + client.post('/savings-goals', json={'name': 'Goal 1', 'target_amount': 1000, 'current_amount': 500}, headers=auth_header) + client.post('/savings-goals', json={'name': 'Goal 2', 'target_amount': 2000, 'current_amount': 0}, headers=auth_header) + + r = client.get('/savings-goals/dashboard', headers=auth_header) + assert r.status_code == 200 + dashboard = r.get_json() + assert dashboard['summary']['total_goals'] == 2 + assert dashboard['summary']['active_goals'] == 2 + assert dashboard['summary']['total_target'] == 3000.0 + assert dashboard['summary']['total_saved'] == 500.0