From e56b5a2661d8102442ff5d2bd559b76a8ff1a471 Mon Sep 17 00:00:00 2001 From: GPradaT Date: Sun, 22 Mar 2026 15:21:52 +0100 Subject: [PATCH 1/5] feat: add goal-based savings tracking with milestones Implements savings goal tracking (#133): - SavingsGoal and GoalContribution models - Full CRUD for goals with status management (ACTIVE/COMPLETED/CANCELLED) - Contribution tracking with auto-complete when target is reached - Progress endpoint with milestone tracking (25/50/75/100%) - Deadline-aware daily savings calculation - 15 comprehensive tests covering CRUD, contributions, and progress Endpoints: GET/POST /goals/ GET/PUT/DELETE /goals/:id GET /goals/:id/progress POST /goals/:id/contribute GET /goals/:id/contributions Closes #133 --- packages/backend/app/db/schema.sql | 22 ++++ packages/backend/app/models.py | 30 +++++ packages/backend/app/routes/__init__.py | 2 + packages/backend/app/routes/goals.py | 148 ++++++++++++++++++++++ packages/backend/app/services/goals.py | 132 +++++++++++++++++++ packages/backend/tests/test_goals.py | 162 ++++++++++++++++++++++++ 6 files changed, 496 insertions(+) create mode 100644 packages/backend/app/routes/goals.py create mode 100644 packages/backend/app/services/goals.py create mode 100644 packages/backend/tests/test_goals.py diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189de..4b70ed30 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, + 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 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 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..e977649d 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -127,6 +127,36 @@ 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/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..115c1eeb --- /dev/null +++ b/packages/backend/app/routes/goals.py @@ -0,0 +1,148 @@ +"""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__, url_prefix="/goals") + + +@bp.get("/") +@jwt_required() +def list_goals(): + user_id = 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 = 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 = 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 = 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 = 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 = 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 = 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: + return jsonify(error=error), 400 + + 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 = 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..29b89c68 --- /dev/null +++ b/packages/backend/app/services/goals.py @@ -0,0 +1,132 @@ +"""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) + + goal.current_amount = Decimal(str(goal.current_amount)) + Decimal(str(amount)) + + # 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(target - current, 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 + 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..e708003c --- /dev/null +++ b/packages/backend/tests/test_goals.py @@ -0,0 +1,162 @@ +"""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 From 8175844d968f275f7d6c794694add08a18e654b2 Mon Sep 17 00:00:00 2001 From: GPradaT Date: Sun, 22 Mar 2026 16:39:17 +0100 Subject: [PATCH 2/5] fix: address Copilot review - url_prefix, jwt identity int cast, 404 for not found, clamp remaining --- packages/backend/app/routes/goals.py | 21 +++++++++++---------- packages/backend/app/services/goals.py | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/backend/app/routes/goals.py b/packages/backend/app/routes/goals.py index 115c1eeb..75291380 100644 --- a/packages/backend/app/routes/goals.py +++ b/packages/backend/app/routes/goals.py @@ -6,13 +6,13 @@ from ..services import goals as goal_service -bp = Blueprint("goals", __name__, url_prefix="/goals") +bp = Blueprint("goals", __name__) @bp.get("/") @jwt_required() def list_goals(): - user_id = get_jwt_identity() + 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 @@ -21,7 +21,7 @@ def list_goals(): @bp.post("/") @jwt_required() def create_goal(): - user_id = get_jwt_identity() + user_id = int(get_jwt_identity()) data = request.get_json() if not data or not data.get("name") or not data.get("target_amount"): @@ -51,7 +51,7 @@ def create_goal(): @bp.get("/") @jwt_required() def get_goal(goal_id): - user_id = get_jwt_identity() + 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 @@ -61,7 +61,7 @@ def get_goal(goal_id): @bp.put("/") @jwt_required() def update_goal(goal_id): - user_id = get_jwt_identity() + user_id = int(get_jwt_identity()) data = request.get_json() or {} goal = goal_service.update_goal(goal_id, user_id, **data) if not goal: @@ -72,7 +72,7 @@ def update_goal(goal_id): @bp.delete("/") @jwt_required() def cancel_goal(goal_id): - user_id = get_jwt_identity() + 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 @@ -82,7 +82,7 @@ def cancel_goal(goal_id): @bp.get("//progress") @jwt_required() def get_progress(goal_id): - user_id = get_jwt_identity() + 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 @@ -92,7 +92,7 @@ def get_progress(goal_id): @bp.post("//contribute") @jwt_required() def add_contribution(goal_id): - user_id = get_jwt_identity() + user_id = int(get_jwt_identity()) data = request.get_json() if not data or not data.get("amount"): @@ -109,7 +109,8 @@ def add_contribution(goal_id): note=data.get("note"), ) if error: - return jsonify(error=error), 400 + status_code = 404 if "not found" in error.lower() else 400 + return jsonify(error=error), status_code return jsonify({ "id": contribution.id, @@ -123,7 +124,7 @@ def add_contribution(goal_id): @bp.get("//contributions") @jwt_required() def list_contributions(goal_id): - user_id = get_jwt_identity() + 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 diff --git a/packages/backend/app/services/goals.py b/packages/backend/app/services/goals.py index 29b89c68..cc41b210 100644 --- a/packages/backend/app/services/goals.py +++ b/packages/backend/app/services/goals.py @@ -80,7 +80,7 @@ def get_progress(goal_id, user_id): "name": goal.name, "target": target, "current": current, - "remaining": round(target - current, 2), + "remaining": round(max(target - current, 0), 2), "progress_pct": pct, "status": goal.status, "on_track": True, From d75cb728f76014baad85e714c03997fb145791fb Mon Sep 17 00:00:00 2001 From: GPradaT Date: Sun, 22 Mar 2026 18:33:23 +0100 Subject: [PATCH 3/5] feat: add frontend UI, API client, OpenAPI docs for savings goals - Frontend: Goals page with progress bars, milestone badges, contribute dialog - Frontend: API client (goals.ts) following existing pattern - Frontend: Route in App.tsx + navigation link in Navbar - OpenAPI: Full /goals paths and SavingsGoal/GoalProgress/Contribution schemas - Auto-complete visual feedback when goal reaches target - Daily savings needed calculation displayed on cards --- app/src/App.tsx | 9 ++ app/src/api/goals.ts | 77 ++++++++++ app/src/components/layout/Navbar.tsx | 1 + app/src/pages/Goals.tsx | 212 +++++++++++++++++++++++++++ packages/backend/app/openapi.yaml | 165 +++++++++++++++++++++ 5 files changed, 464 insertions(+) create mode 100644 app/src/api/goals.ts create mode 100644 app/src/pages/Goals.tsx 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/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 } From fd8ea75d16775e8dac1e6b65e7b5bc311c430675 Mon Sep 17 00:00:00 2001 From: GPradaT Date: Sun, 22 Mar 2026 18:50:16 +0100 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20address=20remaining=20Copilot=20revi?= =?UTF-8?q?ew=20=E2=80=94=20atomic=20update,=20indexes,=20on=5Ftrack=20log?= =?UTF-8?q?ic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/app/db/schema.sql | 4 ++++ packages/backend/app/services/goals.py | 14 +++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 4b70ed30..2452ef64 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -130,6 +130,8 @@ CREATE TABLE IF NOT EXISTS savings_goals ( 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, @@ -139,6 +141,8 @@ CREATE TABLE IF NOT EXISTS goal_contributions ( 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/services/goals.py b/packages/backend/app/services/goals.py index cc41b210..85df4909 100644 --- a/packages/backend/app/services/goals.py +++ b/packages/backend/app/services/goals.py @@ -45,7 +45,14 @@ def add_contribution(goal_id, user_id, amount, note=None): ) db.session.add(contribution) - goal.current_amount = Decimal(str(goal.current_amount)) + Decimal(str(amount)) + # 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: @@ -94,6 +101,11 @@ def get_progress(goal_id, user_id): 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 From 172deb77d11893403cca943ec6b2a8869cfb5c2a Mon Sep 17 00:00:00 2001 From: GPradaT Date: Wed, 25 Mar 2026 08:10:39 +0100 Subject: [PATCH 5/5] fix: apply black formatting to pass CI lint check Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/backend/app/models.py | 4 +- packages/backend/app/routes/goals.py | 39 ++++--- packages/backend/app/services/goals.py | 21 ++-- packages/backend/tests/test_goals.py | 151 +++++++++++++++---------- 4 files changed, 137 insertions(+), 78 deletions(-) diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index e977649d..71db0f25 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -144,7 +144,9 @@ class SavingsGoal(db.Model): 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) + updated_at = db.Column( + db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + ) class GoalContribution(db.Model): diff --git a/packages/backend/app/routes/goals.py b/packages/backend/app/routes/goals.py index 75291380..ade082c7 100644 --- a/packages/backend/app/routes/goals.py +++ b/packages/backend/app/routes/goals.py @@ -112,13 +112,18 @@ def add_contribution(goal_id): 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 + 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") @@ -128,12 +133,20 @@ def list_contributions(goal_id): 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 + 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): diff --git a/packages/backend/app/services/goals.py b/packages/backend/app/services/goals.py index 85df4909..87f16aea 100644 --- a/packages/backend/app/services/goals.py +++ b/packages/backend/app/services/goals.py @@ -47,8 +47,11 @@ def add_contribution(goal_id, user_id, amount, note=None): # 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"), + text( + "UPDATE savings_goals SET current_amount = current_amount + :amt WHERE id = :gid" + ), {"amt": float(amount), "gid": goal_id}, ) db.session.flush() @@ -103,7 +106,9 @@ def get_progress(goal_id, user_id): 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 + 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: @@ -112,11 +117,13 @@ def get_progress(goal_id, user_id): # 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), - }) + milestones.append( + { + "pct": pct_mark, + "reached": pct >= pct_mark, + "amount": round(target * pct_mark / 100, 2), + } + ) result["milestones"] = milestones return result diff --git a/packages/backend/tests/test_goals.py b/packages/backend/tests/test_goals.py index e708003c..57f0ba16 100644 --- a/packages/backend/tests/test_goals.py +++ b/packages/backend/tests/test_goals.py @@ -5,12 +5,16 @@ 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) + 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" @@ -23,27 +27,33 @@ def test_create_goal_missing_fields(self, client, 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) + 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) + 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) + 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) @@ -55,21 +65,27 @@ def test_get_nonexistent_goal(self, client, 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) + 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) + 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) + 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) @@ -79,72 +95,93 @@ def test_cancel_goal(self, client, auth_header): class TestContributions: def test_add_contribution(self, client, auth_header): - r = client.post("/goals/", json={ - "name": "Fund", "target_amount": 1000 - }, headers=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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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