diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d4481..bbea68b 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -133,3 +133,27 @@ 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) + 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) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +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) + title = 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) diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f8..0a92db3 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 import bp as savings_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_bp, url_prefix="/savings") diff --git a/packages/backend/app/routes/savings.py b/packages/backend/app/routes/savings.py new file mode 100644 index 0000000..8229b5b --- /dev/null +++ b/packages/backend/app/routes/savings.py @@ -0,0 +1,326 @@ +from datetime import date +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 +import logging + +bp = Blueprint("savings", __name__) +logger = logging.getLogger("finmind.savings") + + +@bp.get("") +@jwt_required() +def list_savings_goals(): + uid = int(get_jwt_identity()) + goals = ( + db.session.query(SavingsGoal) + .filter_by(user_id=uid) + .order_by(SavingsGoal.created_at.desc()) + .all() + ) + return jsonify([_goal_to_dict(g) for g in goals]) + + +@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="invalid target_amount"), 400 + + deadline = None + if data.get("deadline"): + try: + deadline = date.fromisoformat(data.get("deadline")) + except ValueError: + return jsonify(error="invalid deadline"), 400 + + goal = SavingsGoal( + user_id=uid, + name=name, + target_amount=target_amount, + current_amount=Decimal("0"), + currency=(data.get("currency") or (user.preferred_currency if user else "INR")), + deadline=deadline, + ) + db.session.add(goal) + db.session.commit() + + # Handle milestones if provided + milestones = data.get("milestones", []) + for m in milestones: + milestone_title = (m.get("title") or "").strip() + milestone_amount = _parse_amount(m.get("target_amount")) + if milestone_title and milestone_amount: + ms = SavingsMilestone( + goal_id=goal.id, + title=milestone_title, + target_amount=milestone_amount, + ) + db.session.add(ms) + db.session.commit() + + logger.info("Created savings goal id=%s user=%s target=%s", goal.id, uid, target_amount) + return jsonify(_goal_to_dict(goal)), 201 + + +@bp.get("/") +@jwt_required() +def get_savings_goal(goal_id: int): + 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 + return jsonify(_goal_to_dict(goal)) + + +@bp.patch("/") +@jwt_required() +def update_savings_goal(goal_id: int): + 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 {} + + if "name" in data: + name = (data.get("name") or "").strip() + if not name: + return jsonify(error="name required"), 400 + goal.name = name + + 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="invalid target_amount"), 400 + goal.target_amount = target_amount + + if "current_amount" in data: + current_amount = _parse_amount(data.get("current_amount")) + if current_amount is None: + return jsonify(error="invalid current_amount"), 400 + goal.current_amount = current_amount + # Check if goal is completed + if current_amount >= goal.target_amount and not goal.completed: + goal.completed = True + _check_milestones(goal) + + 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 + + if "currency" in data: + goal.currency = str(data.get("currency") or "INR")[:10] + + db.session.commit() + return jsonify(_goal_to_dict(goal)) + + +@bp.delete("/") +@jwt_required() +def delete_savings_goal(goal_id: int): + 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 + + # Delete associated milestones first + db.session.query(SavingsMilestone).filter_by(goal_id=goal_id).delete() + db.session.delete(goal) + db.session.commit() + return jsonify(message="deleted") + + +@bp.post("//contribute") +@jwt_required() +def contribute_to_goal(goal_id: int): + 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 {} + amount = _parse_amount(data.get("amount")) + if amount is None or amount <= 0: + return jsonify(error="invalid amount"), 400 + + goal.current_amount = goal.current_amount + amount + + # Check if goal is now completed + if goal.current_amount >= goal.target_amount and not goal.completed: + goal.completed = True + + _check_milestones(goal) + db.session.commit() + + return jsonify(_goal_to_dict(goal)) + + +# Milestone endpoints +@bp.get("//milestones") +@jwt_required() +def list_milestones(goal_id: int): + 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 + + milestones = ( + db.session.query(SavingsMilestone) + .filter_by(goal_id=goal_id) + .order_by(SavingsMilestone.target_amount.asc()) + .all() + ) + return jsonify([_milestone_to_dict(m) for m in milestones]) + + +@bp.post("//milestones") +@jwt_required() +def create_milestone(goal_id: int): + 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 {} + title = (data.get("title") or "").strip() + if not title: + return jsonify(error="title required"), 400 + + target_amount = _parse_amount(data.get("target_amount")) + if target_amount is None or target_amount <= 0: + return jsonify(error="invalid target_amount"), 400 + + milestone = SavingsMilestone( + goal_id=goal_id, + title=title, + target_amount=target_amount, + ) + db.session.add(milestone) + db.session.commit() + + # Check if this milestone is already reached + _check_milestones(goal) + + return jsonify(_milestone_to_dict(milestone)), 201 + + +@bp.patch("//milestones/") +@jwt_required() +def update_milestone(goal_id: int, milestone_id: int): + 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 + + milestone = db.session.get(SavingsMilestone, milestone_id) + if not milestone or milestone.goal_id != goal_id: + return jsonify(error="not found"), 404 + + data = request.get_json() or {} + + if "title" in data: + title = (data.get("title") or "").strip() + if not title: + return jsonify(error="title required"), 400 + milestone.title = title + + 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="invalid target_amount"), 400 + milestone.target_amount = target_amount + + db.session.commit() + return jsonify(_milestone_to_dict(milestone)) + + +@bp.delete("//milestones/") +@jwt_required() +def delete_milestone(goal_id: int, milestone_id: int): + 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 + + milestone = db.session.get(SavingsMilestone, milestone_id) + if not milestone or milestone.goal_id != goal_id: + return jsonify(error="not found"), 404 + + db.session.delete(milestone) + db.session.commit() + return jsonify(message="deleted") + + +def _goal_to_dict(g: SavingsGoal) -> dict: + milestones = ( + db.session.query(SavingsMilestone) + .filter_by(goal_id=g.id) + .order_by(SavingsMilestone.target_amount.asc()) + .all() + ) + return { + "id": g.id, + "name": g.name, + "target_amount": float(g.target_amount), + "current_amount": float(g.current_amount), + "currency": g.currency, + "deadline": g.deadline.isoformat() if g.deadline else None, + "completed": g.completed, + "progress_percent": round(float(g.current_amount) / float(g.target_amount) * 100, 1) + if g.target_amount > 0 + else 0, + "milestones": [_milestone_to_dict(m) for m in milestones], + "created_at": g.created_at.isoformat(), + } + + +def _milestone_to_dict(m: SavingsMilestone) -> dict: + return { + "id": m.id, + "goal_id": m.goal_id, + "title": m.title, + "target_amount": float(m.target_amount), + "reached": m.reached, + "reached_at": m.reached_at.isoformat() if m.reached_at else None, + } + + +def _parse_amount(raw) -> Decimal | None: + try: + return Decimal(str(raw)).quantize(Decimal("0.01")) + except (InvalidOperation, ValueError, TypeError): + return None + + +def _check_milestones(goal: SavingsGoal): + """Check and update milestone completion status""" + milestones = ( + db.session.query(SavingsMilestone) + .filter_by(goal_id=goal.id, reached=False) + .order_by(SavingsMilestone.target_amount.asc()) + .all() + ) + from datetime import datetime + + for m in milestones: + if goal.current_amount >= m.target_amount: + m.reached = True + m.reached_at = datetime.utcnow() diff --git a/packages/backend/tests/test_savings.py b/packages/backend/tests/test_savings.py new file mode 100644 index 0000000..9584303 --- /dev/null +++ b/packages/backend/tests/test_savings.py @@ -0,0 +1,129 @@ +from datetime import date, timedelta + + +def test_savings_goals_crud(client, auth_header): + # Initially empty + r = client.get("/savings", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + # Create savings goal + payload = { + "name": "Emergency Fund", + "target_amount": 10000, + "currency": "USD", + "deadline": (date.today() + timedelta(days=180)).isoformat(), + } + r = client.post("/savings", json=payload, headers=auth_header) + assert r.status_code == 201 + goal = r.get_json() + goal_id = goal["id"] + assert goal["name"] == "Emergency Fund" + assert goal["target_amount"] == 10000 + assert goal["current_amount"] == 0 + assert goal["progress_percent"] == 0 + assert goal["completed"] is False + + # Get single goal + r = client.get(f"/savings/{goal_id}", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["id"] == goal_id + + # Contribute to goal + r = client.post(f"/savings/{goal_id}/contribute", json={"amount": 2500}, headers=auth_header) + assert r.status_code == 200 + goal = r.get_json() + assert goal["current_amount"] == 2500 + assert goal["progress_percent"] == 25.0 + + # Update goal + r = client.patch(f"/savings/{goal_id}", json={"current_amount": 5000}, headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["current_amount"] == 5000 + + # Delete goal + r = client.delete(f"/savings/{goal_id}", headers=auth_header) + assert r.status_code == 200 + + # Verify deleted + r = client.get("/savings", headers=auth_header) + assert r.get_json() == [] + + +def test_savings_goal_with_milestones(client, auth_header): + # Create goal with milestones + payload = { + "name": "Vacation Fund", + "target_amount": 5000, + "currency": "USD", + "milestones": [ + {"title": "Flight tickets", "target_amount": 1500}, + {"title": "Hotel", "target_amount": 3500}, + ], + } + r = client.post("/savings", json=payload, headers=auth_header) + assert r.status_code == 201 + goal_id = r.get_json()["id"] + + # Check milestones created + r = client.get(f"/savings/{goal_id}/milestones", headers=auth_header) + assert r.status_code == 200 + milestones = r.get_json() + assert len(milestones) == 2 + assert milestones[0]["title"] == "Flight tickets" + assert milestones[0]["reached"] is False + + # Contribute to reach first milestone + r = client.post(f"/savings/{goal_id}/contribute", json={"amount": 1600}, headers=auth_header) + assert r.status_code == 200 + + # Check milestone reached + r = client.get(f"/savings/{goal_id}/milestones", headers=auth_header) + milestones = r.get_json() + assert milestones[0]["reached"] is True + assert milestones[1]["reached"] is False + + # Add milestone later + r = client.post(f"/savings/{goal_id}/milestones", json={ + "title": "Activities", + "target_amount": 4500 + }, headers=auth_header) + assert r.status_code == 201 + + # Delete milestone + r = client.delete(f"/savings/{goal_id}/milestones/1", headers=auth_header) + assert r.status_code == 200 + + +def test_savings_goal_completion(client, auth_header): + # Create goal + payload = { + "name": "New Laptop", + "target_amount": 2000, + "currency": "USD", + } + r = client.post("/savings", json=payload, headers=auth_header) + assert r.status_code == 201 + goal_id = r.get_json()["id"] + + # Contribute enough to complete + r = client.post(f"/savings/{goal_id}/contribute", json={"amount": 2000}, headers=auth_header) + assert r.status_code == 200 + goal = r.get_json() + assert goal["completed"] is True + assert goal["progress_percent"] == 100.0 + + +def test_savings_goal_defaults_to_user_currency(client, auth_header): + # Set preferred currency + r = client.patch("/auth/me", json={"preferred_currency": "INR"}, headers=auth_header) + assert r.status_code == 200 + + # Create goal without specifying currency + payload = { + "name": "Test Goal", + "target_amount": 5000, + } + r = client.post("/savings", json=payload, headers=auth_header) + assert r.status_code == 201 + assert r.get_json()["currency"] == "INR"