Skip to content
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
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 .savings import bp as savings_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(savings_bp, url_prefix="/savings")
80 changes: 80 additions & 0 deletions packages/backend/app/routes/savings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from flask import Blueprint, jsonify, request
from flask_jwt_extended import jwt_required, get_jwt_identity
from ..services.savings import (
create_goal, list_goals, get_goal, contribute, update_goal, delete_goal,
)
import logging

bp = Blueprint("savings", __name__)
logger = logging.getLogger("finmind.savings")


@bp.post("")
@jwt_required()
def create():
uid = int(get_jwt_identity())
data = request.get_json() or {}
name = data.get("name", "").strip()
target = data.get("target_amount")
if not name or not target:
return jsonify({"error": "name and target_amount are required"}), 400
result = create_goal(
uid, name, float(target),
currency=data.get("currency", "INR"),
deadline=data.get("deadline"),
)
logger.info("Goal created id=%s user=%s", result["id"], uid)
return jsonify(result), 201


@bp.get("")
@jwt_required()
def list_all():
uid = int(get_jwt_identity())
return jsonify(list_goals(uid))


@bp.get("/<int:gid>")
@jwt_required()
def detail(gid):
uid = int(get_jwt_identity())
goal = get_goal(uid, gid)
if not goal:
return jsonify({"error": "Goal not found"}), 404
return jsonify(goal)


@bp.post("/<int:gid>/contribute")
@jwt_required()
def add_contribution(gid):
uid = int(get_jwt_identity())
data = request.get_json() or {}
amount = data.get("amount")
if not amount or float(amount) <= 0:
return jsonify({"error": "Positive amount required"}), 400
try:
result = contribute(uid, gid, float(amount))
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400


@bp.patch("/<int:gid>")
@jwt_required()
def update(gid):
uid = int(get_jwt_identity())
data = request.get_json() or {}
try:
result = update_goal(uid, gid, **data)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400


@bp.delete("/<int:gid>")
@jwt_required()
def delete(gid):
uid = int(get_jwt_identity())
if delete_goal(uid, gid):
return jsonify({"deleted": True})
return jsonify({"error": "Goal not found"}), 404
152 changes: 152 additions & 0 deletions packages/backend/app/services/savings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Savings goals service.

