diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d448104..2cd225fa7 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -133,3 +133,16 @@ 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(100), nullable=False) + target_amount = db.Column(db.Numeric(14, 2), nullable=False) + current_amount = db.Column(db.Numeric(14, 2), default=0, nullable=False) + currency = db.Column(db.String(10), default="USD", 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) diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f897..22631662d 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 .goals import bp as goals_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(goals_bp, url_prefix="/goals") diff --git a/packages/backend/app/routes/goals.py b/packages/backend/app/routes/goals.py new file mode 100644 index 000000000..263b92740 --- /dev/null +++ b/packages/backend/app/routes/goals.py @@ -0,0 +1,131 @@ +"""Savings goals routes — track and manage savings milestones.""" + +from datetime import date +from decimal import Decimal +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from ..extensions import db +from ..models import SavingsGoal +import logging + +bp = Blueprint("goals", __name__) +logger = logging.getLogger("finmind.goals") + + +def _serialize(g: SavingsGoal) -> dict: + progress = ( + round(float(g.current_amount) / float(g.target_amount) * 100, 1) + if g.target_amount > 0 + else 0 + ) + return { + "id": g.id, + "name": g.name, + "target_amount": float(g.target_amount), + "current_amount": float(g.current_amount), + "currency": g.currency, + "deadline": str(g.deadline) if g.deadline else None, + "completed": g.completed, + "progress_percent": progress, + "created_at": g.created_at.isoformat(), + } + + +@bp.post("") +@jwt_required() +def create_goal(): + uid = int(get_jwt_identity()) + data = request.get_json(force=True) + name = (data.get("name") or "").strip() + target = data.get("target_amount") + + if not name: + return jsonify({"error": "name is required"}), 400 + if not target or float(target) <= 0: + return jsonify({"error": "target_amount must be positive"}), 400 + + goal = SavingsGoal( + user_id=uid, + name=name, + target_amount=Decimal(str(target)), + current_amount=Decimal(str(data.get("current_amount", 0))), + currency=data.get("currency", "USD"), + deadline=date.fromisoformat(data["deadline"]) if data.get("deadline") else None, + ) + db.session.add(goal) + db.session.commit() + return jsonify(_serialize(goal)), 201 + + +@bp.get("") +@jwt_required() +def list_goals(): + uid = int(get_jwt_identity()) + goals = SavingsGoal.query.filter_by(user_id=uid).order_by( + SavingsGoal.completed, SavingsGoal.created_at.desc() + ).all() + return jsonify([_serialize(g) for g in goals]) + + +@bp.get("/") +@jwt_required() +def get_goal(goal_id: int): + uid = int(get_jwt_identity()) + goal = SavingsGoal.query.filter_by(id=goal_id, user_id=uid).first_or_404() + return jsonify(_serialize(goal)) + + +@bp.patch("/") +@jwt_required() +def update_goal(goal_id: int): + uid = int(get_jwt_identity()) + goal = SavingsGoal.query.filter_by(id=goal_id, user_id=uid).first_or_404() + data = request.get_json(force=True) + + if "name" in data: + goal.name = data["name"] + if "target_amount" in data: + goal.target_amount = Decimal(str(data["target_amount"])) + if "current_amount" in data: + goal.current_amount = Decimal(str(data["current_amount"])) + if "currency" in data: + goal.currency = data["currency"] + if "deadline" in data: + goal.deadline = date.fromisoformat(data["deadline"]) if data["deadline"] else None + + # Auto-complete when target reached + if goal.current_amount >= goal.target_amount: + goal.completed = True + + db.session.commit() + return jsonify(_serialize(goal)) + + +@bp.post("//contribute") +@jwt_required() +def contribute(goal_id: int): + """Add a contribution towards a savings goal.""" + uid = int(get_jwt_identity()) + goal = SavingsGoal.query.filter_by(id=goal_id, user_id=uid).first_or_404() + data = request.get_json(force=True) + amount = data.get("amount") + + if not amount or float(amount) <= 0: + return jsonify({"error": "amount must be positive"}), 400 + + goal.current_amount += Decimal(str(amount)) + if goal.current_amount >= goal.target_amount: + goal.completed = True + + db.session.commit() + return jsonify(_serialize(goal)) + + +@bp.delete("/") +@jwt_required() +def delete_goal(goal_id: int): + uid = int(get_jwt_identity()) + goal = SavingsGoal.query.filter_by(id=goal_id, user_id=uid).first_or_404() + db.session.delete(goal) + db.session.commit() + return jsonify({"message": "deleted"}) diff --git a/packages/backend/tests/test_goals.py b/packages/backend/tests/test_goals.py new file mode 100644 index 000000000..f1ef28bb5 --- /dev/null +++ b/packages/backend/tests/test_goals.py @@ -0,0 +1,91 @@ +"""Tests for savings goals feature.""" + + +def test_create_goal(client, auth_header): + r = client.post("/goals", json={ + "name": "Emergency Fund", + "target_amount": 10000, + "currency": "USD", + "deadline": "2026-12-31", + }, headers=auth_header) + assert r.status_code == 201 + data = r.get_json() + assert data["name"] == "Emergency Fund" + assert data["target_amount"] == 10000 + assert data["progress_percent"] == 0 + assert data["completed"] is False + + +def test_create_goal_validation(client, auth_header): + r = client.post("/goals", json={"name": "", "target_amount": 100}, headers=auth_header) + assert r.status_code == 400 + + r = client.post("/goals", json={"name": "X", "target_amount": -5}, headers=auth_header) + assert r.status_code == 400 + + +def test_list_goals(client, auth_header): + client.post("/goals", json={"name": "A", "target_amount": 100}, headers=auth_header) + client.post("/goals", json={"name": "B", "target_amount": 200}, headers=auth_header) + + r = client.get("/goals", headers=auth_header) + assert r.status_code == 200 + assert len(r.get_json()) == 2 + + +def test_update_goal(client, auth_header): + r = client.post("/goals", json={"name": "Old", "target_amount": 500}, headers=auth_header) + gid = r.get_json()["id"] + + r = client.patch(f"/goals/{gid}", json={"name": "New", "target_amount": 1000}, headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["name"] == "New" + assert r.get_json()["target_amount"] == 1000 + + +def test_contribute(client, auth_header): + r = client.post("/goals", json={"name": "Trip", "target_amount": 500}, headers=auth_header) + gid = r.get_json()["id"] + + r = client.post(f"/goals/{gid}/contribute", json={"amount": 200}, headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["current_amount"] == 200 + assert r.get_json()["progress_percent"] == 40.0 + + r = client.post(f"/goals/{gid}/contribute", json={"amount": 300}, headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["current_amount"] == 500 + assert r.get_json()["completed"] is True + assert r.get_json()["progress_percent"] == 100.0 + + +def test_contribute_validation(client, auth_header): + r = client.post("/goals", json={"name": "X", "target_amount": 100}, headers=auth_header) + gid = r.get_json()["id"] + + r = client.post(f"/goals/{gid}/contribute", json={"amount": -10}, headers=auth_header) + assert r.status_code == 400 + + +def test_auto_complete_on_update(client, auth_header): + r = client.post("/goals", json={"name": "Car", "target_amount": 1000}, headers=auth_header) + gid = r.get_json()["id"] + + r = client.patch(f"/goals/{gid}", json={"current_amount": 1500}, headers=auth_header) + assert r.get_json()["completed"] is True + + +def test_delete_goal(client, auth_header): + r = client.post("/goals", json={"name": "Gone", "target_amount": 50}, headers=auth_header) + gid = r.get_json()["id"] + + r = client.delete(f"/goals/{gid}", headers=auth_header) + assert r.status_code == 200 + + r = client.get("/goals", headers=auth_header) + assert len(r.get_json()) == 0 + + +def test_goals_unauthorized(client): + r = client.get("/goals") + assert r.status_code in (401, 422)