diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942d..f2f97a84d 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -16,6 +16,8 @@ import NotFound from "./pages/NotFound"; import { Landing } from "./pages/Landing"; import ProtectedRoute from "./components/auth/ProtectedRoute"; import Account from "./pages/Account"; +import Savings from "./pages/Savings"; +import Household from "./pages/Household"; const queryClient = new QueryClient({ defaultOptions: { @@ -83,6 +85,22 @@ const App = () => ( } /> + + + + } + /> + + + + } + /> ('/savings/goals'); +} + +export function createGoal(payload: CreateGoalPayload) { + return api('/savings/goals', { method: 'POST', body: payload }); +} + +export function updateGoal(id: number, payload: UpdateGoalPayload) { + return api(`/savings/goals/${id}`, { method: 'PATCH', body: payload }); +} + +export function deleteGoal(id: number) { + return api(`/savings/goals/${id}`, { method: 'DELETE' }); +} + +export function getMilestones(goalId: number) { + return api(`/savings/goals/${goalId}/milestones`); +} diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b701..c633fc3e3 100644 --- a/app/src/components/layout/Navbar.tsx +++ b/app/src/components/layout/Navbar.tsx @@ -12,7 +12,9 @@ const navigation = [ { name: 'Bills', href: '/bills' }, { name: 'Reminders', href: '/reminders' }, { name: 'Expenses', href: '/expenses' }, + { name: 'Savings', href: '/savings' }, { name: 'Analytics', href: '/analytics' }, + { name: 'Household', href: '/household' }, ]; export function Navbar() { diff --git a/app/src/pages/Savings.tsx b/app/src/pages/Savings.tsx new file mode 100644 index 000000000..443f0c4d2 --- /dev/null +++ b/app/src/pages/Savings.tsx @@ -0,0 +1,193 @@ +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 { + listGoals, + createGoal, + updateGoal, + deleteGoal, + getMilestones, + type SavingsGoal, + type SavingsMilestone, +} from '@/api/savings'; +import { Target, Plus, Trash2, PiggyBank, Trophy } from 'lucide-react'; + +export default function Savings() { + const [goals, setGoals] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [name, setName] = useState(''); + const [targetAmount, setTargetAmount] = useState(''); + const [currency, setCurrency] = useState('INR'); + const [deadline, setDeadline] = useState(''); + const [addFundsId, setAddFundsId] = useState(null); + const [fundsAmount, setFundsAmount] = useState(''); + const [milestonesMap, setMilestonesMap] = useState>({}); + const [expandedGoal, setExpandedGoal] = useState(null); + const { toast } = useToast(); + + const fetchGoals = async () => { + try { + const data = await listGoals(); + setGoals(data); + } catch (e: any) { + toast({ title: 'Error', description: e.message, variant: 'destructive' }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { void fetchGoals(); }, []); + + const handleCreate = async () => { + if (!name.trim() || !targetAmount) return; + try { + await createGoal({ + name: name.trim(), + target_amount: parseFloat(targetAmount), + currency, + deadline: deadline || undefined, + }); + setName(''); setTargetAmount(''); setDeadline(''); setShowForm(false); + toast({ title: 'Goal created!' }); + await fetchGoals(); + } catch (e: any) { + toast({ title: 'Error', description: e.message, variant: 'destructive' }); + } + }; + + const handleAddFunds = async (goalId: number) => { + if (!fundsAmount || parseFloat(fundsAmount) <= 0) return; + try { + await updateGoal(goalId, { add_funds: parseFloat(fundsAmount) }); + setAddFundsId(null); setFundsAmount(''); + toast({ title: 'Funds added!' }); + await fetchGoals(); + if (expandedGoal === goalId) await fetchMilestones(goalId); + } catch (e: any) { + toast({ title: 'Error', description: e.message, variant: 'destructive' }); + } + }; + + const handleDelete = async (id: number) => { + try { + await deleteGoal(id); + toast({ title: 'Goal deleted' }); + await fetchGoals(); + } catch (e: any) { + toast({ title: 'Error', description: e.message, variant: 'destructive' }); + } + }; + + const fetchMilestones = async (goalId: number) => { + try { + const ms = await getMilestones(goalId); + setMilestonesMap(prev => ({ ...prev, [goalId]: ms })); + } catch { /* ignore */ } + }; + + const toggleMilestones = async (goalId: number) => { + if (expandedGoal === goalId) { + setExpandedGoal(null); + } else { + setExpandedGoal(goalId); + if (!milestonesMap[goalId]) await fetchMilestones(goalId); + } + }; + + if (loading) return
Loading…
; + + return ( +
+
+
+ +

Savings Goals

+
+ +
+ + {showForm && ( +
+ setName(e.target.value)} /> +
+ setTargetAmount(e.target.value)} /> + setCurrency(e.target.value)} className="w-24" /> +
+ setDeadline(e.target.value)} /> + +
+ )} + + {goals.length === 0 ? ( +
+ +

No savings goals yet. Create one to start tracking!

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

{goal.name}

+

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

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

{goal.progress_pct}%

+ + {/* Add funds form */} + {addFundsId === goal.id && ( +
+ setFundsAmount(e.target.value)} className="w-40" /> + +
+ )} + + {/* Milestones */} + {expandedGoal === goal.id && milestonesMap[goal.id] && ( +
+

Milestones

+ {milestonesMap[goal.id].map(ms => ( +
+ + + {ms.name} — {goal.currency} {ms.amount.toLocaleString()} + + {ms.reached_at && } +
+ ))} +
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d448104..146c51c96 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -127,6 +127,33 @@ 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) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + milestones = db.relationship( + "SavingsMilestone", backref="goal", cascade="all, delete-orphan", lazy=True + ) + + +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 + ) + name = db.Column(db.String(100), nullable=False) + amount = db.Column(db.Numeric(12, 2), 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 f13b0f897..0a92db36d 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") diff --git a/packages/backend/app/routes/savings.py b/packages/backend/app/routes/savings.py new file mode 100644 index 000000000..6f4d23240 --- /dev/null +++ b/packages/backend/app/routes/savings.py @@ -0,0 +1,200 @@ +from datetime import datetime +from decimal import Decimal, InvalidOperation + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import get_jwt_identity, jwt_required + +from ..extensions import db +from ..models import SavingsGoal, SavingsMilestone + +bp = Blueprint("savings", __name__) + +MILESTONE_PERCENTAGES = [ + (25, "25% milestone"), + (50, "50% milestone"), + (75, "75% milestone"), + (100, "100% – Goal reached!"), +] + + +def _goal_to_dict(goal: SavingsGoal) -> dict: + target = float(goal.target_amount) + current = float(goal.current_amount) + progress = (current / target * 100) if target > 0 else 0 + return { + "id": goal.id, + "name": goal.name, + "target_amount": target, + "current_amount": current, + "currency": goal.currency, + "deadline": goal.deadline.isoformat() if goal.deadline else None, + "progress_pct": round(progress, 2), + "created_at": goal.created_at.isoformat(), + } + + +def _milestone_to_dict(m: SavingsMilestone) -> dict: + return { + "id": m.id, + "goal_id": m.goal_id, + "name": m.name, + "amount": float(m.amount), + "reached_at": m.reached_at.isoformat() if m.reached_at else None, + } + + +def _create_milestones(goal: SavingsGoal) -> None: + """Create milestone records for a goal at 25/50/75/100%.""" + for pct, label in MILESTONE_PERCENTAGES: + amount = goal.target_amount * pct / 100 + ms = SavingsMilestone(goal_id=goal.id, name=label, amount=amount) + db.session.add(ms) + + +def _check_milestones(goal: SavingsGoal) -> None: + """Mark milestones as reached when current_amount meets them.""" + for ms in goal.milestones: + if ms.reached_at is None and goal.current_amount >= ms.amount: + ms.reached_at = datetime.utcnow() + + +@bp.route("/goals", methods=["POST"]) +@jwt_required() +def create_goal(): + data = request.get_json(silent=True) or {} + name = data.get("name") + if not name or not str(name).strip(): + return jsonify(error="name is required"), 400 + + try: + target_amount = Decimal(str(data["target_amount"])) + if target_amount <= 0: + raise ValueError + except (KeyError, ValueError, InvalidOperation): + return jsonify(error="target_amount must be a positive number"), 400 + + current_amount = Decimal("0") + if "current_amount" in data: + try: + current_amount = Decimal(str(data["current_amount"])) + if current_amount < 0: + raise ValueError + except (ValueError, InvalidOperation): + return jsonify(error="current_amount must be a non-negative number"), 400 + + deadline = None + if data.get("deadline"): + try: + deadline = datetime.strptime(data["deadline"], "%Y-%m-%d").date() + except ValueError: + return jsonify(error="deadline must be YYYY-MM-DD"), 400 + + user_id = get_jwt_identity() + goal = SavingsGoal( + user_id=user_id, + name=str(name).strip(), + target_amount=target_amount, + current_amount=current_amount, + currency=data.get("currency", "INR"), + deadline=deadline, + ) + db.session.add(goal) + db.session.flush() # get goal.id + _create_milestones(goal) + _check_milestones(goal) + db.session.commit() + return jsonify(_goal_to_dict(goal)), 201 + + +@bp.route("/goals", methods=["GET"]) +@jwt_required() +def list_goals(): + user_id = get_jwt_identity() + goals = SavingsGoal.query.filter_by(user_id=user_id).order_by(SavingsGoal.created_at.desc()).all() + return jsonify([_goal_to_dict(g) for g in goals]), 200 + + +@bp.route("/goals/", methods=["PUT", "PATCH"]) +@jwt_required() +def update_goal(goal_id: int): + user_id = get_jwt_identity() + goal = SavingsGoal.query.filter_by(id=goal_id, user_id=user_id).first() + if not goal: + return jsonify(error="goal not found"), 404 + + data = request.get_json(silent=True) or {} + + if "name" in data: + if not str(data["name"]).strip(): + return jsonify(error="name cannot be empty"), 400 + goal.name = str(data["name"]).strip() + + if "target_amount" in data: + try: + val = Decimal(str(data["target_amount"])) + if val <= 0: + raise ValueError + goal.target_amount = val + # Recreate milestones with new target + SavingsMilestone.query.filter_by(goal_id=goal.id).delete() + db.session.flush() + _create_milestones(goal) + except (ValueError, InvalidOperation): + return jsonify(error="target_amount must be a positive number"), 400 + + if "current_amount" in data: + try: + val = Decimal(str(data["current_amount"])) + if val < 0: + raise ValueError + goal.current_amount = val + except (ValueError, InvalidOperation): + return jsonify(error="current_amount must be a non-negative number"), 400 + + if "add_funds" in data: + try: + val = Decimal(str(data["add_funds"])) + if val <= 0: + raise ValueError + goal.current_amount = goal.current_amount + val + except (ValueError, InvalidOperation): + return jsonify(error="add_funds must be a positive number"), 400 + + if "currency" in data: + goal.currency = data["currency"] + + if "deadline" in data: + if data["deadline"] is None: + goal.deadline = None + else: + try: + goal.deadline = datetime.strptime(data["deadline"], "%Y-%m-%d").date() + except ValueError: + return jsonify(error="deadline must be YYYY-MM-DD"), 400 + + _check_milestones(goal) + db.session.commit() + return jsonify(_goal_to_dict(goal)), 200 + + +@bp.route("/goals/", methods=["DELETE"]) +@jwt_required() +def delete_goal(goal_id: int): + user_id = get_jwt_identity() + goal = SavingsGoal.query.filter_by(id=goal_id, user_id=user_id).first() + if not goal: + return jsonify(error="goal not found"), 404 + db.session.delete(goal) + db.session.commit() + return jsonify(message="goal deleted"), 200 + + +@bp.route("/goals//milestones", methods=["GET"]) +@jwt_required() +def get_milestones(goal_id: int): + user_id = get_jwt_identity() + goal = SavingsGoal.query.filter_by(id=goal_id, user_id=user_id).first() + if not goal: + return jsonify(error="goal not found"), 404 + milestones = SavingsMilestone.query.filter_by(goal_id=goal_id).order_by(SavingsMilestone.amount).all() + return jsonify([_milestone_to_dict(m) for m in milestones]), 200 diff --git a/packages/backend/tests/test_savings.py b/packages/backend/tests/test_savings.py new file mode 100644 index 000000000..b778706ca --- /dev/null +++ b/packages/backend/tests/test_savings.py @@ -0,0 +1,128 @@ +"""Tests for savings goals and milestones.""" +import pytest + + +class TestSavingsGoals: + """CRUD tests for /savings/goals.""" + + def test_create_goal(self, client, auth_header): + r = client.post( + "/savings/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.0 + assert data["current_amount"] == 0.0 + assert data["currency"] == "USD" + assert data["deadline"] == "2026-12-31" + assert data["progress_pct"] == 0.0 + + def test_create_goal_missing_name(self, client, auth_header): + r = client.post("/savings/goals", json={"target_amount": 100}, headers=auth_header) + assert r.status_code == 400 + + def test_create_goal_bad_amount(self, client, auth_header): + r = client.post("/savings/goals", json={"name": "X", "target_amount": -5}, headers=auth_header) + assert r.status_code == 400 + + def test_list_goals(self, client, auth_header): + client.post("/savings/goals", json={"name": "A", "target_amount": 100}, headers=auth_header) + client.post("/savings/goals", json={"name": "B", "target_amount": 200}, headers=auth_header) + r = client.get("/savings/goals", headers=auth_header) + assert r.status_code == 200 + assert len(r.get_json()) == 2 + + def test_update_goal_add_funds(self, client, auth_header): + r = client.post("/savings/goals", json={"name": "Car", "target_amount": 1000}, headers=auth_header) + goal_id = r.get_json()["id"] + r = client.put(f"/savings/goals/{goal_id}", json={"add_funds": 250}, headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["current_amount"] == 250.0 + assert r.get_json()["progress_pct"] == 25.0 + + def test_update_goal_edit_name(self, client, auth_header): + r = client.post("/savings/goals", json={"name": "Old", "target_amount": 100}, headers=auth_header) + goal_id = r.get_json()["id"] + r = client.put(f"/savings/goals/{goal_id}", json={"name": "New"}, headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["name"] == "New" + + def test_update_goal_not_found(self, client, auth_header): + r = client.put("/savings/goals/9999", json={"name": "X"}, headers=auth_header) + assert r.status_code == 404 + + def test_delete_goal(self, client, auth_header): + r = client.post("/savings/goals", json={"name": "Del", "target_amount": 50}, 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 + r = client.get("/savings/goals", headers=auth_header) + assert len(r.get_json()) == 0 + + def test_delete_goal_not_found(self, client, auth_header): + r = client.delete("/savings/goals/9999", headers=auth_header) + assert r.status_code == 404 + + +class TestMilestones: + """Milestone auto-creation and tracking.""" + + def test_milestones_auto_created(self, client, auth_header): + r = client.post("/savings/goals", json={"name": "M", "target_amount": 1000}, headers=auth_header) + goal_id = r.get_json()["id"] + r = client.get(f"/savings/goals/{goal_id}/milestones", headers=auth_header) + assert r.status_code == 200 + ms = r.get_json() + assert len(ms) == 4 + amounts = [m["amount"] for m in ms] + assert amounts == [250.0, 500.0, 750.0, 1000.0] + assert all(m["reached_at"] is None for m in ms) + + def test_milestones_reached_on_fund(self, client, auth_header): + r = client.post("/savings/goals", json={"name": "M", "target_amount": 100}, headers=auth_header) + goal_id = r.get_json()["id"] + client.put(f"/savings/goals/{goal_id}", json={"add_funds": 50}, headers=auth_header) + r = client.get(f"/savings/goals/{goal_id}/milestones", headers=auth_header) + ms = r.get_json() + reached = [m for m in ms if m["reached_at"] is not None] + assert len(reached) == 2 # 25% (25) and 50% (50) + + def test_milestones_all_reached(self, client, auth_header): + r = client.post("/savings/goals", json={"name": "M", "target_amount": 100}, headers=auth_header) + goal_id = r.get_json()["id"] + client.put(f"/savings/goals/{goal_id}", json={"current_amount": 100}, headers=auth_header) + r = client.get(f"/savings/goals/{goal_id}/milestones", headers=auth_header) + ms = r.get_json() + assert all(m["reached_at"] is not None for m in ms) + + def test_milestones_not_found(self, client, auth_header): + r = client.get("/savings/goals/9999/milestones", headers=auth_header) + assert r.status_code == 404 + + def test_milestones_recreated_on_target_change(self, client, auth_header): + r = client.post("/savings/goals", json={"name": "M", "target_amount": 100}, headers=auth_header) + goal_id = r.get_json()["id"] + client.put(f"/savings/goals/{goal_id}", json={"target_amount": 200}, headers=auth_header) + r = client.get(f"/savings/goals/{goal_id}/milestones", headers=auth_header) + ms = r.get_json() + amounts = [m["amount"] for m in ms] + assert amounts == [50.0, 100.0, 150.0, 200.0] + + def test_create_goal_with_initial_amount_milestones(self, client, auth_header): + r = client.post( + "/savings/goals", + json={"name": "M", "target_amount": 100, "current_amount": 75}, + headers=auth_header, + ) + goal_id = r.get_json()["id"] + r = client.get(f"/savings/goals/{goal_id}/milestones", headers=auth_header) + ms = r.get_json() + reached = [m for m in ms if m["reached_at"] is not None] + assert len(reached) == 3 # 25, 50, 75 + + def test_unauthenticated(self, client): + r = client.get("/savings/goals") + assert r.status_code == 401