diff --git a/packages/backend/app/db/savings_schema.sql b/packages/backend/app/db/savings_schema.sql new file mode 100644 index 000000000..fd638be1f --- /dev/null +++ b/packages/backend/app/db/savings_schema.sql @@ -0,0 +1,39 @@ +-- FinMind Savings Goals Database Schema +DO $$ BEGIN + CREATE TYPE goal_status AS ENUM ('active', 'completed', 'cancelled'); +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 CHECK (target_amount > 0), + current_amount NUMERIC(12, 2) NOT NULL DEFAULT 0 CHECK (current_amount >= 0), + currency VARCHAR(10) NOT NULL DEFAULT 'INR', + deadline DATE, + status goal_status NOT NULL DEFAULT 'active', + description VARCHAR(500), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_savings_goals_user_status ON savings_goals(user_id, status); +CREATE INDEX IF NOT EXISTS idx_savings_goals_user_deadline ON savings_goals(user_id, deadline); + +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 CHECK (amount > 0), + currency VARCHAR(10) NOT NULL DEFAULT 'INR', + note VARCHAR(500), + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_contributions_goal_created ON savings_contributions(goal_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS savings_milestones ( + id SERIAL PRIMARY KEY, + goal_id INT NOT NULL REFERENCES savings_goals(id) ON DELETE CASCADE, + percentage INT NOT NULL CHECK (percentage IN (25, 50, 75, 100)), + achieved_at TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_milestones_goal_pct ON savings_milestones(goal_id, percentage); diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d448104..56b5d941b 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -133,3 +133,149 @@ 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 Goal Models ============== +from datetime import datetime +from enum import Enum +from decimal import Decimal +from sqlalchemy import Enum as SAEnum, Index, CheckConstraint +from .extensions import db + + +class GoalStatus(str, Enum): + ACTIVE = "active" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +class SavingsGoal(db.Model): + """User savings goal""" + __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=Decimal("0"), nullable=False) + currency = db.Column(db.String(10), default="INR", nullable=False) + deadline = db.Column(db.Date, nullable=True) + status = db.Column(SAEnum(GoalStatus), default=GoalStatus.ACTIVE, nullable=False) + description = db.Column(db.String(500), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + contributions = db.relationship("SavingsContribution", back_populates="goal", lazy="dynamic", cascade="all, delete-orphan") + milestones = db.relationship("SavingsMilestone", back_populates="goal", lazy="dynamic", cascade="all, delete-orphan") + + __table_args__ = ( + Index("idx_savings_goals_user_status", "user_id", "status"), + Index("idx_savings_goals_user_deadline", "user_id", "deadline"), + CheckConstraint("target_amount > 0", name="ck_savings_goal_target_positive"), + CheckConstraint("current_amount >= 0", name="ck_savings_goal_current_non_negative"), + ) + + @property + def progress_percentage(self) -> float: + if self.target_amount <= 0: + return 0.0 + pct = float(self.current_amount / self.target_amount * 100) + return min(pct, 100.0) + + @property + def remaining_amount(self) -> Decimal: + remaining = self.target_amount - self.current_amount + return max(remaining, Decimal("0")) + + @property + def is_overdue(self) -> bool: + if not self.deadline: + return False + from datetime import date + return self.deadline < date.today() and self.status == GoalStatus.ACTIVE + + def is_completed(self) -> bool: + return self.current_amount >= self.target_amount + + def check_milestones(self) -> list: + achieved = [] + existing = {m.percentage: m for m in self.milestones.all()} + for pct in [25, 50, 75, 100]: + if self.progress_percentage >= pct: + if pct not in existing: + milestone = SavingsMilestone(goal_id=self.id, percentage=pct, achieved_at=datetime.utcnow()) + db.session.add(milestone) + achieved.append(pct) + elif not existing[pct].achieved_at: + existing[pct].achieved_at = datetime.utcnow() + achieved.append(pct) + return achieved + + def to_dict(self) -> dict: + return { + "id": self.id, + "user_id": self.user_id, + "name": self.name, + "target_amount": float(self.target_amount), + "current_amount": float(self.current_amount), + "remaining_amount": float(self.remaining_amount), + "progress_percentage": round(self.progress_percentage, 2), + "currency": self.currency, + "deadline": self.deadline.isoformat() if self.deadline else None, + "status": self.status.value, + "description": self.description, + "is_overdue": self.is_overdue, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + +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) + currency = db.Column(db.String(10), default="INR", nullable=False) + note = db.Column(db.String(500), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + goal = db.relationship("SavingsGoal", back_populates="contributions") + + __table_args__ = ( + Index("idx_contributions_goal_created", "goal_id", "created_at"), + CheckConstraint("amount > 0", name="ck_contribution_positive"), + ) + + def to_dict(self) -> dict: + return { + "id": self.id, + "goal_id": self.goal_id, + "amount": float(self.amount), + "currency": self.currency, + "note": self.note, + "created_at": self.created_at.isoformat() if self.created_at else None, + } + + +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) + percentage = db.Column(db.Integer, nullable=False) + achieved_at = db.Column(db.DateTime, nullable=True) + + goal = db.relationship("SavingsGoal", back_populates="milestones") + + __table_args__ = ( + Index("idx_milestones_goal_pct", "goal_id", "percentage"), + ) + + def to_dict(self) -> dict: + return { + "id": self.id, + "goal_id": self.goal_id, + "percentage": self.percentage, + "achieved_at": self.achieved_at.isoformat() if self.achieved_at else None, + } diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f897..932ee4008 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -18,3 +18,5 @@ 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") + +from .savings import bp as savings_bp diff --git a/packages/backend/app/routes/savings.py b/packages/backend/app/routes/savings.py new file mode 100644 index 000000000..d7c866c8a --- /dev/null +++ b/packages/backend/app/routes/savings.py @@ -0,0 +1,177 @@ +"""Savings goals API routes""" +from datetime import date +from decimal import Decimal, InvalidOperation +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from ..extensions import db +from ..models import SavingsGoal, SavingsContribution, SavingsMilestone, GoalStatus, User + +bp = Blueprint("savings", __name__) + + +@bp.get("/goals") +@jwt_required() +def list_goals(): + uid = int(get_jwt_identity()) + status = request.args.get("status") + q = db.session.query(SavingsGoal).filter_by(user_id=uid) + if status: + try: + q = q.filter_by(status=GoalStatus(status)) + except ValueError: + pass + goals = q.order_by(SavingsGoal.created_at.desc()).all() + return jsonify([g.to_dict() for g in goals]) + + +@bp.get("/goals/") +@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="goal not found"), 404 + result = goal.to_dict() + result["milestones"] = [m.to_dict() for m in goal.milestones.all()] + return jsonify(result) + + +@bp.post("/goals") +@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 is required"), 400 + try: + target_amount = Decimal(str(data.get("target_amount", 0))) + if target_amount <= 0: + return jsonify(error="target_amount must be positive"), 400 + except (InvalidOperation, ValueError, TypeError): + return jsonify(error="invalid target_amount"), 400 + deadline = None + if data.get("deadline"): + try: + deadline = date.fromisoformat(data["deadline"]) + except ValueError: + return jsonify(error="invalid deadline format"), 400 + goal = SavingsGoal( + user_id=uid, name=name, target_amount=target_amount, + currency=data.get("currency") or (user.preferred_currency if user else "INR"), + deadline=deadline, description=(data.get("description") or "").strip()[:500], + ) + db.session.add(goal) + db.session.commit() + return jsonify(goal.to_dict()), 201 + + +@bp.patch("/goals/") +@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="goal not found"), 404 + data = request.get_json() or {} + if "name" in data: + goal.name = (data["name"] or "").strip()[:200] + if "target_amount" in data: + try: + goal.target_amount = Decimal(str(data["target_amount"])) + except (InvalidOperation, ValueError): + return jsonify(error="invalid target_amount"), 400 + if "deadline" in data: + goal.deadline = date.fromisoformat(data["deadline"]) if data["deadline"] else None + if "description" in data: + goal.description = (data["description"] or "")[:500] + if "status" in data: + goal.status = GoalStatus(data["status"]) + db.session.commit() + return jsonify(goal.to_dict()) + + +@bp.delete("/goals/") +@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="goal not found"), 404 + db.session.delete(goal) + db.session.commit() + return jsonify(message="goal deleted"), 200 + + +@bp.post("/goals//contributions") +@jwt_required() +def add_contribution(goal_id: int): + uid = int(get_jwt_identity()) + # Use row lock to prevent concurrent issues + goal = db.session.query(SavingsGoal).filter_by(id=goal_id).with_for_update().first() + if not goal or goal.user_id != uid: + return jsonify(error="goal not found"), 404 + if goal.status != GoalStatus.ACTIVE: + return jsonify(error="cannot contribute to inactive goal"), 400 + data = request.get_json() or {} + try: + amount = Decimal(str(data.get("amount", 0))) + if amount <= 0: + return jsonify(error="amount must be positive"), 400 + except (InvalidOperation, ValueError, TypeError): + return jsonify(error="invalid amount"), 400 + new_amount = min(goal.current_amount + amount, goal.target_amount) + actual_contribution = new_amount - goal.current_amount + goal.current_amount = new_amount + if goal.is_completed(): + goal.status = GoalStatus.COMPLETED + contribution = SavingsContribution( + goal_id=goal_id, amount=actual_contribution, currency=goal.currency, + note=(data.get("note") or "").strip()[:500], + ) + db.session.add(contribution) + achieved = goal.check_milestones() + db.session.commit() + result = goal.to_dict() + result["contribution"] = contribution.to_dict() + result["achieved_milestones"] = achieved + return jsonify(result), 201 + + +@bp.get("/goals//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="goal not found"), 404 + page = max(1, int(request.args.get("page", "1"))) + page_size = min(100, max(1, int(request.args.get("page_size", "50")))) + contributions = db.session.query(SavingsContribution).filter_by(goal_id=goal_id).order_by( + SavingsContribution.created_at.desc()).offset((page-1)*page_size).limit(page_size).all() + return jsonify({"contributions": [c.to_dict() for c in contributions], "total": goal.contributions.count(), "page": page, "page_size": page_size}) + + +@bp.get("/goals//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="goal not found"), 404 + return jsonify([m.to_dict() for m in goal.milestones.order_by(SavingsMilestone.percentage).all()]) + + +@bp.post("/goals//cancel") +@jwt_required() +def cancel_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="goal not found"), 404 + if goal.status != GoalStatus.ACTIVE: + return jsonify(error="goal is not active"), 400 + goal.status = GoalStatus.CANCELLED + db.session.commit() + return jsonify(goal.to_dict()) diff --git a/packages/backend/tests/test_savings.py b/packages/backend/tests/test_savings.py new file mode 100644 index 000000000..068744cb9 --- /dev/null +++ b/packages/backend/tests/test_savings.py @@ -0,0 +1,83 @@ +"""Savings goals unit tests""" +import pytest +from datetime import date, datetime, timedelta +from decimal import Decimal + + +class TestSavingsGoalModel: + def test_create_goal(self, app, test_user, db): + from packages.backend.app.models import SavingsGoal, GoalStatus + with app.app_context(): + goal = SavingsGoal(user_id=test_user["id"], name="Vacation Fund", target_amount=Decimal("10000.00")) + db.session.add(goal) + db.session.commit() + assert goal.id is not None + assert goal.progress_percentage == 0.0 + + def test_progress_percentage(self, app, test_user, db): + from packages.backend.app.models import SavingsGoal + with app.app_context(): + goal = SavingsGoal(user_id=test_user["id"], name="Test", target_amount=Decimal("10000.00"), current_amount=Decimal("2500.00")) + db.session.add(goal) + db.session.commit() + assert goal.progress_percentage == 25.0 + + def test_is_completed(self, app, test_user, db): + from packages.backend.app.models import SavingsGoal + with app.app_context(): + goal = SavingsGoal(user_id=test_user["id"], name="Done", target_amount=Decimal("10000.00"), current_amount=Decimal("10000.00")) + db.session.add(goal) + db.session.commit() + assert goal.is_completed() is True + + +class TestSavingsContribution: + def test_create_contribution(self, app, test_user, db): + from packages.backend.app.models import SavingsGoal, SavingsContribution + with app.app_context(): + goal = SavingsGoal(user_id=test_user["id"], name="Test", target_amount=Decimal("10000.00")) + db.session.add(goal) + db.session.commit() + contrib = SavingsContribution(goal_id=goal.id, amount=Decimal("1000.00"), note="First") + db.session.add(contrib) + db.session.commit() + assert contrib.id is not None + + +@pytest.fixture +def app(): + from packages.backend.app import create_app + app = create_app(TestSettings()) + app.config["TESTING"] = True + return app + + +@pytest.fixture +def test_user(app): + from packages.backend.app.extensions import db + from packages.backend.app.models import User + with app.app_context(): + user = User(email="test@example.com", password_hash="hash", preferred_currency="INR") + db.session.add(user) + db.session.commit() + yield {"id": user.id} + + +@pytest.fixture +def db(app): + from packages.backend.app.extensions import db as _db + return _db + + +class TestSettings: + database_url = "sqlite:///:memory:" + jwt_secret = "test" + jwt_access_minutes = 30 + jwt_refresh_hours = 24 + openai_api_key = "" + gemini_api_key = "" + gemini_model = "gemini-1.5-flash" + twilio_account_sid = "" + twilio_auth_token = "" + twilio_whatsapp_from = "" + email_from = "test@example.com"