diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189de..04fa413b 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -123,3 +123,21 @@ CREATE TABLE IF NOT EXISTS audit_logs ( action VARCHAR(100) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); + +DO $$ BEGIN + CREATE TYPE savings_goal_status AS ENUM ('ACTIVE','COMPLETED','PAUSED'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +CREATE TABLE IF NOT EXISTS savings_goals ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + target_amount NUMERIC(12,2) NOT NULL, + current_amount NUMERIC(12,2) NOT NULL DEFAULT 0, + deadline DATE, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_savings_goals_user ON savings_goals(user_id, created_at DESC); diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..c613636e 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -133,3 +133,23 @@ 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 SavingsGoalStatus(str, Enum): + ACTIVE = "ACTIVE" + COMPLETED = "COMPLETED" + PAUSED = "PAUSED" + + +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), nullable=False, default=0) + deadline = db.Column(db.Date, nullable=True) + status = db.Column( + db.String(20), default=SavingsGoalStatus.ACTIVE.value, 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 f13b0f89..f03a3c32 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/goals") diff --git a/packages/backend/app/routes/savings.py b/packages/backend/app/routes/savings.py new file mode 100644 index 00000000..cab54171 --- /dev/null +++ b/packages/backend/app/routes/savings.py @@ -0,0 +1,163 @@ +from datetime import date +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from ..extensions import db +from ..models import SavingsGoal, SavingsGoalStatus +import logging + +bp = Blueprint("savings", __name__) +logger = logging.getLogger("finmind.savings") + + +def _goal_to_dict(g: SavingsGoal) -> dict: + target = float(g.target_amount) + current = float(g.current_amount) + progress_pct = round((current / target * 100), 2) if target > 0 else 0.0 + return { + "id": g.id, + "name": g.name, + "target_amount": target, + "current_amount": current, + "progress_pct": progress_pct, + "deadline": g.deadline.isoformat() if g.deadline else None, + "status": g.status, + "created_at": g.created_at.isoformat(), + } + + +@bp.get("") +@jwt_required() +def list_goals(): + uid = int(get_jwt_identity()) + goals = ( + db.session.query(SavingsGoal) + .filter_by(user_id=uid) + .order_by(SavingsGoal.created_at.desc()) + .all() + ) + logger.info("List savings goals user=%s count=%s", uid, len(goals)) + return jsonify([_goal_to_dict(g) for g in goals]) + + +@bp.post("") +@jwt_required() +def create_goal(): + uid = int(get_jwt_identity()) + data = request.get_json() or {} + if not data.get("name") or data.get("target_amount") is None: + return jsonify(error="name and target_amount are required"), 400 + try: + target = float(data["target_amount"]) + if target <= 0: + raise ValueError("target_amount must be positive") + except (ValueError, TypeError) as e: + return jsonify(error=str(e)), 400 + + deadline = None + if data.get("deadline"): + try: + deadline = date.fromisoformat(data["deadline"]) + except ValueError: + return jsonify(error="deadline must be ISO date (YYYY-MM-DD)"), 400 + + goal = SavingsGoal( + user_id=uid, + name=data["name"], + target_amount=target, + current_amount=float(data.get("current_amount", 0)), + deadline=deadline, + status=SavingsGoalStatus.ACTIVE.value, + ) + db.session.add(goal) + db.session.commit() + logger.info("Created savings goal id=%s user=%s name=%s", goal.id, uid, goal.name) + return jsonify(_goal_to_dict(goal)), 201 + + +@bp.get("/") +@jwt_required() +def get_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.put("/") +@jwt_required() +def update_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: + goal.name = data["name"] + if "target_amount" in data: + try: + val = float(data["target_amount"]) + if val <= 0: + raise ValueError("target_amount must be positive") + goal.target_amount = val + except (ValueError, TypeError) as e: + return jsonify(error=str(e)), 400 + if "deadline" in data: + if data["deadline"] is None: + goal.deadline = None + else: + try: + goal.deadline = date.fromisoformat(data["deadline"]) + except ValueError: + return jsonify(error="deadline must be ISO date (YYYY-MM-DD)"), 400 + if "status" in data: + try: + goal.status = SavingsGoalStatus(data["status"]).value + except ValueError: + return jsonify(error=f"invalid status: {data['status']}"), 400 + db.session.commit() + logger.info("Updated savings goal id=%s user=%s", goal.id, uid) + return jsonify(_goal_to_dict(goal)) + + +@bp.delete("/") +@jwt_required() +def delete_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 + db.session.delete(goal) + db.session.commit() + logger.info("Deleted savings goal id=%s user=%s", goal_id, uid) + return jsonify(message="deleted") + + +@bp.post("//contribute") +@jwt_required() +def contribute(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 {} + try: + amount = float(data.get("amount", 0)) + if amount <= 0: + raise ValueError("amount must be positive") + except (ValueError, TypeError) as e: + return jsonify(error=str(e)), 400 + + goal.current_amount = float(goal.current_amount) + amount + # Auto-complete if target reached + if float(goal.current_amount) >= float(goal.target_amount): + goal.status = SavingsGoalStatus.COMPLETED.value + db.session.commit() + logger.info( + "Contribution to goal id=%s user=%s amount=%s new_total=%s", + goal.id, + uid, + amount, + goal.current_amount, + ) + return jsonify(_goal_to_dict(goal)) diff --git a/packages/backend/tests/test_savings.py b/packages/backend/tests/test_savings.py new file mode 100644 index 00000000..690037e8 --- /dev/null +++ b/packages/backend/tests/test_savings.py @@ -0,0 +1,247 @@ +from datetime import date, timedelta + + +def test_savings_goals_initially_empty(client, auth_header): + r = client.get("/savings/goals", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + +def test_create_savings_goal(client, auth_header): + payload = { + "name": "Emergency Fund", + "target_amount": 10000.00, + "deadline": (date.today() + timedelta(days=365)).isoformat(), + } + r = client.post("/savings/goals", json=payload, headers=auth_header) + assert r.status_code == 201 + data = r.get_json() + assert data["id"] is not None + assert data["name"] == "Emergency Fund" + assert data["target_amount"] == 10000.0 + assert data["current_amount"] == 0.0 + assert data["progress_pct"] == 0.0 + assert data["status"] == "ACTIVE" + assert data["deadline"] is not None + + +def test_create_savings_goal_no_deadline(client, auth_header): + payload = {"name": "Vacation", "target_amount": 5000.0} + r = client.post("/savings/goals", json=payload, headers=auth_header) + assert r.status_code == 201 + data = r.get_json() + assert data["deadline"] is None + + +def test_create_savings_goal_missing_fields(client, auth_header): + r = client.post("/savings/goals", json={"name": "Test"}, headers=auth_header) + assert r.status_code == 400 + + r = client.post("/savings/goals", json={"target_amount": 100}, headers=auth_header) + assert r.status_code == 400 + + +def test_list_savings_goals(client, auth_header): + client.post( + "/savings/goals", + json={"name": "Goal A", "target_amount": 1000}, + headers=auth_header, + ) + client.post( + "/savings/goals", + json={"name": "Goal B", "target_amount": 2000}, + headers=auth_header, + ) + r = client.get("/savings/goals", headers=auth_header) + assert r.status_code == 200 + items = r.get_json() + assert len(items) == 2 + names = {i["name"] for i in items} + assert "Goal A" in names + assert "Goal B" in names + + +def test_get_savings_goal(client, auth_header): + r = client.post( + "/savings/goals", + json={"name": "House", "target_amount": 50000}, + headers=auth_header, + ) + goal_id = r.get_json()["id"] + + r = client.get(f"/savings/goals/{goal_id}", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["name"] == "House" + + +def test_get_savings_goal_not_found(client, auth_header): + r = client.get("/savings/goals/99999", headers=auth_header) + assert r.status_code == 404 + + +def test_update_savings_goal(client, auth_header): + r = client.post( + "/savings/goals", + json={"name": "Old Name", "target_amount": 1000}, + headers=auth_header, + ) + goal_id = r.get_json()["id"] + + r = client.put( + f"/savings/goals/{goal_id}", + json={"name": "New Name", "target_amount": 2000}, + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + assert data["name"] == "New Name" + assert data["target_amount"] == 2000.0 + + +def test_update_savings_goal_status(client, auth_header): + r = client.post( + "/savings/goals", + json={"name": "Goal", "target_amount": 1000}, + headers=auth_header, + ) + goal_id = r.get_json()["id"] + + r = client.put( + f"/savings/goals/{goal_id}", json={"status": "PAUSED"}, headers=auth_header + ) + assert r.status_code == 200 + assert r.get_json()["status"] == "PAUSED" + + +def test_delete_savings_goal(client, auth_header): + r = client.post( + "/savings/goals", + json={"name": "To Delete", "target_amount": 500}, + headers=auth_header, + ) + goal_id = r.get_json()["id"] + + r = client.delete(f"/savings/goals/{goal_id}", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["message"] == "deleted" + + r = client.get(f"/savings/goals/{goal_id}", headers=auth_header) + assert r.status_code == 404 + + +def test_contribute_to_savings_goal(client, auth_header): + r = client.post( + "/savings/goals", + json={"name": "Car", "target_amount": 5000}, + headers=auth_header, + ) + goal_id = r.get_json()["id"] + + r = client.post( + f"/savings/goals/{goal_id}/contribute", + json={"amount": 500}, + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + assert data["current_amount"] == 500.0 + assert data["progress_pct"] == 10.0 + assert data["status"] == "ACTIVE" + + +def test_contribute_accumulates(client, auth_header): + r = client.post( + "/savings/goals", + json={"name": "Laptop", "target_amount": 1000}, + headers=auth_header, + ) + goal_id = r.get_json()["id"] + + client.post( + f"/savings/goals/{goal_id}/contribute", + json={"amount": 300}, + headers=auth_header, + ) + r = client.post( + f"/savings/goals/{goal_id}/contribute", + json={"amount": 400}, + headers=auth_header, + ) + assert r.status_code == 200 + assert r.get_json()["current_amount"] == 700.0 + + +def test_contribute_auto_completes_goal(client, auth_header): + r = client.post( + "/savings/goals", + json={"name": "Phone", "target_amount": 1000}, + headers=auth_header, + ) + goal_id = r.get_json()["id"] + + r = client.post( + f"/savings/goals/{goal_id}/contribute", + json={"amount": 1000}, + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + assert data["status"] == "COMPLETED" + assert data["progress_pct"] == 100.0 + + +def test_contribute_invalid_amount(client, auth_header): + r = client.post( + "/savings/goals", + json={"name": "Test", "target_amount": 1000}, + headers=auth_header, + ) + goal_id = r.get_json()["id"] + + r = client.post( + f"/savings/goals/{goal_id}/contribute", + json={"amount": -100}, + headers=auth_header, + ) + assert r.status_code == 400 + + r = client.post( + f"/savings/goals/{goal_id}/contribute", json={"amount": 0}, headers=auth_header + ) + assert r.status_code == 400 + + +def test_goals_isolated_between_users(client): + # Register two users + client.post( + "/auth/register", json={"email": "user1@test.com", "password": "pass123"} + ) + client.post( + "/auth/register", json={"email": "user2@test.com", "password": "pass123"} + ) + + r = client.post( + "/auth/login", json={"email": "user1@test.com", "password": "pass123"} + ) + header1 = {"Authorization": f"Bearer {r.get_json()['access_token']}"} + + r = client.post( + "/auth/login", json={"email": "user2@test.com", "password": "pass123"} + ) + header2 = {"Authorization": f"Bearer {r.get_json()['access_token']}"} + + # User1 creates a goal + r = client.post( + "/savings/goals", + json={"name": "Private Goal", "target_amount": 1000}, + headers=header1, + ) + goal_id = r.get_json()["id"] + + # User2 cannot see it + r = client.get("/savings/goals", headers=header2) + assert r.get_json() == [] + + # User2 cannot access it directly + r = client.get(f"/savings/goals/{goal_id}", headers=header2) + assert r.status_code == 404