diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189def..3cdaf60e2 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -123,3 +123,46 @@ CREATE TABLE IF NOT EXISTS audit_logs ( action VARCHAR(100) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); + +-- Goal-based savings tracking with milestones +DO $$ BEGIN + CREATE TYPE savings_goal_status AS ENUM ('active','completed','abandoned'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +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 savings_goal_status NOT NULL DEFAULT 'active', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_savings_goals_user_status + ON savings_goals(user_id, status); + +CREATE TABLE IF NOT EXISTS savings_milestones ( + id SERIAL PRIMARY KEY, + goal_id INT NOT NULL REFERENCES savings_goals(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + target_amount NUMERIC(12,2) NOT NULL, + reached_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS savings_contributions ( + id SERIAL PRIMARY KEY, + goal_id INT NOT NULL REFERENCES savings_goals(id) ON DELETE CASCADE, + amount NUMERIC(12,2) NOT NULL, + contributed_at TIMESTAMP NOT NULL DEFAULT NOW(), + notes VARCHAR(500), + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_savings_contributions_goal + ON savings_contributions(goal_id, contributed_at DESC); diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d448104..e4f77f105 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -133,3 +133,52 @@ 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 SavingsGoalStatus(str, Enum): + ACTIVE = "active" + COMPLETED = "completed" + ABANDONED = "abandoned" + + +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), nullable=False, default=0) + currency = db.Column(db.String(10), default="INR", nullable=False) + deadline = db.Column(db.Date, nullable=True) + status = db.Column( + SAEnum(SavingsGoalStatus), + default=SavingsGoalStatus.ACTIVE, + 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 SavingsMilestone(db.Model): + __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) + reached_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +class SavingsContribution(db.Model): + __tablename__ = "savings_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) + contributed_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + 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..3c249ca06 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -1,4 +1,5 @@ from flask import Flask + from .auth import bp as auth_bp from .expenses import bp as expenses_bp from .bills import bp as bills_bp @@ -7,6 +8,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 +20,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 000000000..77b8170a7 --- /dev/null +++ b/packages/backend/app/routes/savings.py @@ -0,0 +1,240 @@ +from datetime import date + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from ..extensions import db +from ..models import ( + SavingsGoal, + SavingsGoalStatus, + SavingsMilestone, + SavingsContribution, + User, +) +from ..services import savings as savings_service +import logging + +bp = Blueprint("savings", __name__) +logger = logging.getLogger("finmind.savings") + + +@bp.get("") +@jwt_required() +def list_goals(): + uid = int(get_jwt_identity()) + status_filter = request.args.get("status") + q = db.session.query(SavingsGoal).filter_by(user_id=uid) + if status_filter: + try: + q = q.filter(SavingsGoal.status == SavingsGoalStatus(status_filter)) + except ValueError: + pass # ignore invalid status + items = q.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.post("") +@jwt_required() +def create_goal(): + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + data = request.get_json() or {} + name = (data.get("name") or "").strip() + if not name: + return jsonify(error="name required"), 400 + target = savings_service.parse_amount(data.get("target_amount")) + if target is None or target <= 0: + return jsonify(error="invalid target_amount"), 400 + currency = data.get("currency") or (user.preferred_currency if user else "INR") + deadline = None + if data.get("deadline"): + try: + deadline = date.fromisoformat(data.get("deadline")) + except ValueError: + return jsonify(error="invalid deadline"), 400 + goal = savings_service.create_goal( + user_id=uid, + name=name, + target_amount=target, + currency=currency, + deadline=deadline, + ) + return jsonify(_goal_to_dict(goal)), 201 + + +@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_progress=True)) + + +@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: + name = data.get("name", "").strip() + if not name: + return jsonify(error="name required"), 400 + goal.name = name + if "target_amount" in data: + target = savings_service.parse_amount(data.get("target_amount")) + if target is None or target <= 0: + return jsonify(error="invalid target_amount"), 400 + goal.target_amount = target + if "deadline" in data: + if data.get("deadline"): + try: + goal.deadline = date.fromisoformat(data.get("deadline")) + except ValueError: + return jsonify(error="invalid deadline"), 400 + else: + goal.deadline = None + if "status" in data: + try: + goal.status = SavingsGoalStatus(data.get("status")) + except ValueError: + return jsonify(error="invalid status"), 400 + db.session.commit() + return jsonify(_goal_to_dict(goal, include_progress=True)) + + +@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 + # Delete related milestones and contributions first + db.session.query(SavingsMilestone).filter_by(goal_id=goal.id).delete() + db.session.query(SavingsContribution).filter_by(goal_id=goal.id).delete() + db.session.delete(goal) + db.session.commit() + logger.info("Deleted goal id=%s user=%s", goal_id, uid) + return jsonify(message="deleted") + + +@bp.post("//contributions") +@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 == SavingsGoalStatus.COMPLETED: + return jsonify(error="goal already completed"), 400 + data = request.get_json() or {} + amount = savings_service.parse_amount(data.get("amount")) + if amount is None or amount <= 0: + return jsonify(error="invalid amount"), 400 + notes = (data.get("notes") or "").strip() + if notes and len(notes) > 500: + notes = notes[:500] + contrib = savings_service.add_contribution(goal, amount, notes or None) + logger.info("Added contribution goal=%s amount=%s user=%s", goal_id, amount, uid) + return jsonify(_contribution_to_dict(contrib, goal)), 201 + + +@bp.get("//milestones") +@jwt_required() +def list_milestones(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 + items = ( + db.session.query(SavingsMilestone) + .filter_by(goal_id=goal.id) + .order_by(SavingsMilestone.target_amount) + .all() + ) + return jsonify([_milestone_to_dict(m) for m in items]) + + +@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 + items = ( + db.session.query(SavingsContribution) + .filter_by(goal_id=goal.id) + .order_by(SavingsContribution.contributed_at.desc()) + .all() + ) + return jsonify([_contribution_to_dict(c, goal) for c in items]) + + +@bp.post("//complete") +@jwt_required() +def complete_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 + savings_service.mark_goal_completed(goal) + return jsonify(_goal_to_dict(goal)) + + +@bp.post("//abandon") +@jwt_required() +def abandon_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 + savings_service.abandon_goal(goal) + return jsonify(_goal_to_dict(goal)) + + +def _goal_to_dict(g: SavingsGoal, include_progress: bool = False) -> dict: + result = { + "id": g.id, + "name": g.name, + "target_amount": float(g.target_amount), + "current_amount": float(g.current_amount), + "currency": g.currency, + "deadline": g.deadline.isoformat() if g.deadline else None, + "status": g.status.value, + "created_at": g.created_at.isoformat() if g.created_at else None, + "updated_at": g.updated_at.isoformat() if g.updated_at else None, + } + if include_progress: + result["progress"] = savings_service.get_goal_progress(g) + return result + + +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), + "reached_at": m.reached_at.isoformat() if m.reached_at else None, + } + + +def _contribution_to_dict( + c: SavingsContribution, goal: SavingsGoal | None = None +) -> dict: + result = { + "id": c.id, + "goal_id": c.goal_id, + "amount": float(c.amount), + "contributed_at": c.contributed_at.isoformat() if c.contributed_at else None, + "notes": c.notes or "", + } + if goal: + result["goal_name"] = goal.name + return result diff --git a/packages/backend/app/services/savings.py b/packages/backend/app/services/savings.py new file mode 100644 index 000000000..a72ec0831 --- /dev/null +++ b/packages/backend/app/services/savings.py @@ -0,0 +1,158 @@ +from datetime import datetime +from decimal import Decimal, InvalidOperation +from ..extensions import db +from ..models import ( + SavingsGoal, + SavingsGoalStatus, + SavingsMilestone, + SavingsContribution, +) +import logging + +logger = logging.getLogger("finmind.savings") + + +def parse_amount(raw) -> Decimal | None: + """Parse and validate amount from various input formats.""" + try: + return Decimal(str(raw)).quantize(Decimal("0.01")) + except (InvalidOperation, ValueError, TypeError): + return None + + +def create_goal( + user_id: int, + name: str, + target_amount: Decimal, + currency: str = "INR", + deadline=None, +) -> SavingsGoal: + """Create a new savings goal.""" + goal = SavingsGoal( + user_id=user_id, + name=name.strip(), + target_amount=target_amount, + current_amount=Decimal("0"), + currency=currency, + deadline=deadline, + status=SavingsGoalStatus.ACTIVE, + ) + db.session.add(goal) + db.session.commit() + logger.info( + "Created savings goal id=%s user=%s name=%s target=%s", + goal.id, + user_id, + goal.name, + target_amount, + ) + _create_auto_milestones(goal) + return goal + + +def add_contribution( + goal: SavingsGoal, + amount: Decimal, + notes: str | None = None, +) -> SavingsContribution: + """Add a contribution to a savings goal and check for milestones.""" + contribution = SavingsContribution( + goal_id=goal.id, + amount=amount, + notes=notes, + ) + db.session.add(contribution) + # Update current amount + goal.current_amount += amount + # Check if goal completed + if goal.current_amount >= goal.target_amount: + goal.status = SavingsGoalStatus.COMPLETED + db.session.commit() + logger.info( + "Added contribution goal=%s amount=%s total=%s", + goal.id, + amount, + goal.current_amount, + ) + # Check milestones after commit + _check_milestones(goal) + return contribution + + +def mark_goal_completed(goal: SavingsGoal) -> SavingsGoal: + """Manually mark a goal as completed.""" + goal.status = SavingsGoalStatus.COMPLETED + db.session.commit() + logger.info("Marked goal completed id=%s", goal.id) + return goal + + +def abandon_goal(goal: SavingsGoal) -> SavingsGoal: + """Mark a goal as abandoned.""" + goal.status = SavingsGoalStatus.ABANDONED + db.session.commit() + logger.info("Abandoned goal id=%s", goal.id) + return goal + + +def get_goal_progress(goal: SavingsGoal) -> dict: + """Calculate progress statistics for a goal.""" + percent = 0 + if goal.target_amount > 0: + percent = float(goal.current_amount / goal.target_amount * 100) + days_remaining = None + if goal.deadline: + delta = goal.deadline - datetime.now().date() + days_remaining = max(0, delta.days) + return { + "target": float(goal.target_amount), + "current": float(goal.current_amount), + "percent": round(percent, 1), + "remaining": float(goal.target_amount - goal.current_amount), + "days_remaining": days_remaining, + } + + +def _create_auto_milestones(goal: SavingsGoal): + """Create automatic milestone checkpoints at 25%, 50%, 75%, 100%.""" + if goal.target_amount <= 0: + return + thresholds = [0.25, 0.50, 0.75, 1.0] + milestone_names = ["25% saved", "Halfway there!", "75% saved", "Goal reached!"] + for pct, name in zip(thresholds, milestone_names): + target = goal.target_amount * Decimal(str(pct)) + existing = ( + db.session.query(SavingsMilestone) + .filter_by(goal_id=goal.id, name=name) + .first() + ) + if not existing: + milestone = SavingsMilestone( + goal_id=goal.id, + name=name, + target_amount=target, + ) + db.session.add(milestone) + db.session.commit() + + +def _check_milestones(goal: SavingsGoal): + """Check and mark reached milestones after a contribution.""" + milestones = ( + db.session.query(SavingsMilestone) + .filter_by(goal_id=goal.id) + .filter(SavingsMilestone.reached_at.is_(None)) + .order_by(SavingsMilestone.target_amount) + .all() + ) + for milestone in milestones: + if goal.current_amount >= milestone.target_amount: + milestone.reached_at = datetime.utcnow() + logger.info( + "Milestone reached goal=%s milestone=%s amount=%s", + goal.id, + milestone.id, + milestone.target_amount, + ) + if milestones: + db.session.commit() diff --git a/packages/backend/tests/test_savings.py b/packages/backend/tests/test_savings.py new file mode 100644 index 000000000..1e94a0afc --- /dev/null +++ b/packages/backend/tests/test_savings.py @@ -0,0 +1,195 @@ +"""Tests for savings goals API (bounty #133).""" + +from datetime import date, timedelta + + +def test_savings_create_goal(client, auth_header): + """Test creating a savings goal.""" + payload = { + "name": "Emergency Fund", + "target_amount": 10000, + "currency": "USD", + } + r = client.post("/savings", json=payload, headers=auth_header) + assert r.status_code == 201 + goal = r.get_json() + assert goal["name"] == "Emergency Fund" + assert float(goal["target_amount"]) == 10000.0 + assert goal["status"] == "active" + + +def test_savings_create_goal_with_deadline(client, auth_header): + """Test creating a goal with a deadline.""" + future = (date.today() + timedelta(days=365)).isoformat() + payload = { + "name": "Vacation Fund", + "target_amount": 5000, + "deadline": future, + } + r = client.post("/savings", json=payload, headers=auth_header) + assert r.status_code == 201 + goal = r.get_json() + assert goal["deadline"] is not None + + +def test_savings_create_goal_validation(client, auth_header): + """Test goal creation validation.""" + r = client.post("/savings", json={"target_amount": 1000}, headers=auth_header) + assert r.status_code == 400 + + r = client.post( + "/savings", + json={"name": "Test", "target_amount": -100}, + headers=auth_header, + ) + assert r.status_code == 400 + + +def test_savings_list_goals(client, auth_header): + """Test listing savings goals.""" + for i in range(3): + r = client.post( + "/savings", + json={"name": f"Goal {i}", "target_amount": 1000 * (i + 1)}, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.get("/savings", headers=auth_header) + assert r.status_code == 200 + items = r.get_json() + assert len(items) == 3 + + +def test_savings_get_goal(client, auth_header): + """Test getting a single goal with progress.""" + r = client.post( + "/savings", + json={"name": "Test Goal", "target_amount": 1000}, + headers=auth_header, + ) + assert r.status_code == 201 + goal_id = r.get_json()["id"] + + r = client.get(f"/savings/{goal_id}", headers=auth_header) + assert r.status_code == 200 + goal = r.get_json() + assert goal["id"] == goal_id + assert "progress" in goal + + +def test_savings_get_not_found(client, auth_header): + """Test getting non-existent goal returns 404.""" + r = client.get("/savings/99999", headers=auth_header) + assert r.status_code == 404 + + +def test_savings_add_contribution(client, auth_header): + """Test adding a contribution to a goal.""" + r = client.post( + "/savings", + json={"name": "Save Up", "target_amount": 1000}, + headers=auth_header, + ) + assert r.status_code == 201 + goal_id = r.get_json()["id"] + + r = client.post( + f"/savings/{goal_id}/contributions", + json={"amount": 250, "notes": "First deposit"}, + headers=auth_header, + ) + assert r.status_code == 201 + contrib = r.get_json() + assert float(contrib["amount"]) == 250.0 + + +def test_savings_contribution_invalid_amount(client, auth_header): + """Test contribution with invalid amount.""" + r = client.post( + "/savings", + json={"name": "Test", "target_amount": 1000}, + headers=auth_header, + ) + assert r.status_code == 201 + goal_id = r.get_json()["id"] + + r = client.post( + f"/savings/{goal_id}/contributions", + json={"amount": -50}, + headers=auth_header, + ) + assert r.status_code == 400 + + +def test_savings_list_milestones(client, auth_header): + """Test listing auto-created milestones.""" + r = client.post( + "/savings", + json={"name": "Milestone Test", "target_amount": 1000}, + headers=auth_header, + ) + assert r.status_code == 201 + goal_id = r.get_json()["id"] + + r = client.get(f"/savings/{goal_id}/milestones", headers=auth_header) + assert r.status_code == 200 + milestones = r.get_json() + assert len(milestones) == 4 # 25%, 50%, 75%, 100% + + +def test_savings_list_contributions(client, auth_header): + """Test listing contributions for a goal.""" + r = client.post( + "/savings", + json={"name": "Contrib Test", "target_amount": 1000}, + headers=auth_header, + ) + assert r.status_code == 201 + goal_id = r.get_json()["id"] + + for amt in [100, 200]: + r = client.post( + f"/savings/{goal_id}/contributions", + json={"amount": amt}, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.get(f"/savings/{goal_id}/contributions", headers=auth_header) + assert r.status_code == 200 + contribs = r.get_json() + assert len(contribs) == 2 + + +def test_savings_delete_goal(client, auth_header): + """Test deleting a goal.""" + r = client.post( + "/savings", + json={"name": "Delete Me", "target_amount": 1000}, + headers=auth_header, + ) + assert r.status_code == 201 + goal_id = r.get_json()["id"] + + r = client.delete(f"/savings/{goal_id}", headers=auth_header) + assert r.status_code == 200 + + r = client.get(f"/savings/{goal_id}", headers=auth_header) + assert r.status_code == 404 + + +def test_savings_abandon_goal(client, auth_header): + """Test abandoning a goal.""" + r = client.post( + "/savings", + json={"name": "Abandon Me", "target_amount": 1000}, + headers=auth_header, + ) + assert r.status_code == 201 + goal_id = r.get_json()["id"] + + r = client.post(f"/savings/{goal_id}/abandon", headers=auth_header) + assert r.status_code == 200 + goal = r.get_json() + assert goal["status"] == "abandoned"