diff --git a/packages/backend/app/db/migrations/004_savings_goals.sql b/packages/backend/app/db/migrations/004_savings_goals.sql new file mode 100644 index 00000000..8c787bfb --- /dev/null +++ b/packages/backend/app/db/migrations/004_savings_goals.sql @@ -0,0 +1,30 @@ +-- Migration 004: Goal-based savings tracking & milestones +-- Issue #133 +-- Apply with: psql $DATABASE_URL -f migrations/004_savings_goals.sql + +CREATE TABLE IF NOT EXISTS savings_goals ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + description VARCHAR(500) NULL, + target_amount NUMERIC(12,2) NOT NULL, + current_amount NUMERIC(12,2) NOT NULL DEFAULT 0, + currency VARCHAR(10) NOT NULL DEFAULT 'INR', + target_date DATE NULL, + achieved BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS savings_milestones ( + id SERIAL PRIMARY KEY, + goal_id INTEGER NOT NULL REFERENCES savings_goals(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + target_amount NUMERIC(12,2) NOT NULL, + achieved BOOLEAN NOT NULL DEFAULT FALSE, + achieved_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_savings_goals_user ON savings_goals (user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_savings_milestones_goal ON savings_milestones (goal_id, target_amount); diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..1917e6b6 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -133,3 +133,39 @@ 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) + + +# ── Savings Goals ────────────────────────────────────────────────────────────── + +class SavingsGoal(db.Model): + """A financial savings goal with optional milestones.""" + + __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) + description = db.Column(db.String(500), nullable=True) + target_amount = db.Column(db.Numeric(12, 2), nullable=False) + current_amount = db.Column(db.Numeric(12, 2), default=0, nullable=False) + currency = db.Column(db.String(10), default="INR", nullable=False) + target_date = db.Column(db.Date, nullable=True) + achieved = db.Column(db.Boolean, default=False, 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) + + milestones = db.relationship("SavingsMilestone", back_populates="goal", cascade="all, delete-orphan", order_by="SavingsMilestone.target_amount") + + +class SavingsMilestone(db.Model): + """A milestone (sub-target) within a savings goal.""" + + __tablename__ = "savings_milestones" + id = db.Column(db.Integer, primary_key=True) + goal_id = db.Column(db.Integer, db.ForeignKey("savings_goals.id"), nullable=False) + name = db.Column(db.String(200), nullable=False) + target_amount = db.Column(db.Numeric(12, 2), nullable=False) + achieved = db.Column(db.Boolean, default=False, nullable=False) + achieved_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + goal = db.relationship("SavingsGoal", back_populates="milestones") diff --git a/packages/backend/app/models_savings.py b/packages/backend/app/models_savings.py new file mode 100644 index 00000000..1be07d33 --- /dev/null +++ b/packages/backend/app/models_savings.py @@ -0,0 +1,5 @@ +""" +Savings Goals models — appended to models.py via import. +Defined separately to keep the diff clean. +""" +# This file is intentionally empty — models are added directly to models.py diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f89..0a92db36 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 import bp as savings_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_bp, url_prefix="/savings") diff --git a/packages/backend/app/routes/savings.py b/packages/backend/app/routes/savings.py new file mode 100644 index 00000000..b1aeb942 --- /dev/null +++ b/packages/backend/app/routes/savings.py @@ -0,0 +1,352 @@ +""" +Savings Goals routes — Goal-based savings tracking & milestones (Issue #133). + +Endpoints: + GET /savings/goals → list goals + POST /savings/goals → create goal + GET /savings/goals/ → get goal (with milestones + progress) + PATCH /savings/goals/ → update goal (name, target, date, etc.) + DELETE /savings/goals/ → delete goal + POST /savings/goals//deposit → add/subtract amount (deposit or withdrawal) + GET /savings/goals//milestones → list milestones + POST /savings/goals//milestones → add milestone + PATCH /savings/goals//milestones/ → update milestone + DELETE /savings/goals//milestones/ → delete milestone +""" + +import logging +from datetime import date, datetime +from decimal import Decimal, InvalidOperation + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import get_jwt_identity, jwt_required + +from ..extensions import db +from ..models import SavingsGoal, SavingsMilestone + +bp = Blueprint("savings", __name__) +logger = logging.getLogger("finmind.savings") + + +# ───────────────────────────────────────────────────────────────────────────── +# Goals +# ───────────────────────────────────────────────────────────────────────────── + + +@bp.get("/goals") +@jwt_required() +def list_goals(): + uid = int(get_jwt_identity()) + goals = ( + db.session.query(SavingsGoal) + .filter_by(user_id=uid) + .order_by(SavingsGoal.created_at.desc()) + .all() + ) + return jsonify([_goal_to_dict(g) for g in goals]) + + +@bp.post("/goals") +@jwt_required() +def create_goal(): + uid = int(get_jwt_identity()) + data = request.get_json(silent=True) or {} + + name = (data.get("name") or "").strip() + if not name: + return jsonify(error="name is required"), 400 + + target = _parse_amount(data.get("target_amount")) + if target is None or target <= 0: + return jsonify(error="target_amount must be a positive number"), 400 + + target_date = None + if data.get("target_date"): + try: + target_date = date.fromisoformat(data["target_date"]) + except ValueError: + return jsonify(error="invalid target_date (use YYYY-MM-DD)"), 400 + + goal = SavingsGoal( + user_id=uid, + name=name, + description=(data.get("description") or "").strip() or None, + target_amount=target, + current_amount=Decimal("0"), + currency=(data.get("currency") or "INR").upper()[:10], + target_date=target_date, + ) + db.session.add(goal) + db.session.commit() + logger.info("Created savings goal id=%s user=%s name=%s target=%s", goal.id, uid, name, target) + return jsonify(_goal_to_dict(goal)), 201 + + +@bp.get("/goals/") +@jwt_required() +def get_goal(goal_id: int): + uid = int(get_jwt_identity()) + goal = _get_or_404(goal_id, uid) + if goal is None: + return jsonify(error="not found"), 404 + return jsonify(_goal_to_dict(goal, include_milestones=True)) + + +@bp.patch("/goals/") +@jwt_required() +def update_goal(goal_id: int): + uid = int(get_jwt_identity()) + goal = _get_or_404(goal_id, uid) + if goal is None: + return jsonify(error="not found"), 404 + + data = request.get_json(silent=True) or {} + + if "name" in data: + name = (data["name"] or "").strip() + if not name: + return jsonify(error="name cannot be empty"), 400 + goal.name = name + + if "description" in data: + goal.description = (data["description"] or "").strip() or None + + if "target_amount" in data: + target = _parse_amount(data["target_amount"]) + if target is None or target <= 0: + return jsonify(error="target_amount must be positive"), 400 + goal.target_amount = target + + if "currency" in data: + goal.currency = (data["currency"] or "INR").upper()[:10] + + if "target_date" in data: + if data["target_date"] is None: + goal.target_date = None + else: + try: + goal.target_date = date.fromisoformat(data["target_date"]) + except ValueError: + return jsonify(error="invalid target_date"), 400 + + db.session.commit() + return jsonify(_goal_to_dict(goal, include_milestones=True)) + + +@bp.delete("/goals/") +@jwt_required() +def delete_goal(goal_id: int): + uid = int(get_jwt_identity()) + goal = _get_or_404(goal_id, uid) + if goal is None: + return jsonify(error="not found"), 404 + db.session.delete(goal) + db.session.commit() + return jsonify(message="deleted") + + +@bp.post("/goals//deposit") +@jwt_required() +def deposit(goal_id: int): + """ + Add or subtract an amount from current_amount. + Positive amount = deposit. Negative amount = withdrawal. + Automatically marks goal as achieved when current >= target. + """ + uid = int(get_jwt_identity()) + goal = _get_or_404(goal_id, uid) + if goal is None: + return jsonify(error="not found"), 404 + + data = request.get_json(silent=True) or {} + amount = _parse_amount(data.get("amount")) + if amount is None: + return jsonify(error="amount is required and must be a number"), 400 + if amount == Decimal("0"): + return jsonify(error="amount must be non-zero (use a positive value to deposit, negative to withdraw)"), 400 + + goal.current_amount = max(Decimal("0"), goal.current_amount + amount) + + # Check goal achievement — forward and reverse. + newly_achieved = not goal.achieved and goal.current_amount >= goal.target_amount + if newly_achieved: + goal.achieved = True + elif goal.achieved and goal.current_amount < goal.target_amount: + # Withdrawal brought the balance back below the target: un-mark as achieved + # so the flag accurately reflects current state. + goal.achieved = False + + # Check milestones — forward and reverse. + # Milestones are treated as live state (not immutable historical events): a + # withdrawal that drops current_amount below a milestone's threshold resets + # it so it can be re-earned. If you prefer milestones to be permanent + # records (e.g. "you once hit ₹5,000") simply remove the reverse block below. + newly_achieved_milestones = [] + for m in goal.milestones: + if not m.achieved and goal.current_amount >= m.target_amount: + m.achieved = True + m.achieved_at = datetime.utcnow() + newly_achieved_milestones.append(m.name) + elif m.achieved and goal.current_amount < m.target_amount: + # Reversal: balance fell below this milestone's threshold. + m.achieved = False + m.achieved_at = None + + db.session.commit() + + result = _goal_to_dict(goal, include_milestones=True) + result["newly_achieved"] = newly_achieved + result["newly_achieved_milestones"] = newly_achieved_milestones + logger.info("Deposit goal=%s user=%s amount=%s new_total=%s", goal_id, uid, amount, goal.current_amount) + return jsonify(result) + + +# ───────────────────────────────────────────────────────────────────────────── +# Milestones +# ───────────────────────────────────────────────────────────────────────────── + + +@bp.get("/goals//milestones") +@jwt_required() +def list_milestones(goal_id: int): + uid = int(get_jwt_identity()) + goal = _get_or_404(goal_id, uid) + if goal is None: + return jsonify(error="not found"), 404 + return jsonify([_milestone_to_dict(m) for m in goal.milestones]) + + +@bp.post("/goals//milestones") +@jwt_required() +def create_milestone(goal_id: int): + uid = int(get_jwt_identity()) + goal = _get_or_404(goal_id, uid) + if goal is None: + return jsonify(error="not found"), 404 + + data = request.get_json(silent=True) or {} + name = (data.get("name") or "").strip() + if not name: + return jsonify(error="name is required"), 400 + + target = _parse_amount(data.get("target_amount")) + if target is None or target <= 0: + return jsonify(error="target_amount must be positive"), 400 + + if target > goal.target_amount: + return jsonify(error="milestone target_amount cannot exceed goal target_amount"), 400 + + milestone = SavingsMilestone(goal_id=goal_id, name=name, target_amount=target) + + # Auto-achieve if already reached + if goal.current_amount >= target: + milestone.achieved = True + milestone.achieved_at = datetime.utcnow() + + db.session.add(milestone) + db.session.commit() + return jsonify(_milestone_to_dict(milestone)), 201 + + +@bp.patch("/goals//milestones/") +@jwt_required() +def update_milestone(goal_id: int, milestone_id: int): + uid = int(get_jwt_identity()) + goal = _get_or_404(goal_id, uid) + if goal is None: + return jsonify(error="not found"), 404 + + milestone = db.session.get(SavingsMilestone, milestone_id) + if not milestone or milestone.goal_id != goal_id: + return jsonify(error="not found"), 404 + + data = request.get_json(silent=True) or {} + if "name" in data: + name = (data["name"] or "").strip() + if not name: + return jsonify(error="name cannot be empty"), 400 + milestone.name = name + + if "target_amount" in data: + target = _parse_amount(data["target_amount"]) + if target is None or target <= 0: + return jsonify(error="target_amount must be positive"), 400 + if target > goal.target_amount: + return jsonify(error="milestone target_amount cannot exceed goal target_amount"), 400 + milestone.target_amount = target + + db.session.commit() + return jsonify(_milestone_to_dict(milestone)) + + +@bp.delete("/goals//milestones/") +@jwt_required() +def delete_milestone(goal_id: int, milestone_id: int): + uid = int(get_jwt_identity()) + goal = _get_or_404(goal_id, uid) + if goal is None: + return jsonify(error="not found"), 404 + + milestone = db.session.get(SavingsMilestone, milestone_id) + if not milestone or milestone.goal_id != goal_id: + return jsonify(error="not found"), 404 + + db.session.delete(milestone) + db.session.commit() + return jsonify(message="deleted") + + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + + +def _get_or_404(goal_id: int, user_id: int): + goal = db.session.get(SavingsGoal, goal_id) + if not goal or goal.user_id != user_id: + return None + return goal + + +def _parse_amount(raw) -> Decimal | None: + try: + return Decimal(str(raw)) + except (InvalidOperation, ValueError, TypeError): + return None + + +def _goal_to_dict(goal: SavingsGoal, include_milestones: bool = False) -> dict: + progress_pct = ( + float(goal.current_amount / goal.target_amount * 100) + if goal.target_amount > 0 + else 0.0 + ) + d = { + "id": goal.id, + "name": goal.name, + "description": goal.description, + "target_amount": float(goal.target_amount), + "current_amount": float(goal.current_amount), + "currency": goal.currency, + "target_date": goal.target_date.isoformat() if goal.target_date else None, + "achieved": goal.achieved, + "progress_percent": round(progress_pct, 1), + "remaining_amount": float(max(Decimal("0"), goal.target_amount - goal.current_amount)), + "created_at": goal.created_at.isoformat(), + "updated_at": goal.updated_at.isoformat(), + } + if include_milestones: + d["milestones"] = [_milestone_to_dict(m) for m in goal.milestones] + return d + + +def _milestone_to_dict(m: SavingsMilestone) -> dict: + return { + "id": m.id, + "goal_id": m.goal_id, + "name": m.name, + "target_amount": float(m.target_amount), + "achieved": m.achieved, + "achieved_at": m.achieved_at.isoformat() if m.achieved_at else None, + "created_at": m.created_at.isoformat(), + } diff --git a/packages/backend/tests/test_savings.py b/packages/backend/tests/test_savings.py new file mode 100644 index 00000000..2f801a94 --- /dev/null +++ b/packages/backend/tests/test_savings.py @@ -0,0 +1,298 @@ +""" +Tests for Goal-based savings tracking & milestones (Issue #133). + +Covers: +- CRUD for savings goals +- Deposit / withdrawal updating current_amount +- Goal achievement detection +- Milestone CRUD +- Milestone auto-achievement on deposit +- Progress percentage calculation +- Input validation +- Authentication requirements +""" + +from __future__ import annotations + +import pytest +from app.extensions import db +from app.models import SavingsGoal, SavingsMilestone + + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + +def _auth(client, email="savings@test.com", password="pass1234"): + client.post("/auth/register", json={"email": email, "password": password}) + r = client.post("/auth/login", json={"email": email, "password": password}) + return {"Authorization": f"Bearer {r.get_json()['access_token']}"} + + +def _create_goal(client, headers, **kwargs): + payload = {"name": "Emergency Fund", "target_amount": 10000, "currency": "INR", **kwargs} + return client.post("/savings/goals", json=payload, headers=headers) + + +# ───────────────────────────────────────────────────────────────────────────── +# Goal CRUD +# ───────────────────────────────────────────────────────────────────────────── + +class TestSavingsGoalCrud: + def test_create_goal(self, client, app_fixture): + h = _auth(client) + r = _create_goal(client, h) + assert r.status_code == 201 + d = r.get_json() + assert d["name"] == "Emergency Fund" + assert d["target_amount"] == 10000.0 + assert d["current_amount"] == 0.0 + assert d["achieved"] is False + assert d["progress_percent"] == 0.0 + + def test_create_goal_missing_name(self, client, app_fixture): + h = _auth(client) + r = client.post("/savings/goals", json={"target_amount": 1000}, headers=h) + assert r.status_code == 400 + assert "name" in r.get_json()["error"] + + def test_create_goal_invalid_target(self, client, app_fixture): + h = _auth(client) + r = client.post("/savings/goals", json={"name": "X", "target_amount": -100}, headers=h) + assert r.status_code == 400 + + def test_list_goals_empty(self, client, app_fixture): + h = _auth(client, "list_empty@test.com") + r = client.get("/savings/goals", headers=h) + assert r.status_code == 200 + assert r.get_json() == [] + + def test_list_goals_returns_own_only(self, client, app_fixture): + h1 = _auth(client, "user1@test.com") + h2 = _auth(client, "user2@test.com") + _create_goal(client, h1, name="Goal A") + _create_goal(client, h2, name="Goal B") + + r = client.get("/savings/goals", headers=h1) + goals = r.get_json() + assert all(g["name"] == "Goal A" for g in goals) + assert len(goals) == 1 + + def test_get_goal(self, client, app_fixture): + h = _auth(client) + goal_id = _create_goal(client, h).get_json()["id"] + r = client.get(f"/savings/goals/{goal_id}", headers=h) + assert r.status_code == 200 + assert r.get_json()["id"] == goal_id + assert "milestones" in r.get_json() + + def test_get_goal_not_found(self, client, app_fixture): + h = _auth(client) + r = client.get("/savings/goals/99999", headers=h) + assert r.status_code == 404 + + def test_get_goal_other_user_forbidden(self, client, app_fixture): + h1 = _auth(client, "owner@test.com") + h2 = _auth(client, "other@test.com") + goal_id = _create_goal(client, h1).get_json()["id"] + r = client.get(f"/savings/goals/{goal_id}", headers=h2) + assert r.status_code == 404 + + def test_update_goal(self, client, app_fixture): + h = _auth(client) + goal_id = _create_goal(client, h).get_json()["id"] + r = client.patch(f"/savings/goals/{goal_id}", json={"name": "Updated", "target_amount": 20000}, headers=h) + assert r.status_code == 200 + assert r.get_json()["name"] == "Updated" + assert r.get_json()["target_amount"] == 20000.0 + + def test_update_goal_empty_name_rejected(self, client, app_fixture): + h = _auth(client) + goal_id = _create_goal(client, h).get_json()["id"] + r = client.patch(f"/savings/goals/{goal_id}", json={"name": ""}, headers=h) + assert r.status_code == 400 + + def test_delete_goal(self, client, app_fixture): + h = _auth(client) + goal_id = _create_goal(client, h).get_json()["id"] + r = client.delete(f"/savings/goals/{goal_id}", headers=h) + assert r.status_code == 200 + assert client.get(f"/savings/goals/{goal_id}", headers=h).status_code == 404 + + def test_goals_require_auth(self, client, app_fixture): + assert client.get("/savings/goals").status_code == 401 + assert client.post("/savings/goals", json={}).status_code == 401 + + +# ───────────────────────────────────────────────────────────────────────────── +# Deposits & achievement +# ───────────────────────────────────────────────────────────────────────────── + +class TestDeposits: + def test_deposit_increases_amount(self, client, app_fixture): + h = _auth(client, "dep@test.com") + goal_id = _create_goal(client, h, target_amount=1000).get_json()["id"] + r = client.post(f"/savings/goals/{goal_id}/deposit", json={"amount": 300}, headers=h) + assert r.status_code == 200 + assert r.get_json()["current_amount"] == 300.0 + assert r.get_json()["progress_percent"] == 30.0 + + def test_withdrawal_decreases_amount(self, client, app_fixture): + h = _auth(client, "wd@test.com") + goal_id = _create_goal(client, h, target_amount=1000).get_json()["id"] + client.post(f"/savings/goals/{goal_id}/deposit", json={"amount": 500}, headers=h) + r = client.post(f"/savings/goals/{goal_id}/deposit", json={"amount": -200}, headers=h) + assert r.get_json()["current_amount"] == 300.0 + + def test_withdrawal_cannot_go_below_zero(self, client, app_fixture): + h = _auth(client, "zero@test.com") + goal_id = _create_goal(client, h, target_amount=1000).get_json()["id"] + r = client.post(f"/savings/goals/{goal_id}/deposit", json={"amount": -999999}, headers=h) + assert r.get_json()["current_amount"] == 0.0 + + def test_goal_achieved_on_full_deposit(self, client, app_fixture): + h = _auth(client, "achieve@test.com") + goal_id = _create_goal(client, h, target_amount=1000).get_json()["id"] + r = client.post(f"/savings/goals/{goal_id}/deposit", json={"amount": 1000}, headers=h) + d = r.get_json() + assert d["achieved"] is True + assert d["newly_achieved"] is True + + def test_goal_not_achieved_if_partial(self, client, app_fixture): + h = _auth(client, "partial@test.com") + goal_id = _create_goal(client, h, target_amount=1000).get_json()["id"] + r = client.post(f"/savings/goals/{goal_id}/deposit", json={"amount": 999}, headers=h) + assert r.get_json()["achieved"] is False + + def test_deposit_missing_amount(self, client, app_fixture): + h = _auth(client, "noamt@test.com") + goal_id = _create_goal(client, h).get_json()["id"] + r = client.post(f"/savings/goals/{goal_id}/deposit", json={}, headers=h) + assert r.status_code == 400 + + +# ───────────────────────────────────────────────────────────────────────────── +# Milestones +# ───────────────────────────────────────────────────────────────────────────── + +class TestMilestones: + def _setup(self, client, email="ms@test.com"): + h = _auth(client, email) + goal_id = _create_goal(client, h, target_amount=10000).get_json()["id"] + return h, goal_id + + def test_create_milestone(self, client, app_fixture): + h, gid = self._setup(client, "ms1@test.com") + r = client.post(f"/savings/goals/{gid}/milestones", + json={"name": "25%", "target_amount": 2500}, headers=h) + assert r.status_code == 201 + d = r.get_json() + assert d["name"] == "25%" + assert d["achieved"] is False + + def test_milestone_exceeding_goal_rejected(self, client, app_fixture): + h, gid = self._setup(client, "ms2@test.com") + r = client.post(f"/savings/goals/{gid}/milestones", + json={"name": "Over", "target_amount": 99999}, headers=h) + assert r.status_code == 400 + assert "exceed" in r.get_json()["error"] + + def test_milestone_auto_achieved_if_current_exceeds(self, client, app_fixture): + h, gid = self._setup(client, "ms3@test.com") + # Deposit 5000 first + client.post(f"/savings/goals/{gid}/deposit", json={"amount": 5000}, headers=h) + # Add milestone below current + r = client.post(f"/savings/goals/{gid}/milestones", + json={"name": "25%", "target_amount": 2500}, headers=h) + assert r.get_json()["achieved"] is True + + def test_milestone_achieved_on_deposit(self, client, app_fixture): + h, gid = self._setup(client, "ms4@test.com") + client.post(f"/savings/goals/{gid}/milestones", + json={"name": "Half", "target_amount": 5000}, headers=h) + r = client.post(f"/savings/goals/{gid}/deposit", json={"amount": 5000}, headers=h) + d = r.get_json() + assert "Half" in d["newly_achieved_milestones"] + + def test_list_milestones(self, client, app_fixture): + h, gid = self._setup(client, "ms5@test.com") + client.post(f"/savings/goals/{gid}/milestones", json={"name": "A", "target_amount": 1000}, headers=h) + client.post(f"/savings/goals/{gid}/milestones", json={"name": "B", "target_amount": 5000}, headers=h) + r = client.get(f"/savings/goals/{gid}/milestones", headers=h) + assert r.status_code == 200 + assert len(r.get_json()) == 2 + + def test_delete_milestone(self, client, app_fixture): + h, gid = self._setup(client, "ms6@test.com") + mid = client.post(f"/savings/goals/{gid}/milestones", + json={"name": "X", "target_amount": 1000}, headers=h).get_json()["id"] + r = client.delete(f"/savings/goals/{gid}/milestones/{mid}", headers=h) + assert r.status_code == 200 + assert len(client.get(f"/savings/goals/{gid}/milestones", headers=h).get_json()) == 0 + + def test_update_milestone(self, client, app_fixture): + h, gid = self._setup(client, "ms7@test.com") + mid = client.post(f"/savings/goals/{gid}/milestones", + json={"name": "Old", "target_amount": 1000}, headers=h).get_json()["id"] + r = client.patch(f"/savings/goals/{gid}/milestones/{mid}", json={"name": "New"}, headers=h) + assert r.status_code == 200 + assert r.get_json()["name"] == "New" + + def test_milestone_goal_detail_includes_milestones(self, client, app_fixture): + h, gid = self._setup(client, "ms8@test.com") + client.post(f"/savings/goals/{gid}/milestones", json={"name": "M1", "target_amount": 1000}, headers=h) + r = client.get(f"/savings/goals/{gid}", headers=h) + assert len(r.get_json()["milestones"]) == 1 + + def test_withdrawal_below_milestone_resets_achieved(self, client, app_fixture): + """A withdrawal that drops current_amount below a milestone threshold + must reset milestone.achieved back to False.""" + h, gid = self._setup(client, "ms_rev@test.com") + # Add milestone at 3000 + mid = client.post( + f"/savings/goals/{gid}/milestones", + json={"name": "3k mark", "target_amount": 3000}, + headers=h, + ).get_json()["id"] + + # Deposit 5000 → milestone achieved + client.post(f"/savings/goals/{gid}/deposit", json={"amount": 5000}, headers=h) + ms = client.get(f"/savings/goals/{gid}/milestones", headers=h).get_json() + assert next(m for m in ms if m["id"] == mid)["achieved"] is True + + # Withdraw 3000 → current_amount = 2000 < milestone threshold + client.post(f"/savings/goals/{gid}/deposit", json={"amount": -3000}, headers=h) + ms_after = client.get(f"/savings/goals/{gid}/milestones", headers=h).get_json() + assert next(m for m in ms_after if m["id"] == mid)["achieved"] is False + + +# ───────────────────────────────────────────────────────────────────────────── +# Regression / edge-case tests +# ───────────────────────────────────────────────────────────────────────────── + +class TestDepositEdgeCases: + def test_withdrawal_below_target_resets_goal_achieved(self, client, app_fixture): + """After a goal is marked achieved, a withdrawal that drops current_amount + below target_amount must set goal.achieved back to False.""" + h = _auth(client, "rev_goal@test.com") + goal_id = _create_goal(client, h, target_amount=1000).get_json()["id"] + + # Reach target → achieved=True + r = client.post(f"/savings/goals/{goal_id}/deposit", json={"amount": 1000}, headers=h) + assert r.get_json()["achieved"] is True + + # Withdraw 500 → current=500 < target=1000 → achieved must flip to False + r = client.post(f"/savings/goals/{goal_id}/deposit", json={"amount": -500}, headers=h) + d = r.get_json() + assert r.status_code == 200 + assert d["current_amount"] == 500.0 + assert d["achieved"] is False + + def test_deposit_zero_returns_400(self, client, app_fixture): + """A deposit with amount=0 must be rejected with 400 Bad Request.""" + h = _auth(client, "zero_dep@test.com") + goal_id = _create_goal(client, h, target_amount=1000).get_json()["id"] + + r = client.post(f"/savings/goals/{goal_id}/deposit", json={"amount": 0}, headers=h) + assert r.status_code == 400 + assert "non-zero" in r.get_json()["error"]