diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942d..628905630 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -51,6 +51,14 @@ const App = () => ( } /> + + + + } + /> & { + active?: boolean; +}; + +export type GoalMilestone = { + id: number; + goal_id: number; + label: string; + target_amount: number; + reached: boolean; + reached_at: string | null; + created_at: string | null; +}; + +export type GoalMilestoneCreate = { + label: string; + target_amount: number; +}; + +export async function listGoals(): Promise { + return api('/goals'); +} + +export async function createGoal(payload: GoalCreate): Promise<{ id: number }> { + return api<{ id: number }>('/goals', { method: 'POST', body: payload }); +} + +export async function updateGoal(id: number, payload: GoalUpdate): Promise<{ message: string }> { + return api<{ message: string }>(`/goals/${id}`, { method: 'PATCH', body: payload }); +} + +export async function deleteGoal(id: number): Promise<{ message: string }> { + return api<{ message: string }>(`/goals/${id}`, { method: 'DELETE' }); +} + +export async function contributeToGoal(id: number, amount: number): Promise<{ current_amount: number }> { + return api<{ current_amount: number }>(`/goals/${id}/contribute`, { method: 'POST', body: { amount } }); +} + +export async function listMilestones(goalId: number): Promise { + return api(`/goals/${goalId}/milestones`); +} + +export async function createMilestone(goalId: number, payload: GoalMilestoneCreate): Promise<{ id: number }> { + return api<{ id: number }>(`/goals/${goalId}/milestones`, { method: 'POST', body: payload }); +} + +export async function deleteMilestone(goalId: number, milestoneId: number): Promise<{ message: string }> { + return api<{ message: string }>(`/goals/${goalId}/milestones/${milestoneId}`, { method: 'DELETE' }); +} diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b701..b8fbb7a63 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: 'Goals', href: '/goals' }, { name: 'Bills', href: '/bills' }, { name: 'Reminders', href: '/reminders' }, { name: 'Expenses', href: '/expenses' }, diff --git a/app/src/pages/Goals.tsx b/app/src/pages/Goals.tsx new file mode 100644 index 000000000..1d6284f16 --- /dev/null +++ b/app/src/pages/Goals.tsx @@ -0,0 +1,392 @@ +import { useState, useEffect, useCallback } from 'react'; +import { FinancialCard, FinancialCardContent, FinancialCardHeader, FinancialCardTitle, FinancialCardDescription } from '@/components/ui/financial-card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dailog'; +import { Target, Plus, Coins, Trophy, Trash2, Pencil, Milestone } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { formatMoney } from '@/lib/currency'; +import { + listGoals, + createGoal, + updateGoal, + deleteGoal, + contributeToGoal, + listMilestones, + createMilestone, + deleteMilestone, + type Goal, + type GoalMilestone, +} from '@/api/goals'; + +export function Goals() { + const [goals, setGoals] = useState([]); + const [loading, setLoading] = useState(true); + const [createOpen, setCreateOpen] = useState(false); + const [editGoal, setEditGoal] = useState(null); + const [contributeGoal, setContributeGoal] = useState(null); + const [contributeAmount, setContributeAmount] = useState(''); + const [milestonesGoal, setMilestonesGoal] = useState(null); + const [milestones, setMilestones] = useState([]); + const [newMilestoneLabel, setNewMilestoneLabel] = useState(''); + const [newMilestoneAmount, setNewMilestoneAmount] = useState(''); + const { toast } = useToast(); + + const [formTitle, setFormTitle] = useState(''); + const [formDescription, setFormDescription] = useState(''); + const [formTarget, setFormTarget] = useState(''); + const [formDeadline, setFormDeadline] = useState(''); + const [formIcon, setFormIcon] = useState(''); + + const fetchGoals = useCallback(async () => { + try { + setLoading(true); + const data = await listGoals(); + setGoals(data); + } catch (err) { + toast({ title: 'Error', description: String(err), variant: 'destructive' }); + } finally { + setLoading(false); + } + }, [toast]); + + useEffect(() => { + fetchGoals(); + }, [fetchGoals]); + + const resetForm = () => { + setFormTitle(''); + setFormDescription(''); + setFormTarget(''); + setFormDeadline(''); + setFormIcon(''); + }; + + const handleCreate = async () => { + try { + await createGoal({ + title: formTitle, + description: formDescription || null, + target_amount: parseFloat(formTarget), + deadline: formDeadline || null, + icon: formIcon || null, + }); + toast({ title: 'Goal created' }); + setCreateOpen(false); + resetForm(); + fetchGoals(); + } catch (err) { + toast({ title: 'Error', description: String(err), variant: 'destructive' }); + } + }; + + const handleEdit = async () => { + if (!editGoal) return; + try { + await updateGoal(editGoal.id, { + title: formTitle, + description: formDescription || null, + target_amount: parseFloat(formTarget), + deadline: formDeadline || null, + icon: formIcon || null, + }); + toast({ title: 'Goal updated' }); + setEditGoal(null); + resetForm(); + fetchGoals(); + } catch (err) { + toast({ title: 'Error', description: String(err), variant: 'destructive' }); + } + }; + + const handleDelete = async (id: number) => { + try { + await deleteGoal(id); + toast({ title: 'Goal deleted' }); + fetchGoals(); + } catch (err) { + toast({ title: 'Error', description: String(err), variant: 'destructive' }); + } + }; + + const handleContribute = async () => { + if (!contributeGoal) return; + try { + await contributeToGoal(contributeGoal.id, parseFloat(contributeAmount)); + toast({ title: 'Contribution added' }); + setContributeGoal(null); + setContributeAmount(''); + fetchGoals(); + } catch (err) { + toast({ title: 'Error', description: String(err), variant: 'destructive' }); + } + }; + + const openEdit = (g: Goal) => { + setEditGoal(g); + setFormTitle(g.title); + setFormDescription(g.description || ''); + setFormTarget(String(g.target_amount)); + setFormDeadline(g.deadline || ''); + setFormIcon(g.icon || ''); + }; + + const fetchMilestones = async (g: Goal) => { + try { + const data = await listMilestones(g.id); + setMilestones(data); + setMilestonesGoal(g); + } catch (err) { + toast({ title: 'Error', description: String(err), variant: 'destructive' }); + } + }; + + const handleCreateMilestone = async () => { + if (!milestonesGoal) return; + try { + await createMilestone(milestonesGoal.id, { + label: newMilestoneLabel, + target_amount: parseFloat(newMilestoneAmount), + }); + toast({ title: 'Milestone created' }); + setNewMilestoneLabel(''); + setNewMilestoneAmount(''); + fetchMilestones(milestonesGoal); + } catch (err) { + toast({ title: 'Error', description: String(err), variant: 'destructive' }); + } + }; + + const handleDeleteMilestone = async (mid: number) => { + if (!milestonesGoal) return; + try { + await deleteMilestone(milestonesGoal.id, mid); + toast({ title: 'Milestone deleted' }); + fetchMilestones(milestonesGoal); + } catch (err) { + toast({ title: 'Error', description: String(err), variant: 'destructive' }); + } + }; + + const getProgress = (g: Goal) => { + if (g.target_amount <= 0) return 0; + return Math.min(100, (g.current_amount / g.target_amount) * 100); + }; + + return ( +
+
+
+

+ + Savings Goals +

+

Track your savings goals and milestones

+
+ { setCreateOpen(open); if (!open) resetForm(); }}> + + + + + + Create Savings Goal + Set a target and start saving towards it. + +
+
+ + setFormTitle(e.target.value)} placeholder="e.g. Emergency Fund" /> +
+
+ + setFormDescription(e.target.value)} placeholder="Optional description" /> +
+
+ + setFormTarget(e.target.value)} placeholder="50000" /> +
+
+ + setFormDeadline(e.target.value)} /> +
+
+ + setFormIcon(e.target.value)} placeholder="e.g. piggy-bank" /> +
+
+ + + + +
+
+
+ + {loading && ( +
+
+
+ )} + + {!loading && goals.length === 0 && ( + + + +

No savings goals yet

+

Create your first savings goal to start tracking progress.

+
+
+ )} + +
+ {goals.map((g) => { + const pct = getProgress(g); + const isComplete = pct >= 100; + return ( + + +
+
+ {g.icon ? {g.icon} : } + {g.title} +
+ {isComplete && } +
+ {g.description && {g.description}} +
+ +
+ Progress + {formatMoney(g.current_amount, g.currency)} / {formatMoney(g.target_amount, g.currency)} +
+ +
+ {pct.toFixed(1)}% + {g.deadline && Due: {g.deadline}} +
+
+ + + + + + + + + + Delete Goal + Are you sure you want to delete "{g.title}"? + + + Cancel + handleDelete(g.id)}>Delete + + + +
+
+
+ ); + })} +
+ + { if (!open) setContributeGoal(null); setContributeAmount(''); }}> + + + Contribute to Goal + Add savings to "{contributeGoal?.title}" + +
+ + setContributeAmount(e.target.value)} placeholder="1000" /> +
+ + + + +
+
+ + { if (!open) { setEditGoal(null); resetForm(); } }}> + + + Edit Goal + Update your savings goal details. + +
+
+ + setFormTitle(e.target.value)} /> +
+
+ + setFormDescription(e.target.value)} /> +
+
+ + setFormTarget(e.target.value)} /> +
+
+ + setFormDeadline(e.target.value)} /> +
+
+ + + + +
+
+ + { if (!open) { setMilestonesGoal(null); setMilestones([]); } }}> + + + Milestones for "{milestonesGoal?.title}" + Track progress towards your goal with milestones. + +
+ {milestones.length === 0 && ( +

No milestones yet. Create one below.

+ )} + {milestones.map((m) => ( +
+
+ {m.reached ? 'Reached' : 'Pending'} + {m.label} + {formatMoney(m.target_amount, milestonesGoal?.currency)} +
+ +
+ ))} +
+
+

