diff --git a/app/src/api/savings-goals.ts b/app/src/api/savings-goals.ts new file mode 100644 index 00000000..39ba6a71 --- /dev/null +++ b/app/src/api/savings-goals.ts @@ -0,0 +1,69 @@ +import { api } from './client'; + +export type SavingsGoal = { + id: number; + title: string; + target_amount: number; + current_amount: number; + currency: string; + deadline: string | null; + status: 'ON_TRACK' | 'BEHIND' | 'AHEAD' | 'COMPLETED'; + monthly_target: number | null; + created_at: string; +}; + +export type SavingsGoalCreate = { + title: string; + target_amount: number; + current_amount?: number; + currency?: string; + deadline?: string; +}; + +export type SavingsGoalUpdate = Partial; + +export type Milestone = { + id: number; + goal_id: number; + title: string; + amount: number; + reached_at: string | null; + created_at: string; +}; + +export type MilestoneCreate = { + title: string; + amount: number; +}; + +export async function listSavingsGoals(): Promise { + return api('/savings-goals'); +} + +export async function createSavingsGoal(payload: SavingsGoalCreate): Promise { + return api('/savings-goals', { method: 'POST', body: payload }); +} + +export async function getSavingsGoal(id: number): Promise { + return api(`/savings-goals/${id}`); +} + +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 listMilestones(goalId: number): Promise { + return api(`/savings-goals/${goalId}/milestones`); +} + +export async function createMilestone(goalId: number, payload: MilestoneCreate): Promise { + return api(`/savings-goals/${goalId}/milestones`, { method: 'POST', body: payload }); +} + +export async function deleteMilestone(goalId: number, milestoneId: number): Promise<{ message: string }> { + return api(`/savings-goals/${goalId}/milestones/${milestoneId}`, { method: 'DELETE' }); +} diff --git a/app/src/pages/Budgets.tsx b/app/src/pages/Budgets.tsx index ec687baa..23310155 100644 --- a/app/src/pages/Budgets.tsx +++ b/app/src/pages/Budgets.tsx @@ -1,8 +1,10 @@ -import { useState } from 'react'; +import { useState, useEffect, useCallback } 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, Trash2 } from 'lucide-react'; +import { listSavingsGoals, createSavingsGoal, deleteSavingsGoal, updateSavingsGoal } from '@/api/savings-goals'; +import type { SavingsGoal as SavingsGoalType } from '@/api/savings-goals'; const budgetCategories = [ { @@ -67,38 +69,50 @@ const budgetCategories = [ } ]; -const budgetGoals = [ - { - id: 1, - title: 'Emergency Fund', - target: 10000, - current: 7250, - deadline: 'Dec 2025', - monthlyTarget: 458, - status: 'on-track' - }, - { - id: 2, - title: 'Vacation Fund', - target: 3000, - current: 1850, - deadline: 'Jun 2025', - monthlyTarget: 383, - status: 'behind' - }, - { - id: 3, - title: 'New Car', - target: 25000, - current: 15600, - deadline: 'Mar 2026', - monthlyTarget: 625, - status: 'ahead' - } -]; - export function Budgets() { const [selectedPeriod] = useState('monthly'); + const [savingsGoals, setSavingsGoals] = useState([]); + const [goalsLoading, setGoalsLoading] = useState(true); + const [showGoalForm, setShowGoalForm] = useState(false); + const [newGoal, setNewGoal] = useState({ title: '', target_amount: '', deadline: '' }); + + const fetchGoals = useCallback(async () => { + try { + const goals = await listSavingsGoals(); + setSavingsGoals(goals); + } catch { + // silently handle - user may not be authenticated + } finally { + setGoalsLoading(false); + } + }, []); + + useEffect(() => { fetchGoals(); }, [fetchGoals]); + + const handleCreateGoal = async () => { + if (!newGoal.title || !newGoal.target_amount) return; + try { + await createSavingsGoal({ + title: newGoal.title, + target_amount: parseFloat(newGoal.target_amount), + deadline: newGoal.deadline || undefined, + }); + setNewGoal({ title: '', target_amount: '', deadline: '' }); + setShowGoalForm(false); + fetchGoals(); + } catch { + // handle error silently + } + }; + + const handleDeleteGoal = async (id: number) => { + try { + await deleteSavingsGoal(id); + fetchGoals(); + } catch { + // handle error silently + } + }; const totalAllocated = budgetCategories.reduce((sum, cat) => sum + cat.allocated, 0); const totalSpent = budgetCategories.reduce((sum, cat) => sum + cat.spent, 0); @@ -269,7 +283,7 @@ export function Budgets() {
Savings Goals -
@@ -278,51 +292,98 @@ export function Budgets() {
+ {showGoalForm && ( +
+ setNewGoal({ ...newGoal, title: e.target.value })} + /> + setNewGoal({ ...newGoal, target_amount: e.target.value })} + /> + setNewGoal({ ...newGoal, deadline: e.target.value })} + /> +
+ + +
+
+ )}
- {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)}% - -
-
-
+ {goalsLoading ? ( +
+ +
+ ) : savingsGoals.length === 0 ? ( +
+ No savings goals yet. Create one to get started! +
+ ) : ( + savingsGoals.map((goal) => { + const percentage = (goal.current_amount / goal.target_amount) * 100; + const statusLabel = goal.status === 'ON_TRACK' ? 'On Track' : + goal.status === 'AHEAD' ? 'Ahead' : + goal.status === 'COMPLETED' ? 'Completed' : 'Behind'; + const statusVariant = goal.status === 'ON_TRACK' ? 'default' as const : + goal.status === 'AHEAD' ? 'secondary' as const : + goal.status === 'COMPLETED' ? 'secondary' as const : 'destructive' as const; + + return ( +
+
+
+ {goal.title} +
+
+ + {statusLabel} + + +
-
- Target: {goal.deadline} - ${goal.monthlyTarget}/mo +
+
+ + ${goal.current_amount.toLocaleString()} / ${goal.target_amount.toLocaleString()} + + + {percentage.toFixed(0)}% + +
+
+
+
+
+ {goal.deadline && Target: {goal.deadline}} + {goal.monthly_target !== null && ${goal.monthly_target}/mo} +
-
- ); - })} + ); + }) + )}
- diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189de..c0e5b9e1 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -117,6 +117,28 @@ CREATE TABLE IF NOT EXISTS user_subscriptions ( started_at TIMESTAMP NOT NULL DEFAULT NOW() ); +CREATE TABLE IF NOT EXISTS savings_goals ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(200) NOT NULL, + target_amount NUMERIC(12,2) NOT NULL, + current_amount NUMERIC(12,2) NOT NULL DEFAULT 0, + currency VARCHAR(10) NOT NULL DEFAULT 'INR', + deadline DATE, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_savings_goals_user ON savings_goals(user_id); + +CREATE TABLE IF NOT EXISTS savings_goal_milestones ( + id SERIAL PRIMARY KEY, + goal_id INT NOT NULL REFERENCES savings_goals(id) ON DELETE CASCADE, + title VARCHAR(200) NOT NULL, + amount NUMERIC(12,2) NOT NULL, + reached_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_savings_goal_milestones_goal ON savings_goal_milestones(goal_id); + CREATE TABLE IF NOT EXISTS audit_logs ( id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id) ON DELETE SET NULL, diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..a4ddb523 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -127,6 +127,37 @@ class UserSubscription(db.Model): started_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) +class GoalStatus(str, Enum): + ON_TRACK = "ON_TRACK" + BEHIND = "BEHIND" + AHEAD = "AHEAD" + 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) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +class SavingsGoalMilestone(db.Model): + __tablename__ = "savings_goal_milestones" + id = db.Column(db.Integer, primary_key=True) + goal_id = db.Column( + db.Integer, db.ForeignKey("savings_goals.id", ondelete="CASCADE"), nullable=False + ) + title = db.Column(db.String(200), nullable=False) + amount = db.Column(db.Numeric(12, 2), nullable=False) + reached_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + 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..857ff855 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..94b07008 --- /dev/null +++ b/packages/backend/app/routes/savings_goals.py @@ -0,0 +1,264 @@ +from datetime import date, datetime +from decimal import Decimal, InvalidOperation +from math import ceil + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from ..extensions import db +from ..models import SavingsGoal, SavingsGoalMilestone, User +import logging + +bp = Blueprint("savings_goals", __name__) +logger = logging.getLogger("finmind.savings_goals") + + +def _goal_status(goal: SavingsGoal) -> str: + if float(goal.current_amount) >= float(goal.target_amount): + return "COMPLETED" + if not goal.deadline: + return "ON_TRACK" + today = date.today() + if goal.deadline <= today: + return "BEHIND" + total_days = (goal.deadline - goal.created_at.date()).days or 1 + elapsed_days = (today - goal.created_at.date()).days + expected_progress = elapsed_days / total_days + actual_progress = float(goal.current_amount) / float(goal.target_amount) + if actual_progress >= expected_progress + 0.05: + return "AHEAD" + if actual_progress < expected_progress - 0.05: + return "BEHIND" + return "ON_TRACK" + + +def _monthly_target(goal: SavingsGoal) -> float | None: + if not goal.deadline: + return None + today = date.today() + remaining = float(goal.target_amount) - float(goal.current_amount) + if remaining <= 0: + return 0.0 + months_left = ( + (goal.deadline.year - today.year) * 12 + + (goal.deadline.month - today.month) + ) + if months_left <= 0: + return round(remaining, 2) + return round(remaining / months_left, 2) + + +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": _goal_status(g), + "monthly_target": _monthly_target(g), + "created_at": g.created_at.isoformat(), + } + + +def _milestone_to_dict(m: SavingsGoalMilestone) -> dict: + return { + "id": m.id, + "goal_id": m.goal_id, + "title": m.title, + "amount": float(m.amount), + "reached_at": m.reached_at.isoformat() if m.reached_at else None, + "created_at": m.created_at.isoformat(), + } + + +def _parse_amount(raw) -> Decimal | None: + try: + return Decimal(str(raw)).quantize(Decimal("0.01")) + except (InvalidOperation, ValueError, TypeError): + return None + + +def _check_milestones(goal: SavingsGoal): + """Mark milestones as reached when current_amount meets their threshold.""" + milestones = ( + db.session.query(SavingsGoalMilestone) + .filter_by(goal_id=goal.id) + .filter(SavingsGoalMilestone.reached_at.is_(None)) + .all() + ) + now = datetime.utcnow() + for m in milestones: + if float(goal.current_amount) >= float(m.amount): + m.reached_at = now + + +@bp.get("") +@jwt_required() +def list_goals(): + uid = int(get_jwt_identity()) + items = ( + db.session.query(SavingsGoal) + .filter_by(user_id=uid) + .order_by(SavingsGoal.created_at.desc()) + .all() + ) + logger.info("List savings goals user=%s count=%s", uid, len(items)) + return jsonify([_goal_to_dict(g) for g in items]) + + +@bp.post("") +@jwt_required() +def create_goal(): + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + data = request.get_json() or {} + title = (data.get("title") or "").strip() + if not title: + return jsonify(error="title required"), 400 + target = _parse_amount(data.get("target_amount")) + if target is None or target <= 0: + return jsonify(error="invalid target_amount"), 400 + current = _parse_amount(data.get("current_amount", 0)) or Decimal("0.00") + deadline = None + if data.get("deadline"): + try: + deadline = date.fromisoformat(data["deadline"]) + except ValueError: + return jsonify(error="invalid deadline"), 400 + g = SavingsGoal( + user_id=uid, + title=title, + target_amount=target, + current_amount=current, + currency=data.get("currency") or (user.preferred_currency if user else "INR"), + deadline=deadline, + ) + db.session.add(g) + db.session.commit() + logger.info("Created savings goal id=%s user=%s title=%s", g.id, uid, g.title) + return jsonify(_goal_to_dict(g)), 201 + + +@bp.get("/") +@jwt_required() +def get_goal(goal_id: int): + uid = int(get_jwt_identity()) + g = db.session.get(SavingsGoal, goal_id) + if not g or g.user_id != uid: + return jsonify(error="not found"), 404 + return jsonify(_goal_to_dict(g)) + + +@bp.patch("/") +@jwt_required() +def update_goal(goal_id: int): + uid = int(get_jwt_identity()) + g = db.session.get(SavingsGoal, goal_id) + if not g or g.user_id != uid: + return jsonify(error="not found"), 404 + data = request.get_json() or {} + if "title" in data: + title = (data["title"] or "").strip() + if not title: + return jsonify(error="title required"), 400 + g.title = title + if "target_amount" in data: + target = _parse_amount(data["target_amount"]) + if target is None or target <= 0: + return jsonify(error="invalid target_amount"), 400 + g.target_amount = target + if "current_amount" in data: + current = _parse_amount(data["current_amount"]) + if current is None or current < 0: + return jsonify(error="invalid current_amount"), 400 + g.current_amount = current + if "currency" in data: + g.currency = str(data["currency"] or "INR")[:10] + if "deadline" in data: + if data["deadline"]: + try: + g.deadline = date.fromisoformat(data["deadline"]) + except ValueError: + return jsonify(error="invalid deadline"), 400 + else: + g.deadline = None + _check_milestones(g) + db.session.commit() + logger.info("Updated savings goal id=%s user=%s", g.id, uid) + return jsonify(_goal_to_dict(g)) + + +@bp.delete("/") +@jwt_required() +def delete_goal(goal_id: int): + uid = int(get_jwt_identity()) + g = db.session.get(SavingsGoal, goal_id) + if not g or g.user_id != uid: + return jsonify(error="not found"), 404 + db.session.delete(g) + db.session.commit() + logger.info("Deleted savings goal id=%s user=%s", g.id, uid) + return jsonify(message="deleted") + + +# --- Milestones --- + + +@bp.get("//milestones") +@jwt_required() +def list_milestones(goal_id: int): + uid = int(get_jwt_identity()) + g = db.session.get(SavingsGoal, goal_id) + if not g or g.user_id != uid: + return jsonify(error="not found"), 404 + items = ( + db.session.query(SavingsGoalMilestone) + .filter_by(goal_id=goal_id) + .order_by(SavingsGoalMilestone.amount) + .all() + ) + return jsonify([_milestone_to_dict(m) for m in items]) + + +@bp.post("//milestones") +@jwt_required() +def create_milestone(goal_id: int): + uid = int(get_jwt_identity()) + g = db.session.get(SavingsGoal, goal_id) + if not g or g.user_id != uid: + return jsonify(error="not found"), 404 + data = request.get_json() or {} + title = (data.get("title") or "").strip() + if not title: + return jsonify(error="title required"), 400 + amount = _parse_amount(data.get("amount")) + if amount is None or amount <= 0: + return jsonify(error="invalid amount"), 400 + m = SavingsGoalMilestone( + goal_id=goal_id, + title=title, + amount=amount, + ) + if float(g.current_amount) >= float(amount): + m.reached_at = datetime.utcnow() + db.session.add(m) + db.session.commit() + logger.info("Created milestone id=%s goal=%s", m.id, goal_id) + return jsonify(_milestone_to_dict(m)), 201 + + +@bp.delete("//milestones/") +@jwt_required() +def delete_milestone(goal_id: int, milestone_id: int): + uid = int(get_jwt_identity()) + g = db.session.get(SavingsGoal, goal_id) + if not g or g.user_id != uid: + return jsonify(error="not found"), 404 + m = db.session.get(SavingsGoalMilestone, milestone_id) + if not m or m.goal_id != goal_id: + return jsonify(error="not found"), 404 + db.session.delete(m) + db.session.commit() + logger.info("Deleted milestone id=%s goal=%s", milestone_id, goal_id) + return jsonify(message="deleted") diff --git a/packages/backend/tests/test_savings_goals.py b/packages/backend/tests/test_savings_goals.py new file mode 100644 index 00000000..9fccd466 --- /dev/null +++ b/packages/backend/tests/test_savings_goals.py @@ -0,0 +1,273 @@ +from datetime import date, timedelta + + +def test_savings_goals_crud(client, auth_header): + # Initially empty + r = client.get("/savings-goals", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + # Create goal + payload = { + "title": "Emergency Fund", + "target_amount": 10000, + "current_amount": 2500, + "deadline": (date.today() + timedelta(days=365)).isoformat(), + } + r = client.post("/savings-goals", json=payload, headers=auth_header) + assert r.status_code == 201 + goal = r.get_json() + goal_id = goal["id"] + assert goal["title"] == "Emergency Fund" + assert goal["target_amount"] == 10000.0 + assert goal["current_amount"] == 2500.0 + assert goal["status"] in ("ON_TRACK", "BEHIND", "AHEAD") + assert goal["monthly_target"] is not None + + # List has 1 + r = client.get("/savings-goals", headers=auth_header) + assert r.status_code == 200 + assert len(r.get_json()) == 1 + + # Get single + r = client.get(f"/savings-goals/{goal_id}", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["id"] == goal_id + + # Update + r = client.patch( + f"/savings-goals/{goal_id}", + json={"current_amount": 5000, "title": "Emergency Savings"}, + headers=auth_header, + ) + assert r.status_code == 200 + updated = r.get_json() + assert updated["current_amount"] == 5000.0 + assert updated["title"] == "Emergency Savings" + + # Delete + r = client.delete(f"/savings-goals/{goal_id}", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["message"] == "deleted" + + # Confirm deleted + r = client.get("/savings-goals", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + +def test_savings_goal_validation(client, auth_header): + # Missing title + r = client.post( + "/savings-goals", + json={"target_amount": 1000}, + headers=auth_header, + ) + assert r.status_code == 400 + assert "title" in r.get_json()["error"] + + # Invalid target + r = client.post( + "/savings-goals", + json={"title": "Test", "target_amount": -100}, + headers=auth_header, + ) + assert r.status_code == 400 + assert "target_amount" in r.get_json()["error"] + + # Invalid deadline + r = client.post( + "/savings-goals", + json={"title": "Test", "target_amount": 1000, "deadline": "not-a-date"}, + headers=auth_header, + ) + assert r.status_code == 400 + assert "deadline" in r.get_json()["error"] + + +def test_savings_goal_not_found(client, auth_header): + r = client.get("/savings-goals/9999", headers=auth_header) + assert r.status_code == 404 + + r = client.patch( + "/savings-goals/9999", json={"title": "X"}, headers=auth_header + ) + assert r.status_code == 404 + + r = client.delete("/savings-goals/9999", headers=auth_header) + assert r.status_code == 404 + + +def test_savings_goal_completed_status(client, auth_header): + payload = { + "title": "Small Goal", + "target_amount": 100, + "current_amount": 100, + } + r = client.post("/savings-goals", json=payload, headers=auth_header) + assert r.status_code == 201 + assert r.get_json()["status"] == "COMPLETED" + + +def test_savings_goal_no_deadline(client, auth_header): + payload = { + "title": "Open Goal", + "target_amount": 5000, + } + r = client.post("/savings-goals", json=payload, headers=auth_header) + assert r.status_code == 201 + goal = r.get_json() + assert goal["deadline"] is None + assert goal["monthly_target"] is None + assert goal["status"] == "ON_TRACK" + + +def test_milestones_crud(client, auth_header): + # Create a goal first + r = client.post( + "/savings-goals", + json={ + "title": "Vacation", + "target_amount": 5000, + "current_amount": 1000, + }, + headers=auth_header, + ) + assert r.status_code == 201 + goal_id = r.get_json()["id"] + + # Create milestone (not yet reached) + r = client.post( + f"/savings-goals/{goal_id}/milestones", + json={"title": "25% saved", "amount": 1250}, + headers=auth_header, + ) + assert r.status_code == 201 + milestone = r.get_json() + assert milestone["title"] == "25% saved" + assert milestone["reached_at"] is None + + # Create milestone (already reached) + r = client.post( + f"/savings-goals/{goal_id}/milestones", + json={"title": "First $500", "amount": 500}, + headers=auth_header, + ) + assert r.status_code == 201 + assert r.get_json()["reached_at"] is not None + + # List milestones + r = client.get(f"/savings-goals/{goal_id}/milestones", headers=auth_header) + assert r.status_code == 200 + milestones = r.get_json() + assert len(milestones) == 2 + + # Delete milestone + m_id = milestones[0]["id"] + r = client.delete( + f"/savings-goals/{goal_id}/milestones/{m_id}", headers=auth_header + ) + assert r.status_code == 200 + + # Confirm deleted + r = client.get(f"/savings-goals/{goal_id}/milestones", headers=auth_header) + assert len(r.get_json()) == 1 + + +def test_milestone_auto_reach_on_update(client, auth_header): + # Create goal with milestone + r = client.post( + "/savings-goals", + json={"title": "Car Fund", "target_amount": 10000, "current_amount": 0}, + headers=auth_header, + ) + goal_id = r.get_json()["id"] + + r = client.post( + f"/savings-goals/{goal_id}/milestones", + json={"title": "Half way", "amount": 5000}, + headers=auth_header, + ) + milestone_id = r.get_json()["id"] + assert r.get_json()["reached_at"] is None + + # Update goal to pass milestone + r = client.patch( + f"/savings-goals/{goal_id}", + json={"current_amount": 6000}, + headers=auth_header, + ) + assert r.status_code == 200 + + # Check milestone is now reached + r = client.get(f"/savings-goals/{goal_id}/milestones", headers=auth_header) + milestones = r.get_json() + reached = [m for m in milestones if m["id"] == milestone_id] + assert len(reached) == 1 + assert reached[0]["reached_at"] is not None + + +def test_milestone_validation(client, auth_header): + r = client.post( + "/savings-goals", + json={"title": "Test", "target_amount": 1000}, + headers=auth_header, + ) + goal_id = r.get_json()["id"] + + # Missing title + r = client.post( + f"/savings-goals/{goal_id}/milestones", + json={"amount": 500}, + headers=auth_header, + ) + assert r.status_code == 400 + + # Invalid amount + r = client.post( + f"/savings-goals/{goal_id}/milestones", + json={"title": "Bad", "amount": -10}, + headers=auth_header, + ) + assert r.status_code == 400 + + +def test_milestone_not_found(client, auth_header): + # Non-existent goal + r = client.get("/savings-goals/9999/milestones", headers=auth_header) + assert r.status_code == 404 + + r = client.post( + "/savings-goals/9999/milestones", + json={"title": "X", "amount": 100}, + headers=auth_header, + ) + assert r.status_code == 404 + + # Create a goal, then try non-existent milestone + r = client.post( + "/savings-goals", + json={"title": "Test", "target_amount": 1000}, + headers=auth_header, + ) + goal_id = r.get_json()["id"] + + r = client.delete( + f"/savings-goals/{goal_id}/milestones/9999", headers=auth_header + ) + assert r.status_code == 404 + + +def test_savings_goal_defaults_to_user_currency(client, auth_header): + r = client.patch( + "/auth/me", json={"preferred_currency": "EUR"}, headers=auth_header + ) + assert r.status_code == 200 + + r = client.post( + "/savings-goals", + json={"title": "Euro Goal", "target_amount": 5000}, + headers=auth_header, + ) + assert r.status_code == 201 + assert r.get_json()["currency"] == "EUR"