From 4870735077e2b2be59f7fca3adbc2038c146dd39 Mon Sep 17 00:00:00 2001 From: Manishcs076 Date: Sat, 28 Mar 2026 00:08:44 +0530 Subject: [PATCH 1/2] feat: add goal-based savings tracking & milestones Implements savings goals with auto-generated milestones at 25%, 50%, 75%, and 100% completion. Milestones are automatically marked as reached when contributions bring the goal past each threshold. Backend: - SavingsGoal and SavingsMilestone SQLAlchemy models - CRUD endpoints at /savings/goals with JWT auth - POST /savings/goals/:id/contribute for adding funds - Auto-milestone generation on goal creation - 13 test cases covering all endpoints and edge cases Frontend: - SavingsGoals page with create form, progress bars, milestone badges - API client with full TypeScript types - Navigation link in navbar - Route registered in App.tsx Closes #133 --- app/src/App.tsx | 9 + app/src/api/savings_goals.ts | 57 +++++ app/src/components/layout/Navbar.tsx | 1 + app/src/pages/SavingsGoals.tsx | 220 +++++++++++++++++++ packages/backend/app/models.py | 28 +++ packages/backend/app/routes/__init__.py | 2 + packages/backend/app/routes/savings_goals.py | 173 +++++++++++++++ packages/backend/tests/test_savings_goals.py | 183 +++++++++++++++ 8 files changed, 673 insertions(+) create mode 100644 app/src/api/savings_goals.ts create mode 100644 app/src/pages/SavingsGoals.tsx create mode 100644 packages/backend/app/routes/savings_goals.py create mode 100644 packages/backend/tests/test_savings_goals.py diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942..16cb2795 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -16,6 +16,7 @@ import NotFound from "./pages/NotFound"; import { Landing } from "./pages/Landing"; import ProtectedRoute from "./components/auth/ProtectedRoute"; import Account from "./pages/Account"; +import SavingsGoals from "./pages/SavingsGoals"; const queryClient = new QueryClient({ defaultOptions: { @@ -83,6 +84,14 @@ const App = () => ( } /> + + + + } + /> ; + +export async function listSavingsGoals(): Promise { + return api('/savings/goals'); +} + +export async function getSavingsGoal(id: number): Promise { + return api(`/savings/goals/${id}`); +} + +export async function createSavingsGoal(payload: SavingsGoalCreate): Promise { + return api('/savings/goals', { method: 'POST', body: payload }); +} + +export async function updateSavingsGoal(id: number, payload: SavingsGoalUpdate): Promise { + return api(`/savings/goals/${id}`, { method: 'PATCH', body: payload }); +} + +export async function deleteSavingsGoal(id: number): Promise<{ message: string }> { + return api(`/savings/goals/${id}`, { method: 'DELETE' }); +} + +export async function contributeSavingsGoal(id: number, amount: number): Promise { + return api(`/savings/goals/${id}/contribute`, { + method: 'POST', + body: { amount }, + }); +} diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b70..c0bb7eaf 100644 --- a/app/src/components/layout/Navbar.tsx +++ b/app/src/components/layout/Navbar.tsx @@ -9,6 +9,7 @@ import { logout as logoutApi } from '@/api/auth'; const navigation = [ { name: 'Dashboard', href: '/dashboard' }, { name: 'Budgets', href: '/budgets' }, + { name: 'Savings', href: '/savings' }, { name: 'Bills', href: '/bills' }, { name: 'Reminders', href: '/reminders' }, { name: 'Expenses', href: '/expenses' }, diff --git a/app/src/pages/SavingsGoals.tsx b/app/src/pages/SavingsGoals.tsx new file mode 100644 index 00000000..c6c93c6f --- /dev/null +++ b/app/src/pages/SavingsGoals.tsx @@ -0,0 +1,220 @@ +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useToast } from '@/components/ui/use-toast'; +import { Target, Plus, TrendingUp, Trophy, Trash2 } from 'lucide-react'; +import { + listSavingsGoals, + createSavingsGoal, + contributeSavingsGoal, + deleteSavingsGoal, + type SavingsGoal, +} from '@/api/savings_goals'; + +export default function SavingsGoals() { + const [goals, setGoals] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [contributeId, setContributeId] = useState(null); + const [contributeAmount, setContributeAmount] = useState(''); + const [form, setForm] = useState({ name: '', target_amount: '', currency: '', deadline: '' }); + const { toast } = useToast(); + + const fetchGoals = async () => { + try { + const data = await listSavingsGoals(); + setGoals(data); + } catch { + toast({ title: 'Error', description: 'Failed to load savings goals', variant: 'destructive' }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { fetchGoals(); }, []); + + const handleCreate = async () => { + if (!form.name || !form.target_amount) { + toast({ title: 'Error', description: 'Name and target amount are required', variant: 'destructive' }); + return; + } + try { + await createSavingsGoal({ + name: form.name, + target_amount: parseFloat(form.target_amount), + currency: form.currency || undefined, + deadline: form.deadline || null, + }); + setForm({ name: '', target_amount: '', currency: '', deadline: '' }); + setShowCreate(false); + toast({ title: 'Success', description: 'Savings goal created!' }); + fetchGoals(); + } catch { + toast({ title: 'Error', description: 'Failed to create goal', variant: 'destructive' }); + } + }; + + const handleContribute = async (goalId: number) => { + const amount = parseFloat(contributeAmount); + if (!amount || amount <= 0) { + toast({ title: 'Error', description: 'Enter a valid amount', variant: 'destructive' }); + return; + } + try { + await contributeSavingsGoal(goalId, amount); + setContributeId(null); + setContributeAmount(''); + toast({ title: 'Success', description: 'Contribution added!' }); + fetchGoals(); + } catch { + toast({ title: 'Error', description: 'Failed to contribute', variant: 'destructive' }); + } + }; + + const handleDelete = async (goalId: number) => { + try { + await deleteSavingsGoal(goalId); + toast({ title: 'Deleted', description: 'Goal removed' }); + fetchGoals(); + } catch { + toast({ title: 'Error', description: 'Failed to delete', variant: 'destructive' }); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+ +