Add Milestone

+
+ setNewMilestoneLabel(e.target.value)} placeholder="Label" /> + setNewMilestoneAmount(e.target.value)} placeholder="Amount" /> + +
+
+
+
+
+ ); +} diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189def..14d8d7eb9 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -123,3 +123,29 @@ CREATE TABLE IF NOT EXISTS audit_logs ( action VARCHAR(100) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); + +CREATE TABLE IF NOT EXISTS goals ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(200) NOT NULL, + description VARCHAR(500), + 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, + icon VARCHAR(50), + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_goals_user ON goals(user_id); + +CREATE TABLE IF NOT EXISTS goal_milestones ( + id SERIAL PRIMARY KEY, + goal_id INT NOT NULL REFERENCES goals(id) ON DELETE CASCADE, + label VARCHAR(200) NOT NULL, + target_amount NUMERIC(12,2) NOT NULL, + reached BOOLEAN NOT NULL DEFAULT FALSE, + reached_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_goal_milestones_goal ON goal_milestones(goal_id); diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d448104..e89d76467 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -133,3 +133,30 @@ 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 Goal(db.Model): + __tablename__ = "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) + description = db.Column(db.String(500), nullable=True) + 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) + icon = db.Column(db.String(50), 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("GoalMilestone", backref="goal", cascade="all, delete-orphan") + + +class GoalMilestone(db.Model): + __tablename__ = "goal_milestones" + id = db.Column(db.Integer, primary_key=True) + goal_id = db.Column(db.Integer, db.ForeignKey("goals.id"), nullable=False) + label = db.Column(db.String(200), nullable=False) + target_amount = db.Column(db.Numeric(12, 2), nullable=False) + reached = db.Column(db.Boolean, default=False, nullable=False) + reached_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..0b07adda1 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -18,3 +18,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(goals_bp, url_prefix="/goals") diff --git a/packages/backend/app/routes/goals.py b/packages/backend/app/routes/goals.py new file mode 100644 index 000000000..8c2a2fb59 --- /dev/null +++ b/packages/backend/app/routes/goals.py @@ -0,0 +1,186 @@ +from datetime import datetime, date +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 Goal, GoalMilestone, User +from ..services.cache import cache_delete_patterns +import logging + +bp = Blueprint("goals", __name__) +logger = logging.getLogger("finmind.goals") + + +def _goal_dict(g: Goal) -> dict: + return { + "id": g.id, + "title": g.title, + "description": g.description, + "target_amount": float(g.target_amount), + "current_amount": float(g.current_amount), + "currency": g.currency, + "deadline": g.deadline.isoformat() if g.deadline else None, + "icon": g.icon, + "active": g.active, + "created_at": g.created_at.isoformat() if g.created_at else None, + } + + +def _milestone_dict(m: GoalMilestone) -> dict: + return { + "id": m.id, + "goal_id": m.goal_id, + "label": m.label, + "target_amount": float(m.target_amount), + "reached": m.reached, + "reached_at": m.reached_at.isoformat() if m.reached_at else None, + "created_at": m.created_at.isoformat() if m.created_at else None, + } + + +@bp.get("") +@jwt_required() +def list_goals(): + uid = int(get_jwt_identity()) + items = ( + db.session.query(Goal) + .filter_by(user_id=uid, active=True) + .order_by(Goal.created_at.desc()) + .all() + ) + logger.info("List goals user=%s count=%s", uid, len(items)) + return jsonify([_goal_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 {} + deadline = None + if data.get("deadline"): + deadline = date.fromisoformat(data["deadline"]) + g = Goal( + user_id=uid, + title=data["title"], + description=data.get("description"), + target_amount=data["target_amount"], + current_amount=data.get("current_amount", 0), + currency=data.get("currency") or (user.preferred_currency if user else "INR"), + deadline=deadline, + icon=data.get("icon"), + ) + db.session.add(g) + db.session.commit() + logger.info("Created goal id=%s user=%s title=%s", g.id, uid, g.title) + cache_delete_patterns([f"user:{uid}:goals*"]) + return jsonify(id=g.id), 201 + + +@bp.patch("/") +@jwt_required() +def update_goal(goal_id: int): + uid = int(get_jwt_identity()) + g = db.session.get(Goal, goal_id) + if not g or g.user_id != uid: + return jsonify(error="not found"), 404 + data = request.get_json() or {} + for field in ("title", "description", "icon"): + if field in data: + setattr(g, field, data[field]) + if "target_amount" in data: + g.target_amount = data["target_amount"] + if "current_amount" in data: + g.current_amount = data["current_amount"] + if "currency" in data: + g.currency = data["currency"] + if "active" in data: + g.active = bool(data["active"]) + if "deadline" in data: + g.deadline = date.fromisoformat(data["deadline"]) if data["deadline"] else None + db.session.commit() + logger.info("Updated goal id=%s user=%s", g.id, uid) + cache_delete_patterns([f"user:{uid}:goals*"]) + return jsonify(message="updated") + + +@bp.delete("/") +@jwt_required() +def delete_goal(goal_id: int): + uid = int(get_jwt_identity()) + g = db.session.get(Goal, goal_id) + if not g or g.user_id != uid: + return jsonify(error="not found"), 404 + g.active = False + db.session.commit() + logger.info("Deleted goal id=%s user=%s", g.id, uid) + cache_delete_patterns([f"user:{uid}:goals*"]) + return jsonify(message="deleted") + + +@bp.post("//contribute") +@jwt_required() +def contribute_goal(goal_id: int): + uid = int(get_jwt_identity()) + g = db.session.get(Goal, goal_id) + if not g or g.user_id != uid: + return jsonify(error="not found"), 404 + data = request.get_json() or {} + amount = data.get("amount", 0) + g.current_amount = float(g.current_amount) + float(amount) + db.session.commit() + logger.info("Contributed goal id=%s user=%s amount=%s", g.id, uid, amount) + cache_delete_patterns([f"user:{uid}:goals*"]) + return jsonify(current_amount=float(g.current_amount)) + + +@bp.get("//milestones") +@jwt_required() +def list_milestones(goal_id: int): + uid = int(get_jwt_identity()) + g = db.session.get(Goal, goal_id) + if not g or g.user_id != uid: + return jsonify(error="not found"), 404 + items = ( + db.session.query(GoalMilestone) + .filter_by(goal_id=goal_id) + .order_by(GoalMilestone.target_amount) + .all() + ) + return jsonify([_milestone_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(Goal, goal_id) + if not g or g.user_id != uid: + return jsonify(error="not found"), 404 + data = request.get_json() or {} + m = GoalMilestone( + goal_id=goal_id, + label=data["label"], + target_amount=data["target_amount"], + ) + db.session.add(m) + db.session.commit() + logger.info("Created milestone id=%s goal_id=%s", m.id, goal_id) + return jsonify(id=m.id), 201 + + +@bp.delete("//milestones/") +@jwt_required() +def delete_milestone(goal_id: int, milestone_id: int): + uid = int(get_jwt_identity()) + g = db.session.get(Goal, goal_id) + if not g or g.user_id != uid: + return jsonify(error="not found"), 404 + m = db.session.get(GoalMilestone, 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_id=%s", milestone_id, goal_id) + return jsonify(message="deleted") diff --git a/packages/backend/tests/test_goals.py b/packages/backend/tests/test_goals.py new file mode 100644 index 000000000..ff9167953 --- /dev/null +++ b/packages/backend/tests/test_goals.py @@ -0,0 +1,118 @@ +from datetime import date + + +def test_goals_crud(client, auth_header): + # Initially empty + r = client.get("/goals", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + # Create goal + payload = { + "title": "Emergency Fund", + "description": "Save for emergencies", + "target_amount": 50000, + "currency": "INR", + "deadline": "2026-12-31", + } + r = client.post("/goals", json=payload, headers=auth_header) + assert r.status_code == 201 + goal_id = r.get_json()["id"] + + # List has 1 + r = client.get("/goals", headers=auth_header) + assert r.status_code == 200 + items = r.get_json() + assert len(items) == 1 + assert items[0]["id"] == goal_id + assert items[0]["title"] == "Emergency Fund" + assert items[0]["target_amount"] == 50000 + assert items[0]["current_amount"] == 0 + + # Update goal + r = client.patch(f"/goals/{goal_id}", json={"title": "Big Emergency Fund"}, headers=auth_header) + assert r.status_code == 200 + r = client.get("/goals", headers=auth_header) + assert r.get_json()[0]["title"] == "Big Emergency Fund" + + # Delete goal (soft delete) + r = client.delete(f"/goals/{goal_id}", headers=auth_header) + assert r.status_code == 200 + r = client.get("/goals", headers=auth_header) + assert r.get_json() == [] + + +def test_goal_contribute(client, auth_header): + payload = { + "title": "Vacation", + "target_amount": 10000, + } + r = client.post("/goals", json=payload, headers=auth_header) + assert r.status_code == 201 + goal_id = r.get_json()["id"] + + # Contribute + r = client.post(f"/goals/{goal_id}/contribute", json={"amount": 2500}, headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["current_amount"] == 2500 + + # Contribute again + r = client.post(f"/goals/{goal_id}/contribute", json={"amount": 1500}, headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["current_amount"] == 4000 + + +def test_goal_milestones(client, auth_header): + payload = {"title": "Car", "target_amount": 300000} + r = client.post("/goals", json=payload, headers=auth_header) + assert r.status_code == 201 + goal_id = r.get_json()["id"] + + # Initially no milestones + r = client.get(f"/goals/{goal_id}/milestones", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + # Create milestone + r = client.post( + f"/goals/{goal_id}/milestones", + json={"label": "First 50k", "target_amount": 50000}, + headers=auth_header, + ) + assert r.status_code == 201 + mid = r.get_json()["id"] + + # List milestones + r = client.get(f"/goals/{goal_id}/milestones", headers=auth_header) + assert r.status_code == 200 + ms = r.get_json() + assert len(ms) == 1 + assert ms[0]["label"] == "First 50k" + + # Delete milestone + r = client.delete(f"/goals/{goal_id}/milestones/{mid}", headers=auth_header) + assert r.status_code == 200 + r = client.get(f"/goals/{goal_id}/milestones", headers=auth_header) + assert r.get_json() == [] + + +def test_goal_not_found(client, auth_header): + r = client.patch("/goals/9999", json={"title": "x"}, headers=auth_header) + assert r.status_code == 404 + r = client.delete("/goals/9999", headers=auth_header) + assert r.status_code == 404 + r = client.post("/goals/9999/contribute", json={"amount": 100}, headers=auth_header) + assert r.status_code == 404 + + +def test_goal_create_defaults_to_user_currency(client, auth_header): + r = client.patch("/auth/me", json={"preferred_currency": "USD"}, headers=auth_header) + assert r.status_code == 200 + + payload = {"title": "Laptop", "target_amount": 2000} + r = client.post("/goals", json=payload, headers=auth_header) + assert r.status_code == 201 + goal_id = r.get_json()["id"] + + r = client.get("/goals", headers=auth_header) + assert r.get_json()[0]["currency"] == "USD"