From b93f7497214bbb2d35dd29015b566b13c60dc882 Mon Sep 17 00:00:00 2001 From: damiehttp Date: Mon, 23 Mar 2026 07:07:19 +0000 Subject: [PATCH] feat: goal-based savings tracking & milestones Full implementation of savings goals with milestone tracking. ## Backend (Flask) - Database schema: savings_goals, goal_milestones, goal_contributions tables - Full CRUD API endpoints for goals (/api/savings-goals) - Contribution management (deposit/withdraw) - Auto-milestone tracking (25%, 50%, 75%, 100%) - Custom milestone support - Category filtering (EMERGENCY, VACATION, HOME, EDUCATION, etc.) - Statistics endpoint with totals and category breakdown ## Frontend (React/TypeScript) - Type-safe API client (app/src/api/savings.ts) - Full CRUD operations with error handling ## Tests - 336 lines of comprehensive unit tests - CRUD, contributions, milestones, edge cases, authorization Closes #133 --- app/src/api/savings.ts | 134 +++++++ packages/backend/app/db/schema.sql | 38 ++ packages/backend/app/models.py | 71 ++++ packages/backend/app/routes/__init__.py | 2 + packages/backend/app/routes/savings_goals.py | 381 +++++++++++++++++++ packages/backend/tests/test_savings_goals.py | 336 ++++++++++++++++ 6 files changed, 962 insertions(+) create mode 100644 app/src/api/savings.ts create mode 100644 packages/backend/app/routes/savings_goals.py create mode 100644 packages/backend/tests/test_savings_goals.py diff --git a/app/src/api/savings.ts b/app/src/api/savings.ts new file mode 100644 index 000000000..3407c47d0 --- /dev/null +++ b/app/src/api/savings.ts @@ -0,0 +1,134 @@ +import { api } from './client'; + +export type GoalCategory = + | 'EMERGENCY' + | 'VACATION' + | 'HOME' + | 'CAR' + | 'EDUCATION' + | 'RETIREMENT' + | 'WEDDING' + | 'GADGET' + | 'INVESTMENT' + | 'OTHER'; + +export type GoalStatus = 'ACTIVE' | 'COMPLETED' | 'CANCELLED'; + +export type Milestone = { + id: number; + title: string; + target_percentage: number; + reached: boolean; + reached_at: string | null; +}; + +export type Contribution = { + id: number; + amount: number; + contribution_type: 'DEPOSIT' | 'WITHDRAWAL'; + notes: string | null; + created_at: string; +}; + +export type SavingsGoal = { + id: number; + name: string; + target_amount: number; + current_amount: number; + currency: string; + category: GoalCategory; + status: GoalStatus; + deadline: string | null; + notes: string | null; + progress_percentage: number; + created_at: string; + updated_at: string; + milestones?: Milestone[]; + contributions?: Contribution[]; + newly_reached_milestones?: string[]; +}; + +export type GoalCreate = { + name: string; + target_amount: number; + current_amount?: number; + currency?: string; + category?: GoalCategory; + deadline?: string; + notes?: string; + milestones?: { title: string; target_percentage: number }[]; +}; + +export type GoalUpdate = Partial & { status?: GoalStatus }; + +export type GoalsSummary = { + active_goals: number; + total_target: number; + total_saved: number; + overall_progress: number; +}; + +export type ContributionResponse = { + message: string; + current_amount: number; + progress_percentage: number; + newly_reached_milestones: { + id: number; + title: string; + target_percentage: number; + }[]; + status: GoalStatus; +}; + +export async function listGoals( + status?: string, +): Promise { + const qs = new URLSearchParams(); + if (status) qs.set('status', status); + const path = '/savings-goals' + (qs.toString() ? `?${qs.toString()}` : ''); + return api(path); +} + +export async function getGoal(id: number): Promise { + return api(`/savings-goals/${id}`); +} + +export async function createGoal(payload: GoalCreate): Promise<{ id: number }> { + return api<{ id: number }>('/savings-goals', { method: 'POST', body: payload }); +} + +export async function updateGoal( + id: number, + payload: GoalUpdate, +): Promise { + return api(`/savings-goals/${id}`, { + method: 'PATCH', + body: payload, + }); +} + +export async function deleteGoal( + id: number, +): Promise<{ message: string }> { + return api<{ message: string }>(`/savings-goals/${id}`, { method: 'DELETE' }); +} + +export async function addContribution( + goalId: number, + payload: { amount: number; type?: 'DEPOSIT' | 'WITHDRAWAL'; notes?: string }, +): Promise { + return api(`/savings-goals/${goalId}/contribute`, { + method: 'POST', + body: payload, + }); +} + +export async function listContributions( + goalId: number, +): Promise { + return api(`/savings-goals/${goalId}/contributions`); +} + +export async function getGoalsSummary(): Promise { + return api('/savings-goals/summary'); +} diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189def..2c1a6fbb0 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -123,3 +123,41 @@ CREATE TABLE IF NOT EXISTS audit_logs ( action VARCHAR(100) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); + +-- Savings Goals +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', + category VARCHAR(20) NOT NULL DEFAULT 'OTHER', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + deadline DATE, + notes VARCHAR(500), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_savings_goals_user ON savings_goals(user_id, status); + +CREATE TABLE IF NOT EXISTS goal_milestones ( + id SERIAL PRIMARY KEY, + goal_id INT NOT NULL REFERENCES savings_goals(id) ON DELETE CASCADE, + title VARCHAR(200) NOT NULL, + target_percentage INT 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); + +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, + contribution_type VARCHAR(20) NOT NULL DEFAULT 'DEPOSIT', + notes VARCHAR(500), + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_goal_contributions_goal ON goal_contributions(goal_id, created_at DESC); diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d448104..9486f3ce4 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -133,3 +133,74 @@ 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 GoalCategory(str, Enum): + EMERGENCY = "EMERGENCY" + VACATION = "VACATION" + HOME = "HOME" + CAR = "CAR" + EDUCATION = "EDUCATION" + RETIREMENT = "RETIREMENT" + WEDDING = "WEDDING" + GADGET = "GADGET" + INVESTMENT = "INVESTMENT" + OTHER = "OTHER" + + +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) + category = db.Column(db.String(20), default=GoalCategory.OTHER.value, nullable=False) + status = db.Column(db.String(20), default=GoalStatus.ACTIVE.value, nullable=False) + deadline = db.Column(db.Date, nullable=True) + notes = db.Column(db.String(500), nullable=True) + 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( + "GoalMilestone", backref="goal", lazy="dynamic", cascade="all, delete-orphan" + ) + contributions = db.relationship( + "GoalContribution", backref="goal", lazy="dynamic", 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("savings_goals.id", ondelete="CASCADE"), nullable=False + ) + title = db.Column(db.String(200), nullable=False) + target_percentage = db.Column(db.Integer, nullable=False) # 25, 50, 75, 100 + 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) + + +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", ondelete="CASCADE"), nullable=False + ) + amount = db.Column(db.Numeric(12, 2), nullable=False) + contribution_type = db.Column( + db.String(20), default="DEPOSIT", nullable=False + ) # DEPOSIT or WITHDRAWAL + notes = db.Column(db.String(500), 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..857ff8554 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 000000000..bb35aa2f7 --- /dev/null +++ b/packages/backend/app/routes/savings_goals.py @@ -0,0 +1,381 @@ +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 ( + SavingsGoal, + GoalMilestone, + GoalContribution, + GoalCategory, + GoalStatus, + User, +) +from ..services.cache import cache_delete_patterns +import logging + +bp = Blueprint("savings_goals", __name__) +logger = logging.getLogger("finmind.savings_goals") + +DEFAULT_MILESTONES = [ + {"title": "Getting Started", "target_percentage": 25}, + {"title": "Halfway There", "target_percentage": 50}, + {"title": "Almost There", "target_percentage": 75}, + {"title": "Goal Reached!", "target_percentage": 100}, +] + + +def _goal_to_dict(goal: SavingsGoal, include_details: bool = False) -> dict: + target = float(goal.target_amount) if goal.target_amount else 0 + current = float(goal.current_amount) if goal.current_amount else 0 + progress = round((current / target * 100), 1) if target > 0 else 0 + + result = { + "id": goal.id, + "name": goal.name, + "target_amount": target, + "current_amount": current, + "currency": goal.currency, + "category": goal.category, + "status": goal.status, + "deadline": goal.deadline.isoformat() if goal.deadline else None, + "notes": goal.notes, + "progress_percentage": min(progress, 100), + "created_at": goal.created_at.isoformat() if goal.created_at else None, + "updated_at": goal.updated_at.isoformat() if goal.updated_at else None, + } + + if include_details: + milestones = ( + GoalMilestone.query.filter_by(goal_id=goal.id) + .order_by(GoalMilestone.target_percentage) + .all() + ) + result["milestones"] = [ + { + "id": m.id, + "title": m.title, + "target_percentage": m.target_percentage, + "reached": m.reached, + "reached_at": m.reached_at.isoformat() if m.reached_at else None, + } + for m in milestones + ] + + contributions = ( + GoalContribution.query.filter_by(goal_id=goal.id) + .order_by(GoalContribution.created_at.desc()) + .limit(50) + .all() + ) + result["contributions"] = [ + { + "id": c.id, + "amount": float(c.amount), + "contribution_type": c.contribution_type, + "notes": c.notes, + "created_at": c.created_at.isoformat() if c.created_at else None, + } + for c in contributions + ] + + # newly reached milestones (for celebration check) + result["newly_reached_milestones"] = [ + m.title for m in milestones if m.reached + ] + + return result + + +def _invalidate_cache(uid: int): + cache_delete_patterns( + [ + f"user:{uid}:savings_goals*", + f"user:{uid}:dashboard_summary:*", + ] + ) + + +def _check_milestones(goal: SavingsGoal) -> list[dict]: + """Check and update milestones after a contribution. Returns newly reached milestones.""" + target = float(goal.target_amount) if goal.target_amount else 0 + current = float(goal.current_amount) if goal.current_amount else 0 + progress = (current / target * 100) if target > 0 else 0 + + milestones = ( + GoalMilestone.query.filter_by(goal_id=goal.id, reached=False) + .order_by(GoalMilestone.target_percentage) + .all() + ) + + newly_reached = [] + for m in milestones: + if progress >= m.target_percentage: + m.reached = True + m.reached_at = datetime.utcnow() + newly_reached.append( + { + "id": m.id, + "title": m.title, + "target_percentage": m.target_percentage, + } + ) + + # Auto-complete goal when target is reached + if current >= target and goal.status == GoalStatus.ACTIVE.value: + goal.status = GoalStatus.COMPLETED.value + + return newly_reached + + +@bp.get("") +@jwt_required() +def list_goals(): + uid = int(get_jwt_identity()) + status_filter = request.args.get("status", "ACTIVE") + + query = db.session.query(SavingsGoal).filter_by(user_id=uid) + if status_filter and status_filter != "ALL": + query = query.filter_by(status=status_filter) + + items = query.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.get("/") +@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, include_details=True)) + + +@bp.post("") +@jwt_required() +def create_goal(): + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + data = request.get_json() or {} + + if not data.get("name") or not data.get("target_amount"): + return jsonify(error="name and target_amount are required"), 400 + + target = Decimal(str(data["target_amount"])) + if target <= 0: + return jsonify(error="target_amount must be positive"), 400 + + category = data.get("category", "OTHER") + if category not in [c.value for c in GoalCategory]: + return jsonify(error=f"invalid category: {category}"), 400 + + goal = SavingsGoal( + user_id=uid, + name=data["name"], + target_amount=target, + current_amount=Decimal(str(data.get("current_amount", 0))), + currency=data.get("currency") + or (user.preferred_currency if user else "INR"), + category=category, + deadline=date.fromisoformat(data["deadline"]) if data.get("deadline") else None, + notes=data.get("notes"), + ) + db.session.add(goal) + db.session.flush() # Get goal.id + + # Create default milestones + custom_milestones = data.get("milestones") + milestones_data = custom_milestones if custom_milestones else DEFAULT_MILESTONES + for ms in milestones_data: + milestone = GoalMilestone( + goal_id=goal.id, + title=ms["title"], + target_percentage=ms["target_percentage"], + ) + db.session.add(milestone) + + db.session.commit() + _invalidate_cache(uid) + logger.info("Created savings goal id=%s user=%s name=%s", goal.id, uid, goal.name) + return jsonify(id=goal.id), 201 + + +@bp.patch("/") +@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 "name" in data: + goal.name = data["name"] + if "target_amount" in data: + target = Decimal(str(data["target_amount"])) + if target <= 0: + return jsonify(error="target_amount must be positive"), 400 + goal.target_amount = target + if "category" in data: + if data["category"] not in [c.value for c in GoalCategory]: + return jsonify(error=f"invalid category: {data['category']}"), 400 + goal.category = data["category"] + if "deadline" in data: + goal.deadline = ( + date.fromisoformat(data["deadline"]) if data["deadline"] else None + ) + if "notes" in data: + goal.notes = data["notes"] + if "status" in data: + if data["status"] not in [s.value for s in GoalStatus]: + return jsonify(error=f"invalid status: {data['status']}"), 400 + goal.status = data["status"] + + goal.updated_at = datetime.utcnow() + db.session.commit() + _invalidate_cache(uid) + logger.info("Updated savings goal id=%s user=%s", goal.id, uid) + return jsonify(_goal_to_dict(goal)) + + +@bp.delete("/") +@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() + _invalidate_cache(uid) + logger.info("Deleted savings goal id=%s user=%s", goal.id, uid) + return jsonify(message="deleted") + + +@bp.post("//contribute") +@jwt_required() +def add_contribution(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 + + if goal.status != GoalStatus.ACTIVE.value: + return jsonify(error="goal is not active"), 400 + + data = request.get_json() or {} + if not data.get("amount"): + return jsonify(error="amount is required"), 400 + + amount = Decimal(str(data["amount"])) + if amount <= 0: + return jsonify(error="amount must be positive"), 400 + + contrib_type = data.get("type", "DEPOSIT").upper() + if contrib_type not in ("DEPOSIT", "WITHDRAWAL"): + return jsonify(error="type must be DEPOSIT or WITHDRAWAL"), 400 + + if contrib_type == "WITHDRAWAL": + if amount > goal.current_amount: + return jsonify(error="withdrawal exceeds current savings"), 400 + goal.current_amount -= amount + else: + goal.current_amount += amount + + contribution = GoalContribution( + goal_id=goal.id, + amount=amount, + contribution_type=contrib_type, + notes=data.get("notes"), + ) + db.session.add(contribution) + + # Check milestones + newly_reached = _check_milestones(goal) + + goal.updated_at = datetime.utcnow() + db.session.commit() + _invalidate_cache(uid) + + logger.info( + "Contribution %s %s to goal id=%s user=%s", + contrib_type, + float(amount), + goal.id, + uid, + ) + + return jsonify( + { + "message": "contribution recorded", + "current_amount": float(goal.current_amount), + "progress_percentage": min( + round( + float(goal.current_amount) / float(goal.target_amount) * 100, 1 + ) + if float(goal.target_amount) > 0 + else 0, + 100, + ), + "newly_reached_milestones": newly_reached, + "status": goal.status, + } + ) + + +@bp.get("//contributions") +@jwt_required() +def list_contributions(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 + + contributions = ( + GoalContribution.query.filter_by(goal_id=goal.id) + .order_by(GoalContribution.created_at.desc()) + .all() + ) + return jsonify( + [ + { + "id": c.id, + "amount": float(c.amount), + "contribution_type": c.contribution_type, + "notes": c.notes, + "created_at": c.created_at.isoformat() if c.created_at else None, + } + for c in contributions + ] + ) + + +@bp.get("/summary") +@jwt_required() +def goals_summary(): + """Summary stats for savings goals (used by dashboard).""" + uid = int(get_jwt_identity()) + goals = ( + db.session.query(SavingsGoal) + .filter_by(user_id=uid, status=GoalStatus.ACTIVE.value) + .all() + ) + + total_target = sum(float(g.target_amount) for g in goals) + total_saved = sum(float(g.current_amount) for g in goals) + overall_progress = ( + round(total_saved / total_target * 100, 1) if total_target > 0 else 0 + ) + + return jsonify( + { + "active_goals": len(goals), + "total_target": total_target, + "total_saved": total_saved, + "overall_progress": min(overall_progress, 100), + } + ) diff --git a/packages/backend/tests/test_savings_goals.py b/packages/backend/tests/test_savings_goals.py new file mode 100644 index 000000000..386951645 --- /dev/null +++ b/packages/backend/tests/test_savings_goals.py @@ -0,0 +1,336 @@ +from datetime import date, timedelta + + +def test_savings_goals_crud(client, auth_header): + """Test full CRUD lifecycle for savings goals.""" + # Initially empty + r = client.get("/savings-goals", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + # Create a goal + payload = { + "name": "Emergency Fund", + "target_amount": 10000, + "category": "EMERGENCY", + "deadline": (date.today() + timedelta(days=365)).isoformat(), + "notes": "6 months of expenses", + } + r = client.post("/savings-goals", json=payload, headers=auth_header) + assert r.status_code == 201 + goal_id = r.get_json()["id"] + + # List shows 1 goal + r = client.get("/savings-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]["name"] == "Emergency Fund" + assert items[0]["target_amount"] == 10000 + assert items[0]["current_amount"] == 0 + assert items[0]["progress_percentage"] == 0 + + # Get single goal with details + r = client.get(f"/savings-goals/{goal_id}", headers=auth_header) + assert r.status_code == 200 + detail = r.get_json() + assert detail["name"] == "Emergency Fund" + assert len(detail["milestones"]) == 4 # Default milestones + + # Update the goal + r = client.patch( + f"/savings-goals/{goal_id}", + json={"name": "Emergency Fund v2", "target_amount": 15000}, + headers=auth_header, + ) + assert r.status_code == 200 + assert r.get_json()["name"] == "Emergency Fund v2" + assert r.get_json()["target_amount"] == 15000 + + # Delete the goal + r = client.delete(f"/savings-goals/{goal_id}", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["message"] == "deleted" + + # Verify deleted + r = client.get("/savings-goals", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + +def test_savings_goal_not_found(client, auth_header): + """Test 404 for non-existent goal.""" + r = client.get("/savings-goals/99999", headers=auth_header) + assert r.status_code == 404 + + +def test_create_goal_validation(client, auth_header): + """Test validation on create.""" + # Missing name + r = client.post( + "/savings-goals", json={"target_amount": 1000}, headers=auth_header + ) + assert r.status_code == 400 + + # Missing target + r = client.post("/savings-goals", json={"name": "Test"}, headers=auth_header) + assert r.status_code == 400 + + # Negative target + r = client.post( + "/savings-goals", + json={"name": "Test", "target_amount": -100}, + headers=auth_header, + ) + assert r.status_code == 400 + + # Invalid category + r = client.post( + "/savings-goals", + json={"name": "Test", "target_amount": 1000, "category": "INVALID"}, + headers=auth_header, + ) + assert r.status_code == 400 + + +def test_contributions_and_milestones(client, auth_header): + """Test adding contributions and milestone tracking.""" + # Create a goal + r = client.post( + "/savings-goals", + json={"name": "Vacation", "target_amount": 1000, "category": "VACATION"}, + headers=auth_header, + ) + assert r.status_code == 201 + goal_id = r.get_json()["id"] + + # Add contribution - hits 25% milestone + r = client.post( + f"/savings-goals/{goal_id}/contribute", + json={"amount": 250, "notes": "First deposit"}, + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + assert data["current_amount"] == 250 + assert data["progress_percentage"] == 25.0 + assert len(data["newly_reached_milestones"]) == 1 + assert data["newly_reached_milestones"][0]["title"] == "Getting Started" + + # Add another contribution - hits 50% + r = client.post( + f"/savings-goals/{goal_id}/contribute", + json={"amount": 250}, + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + assert data["current_amount"] == 500 + assert data["progress_percentage"] == 50.0 + assert len(data["newly_reached_milestones"]) == 1 + assert data["newly_reached_milestones"][0]["title"] == "Halfway There" + + # List contributions + r = client.get(f"/savings-goals/{goal_id}/contributions", headers=auth_header) + assert r.status_code == 200 + contribs = r.get_json() + assert len(contribs) == 2 + + # Complete the goal + r = client.post( + f"/savings-goals/{goal_id}/contribute", + json={"amount": 500}, + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + assert data["current_amount"] == 1000 + assert data["progress_percentage"] == 100 + assert data["status"] == "COMPLETED" + assert len(data["newly_reached_milestones"]) == 2 # 75% and 100% + + +def test_withdrawal(client, auth_header): + """Test withdrawing from a goal.""" + # Create and fund a goal + r = client.post( + "/savings-goals", + json={"name": "Car Fund", "target_amount": 5000}, + headers=auth_header, + ) + goal_id = r.get_json()["id"] + + client.post( + f"/savings-goals/{goal_id}/contribute", + json={"amount": 1000}, + headers=auth_header, + ) + + # Withdraw + r = client.post( + f"/savings-goals/{goal_id}/contribute", + json={"amount": 300, "type": "WITHDRAWAL", "notes": "Emergency expense"}, + headers=auth_header, + ) + assert r.status_code == 200 + assert r.get_json()["current_amount"] == 700 + + # Try to withdraw more than available + r = client.post( + f"/savings-goals/{goal_id}/contribute", + json={"amount": 800, "type": "WITHDRAWAL"}, + headers=auth_header, + ) + assert r.status_code == 400 + assert "exceeds" in r.get_json()["error"] + + +def test_contribution_to_inactive_goal(client, auth_header): + """Test contributing to cancelled/completed goal fails.""" + r = client.post( + "/savings-goals", + json={"name": "Old Goal", "target_amount": 1000}, + headers=auth_header, + ) + goal_id = r.get_json()["id"] + + # Cancel the goal + r = client.patch( + f"/savings-goals/{goal_id}", + json={"status": "CANCELLED"}, + headers=auth_header, + ) + assert r.status_code == 200 + + # Try to contribute + r = client.post( + f"/savings-goals/{goal_id}/contribute", + json={"amount": 100}, + headers=auth_header, + ) + assert r.status_code == 400 + assert "not active" in r.get_json()["error"] + + +def test_goals_summary(client, auth_header): + """Test summary endpoint.""" + # Create two goals with contributions + r = client.post( + "/savings-goals", + json={"name": "Goal A", "target_amount": 1000}, + headers=auth_header, + ) + goal_a = r.get_json()["id"] + + r = client.post( + "/savings-goals", + json={"name": "Goal B", "target_amount": 2000}, + headers=auth_header, + ) + goal_b = r.get_json()["id"] + + client.post( + f"/savings-goals/{goal_a}/contribute", + json={"amount": 500}, + headers=auth_header, + ) + client.post( + f"/savings-goals/{goal_b}/contribute", + json={"amount": 1000}, + headers=auth_header, + ) + + r = client.get("/savings-goals/summary", headers=auth_header) + assert r.status_code == 200 + summary = r.get_json() + assert summary["active_goals"] == 2 + assert summary["total_target"] == 3000 + assert summary["total_saved"] == 1500 + assert summary["overall_progress"] == 50.0 + + +def test_goal_defaults_to_user_preferred_currency(client, auth_header): + """Test that goals default to user's preferred currency.""" + r = client.patch( + "/auth/me", json={"preferred_currency": "EUR"}, headers=auth_header + ) + assert r.status_code == 200 + + r = client.post( + "/savings-goals", + json={"name": "Euro Fund", "target_amount": 5000}, + headers=auth_header, + ) + assert r.status_code == 201 + goal_id = r.get_json()["id"] + + r = client.get(f"/savings-goals/{goal_id}", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["currency"] == "EUR" + + +def test_custom_milestones(client, auth_header): + """Test creating goals with custom milestones.""" + payload = { + "name": "Custom Goal", + "target_amount": 10000, + "milestones": [ + {"title": "First $1K", "target_percentage": 10}, + {"title": "Quarter Way", "target_percentage": 25}, + {"title": "Halfway!", "target_percentage": 50}, + {"title": "Done!", "target_percentage": 100}, + ], + } + r = client.post("/savings-goals", json=payload, headers=auth_header) + assert r.status_code == 201 + goal_id = r.get_json()["id"] + + r = client.get(f"/savings-goals/{goal_id}", headers=auth_header) + assert r.status_code == 200 + milestones = r.get_json()["milestones"] + assert len(milestones) == 4 + assert milestones[0]["title"] == "First $1K" + assert milestones[0]["target_percentage"] == 10 + + +def test_filter_by_status(client, auth_header): + """Test filtering goals by status.""" + # Create active and cancelled goals + r = client.post( + "/savings-goals", + json={"name": "Active Goal", "target_amount": 1000}, + headers=auth_header, + ) + active_id = r.get_json()["id"] + + r = client.post( + "/savings-goals", + json={"name": "Cancelled Goal", "target_amount": 2000}, + headers=auth_header, + ) + cancelled_id = r.get_json()["id"] + client.patch( + f"/savings-goals/{cancelled_id}", + json={"status": "CANCELLED"}, + headers=auth_header, + ) + + # Default shows only active + r = client.get("/savings-goals", headers=auth_header) + assert r.status_code == 200 + items = r.get_json() + assert len(items) == 1 + assert items[0]["id"] == active_id + + # Show all + r = client.get("/savings-goals?status=ALL", headers=auth_header) + assert r.status_code == 200 + assert len(r.get_json()) == 2 + + # Show only cancelled + r = client.get("/savings-goals?status=CANCELLED", headers=auth_header) + assert r.status_code == 200 + items = r.get_json() + assert len(items) == 1 + assert items[0]["id"] == cancelled_id