diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189de..29e3dbe0 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -123,3 +123,39 @@ CREATE TABLE IF NOT EXISTS audit_logs ( action VARCHAR(100) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); + +-- Savings Goals tables + +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, + current_amount NUMERIC(12,2) NOT NULL DEFAULT 0, + currency VARCHAR(10) NOT NULL DEFAULT 'INR', + target_date DATE, + status goal_status NOT NULL DEFAULT 'ACTIVE', + notes VARCHAR(500), + 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, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + goal_id INT NOT NULL REFERENCES savings_goals(id) ON DELETE CASCADE, + amount NUMERIC(12,2) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'INR', + note VARCHAR(500), + contributed_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_savings_goals_user ON savings_goals(user_id); +CREATE INDEX IF NOT EXISTS idx_goal_contributions_goal ON goal_contributions(goal_id); diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..44ed6fbd 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -127,6 +127,39 @@ 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) + target_date = db.Column(db.Date, nullable=True) + status = db.Column(SAEnum(GoalStatus), default=GoalStatus.ACTIVE, nullable=False) + notes = db.Column(db.String(500), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + +class GoalContribution(db.Model): + __tablename__ = "goal_contributions" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + 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) + contributed_at = db.Column(db.DateTime, default=datetime.utcnow, 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..857ff855 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -7,6 +7,7 @@ from .categories import bp as categories_bp from .docs import bp as docs_bp from .dashboard import bp as dashboard_bp +from .savings_goals import bp as savings_goals_bp def register_routes(app: Flask): @@ -18,3 +19,4 @@ def register_routes(app: Flask): app.register_blueprint(categories_bp, url_prefix="/categories") app.register_blueprint(docs_bp, url_prefix="/docs") app.register_blueprint(dashboard_bp, url_prefix="/dashboard") + app.register_blueprint(savings_goals_bp, url_prefix="/savings-goals") diff --git a/packages/backend/app/routes/savings_goals.py b/packages/backend/app/routes/savings_goals.py new file mode 100644 index 00000000..e4c50d99 --- /dev/null +++ b/packages/backend/app/routes/savings_goals.py @@ -0,0 +1,263 @@ +""" +API routes for savings goals and milestones. +""" + +from datetime import date +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 GoalContribution, GoalStatus, SavingsGoal, User + +bp = Blueprint("savings_goals", __name__) + + +@bp.get("") +@jwt_required() +def list_goals(): + """List all savings goals for the current user.""" + 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_by(status=GoalStatus(status_filter.upper())) + except ValueError: + pass # Ignore invalid status filter + + goals = q.order_by(SavingsGoal.created_at.desc()).all() + return jsonify([_goal_to_dict(g) for g in goals]) + + +@bp.post("") +@jwt_required() +def create_goal(): + """Create a new savings 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 + + target_amount = _parse_amount(data.get("target_amount")) + if target_amount is None or target_amount <= 0: + return jsonify(error="valid target_amount is required"), 400 + + target_date = None + if data.get("target_date"): + try: + target_date = date.fromisoformat(data.get("target_date")) + except ValueError: + return jsonify(error="invalid target_date format (use YYYY-MM-DD)"), 400 + + goal = SavingsGoal( + user_id=uid, + name=name, + target_amount=target_amount, + current_amount=Decimal("0"), + currency=data.get("currency") or (user.preferred_currency if user else "INR"), + target_date=target_date, + notes=data.get("notes"), + status=GoalStatus.ACTIVE, + ) + db.session.add(goal) + db.session.commit() + return jsonify(_goal_to_dict(goal)), 201 + + +@bp.get("/") +@jwt_required() +def get_goal(goal_id: int): + """Get a specific savings goal with contributions.""" + uid = int(get_jwt_identity()) + goal = db.session.get(SavingsGoal, goal_id) + + if not goal or goal.user_id != uid: + return jsonify(error="not found"), 404 + + contributions = ( + db.session.query(GoalContribution) + .filter_by(goal_id=goal_id) + .order_by(GoalContribution.contributed_at.desc()) + .all() + ) + + result = _goal_to_dict(goal) + result["contributions"] = [_contribution_to_dict(c) for c in contributions] + return jsonify(result) + + +@bp.patch("/") +@jwt_required() +def update_goal(goal_id: int): + """Update a savings goal.""" + 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["name"].strip() + if not name: + return jsonify(error="name cannot be empty"), 400 + goal.name = name + + if "target_amount" in data: + target_amount = _parse_amount(data["target_amount"]) + if target_amount is None or target_amount <= 0: + return jsonify(error="invalid target_amount"), 400 + goal.target_amount = target_amount + + if "current_amount" in data: + current_amount = _parse_amount(data["current_amount"]) + if current_amount is None or current_amount < 0: + return jsonify(error="invalid current_amount"), 400 + goal.current_amount = current_amount + # Auto-complete if target reached + if goal.current_amount >= goal.target_amount: + goal.status = GoalStatus.COMPLETED + + if "target_date" in data: + if data["target_date"]: + try: + goal.target_date = date.fromisoformat(data["target_date"]) + except ValueError: + return jsonify(error="invalid target_date format"), 400 + else: + goal.target_date = None + + if "status" in data: + try: + goal.status = GoalStatus(data["status"].upper()) + except ValueError: + return jsonify(error="invalid status (ACTIVE, COMPLETED, CANCELLED)"), 400 + + if "notes" in data: + goal.notes = data["notes"] + + db.session.commit() + return jsonify(_goal_to_dict(goal)) + + +@bp.delete("/") +@jwt_required() +def delete_goal(goal_id: int): + """Delete a savings goal and its contributions.""" + 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 associated contributions + db.session.query(GoalContribution).filter_by(goal_id=goal_id).delete() + db.session.delete(goal) + db.session.commit() + return jsonify(message="deleted") + + +# --- Contribution endpoints --- + +@bp.post("//contributions") +@jwt_required() +def add_contribution(goal_id: int): + """Add a contribution to a savings goal.""" + 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 {} + + amount = _parse_amount(data.get("amount")) + if amount is None or amount <= 0: + return jsonify(error="valid amount is required"), 400 + + currency = data.get("currency") or goal.currency + + contribution = GoalContribution( + user_id=uid, + goal_id=goal_id, + amount=amount, + currency=currency, + note=data.get("note"), + ) + db.session.add(contribution) + + # Update goal's current amount + goal.current_amount = (goal.current_amount or Decimal("0")) + amount + + # Auto-complete if target reached + if goal.current_amount >= goal.target_amount: + goal.status = GoalStatus.COMPLETED + + db.session.commit() + return jsonify(_goal_to_dict(goal)), 201 + + +@bp.get("//contributions") +@jwt_required() +def list_contributions(goal_id: int): + """List all contributions for a goal.""" + 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 + + contributions = ( + db.session.query(GoalContribution) + .filter_by(goal_id=goal_id) + .order_by(GoalContribution.contributed_at.desc()) + .all() + ) + return jsonify([_contribution_to_dict(c) for c in contributions]) + + +# --- Helper functions --- + +def _goal_to_dict(goal: SavingsGoal) -> dict: + progress = 0.0 + if goal.target_amount and goal.target_amount > 0: + progress = float(goal.current_amount or 0) / float(goal.target_amount) * 100 + + return { + "id": goal.id, + "name": goal.name, + "target_amount": float(goal.target_amount), + "current_amount": float(goal.current_amount or 0), + "currency": goal.currency, + "target_date": goal.target_date.isoformat() if goal.target_date else None, + "status": goal.status.value if goal.status else GoalStatus.ACTIVE.value, + "notes": goal.notes, + "progress": round(progress, 2), + "created_at": goal.created_at.isoformat() if goal.created_at else None, + "updated_at": goal.updated_at.isoformat() if goal.updated_at else None, + } + + +def _contribution_to_dict(contribution: GoalContribution) -> dict: + return { + "id": contribution.id, + "amount": float(contribution.amount), + "currency": contribution.currency, + "note": contribution.note, + "contributed_at": contribution.contributed_at.isoformat() if contribution.contributed_at else None, + } + + +def _parse_amount(raw) -> Decimal | None: + try: + return Decimal(str(raw)).quantize(Decimal("0.01")) + except (InvalidOperation, ValueError, TypeError): + return None