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
64 changes: 64 additions & 0 deletions app/src/api/savings.ts
Original file line number Diff line number Diff line change
@@ -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<SavingsGoal[]>('/savings/goals'),
});
};

export const useCreateGoal = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<SavingsGoal>) => api<SavingsGoal>('/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<SavingsGoal>) => api<SavingsGoal>(`/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<SavingsMilestone>) => api<SavingsMilestone>(`/savings/goals/${goalId}/milestones`, { method: 'POST', body: data }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['savings-goals'] }),
});
};
88 changes: 50 additions & 38 deletions app/src/pages/Budgets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -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 (
<div className="page-wrap">
<div className="page-header">
Expand Down Expand Up @@ -279,46 +283,54 @@ export function Budgets() {
</FinancialCardHeader>
<FinancialCardContent>
<div className="space-y-4">
{budgetGoals.map((goal) => {
const percentage = (goal.current / goal.target) * 100;

return (
<div key={goal.id} className="interactive-row p-3 rounded-lg border border-border">
<div className="flex items-center justify-between mb-2">
<div className="font-medium text-foreground text-sm">
{goal.title}
</div>
<Badge
variant={
goal.status === 'on-track' ? 'default' :
goal.status === 'ahead' ? 'secondary' : 'destructive'
}
className="text-xs"
>
{goal.status === 'on-track' ? 'On Track' :
goal.status === 'ahead' ? 'Ahead' : 'Behind'}
</Badge>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
${goal.current.toLocaleString()} / ${goal.target.toLocaleString()}
</span>
<span className="text-foreground font-medium">
{percentage.toFixed(0)}%
</span>
</div>
<div className="chart-track">
<div className="chart-fill-success" style={{ width: `${Math.min(percentage, 100)}%` }} />
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
) : (
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 (
<div key={goal.id} className="interactive-row p-3 rounded-lg border border-border">
<div className="flex items-center justify-between mb-2">
<div className="font-medium text-foreground text-sm">
{goal.title}
</div>
<Badge
variant={
goal.status === 'on-track' ? 'default' :
goal.status === 'ahead' ? 'secondary' :
goal.status === 'completed' ? 'success' : 'destructive'
}
className="text-xs"
>
{goal.status.charAt(0).toUpperCase() + goal.status.slice(1)}
</Badge>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Target: {goal.deadline}</span>
<span>${goal.monthlyTarget}/mo</span>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
${currentAmount.toLocaleString()} / ${targetAmount.toLocaleString()}
</span>
<span className="text-foreground font-medium">
{percentage.toFixed(0)}%
</span>
</div>
<div className="chart-track">
<div className="chart-fill-success" style={{ width: `${Math.min(percentage, 100)}%` }} />
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Target: {goal.deadline || 'No deadline'}</span>
{'monthlyTarget' in goal && <span>${(goal as any).monthlyTarget}/mo</span>}
</div>
</div>
</div>
</div>
);
})}
);
})
)}
</div>
</FinancialCardContent>
<FinancialCardFooter>
Expand Down
38 changes: 38 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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")
150 changes: 150 additions & 0 deletions packages/backend/app/routes/savings.py
Original file line number Diff line number Diff line change
@@ -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/<int:goal_id>")
@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/<int:goal_id>")
@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/<int:goal_id>")
@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/<int:goal_id>/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