Skip to content
This repository was archived by the owner on Jun 19, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 2 additions & 0 deletions packages/backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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")
131 changes: 131 additions & 0 deletions packages/backend/app/routes/goals.py
Original file line number Diff line number Diff line change
@@ -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:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generic use of parameter's name, please change it.

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())

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure how it's helpful while creating multiple goals at the same time, it will override the previous one

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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use 412 instead of 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("/<int:goal_id>")
@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("/<int:goal_id>")
@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("/<int:goal_id>/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("/<int:goal_id>")
@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"})
91 changes: 91 additions & 0 deletions packages/backend/tests/test_goals.py
Original file line number Diff line number Diff line change
@@ -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)
Loading