diff --git a/app/src/api/savings.ts b/app/src/api/savings.ts new file mode 100644 index 000000000..7a5c6bd3e --- /dev/null +++ b/app/src/api/savings.ts @@ -0,0 +1,64 @@ +import { api } from './client'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +export type SavingsMilestone = { + id: number; + title: string; + target_amount: number; + is_completed: boolean; + completed_at?: string; +}; + +export type SavingsGoal = { + id: number; + title: string; + target_amount: number; + current_amount: number; + currency: string; + deadline?: string; + status: 'on-track' | 'ahead' | 'behind' | 'completed'; + created_at: string; + milestones: SavingsMilestone[]; +}; + +export const useSavingsGoals = () => { + return useQuery({ + queryKey: ['savings-goals'], + queryFn: () => api('/savings/goals'), + }); +}; + +export const useCreateGoal = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: Partial) => api('/savings/goals', { method: 'POST', body: data }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['savings-goals'] }), + }); +}; + +export const useUpdateGoal = (goalId: number) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: Partial) => api(`/savings/goals/${goalId}`, { method: 'PATCH', body: data }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['savings-goals'] }); + queryClient.invalidateQueries({ queryKey: ['savings-goals', goalId] }); + }, + }); +}; + +export const useDeleteGoal = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (goalId: number) => api(`/savings/goals/${goalId}`, { method: 'DELETE' }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['savings-goals'] }), + }); +}; + +export const useAddMilestone = (goalId: number) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: Partial) => api(`/savings/goals/${goalId}/milestones`, { method: 'POST', body: data }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['savings-goals'] }), + }); +}; diff --git a/app/src/pages/Budgets.tsx b/app/src/pages/Budgets.tsx index ec687baa5..b75d9d064 100644 --- a/app/src/pages/Budgets.tsx +++ b/app/src/pages/Budgets.tsx @@ -2,7 +2,8 @@ import { useState } from 'react'; import { FinancialCard, FinancialCardContent, FinancialCardDescription, FinancialCardFooter, FinancialCardHeader, FinancialCardTitle } from '@/components/ui/financial-card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { Calendar, DollarSign, Plus, PieChart, TrendingDown, TrendingUp, Target, AlertCircle, Settings } from 'lucide-react'; +import { Calendar, DollarSign, Plus, PieChart, TrendingDown, TrendingUp, Target, AlertCircle, Settings, Loader2 } from 'lucide-react'; +import { useSavingsGoals } from '@/api/savings'; const budgetCategories = [ { @@ -99,11 +100,14 @@ const budgetGoals = [ export function Budgets() { const [selectedPeriod] = useState('monthly'); + const { data: goals, isLoading } = useSavingsGoals(); const totalAllocated = budgetCategories.reduce((sum, cat) => sum + cat.allocated, 0); const totalSpent = budgetCategories.reduce((sum, cat) => sum + cat.spent, 0); const totalRemaining = totalAllocated - totalSpent; + const displayGoals = goals && goals.length > 0 ? goals : budgetGoals; + return (
@@ -279,46 +283,54 @@ export function Budgets() {
- {budgetGoals.map((goal) => { - const percentage = (goal.current / goal.target) * 100; - - return ( -
-
-
- {goal.title} -
- - {goal.status === 'on-track' ? 'On Track' : - goal.status === 'ahead' ? 'Ahead' : 'Behind'} - -
-
-
- - ${goal.current.toLocaleString()} / ${goal.target.toLocaleString()} - - - {percentage.toFixed(0)}% - -
-
-
+ {isLoading ? ( +
+ +
+ ) : ( + displayGoals.map((goal) => { + const targetAmount = 'target_amount' in goal ? goal.target_amount : (goal as any).target; + const currentAmount = 'current_amount' in goal ? goal.current_amount : (goal as any).current; + const percentage = (currentAmount / targetAmount) * 100; + + return ( +
+
+
+ {goal.title} +
+ + {goal.status.charAt(0).toUpperCase() + goal.status.slice(1)} +
-
- Target: {goal.deadline} - ${goal.monthlyTarget}/mo +
+
+ + ${currentAmount.toLocaleString()} / ${targetAmount.toLocaleString()} + + + {percentage.toFixed(0)}% + +
+
+
+
+
+ Target: {goal.deadline || 'No deadline'} + {'monthlyTarget' in goal && ${(goal as any).monthlyTarget}/mo} +
-
- ); - })} + ); + }) + )}
diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d448104..d04c7d46e 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -133,3 +133,41 @@ 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 GoalStatus(str, Enum): + ON_TRACK = "on-track" + AHEAD = "ahead" + BEHIND = "behind" + COMPLETED = "completed" + + +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) + title = 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) + status = db.Column(SAEnum(GoalStatus), default=GoalStatus.ON_TRACK, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column( + db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + ) + + milestones = db.relationship( + "SavingsMilestone", backref="goal", 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) + title = db.Column(db.String(200), nullable=False) + target_amount = db.Column(db.Numeric(12, 2), nullable=False) + is_completed = db.Column(db.Boolean, default=False, nullable=False) + completed_at = db.Column(db.DateTime, nullable=True) + 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 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..3898f7d56 --- /dev/null +++ b/packages/backend/app/routes/savings.py @@ -0,0 +1,150 @@ +from datetime import date +from decimal import Decimal, InvalidOperation + +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, GoalStatus, User +import logging + +bp = Blueprint("savings", __name__) +logger = logging.getLogger("finmind.savings") + + +@bp.get("/goals") +@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() + return jsonify([_goal_to_dict(g) for g in goals]) + + +@bp.post("/goals") +@jwt_required() +def create_goal(): + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + data = request.get_json() or {} + + amount = _parse_amount(data.get("target_amount")) + if amount is None: + return jsonify(error="invalid target amount"), 400 + + title = str(data.get("title") or "").strip() + if not title: + return jsonify(error="title required"), 400 + + goal = SavingsGoal( + user_id=uid, + title=title, + target_amount=amount, + current_amount=_parse_amount(data.get("current_amount", 0)) or Decimal(0), + currency=data.get("currency") or (user.preferred_currency if user else "INR"), + deadline=date.fromisoformat(data.get("deadline")) if data.get("deadline") else None, + status=GoalStatus(data.get("status", "on-track")) + ) + + db.session.add(goal) + db.session.commit() + + return jsonify(_goal_to_dict(goal)), 201 + + +@bp.get("/goals/") +@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("/goals/") +@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 "target_amount" in data: + goal.target_amount = _parse_amount(data["target_amount"]) + if "current_amount" in data: + goal.current_amount = _parse_amount(data["current_amount"]) + if "title" in data: + goal.title = data["title"] + if "status" in data: + goal.status = GoalStatus(data["status"]) + if "deadline" in data: + goal.deadline = date.fromisoformat(data["deadline"]) if data["deadline"] else None + + db.session.commit() + return jsonify(_goal_to_dict(goal)) + + +@bp.delete("/goals/") +@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() + return jsonify(message="deleted") + + +@bp.post("/goals//milestones") +@jwt_required() +def add_milestone(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 = _parse_amount(data.get("target_amount")) + if amount is None: + return jsonify(error="invalid target amount"), 400 + + milestone = SavingsMilestone( + goal_id=goal.id, + title=data.get("title", "New Milestone"), + target_amount=amount + ) + db.session.add(milestone) + db.session.commit() + return jsonify(_milestone_to_dict(milestone)), 201 + + +def _goal_to_dict(g: SavingsGoal) -> dict: + return { + "id": g.id, + "title": g.title, + "target_amount": float(g.target_amount), + "current_amount": float(g.current_amount), + "currency": g.currency, + "deadline": g.deadline.isoformat() if g.deadline else None, + "status": g.status.value, + "created_at": g.created_at.isoformat(), + "milestones": [_milestone_to_dict(m) for g in [g] for m in g.milestones] + } + + +def _milestone_to_dict(m: SavingsMilestone) -> dict: + return { + "id": m.id, + "title": m.title, + "target_amount": float(m.target_amount), + "is_completed": m.is_completed, + "completed_at": m.completed_at.isoformat() if m.completed_at else None + } + + +def _parse_amount(raw) -> Decimal | None: + try: + return Decimal(str(raw)).quantize(Decimal("0.01")) + except (InvalidOperation, ValueError, TypeError): + return None