diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942..84a898bb 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -16,6 +16,7 @@ import NotFound from "./pages/NotFound"; import { Landing } from "./pages/Landing"; import ProtectedRoute from "./components/auth/ProtectedRoute"; import Account from "./pages/Account"; +import Goals from "./pages/Goals"; const queryClient = new QueryClient({ defaultOptions: { @@ -83,6 +84,14 @@ const App = () => ( } /> + + + + } + /> { + const qs = status ? `?status=${status}` : ''; + return api(`/goals${qs}`); +} + +export async function createGoal(payload: GoalCreate): Promise { + return api('/goals', { method: 'POST', body: payload }); +} + +export async function getGoal(id: number): Promise { + return api(`/goals/${id}`); +} + +export async function updateGoal(id: number, payload: Partial): Promise { + return api(`/goals/${id}`, { method: 'PATCH', body: payload }); +} + +export async function cancelGoal(id: number): Promise { + return api(`/goals/${id}`, { method: 'DELETE' }); +} + +export async function getProgress(id: number): Promise { + return api(`/goals/${id}/progress`); +} + +export async function addContribution(id: number, amount: number, note?: string): Promise { + return api(`/goals/${id}/contribute`, { method: 'POST', body: { amount, note } }); +} + +export async function listContributions(id: number): Promise { + return api(`/goals/${id}/contributions`); +} diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b70..56b8c44e 100644 --- a/app/src/components/layout/Navbar.tsx +++ b/app/src/components/layout/Navbar.tsx @@ -13,6 +13,7 @@ const navigation = [ { name: 'Reminders', href: '/reminders' }, { name: 'Expenses', href: '/expenses' }, { name: 'Analytics', href: '/analytics' }, + { name: 'Goals', href: '/goals' }, ]; export function Navbar() { diff --git a/app/src/pages/Goals.tsx b/app/src/pages/Goals.tsx new file mode 100644 index 00000000..dc8b3a75 --- /dev/null +++ b/app/src/pages/Goals.tsx @@ -0,0 +1,212 @@ +import { useState, useEffect, useCallback } from 'react'; +import { FinancialCard, FinancialCardContent, FinancialCardHeader, FinancialCardTitle } from '@/components/ui/financial-card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Target, Plus, TrendingUp, Calendar, CheckCircle2, XCircle } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { listGoals, createGoal, getProgress, addContribution, cancelGoal, type SavingsGoal, type GoalProgress } from '@/api/goals'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { formatMoney } from '@/lib/currency'; + +const STATUS_COLORS: Record = { + ACTIVE: 'bg-blue-100 text-blue-800', + COMPLETED: 'bg-green-100 text-green-800', + CANCELLED: 'bg-gray-100 text-gray-800', +}; + +export default function Goals() { + const { toast } = useToast(); + const [goals, setGoals] = useState([]); + const [progressMap, setProgressMap] = useState>({}); + const [loading, setLoading] = useState(true); + const [createOpen, setCreateOpen] = useState(false); + const [contributeGoalId, setContributeGoalId] = useState(null); + const [newGoal, setNewGoal] = useState({ name: '', target_amount: '', currency: 'INR', deadline: '' }); + const [contributeAmount, setContributeAmount] = useState(''); + const [contributeNote, setContributeNote] = useState(''); + + const loadData = useCallback(async () => { + try { + const g = await listGoals(); + setGoals(g); + const progEntries = await Promise.all( + g.filter(gl => gl.status === 'ACTIVE').map(async gl => { + try { + const p = await getProgress(gl.id); + return [gl.id, p] as [number, GoalProgress]; + } catch { return null; } + }) + ); + const map: Record = {}; + progEntries.forEach(e => { if (e) map[e[0]] = e[1]; }); + setProgressMap(map); + } catch (e: unknown) { + toast({ title: 'Error', description: e instanceof Error ? e.message : 'Failed to load goals', variant: 'destructive' }); + } finally { setLoading(false); } + }, [toast]); + + useEffect(() => { loadData(); }, [loadData]); + + const handleCreate = async () => { + try { + await createGoal({ + name: newGoal.name, + target_amount: parseFloat(newGoal.target_amount), + currency: newGoal.currency, + deadline: newGoal.deadline || undefined, + }); + toast({ title: 'Goal created' }); + setCreateOpen(false); + setNewGoal({ name: '', target_amount: '', currency: 'INR', deadline: '' }); + loadData(); + } catch (e: unknown) { + toast({ title: 'Error', description: e instanceof Error ? e.message : 'Failed', variant: 'destructive' }); + } + }; + + const handleContribute = async () => { + if (!contributeGoalId) return; + try { + await addContribution(contributeGoalId, parseFloat(contributeAmount), contributeNote || undefined); + toast({ title: 'Contribution added' }); + setContributeGoalId(null); + setContributeAmount(''); + setContributeNote(''); + loadData(); + } catch (e: unknown) { + toast({ title: 'Error', description: e instanceof Error ? e.message : 'Failed', variant: 'destructive' }); + } + }; + + const handleCancel = async (id: number) => { + try { + await cancelGoal(id); + toast({ title: 'Goal cancelled' }); + loadData(); + } catch (e: unknown) { + toast({ title: 'Error', description: e instanceof Error ? e.message : 'Failed', variant: 'destructive' }); + } + }; + + if (loading) return
Loading...
; + + return ( +
+
+
+

Savings Goals

+

Track your savings goals and milestones

+
+ + + + + + + Create Savings Goal + Set a target and track your progress. + +
+ setNewGoal(p => ({ ...p, name: e.target.value }))} /> + setNewGoal(p => ({ ...p, target_amount: e.target.value }))} /> + setNewGoal(p => ({ ...p, currency: e.target.value }))} /> + setNewGoal(p => ({ ...p, deadline: e.target.value }))} /> +
+ + + +
+
+
+ + {/* Contribute Dialog */} + { if (!open) setContributeGoalId(null); }}> + + + Add Contribution + +
+ setContributeAmount(e.target.value)} /> + setContributeNote(e.target.value)} /> +
+ + + +
+
+ + {goals.length === 0 ? ( + + + +

No goals yet. Create your first savings goal!

+
+
+ ) : ( +
+ {goals.map(goal => { + const progress = progressMap[goal.id]; + const pct = progress?.progress_pct ?? (goal.target_amount > 0 ? (goal.current_amount / goal.target_amount) * 100 : 0); + + return ( + + +
+ {goal.status === 'COMPLETED' ? : } + {goal.name} +
+ {goal.status} +
+ +
+ {formatMoney(goal.current_amount, goal.currency)} + of {formatMoney(goal.target_amount, goal.currency)} +
+ {/* Progress bar */} +
+
+
+
+ {pct.toFixed(1)}% + {goal.deadline && ( + + + {goal.deadline} + + )} +
+ {/* Milestones */} + {progress?.milestones && ( +
+ {progress.milestones.map(m => ( +
+ {m.pct}% +
+ ))} +
+ )} + {progress?.daily_savings_needed && ( +

+ + Save {formatMoney(progress.daily_savings_needed, goal.currency)}/day to reach your goal +

+ )} + {goal.status === 'ACTIVE' && ( +
+ + +
+ )} + + + ); + })} +
+ )} +
+ ); +} diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189de..2452ef64 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -117,6 +117,32 @@ 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, + name 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, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_savings_goals_user_status ON savings_goals(user_id, status, created_at DESC); + +CREATE TABLE IF NOT EXISTS goal_contributions ( + id SERIAL PRIMARY KEY, + goal_id INT NOT NULL REFERENCES savings_goals(id) ON DELETE CASCADE, + amount NUMERIC(12,2) NOT NULL, + note VARCHAR(200), + contributed_at DATE NOT NULL DEFAULT CURRENT_DATE, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_goal_contributions_goal ON goal_contributions(goal_id, contributed_at DESC); + 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..71db0f25 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -127,6 +127,38 @@ class UserSubscription(db.Model): started_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) +class GoalStatus(str, Enum): + ACTIVE = "ACTIVE" + COMPLETED = "COMPLETED" + CANCELLED = "CANCELLED" + + +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) + status = db.Column(db.String(20), default=GoalStatus.ACTIVE.value, 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 + ) + + +class GoalContribution(db.Model): + __tablename__ = "goal_contributions" + id = db.Column(db.Integer, primary_key=True) + goal_id = db.Column(db.Integer, db.ForeignKey("savings_goals.id"), nullable=False) + amount = db.Column(db.Numeric(12, 2), nullable=False) + note = db.Column(db.String(200), nullable=True) + contributed_at = db.Column(db.Date, default=date.today, nullable=False) + 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/openapi.yaml b/packages/backend/app/openapi.yaml index 3f8ec3f0..5e1e7fda 100644 --- a/packages/backend/app/openapi.yaml +++ b/packages/backend/app/openapi.yaml @@ -12,6 +12,7 @@ tags: - name: Bills - name: Reminders - name: Insights + - name: Goals paths: /auth/register: post: @@ -251,6 +252,121 @@ paths: properties: inserted: { type: integer } + /goals: + get: + summary: List savings goals + tags: [Goals] + security: [{ bearerAuth: [] }] + parameters: + - in: query + name: status + schema: { type: string, enum: [ACTIVE, COMPLETED, CANCELLED] } + responses: + '200': + description: OK + content: + application/json: + schema: { type: array, items: { $ref: '#/components/schemas/SavingsGoal' } } + post: + summary: Create savings goal + tags: [Goals] + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/NewGoal' } + responses: + '201': { description: Created } + /goals/{goalId}: + get: + summary: Get goal + tags: [Goals] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: goalId + required: true + schema: { type: integer } + responses: + '200': { description: OK } + '404': { description: Not found } + put: + summary: Update goal + tags: [Goals] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: goalId + required: true + schema: { type: integer } + responses: + '200': { description: Updated } + delete: + summary: Cancel goal + tags: [Goals] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: goalId + required: true + schema: { type: integer } + responses: + '200': { description: Cancelled } + /goals/{goalId}/progress: + get: + summary: Get goal progress with milestones + tags: [Goals] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: goalId + required: true + schema: { type: integer } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/GoalProgress' } + /goals/{goalId}/contribute: + post: + summary: Add contribution to goal + tags: [Goals] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: goalId + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [amount] + properties: + amount: { type: number } + note: { type: string } + responses: + '201': { description: Created } + /goals/{goalId}/contributions: + get: + summary: List contributions for goal + tags: [Goals] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: goalId + required: true + schema: { type: integer } + responses: + '200': + description: OK + content: + application/json: + schema: { type: array, items: { $ref: '#/components/schemas/Contribution' } } /bills: get: summary: List bills @@ -587,3 +703,52 @@ components: message: { type: string } send_at: { type: string, format: date-time } channel: { type: string, enum: [email, whatsapp], default: email } + SavingsGoal: + type: object + properties: + id: { type: integer } + name: { type: string } + target_amount: { type: number } + current_amount: { type: number } + currency: { type: string } + deadline: { type: string, format: date, nullable: true } + status: { type: string, enum: [ACTIVE, COMPLETED, CANCELLED] } + created_at: { type: string, format: date-time } + NewGoal: + type: object + required: [name, target_amount] + properties: + name: { type: string } + target_amount: { type: number } + currency: { type: string, default: INR } + deadline: { type: string, format: date } + GoalProgress: + type: object + properties: + goal_id: { type: integer } + name: { type: string } + target: { type: number } + current: { type: number } + remaining: { type: number } + progress_pct: { type: number } + status: { type: string } + on_track: { type: boolean } + deadline: { type: string, format: date } + days_left: { type: integer } + daily_savings_needed: { type: number } + milestones: + type: array + items: + type: object + properties: + pct: { type: integer } + reached: { type: boolean } + amount: { type: number } + Contribution: + type: object + properties: + id: { type: integer } + goal_id: { type: integer } + amount: { type: number } + note: { type: string, nullable: true } + contributed_at: { type: string, format: date } diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f89..22631662 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 .goals import bp as 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(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 00000000..ade082c7 --- /dev/null +++ b/packages/backend/app/routes/goals.py @@ -0,0 +1,162 @@ +"""Savings goals endpoints.""" + +from datetime import date +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity + +from ..services import goals as goal_service + +bp = Blueprint("goals", __name__) + + +@bp.get("/") +@jwt_required() +def list_goals(): + user_id = int(get_jwt_identity()) + status = request.args.get("status") + items = goal_service.get_goals(user_id, status) + return jsonify([_serialize_goal(g) for g in items]), 200 + + +@bp.post("/") +@jwt_required() +def create_goal(): + user_id = int(get_jwt_identity()) + data = request.get_json() + + if not data or not data.get("name") or not data.get("target_amount"): + return jsonify(error="name and target_amount are required"), 400 + + target = data["target_amount"] + if not isinstance(target, (int, float)) or target <= 0: + return jsonify(error="target_amount must be a positive number"), 400 + + deadline = None + if data.get("deadline"): + try: + deadline = date.fromisoformat(data["deadline"]) + except ValueError: + return jsonify(error="deadline must be YYYY-MM-DD format"), 400 + + goal = goal_service.create_goal( + user_id=user_id, + name=data["name"], + target_amount=target, + currency=data.get("currency", "INR"), + deadline=deadline, + ) + return jsonify(_serialize_goal(goal)), 201 + + +@bp.get("/") +@jwt_required() +def get_goal(goal_id): + user_id = int(get_jwt_identity()) + goal = goal_service.get_goal(goal_id, user_id) + if not goal: + return jsonify(error="Goal not found"), 404 + return jsonify(_serialize_goal(goal)), 200 + + +@bp.put("/") +@jwt_required() +def update_goal(goal_id): + user_id = int(get_jwt_identity()) + data = request.get_json() or {} + goal = goal_service.update_goal(goal_id, user_id, **data) + if not goal: + return jsonify(error="Goal not found"), 404 + return jsonify(_serialize_goal(goal)), 200 + + +@bp.delete("/") +@jwt_required() +def cancel_goal(goal_id): + user_id = int(get_jwt_identity()) + goal = goal_service.cancel_goal(goal_id, user_id) + if not goal: + return jsonify(error="Goal not found"), 404 + return jsonify(_serialize_goal(goal)), 200 + + +@bp.get("//progress") +@jwt_required() +def get_progress(goal_id): + user_id = int(get_jwt_identity()) + progress = goal_service.get_progress(goal_id, user_id) + if not progress: + return jsonify(error="Goal not found"), 404 + return jsonify(progress), 200 + + +@bp.post("//contribute") +@jwt_required() +def add_contribution(goal_id): + user_id = int(get_jwt_identity()) + data = request.get_json() + + if not data or not data.get("amount"): + return jsonify(error="amount is required"), 400 + + amount = data["amount"] + if not isinstance(amount, (int, float)) or amount <= 0: + return jsonify(error="amount must be a positive number"), 400 + + contribution, error = goal_service.add_contribution( + goal_id=goal_id, + user_id=user_id, + amount=amount, + note=data.get("note"), + ) + if error: + status_code = 404 if "not found" in error.lower() else 400 + return jsonify(error=error), status_code + + return ( + jsonify( + { + "id": contribution.id, + "goal_id": contribution.goal_id, + "amount": float(contribution.amount), + "note": contribution.note, + "contributed_at": contribution.contributed_at.isoformat(), + } + ), + 201, + ) + + +@bp.get("//contributions") +@jwt_required() +def list_contributions(goal_id): + user_id = int(get_jwt_identity()) + items = goal_service.get_contributions(goal_id, user_id) + if items is None: + return jsonify(error="Goal not found"), 404 + return ( + jsonify( + [ + { + "id": c.id, + "amount": float(c.amount), + "note": c.note, + "contributed_at": c.contributed_at.isoformat(), + } + for c in items + ] + ), + 200, + ) + + +def _serialize_goal(goal): + return { + "id": goal.id, + "name": goal.name, + "target_amount": float(goal.target_amount), + "current_amount": float(goal.current_amount), + "currency": goal.currency, + "deadline": goal.deadline.isoformat() if goal.deadline else None, + "status": goal.status, + "created_at": goal.created_at.isoformat(), + } diff --git a/packages/backend/app/services/goals.py b/packages/backend/app/services/goals.py new file mode 100644 index 00000000..87f16aea --- /dev/null +++ b/packages/backend/app/services/goals.py @@ -0,0 +1,151 @@ +"""Savings goal tracking service.""" + +from datetime import date +from decimal import Decimal + +from ..extensions import db +from ..models import SavingsGoal, GoalContribution, GoalStatus + + +def create_goal(user_id, name, target_amount, currency="INR", deadline=None): + goal = SavingsGoal( + user_id=user_id, + name=name, + target_amount=target_amount, + currency=currency, + deadline=deadline, + ) + db.session.add(goal) + db.session.commit() + return goal + + +def get_goals(user_id, status=None): + query = SavingsGoal.query.filter_by(user_id=user_id) + if status: + query = query.filter_by(status=status) + return query.order_by(SavingsGoal.created_at.desc()).all() + + +def get_goal(goal_id, user_id): + return SavingsGoal.query.filter_by(id=goal_id, user_id=user_id).first() + + +def add_contribution(goal_id, user_id, amount, note=None): + goal = get_goal(goal_id, user_id) + if not goal: + return None, "Goal not found" + if goal.status != GoalStatus.ACTIVE.value: + return None, "Goal is not active" + + contribution = GoalContribution( + goal_id=goal_id, + amount=amount, + note=note, + ) + db.session.add(contribution) + + # Atomic DB-side update to avoid race conditions + from sqlalchemy import text + + db.session.execute( + text( + "UPDATE savings_goals SET current_amount = current_amount + :amt WHERE id = :gid" + ), + {"amt": float(amount), "gid": goal_id}, + ) + db.session.flush() + db.session.refresh(goal) + + # Auto-complete if target reached + if goal.current_amount >= goal.target_amount: + goal.status = GoalStatus.COMPLETED.value + + db.session.commit() + return contribution, None + + +def get_contributions(goal_id, user_id): + goal = get_goal(goal_id, user_id) + if not goal: + return None + return ( + GoalContribution.query.filter_by(goal_id=goal_id) + .order_by(GoalContribution.contributed_at.desc()) + .all() + ) + + +def get_progress(goal_id, user_id): + goal = get_goal(goal_id, user_id) + if not goal: + return None + + target = float(goal.target_amount) + current = float(goal.current_amount) + pct = round((current / target) * 100, 2) if target > 0 else 0 + + result = { + "goal_id": goal.id, + "name": goal.name, + "target": target, + "current": current, + "remaining": round(max(target - current, 0), 2), + "progress_pct": pct, + "status": goal.status, + "on_track": True, + } + + if goal.deadline: + days_left = (goal.deadline - date.today()).days + result["deadline"] = goal.deadline.isoformat() + result["days_left"] = max(days_left, 0) + + if days_left > 0 and current < target: + daily_needed = round((target - current) / days_left, 2) + result["daily_savings_needed"] = daily_needed + # Check if current savings pace is sufficient + days_elapsed = (date.today() - goal.created_at.date()).days or 1 + expected_pct = ( + days_elapsed / max((goal.deadline - goal.created_at.date()).days, 1) + ) * 100 + if pct < expected_pct * 0.8: # more than 20% behind schedule + result["on_track"] = False + elif days_left <= 0 and current < target: + result["on_track"] = False + + # Milestones + milestones = [] + for pct_mark in [25, 50, 75, 100]: + milestones.append( + { + "pct": pct_mark, + "reached": pct >= pct_mark, + "amount": round(target * pct_mark / 100, 2), + } + ) + result["milestones"] = milestones + + return result + + +def update_goal(goal_id, user_id, **kwargs): + goal = get_goal(goal_id, user_id) + if not goal: + return None + + for key in ("name", "target_amount", "currency", "deadline", "status"): + if key in kwargs and kwargs[key] is not None: + setattr(goal, key, kwargs[key]) + + db.session.commit() + return goal + + +def cancel_goal(goal_id, user_id): + goal = get_goal(goal_id, user_id) + if not goal: + return None + goal.status = GoalStatus.CANCELLED.value + db.session.commit() + return goal diff --git a/packages/backend/tests/test_goals.py b/packages/backend/tests/test_goals.py new file mode 100644 index 00000000..57f0ba16 --- /dev/null +++ b/packages/backend/tests/test_goals.py @@ -0,0 +1,199 @@ +"""Tests for savings goals feature.""" + +from datetime import date, timedelta + + +class TestGoalsCRUD: + def test_create_goal(self, client, auth_header): + r = client.post( + "/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 + assert data["current_amount"] == 0 + assert data["status"] == "ACTIVE" + + def test_create_goal_missing_fields(self, client, auth_header): + r = client.post("/goals/", json={"name": "Test"}, headers=auth_header) + assert r.status_code == 400 + + def test_create_goal_invalid_amount(self, client, auth_header): + r = client.post( + "/goals/", json={"name": "Test", "target_amount": -100}, headers=auth_header + ) + assert r.status_code == 400 + + def test_list_goals(self, client, auth_header): + client.post( + "/goals/", + json={"name": "Goal 1", "target_amount": 1000}, + headers=auth_header, + ) + client.post( + "/goals/", + json={"name": "Goal 2", "target_amount": 2000}, + headers=auth_header, + ) + + r = client.get("/goals/", headers=auth_header) + assert r.status_code == 200 + assert len(r.get_json()) == 2 + + def test_get_goal(self, client, auth_header): + r = client.post( + "/goals/", + json={"name": "Vacation", "target_amount": 5000}, + headers=auth_header, + ) + goal_id = r.get_json()["id"] + + r = client.get(f"/goals/{goal_id}", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["name"] == "Vacation" + + def test_get_nonexistent_goal(self, client, auth_header): + r = client.get("/goals/999", headers=auth_header) + assert r.status_code == 404 + + def test_update_goal(self, client, auth_header): + r = client.post( + "/goals/", + json={"name": "Old Name", "target_amount": 1000}, + headers=auth_header, + ) + goal_id = r.get_json()["id"] + + r = client.put( + f"/goals/{goal_id}", + json={"name": "New Name", "target_amount": 2000}, + headers=auth_header, + ) + assert r.status_code == 200 + assert r.get_json()["name"] == "New Name" + + def test_cancel_goal(self, client, auth_header): + r = client.post( + "/goals/", + json={"name": "Cancel Me", "target_amount": 500}, + headers=auth_header, + ) + goal_id = r.get_json()["id"] + + r = client.delete(f"/goals/{goal_id}", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["status"] == "CANCELLED" + + +class TestContributions: + def test_add_contribution(self, client, auth_header): + r = client.post( + "/goals/", json={"name": "Fund", "target_amount": 1000}, headers=auth_header + ) + goal_id = r.get_json()["id"] + + r = client.post( + f"/goals/{goal_id}/contribute", + json={"amount": 250, "note": "First deposit"}, + headers=auth_header, + ) + assert r.status_code == 201 + assert r.get_json()["amount"] == 250 + + def test_contribution_invalid_amount(self, client, auth_header): + r = client.post( + "/goals/", json={"name": "Fund", "target_amount": 1000}, headers=auth_header + ) + goal_id = r.get_json()["id"] + + r = client.post( + f"/goals/{goal_id}/contribute", json={"amount": -50}, headers=auth_header + ) + assert r.status_code == 400 + + def test_auto_complete_on_target(self, client, auth_header): + r = client.post( + "/goals/", + json={"name": "Quick Goal", "target_amount": 100}, + headers=auth_header, + ) + goal_id = r.get_json()["id"] + + client.post( + f"/goals/{goal_id}/contribute", json={"amount": 100}, headers=auth_header + ) + + r = client.get(f"/goals/{goal_id}", headers=auth_header) + assert r.get_json()["status"] == "COMPLETED" + + def test_list_contributions(self, client, auth_header): + r = client.post( + "/goals/", json={"name": "Fund", "target_amount": 1000}, headers=auth_header + ) + goal_id = r.get_json()["id"] + + client.post( + f"/goals/{goal_id}/contribute", json={"amount": 100}, headers=auth_header + ) + client.post( + f"/goals/{goal_id}/contribute", json={"amount": 200}, headers=auth_header + ) + + r = client.get(f"/goals/{goal_id}/contributions", headers=auth_header) + assert r.status_code == 200 + assert len(r.get_json()) == 2 + + def test_contribute_to_cancelled_goal(self, client, auth_header): + r = client.post( + "/goals/", + json={"name": "Dead Goal", "target_amount": 500}, + headers=auth_header, + ) + goal_id = r.get_json()["id"] + client.delete(f"/goals/{goal_id}", headers=auth_header) + + r = client.post( + f"/goals/{goal_id}/contribute", json={"amount": 50}, headers=auth_header + ) + assert r.status_code == 400 + + +class TestProgress: + def test_progress_endpoint(self, client, auth_header): + r = client.post( + "/goals/", + json={ + "name": "House", + "target_amount": 50000, + "deadline": (date.today() + timedelta(days=365)).isoformat(), + }, + headers=auth_header, + ) + goal_id = r.get_json()["id"] + + client.post( + f"/goals/{goal_id}/contribute", json={"amount": 12500}, headers=auth_header + ) + + r = client.get(f"/goals/{goal_id}/progress", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert data["progress_pct"] == 25.0 + assert data["remaining"] == 37500.0 + assert "milestones" in data + assert data["milestones"][0]["reached"] # 25% reached + assert not data["milestones"][1]["reached"] # 50% not yet + assert "daily_savings_needed" in data + assert data["on_track"] + + def test_progress_nonexistent(self, client, auth_header): + r = client.get("/goals/999/progress", headers=auth_header) + assert r.status_code == 404