Track savings goals with target amounts, deadlines, and milestone progress.
"""

from datetime import date, datetime

from sqlalchemy import func

from ..extensions import db
from ..models import Expense


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)
achieved = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
milestones = db.relationship("GoalMilestone", backref="goal", lazy=True,
order_by="GoalMilestone.threshold_pct")


class GoalMilestone(db.Model):
__tablename__ = "goal_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)
threshold_pct = db.Column(db.Integer, nullable=False) # e.g. 25, 50, 75, 100
reached = db.Column(db.Boolean, default=False, nullable=False)
reached_at = db.Column(db.DateTime, nullable=True)


def _goal_to_dict(g: SavingsGoal) -> dict:
target = float(g.target_amount)
current = float(g.current_amount)
progress = round((current / target * 100), 2) if target > 0 else 0
days_left = (g.deadline - date.today()).days if g.deadline else None
return {
"id": g.id,
"name": g.name,
"target_amount": target,
"current_amount": current,
"currency": g.currency,
"progress_pct": progress,
"deadline": g.deadline.isoformat() if g.deadline else None,
"days_left": days_left,
"achieved": g.achieved,
"milestones": [
{
"id": m.id,
"name": m.name,
"threshold_pct": m.threshold_pct,
"reached": m.reached,
"reached_at": m.reached_at.isoformat() if m.reached_at else None,
}
for m in g.milestones
],
}


def _check_milestones(goal: SavingsGoal):
"""Update milestone status based on current progress."""
target = float(goal.target_amount)
current = float(goal.current_amount)
if target <= 0:
return
progress = current / target * 100
for m in goal.milestones:
if not m.reached and progress >= m.threshold_pct:
m.reached = True
m.reached_at = datetime.utcnow()
if current >= target and not goal.achieved:
goal.achieved = True


DEFAULT_MILESTONES = [
(25, "25% — Quarter way there!"),
(50, "50% — Halfway!"),
(75, "75% — Almost there!"),
(100, "100% — Goal achieved!"),
]


def create_goal(user_id: int, name: str, target_amount: float,
currency: str = "INR", deadline: str | None = None) -> dict:
dl = date.fromisoformat(deadline) if deadline else None
g = SavingsGoal(
user_id=user_id, name=name, target_amount=target_amount,
currency=currency, deadline=dl,
)
db.session.add(g)
db.session.flush()
for pct, label in DEFAULT_MILESTONES:
m = GoalMilestone(goal_id=g.id, name=label, threshold_pct=pct)
db.session.add(m)
db.session.commit()
return _goal_to_dict(g)


def list_goals(user_id: int) -> list[dict]:
goals = SavingsGoal.query.filter_by(user_id=user_id).order_by(
SavingsGoal.created_at.desc()
).all()
return [_goal_to_dict(g) for g in goals]


def get_goal(user_id: int, goal_id: int) -> dict | None:
g = SavingsGoal.query.filter_by(id=goal_id, user_id=user_id).first()
return _goal_to_dict(g) if g else None


def contribute(user_id: int, goal_id: int, amount: float) -> dict:
g = SavingsGoal.query.filter_by(id=goal_id, user_id=user_id).first()
if not g:
raise ValueError("Goal not found.")
if amount <= 0:
raise ValueError("Amount must be positive.")
g.current_amount = float(g.current_amount) + amount
_check_milestones(g)
db.session.commit()
return _goal_to_dict(g)


def update_goal(user_id: int, goal_id: int, **kwargs) -> dict:
g = SavingsGoal.query.filter_by(id=goal_id, user_id=user_id).first()
if not g:
raise ValueError("Goal not found.")
if "name" in kwargs and kwargs["name"]:
g.name = kwargs["name"]
if "target_amount" in kwargs and kwargs["target_amount"]:
g.target_amount = kwargs["target_amount"]
if "deadline" in kwargs:
g.deadline = date.fromisoformat(kwargs["deadline"]) if kwargs["deadline"] else None
_check_milestones(g)
db.session.commit()
return _goal_to_dict(g)


def delete_goal(user_id: int, goal_id: int) -> bool:
g = SavingsGoal.query.filter_by(id=goal_id, user_id=user_id).first()
if not g:
return False
GoalMilestone.query.filter_by(goal_id=g.id).delete()
db.session.delete(g)
db.session.commit()
return True
99 changes: 99 additions & 0 deletions packages/backend/tests/test_savings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
def test_create_goal(client, auth_header):
r = client.post("/savings", json={
"name": "Emergency Fund",
"target_amount": 1000,
"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"] == 1000
assert data["progress_pct"] == 0
assert len(data["milestones"]) == 4


def test_create_goal_missing_fields(client, auth_header):
r = client.post("/savings", json={"name": ""}, headers=auth_header)
assert r.status_code == 400


def test_list_goals(client, auth_header):
client.post("/savings", json={
"name": "Vacation", "target_amount": 500,
}, headers=auth_header)
r = client.get("/savings", headers=auth_header)
assert r.status_code == 200
assert len(r.get_json()) >= 1


def test_get_goal_detail(client, auth_header):
r = client.post("/savings", json={
"name": "Car", "target_amount": 5000,
}, headers=auth_header)
gid = r.get_json()["id"]
r = client.get(f"/savings/{gid}", headers=auth_header)
assert r.status_code == 200
assert r.get_json()["name"] == "Car"


def test_contribute_and_milestones(client, auth_header):
r = client.post("/savings", json={
"name": "Laptop", "target_amount": 100,
}, headers=auth_header)
gid = r.get_json()["id"]

# Contribute 50 -> 50%
r = client.post(f"/savings/{gid}/contribute", json={"amount": 50}, headers=auth_header)
assert r.status_code == 200
data = r.get_json()
assert data["progress_pct"] == 50.0
reached = [m for m in data["milestones"] if m["reached"]]
assert len(reached) == 2 # 25% and 50%

# Contribute 50 more -> 100%
r = client.post(f"/savings/{gid}/contribute", json={"amount": 50}, headers=auth_header)
data = r.get_json()
assert data["achieved"] is True
assert data["progress_pct"] == 100.0
reached = [m for m in data["milestones"] if m["reached"]]
assert len(reached) == 4


def test_contribute_invalid_amount(client, auth_header):
r = client.post("/savings", json={
"name": "Test", "target_amount": 100,
}, headers=auth_header)
gid = r.get_json()["id"]
r = client.post(f"/savings/{gid}/contribute", json={"amount": -10}, headers=auth_header)
assert r.status_code == 400


def test_update_goal(client, auth_header):
r = client.post("/savings", json={
"name": "Old Name", "target_amount": 100,
}, headers=auth_header)
gid = r.get_json()["id"]
r = client.patch(f"/savings/{gid}", json={"name": "New Name"}, headers=auth_header)
assert r.status_code == 200
assert r.get_json()["name"] == "New Name"


def test_delete_goal(client, auth_header):
r = client.post("/savings", json={
"name": "Delete Me", "target_amount": 50,
}, headers=auth_header)
gid = r.get_json()["id"]
r = client.delete(f"/savings/{gid}", headers=auth_header)
assert r.status_code == 200
r = client.get(f"/savings/{gid}", headers=auth_header)
assert r.status_code == 404


def test_goal_not_found(client, auth_header):
r = client.get("/savings/99999", headers=auth_header)
assert r.status_code == 404


def test_requires_auth(client):
r = client.get("/savings")
assert r.status_code == 401