Savings Goals

+
+ +
+ + {showCreate && ( +
+

Create New Goal

+
+ setForm({ ...form, name: e.target.value })} + /> + setForm({ ...form, target_amount: e.target.value })} + /> + setForm({ ...form, currency: e.target.value })} + /> + setForm({ ...form, deadline: e.target.value })} + /> +
+
+ + +
+
+ )} + + {goals.length === 0 && !showCreate ? ( +
+ +

No savings goals yet

+

Create your first goal to start tracking your savings.

+
+ ) : ( +
+ {goals.map((goal) => ( +
+
+
+

{goal.name}

+

+ {goal.currency} {goal.current_amount.toLocaleString()} / {goal.target_amount.toLocaleString()} + {goal.deadline && ` · Due ${goal.deadline}`} +

+
+
+ + +
+
+ + {/* Progress bar */} +
+
+
+

{goal.progress}% complete

+ + {/* Milestones */} +
+ {goal.milestones.map((m) => ( +
+ + {m.percent}% +
+ ))} +
+ + {/* Contribute form */} + {contributeId === goal.id && ( +
+ setContributeAmount(e.target.value)} + className="max-w-[200px]" + /> + + +
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..813cec0d 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -127,6 +127,34 @@ class UserSubscription(db.Model): started_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) + active = db.Column(db.Boolean, default=True, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + milestones = db.relationship( + "SavingsMilestone", backref="goal", lazy=True, cascade="all, delete-orphan" + ) + + +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 + ) + percent = db.Column(db.Integer, nullable=False) + reached = db.Column(db.Boolean, default=False, nullable=False) + reached_at = db.Column(db.DateTime, nullable=True) + + class AuditLog(db.Model): __tablename__ = "audit_logs" id = db.Column(db.Integer, primary_key=True) diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f89..ee35ec71 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_goals import bp as savings_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(savings_goals_bp, url_prefix="/savings/goals") diff --git a/packages/backend/app/routes/savings_goals.py b/packages/backend/app/routes/savings_goals.py new file mode 100644 index 00000000..649b5ee2 --- /dev/null +++ b/packages/backend/app/routes/savings_goals.py @@ -0,0 +1,173 @@ +from datetime import datetime +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, SavingsMilestone, User +import logging + +bp = Blueprint("savings_goals", __name__) +logger = logging.getLogger("finmind.savings_goals") + +MILESTONE_PERCENTS = [25, 50, 75, 100] + + +def _goal_to_dict(g: SavingsGoal) -> dict: + progress = ( + float(g.current_amount / g.target_amount * 100) + if g.target_amount > 0 + else 0.0 + ) + 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, + "progress": round(min(progress, 100.0), 2), + "active": g.active, + "created_at": g.created_at.isoformat(), + "milestones": [ + { + "id": m.id, + "percent": m.percent, + "reached": m.reached, + "reached_at": m.reached_at.isoformat() if m.reached_at else None, + } + for m in sorted(g.milestones, key=lambda x: x.percent) + ], + } + + +def _create_milestones(goal: SavingsGoal): + for pct in MILESTONE_PERCENTS: + ms = SavingsMilestone(goal_id=goal.id, percent=pct) + db.session.add(ms) + + +def _update_milestones(goal: SavingsGoal): + if goal.target_amount <= 0: + return + progress = float(goal.current_amount / goal.target_amount * 100) + for ms in goal.milestones: + if not ms.reached and progress >= ms.percent: + ms.reached = True + ms.reached_at = datetime.utcnow() + + +@bp.get("") +@jwt_required() +def list_goals(): + uid = int(get_jwt_identity()) + goals = ( + db.session.query(SavingsGoal) + .filter_by(user_id=uid, active=True) + .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()) + user = db.session.get(User, uid) + data = request.get_json() or {} + + if not data.get("name") or not data.get("target_amount"): + return jsonify(error="name and target_amount are required"), 400 + + target = Decimal(str(data["target_amount"])) + if target <= 0: + return jsonify(error="target_amount must be positive"), 400 + + goal = SavingsGoal( + user_id=uid, + name=data["name"], + target_amount=target, + currency=data.get("currency") or (user.preferred_currency if user else "INR"), + deadline=(data["deadline"] if data.get("deadline") else None), + ) + db.session.add(goal) + db.session.flush() + _create_milestones(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.patch("/") +@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: + goal.target_amount = Decimal(str(data["target_amount"])) + if "currency" in data: + goal.currency = data["currency"] + if "deadline" in data: + goal.deadline = data["deadline"] if data["deadline"] else None + + _update_milestones(goal) + 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 + + goal.active = False + 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 {} + amount = Decimal(str(data.get("amount", 0))) + if amount <= 0: + return jsonify(error="amount must be positive"), 400 + + goal.current_amount = goal.current_amount + amount + _update_milestones(goal) + 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_goals.py b/packages/backend/tests/test_savings_goals.py new file mode 100644 index 00000000..36608835 --- /dev/null +++ b/packages/backend/tests/test_savings_goals.py @@ -0,0 +1,183 @@ +from datetime import date + + +def test_savings_goals_list_empty(client, auth_header): + r = client.get("/savings/goals", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + +def test_savings_goals_create(client, auth_header): + payload = { + "name": "Emergency Fund", + "target_amount": 10000, + "currency": "USD", + "deadline": "2026-12-31", + } + r = client.post("/savings/goals", json=payload, headers=auth_header) + assert r.status_code == 201 + data = r.get_json() + assert data["name"] == "Emergency Fund" + assert data["target_amount"] == 10000.0 + assert data["current_amount"] == 0.0 + assert data["currency"] == "USD" + assert data["deadline"] == "2026-12-31" + assert data["progress"] == 0.0 + assert len(data["milestones"]) == 4 + assert [m["percent"] for m in data["milestones"]] == [25, 50, 75, 100] + assert all(m["reached"] is False for m in data["milestones"]) + + +def test_savings_goals_create_defaults_user_currency(client, auth_header): + client.patch( + "/auth/me", json={"preferred_currency": "EUR"}, headers=auth_header + ) + payload = {"name": "Vacation", "target_amount": 5000} + r = client.post("/savings/goals", json=payload, headers=auth_header) + assert r.status_code == 201 + assert r.get_json()["currency"] == "EUR" + + +def test_savings_goals_create_validation(client, auth_header): + r = client.post("/savings/goals", json={}, headers=auth_header) + assert r.status_code == 400 + + r = client.post( + "/savings/goals", + json={"name": "Bad", "target_amount": -100}, + headers=auth_header, + ) + assert r.status_code == 400 + + +def test_savings_goals_get_by_id(client, auth_header): + payload = {"name": "Car", "target_amount": 20000} + r = client.post("/savings/goals", json=payload, 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"] == "Car" + + +def test_savings_goals_get_not_found(client, auth_header): + r = client.get("/savings/goals/99999", headers=auth_header) + assert r.status_code == 404 + + +def test_savings_goals_update(client, auth_header): + payload = {"name": "House", "target_amount": 50000} + r = client.post("/savings/goals", json=payload, headers=auth_header) + goal_id = r.get_json()["id"] + + r = client.patch( + f"/savings/goals/{goal_id}", + json={"name": "Dream House", "target_amount": 60000}, + headers=auth_header, + ) + assert r.status_code == 200 + assert r.get_json()["name"] == "Dream House" + assert r.get_json()["target_amount"] == 60000.0 + + +def test_savings_goals_delete(client, auth_header): + payload = {"name": "Temp Goal", "target_amount": 1000} + r = client.post("/savings/goals", json=payload, 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" + + # Should not appear in list + r = client.get("/savings/goals", headers=auth_header) + assert all(g["id"] != goal_id for g in r.get_json()) + + +def test_savings_goals_contribute(client, auth_header): + payload = {"name": "Fund", "target_amount": 1000} + r = client.post("/savings/goals", json=payload, headers=auth_header) + goal_id = r.get_json()["id"] + + r = client.post( + f"/savings/goals/{goal_id}/contribute", + json={"amount": 250}, + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + assert data["current_amount"] == 250.0 + assert data["progress"] == 25.0 + # 25% milestone should be reached + m25 = next(m for m in data["milestones"] if m["percent"] == 25) + assert m25["reached"] is True + + +def test_savings_goals_contribute_validation(client, auth_header): + payload = {"name": "Fund2", "target_amount": 1000} + r = client.post("/savings/goals", json=payload, headers=auth_header) + goal_id = r.get_json()["id"] + + r = client.post( + f"/savings/goals/{goal_id}/contribute", + json={"amount": -50}, + headers=auth_header, + ) + assert r.status_code == 400 + + +def test_savings_goals_milestones_progression(client, auth_header): + payload = {"name": "Milestone Test", "target_amount": 100} + r = client.post("/savings/goals", json=payload, headers=auth_header) + goal_id = r.get_json()["id"] + + # Contribute 50 → 50% milestone + r = client.post( + f"/savings/goals/{goal_id}/contribute", + json={"amount": 50}, + headers=auth_header, + ) + data = r.get_json() + assert data["progress"] == 50.0 + reached = [m["percent"] for m in data["milestones"] if m["reached"]] + assert 25 in reached + assert 50 in reached + assert 75 not in reached + + # Contribute 50 more → 100% + r = client.post( + f"/savings/goals/{goal_id}/contribute", + json={"amount": 50}, + headers=auth_header, + ) + data = r.get_json() + assert data["progress"] == 100.0 + assert all(m["reached"] for m in data["milestones"]) + + +def test_savings_goals_list_after_create(client, auth_header): + client.post( + "/savings/goals", + json={"name": "G1", "target_amount": 100}, + headers=auth_header, + ) + client.post( + "/savings/goals", + json={"name": "G2", "target_amount": 200}, + headers=auth_header, + ) + r = client.get("/savings/goals", headers=auth_header) + assert r.status_code == 200 + goals = r.get_json() + names = [g["name"] for g in goals] + assert "G1" in names + assert "G2" in names + + +def test_savings_goals_contribute_not_found(client, auth_header): + r = client.post( + "/savings/goals/99999/contribute", + json={"amount": 100}, + headers=auth_header, + ) + assert r.status_code == 404 From 08e8f54ee890a600cfb725096398c64a3570c682 Mon Sep 17 00:00:00 2001 From: Manishcs076 Date: Sat, 28 Mar 2026 01:12:13 +0530 Subject: [PATCH 2/2] fix: parse deadline string to date object for SQLite compatibility --- packages/backend/app/routes/savings_goals.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/backend/app/routes/savings_goals.py b/packages/backend/app/routes/savings_goals.py index 649b5ee2..785f3fe4 100644 --- a/packages/backend/app/routes/savings_goals.py +++ b/packages/backend/app/routes/savings_goals.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import date, datetime from decimal import Decimal from flask import Blueprint, jsonify, request from flask_jwt_extended import jwt_required, get_jwt_identity @@ -89,7 +89,7 @@ def create_goal(): name=data["name"], target_amount=target, currency=data.get("currency") or (user.preferred_currency if user else "INR"), - deadline=(data["deadline"] if data.get("deadline") else None), + deadline=(date.fromisoformat(data["deadline"]) if data.get("deadline") else None), ) db.session.add(goal) db.session.flush() @@ -127,7 +127,7 @@ def update_goal(goal_id: int): if "currency" in data: goal.currency = data["currency"] if "deadline" in data: - goal.deadline = data["deadline"] if data["deadline"] else None + goal.deadline = date.fromisoformat(data["deadline"]) if data["deadline"] else None _update_milestones(goal) db.session.commit()