Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,21 @@ CREATE TABLE IF NOT EXISTS audit_logs (
action VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

DO $$ BEGIN
CREATE TYPE savings_goal_status AS ENUM ('ACTIVE','COMPLETED','PAUSED');
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,
deadline DATE,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_savings_goals_user ON savings_goals(user_id, created_at DESC);
20 changes: 20 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,23 @@ 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"
PAUSED = "PAUSED"


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)
deadline = db.Column(db.Date, nullable=True)
status = db.Column(
db.String(20), default=SavingsGoalStatus.ACTIVE.value, nullable=False
)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
2 changes: 2 additions & 0 deletions packages/backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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/goals")
163 changes: 163 additions & 0 deletions packages/backend/app/routes/savings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
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
import logging

bp = Blueprint("savings", __name__)
logger = logging.getLogger("finmind.savings")


def _goal_to_dict(g: SavingsGoal) -> dict:
target = float(g.target_amount)
current = float(g.current_amount)
progress_pct = round((current / target * 100), 2) if target > 0 else 0.0
return {
"id": g.id,
"name": g.name,
"target_amount": target,
"current_amount": current,
"progress_pct": progress_pct,
"deadline": g.deadline.isoformat() if g.deadline else None,
"status": g.status,
"created_at": g.created_at.isoformat(),
}


@bp.get("")
@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()
)
logger.info("List savings goals user=%s count=%s", uid, len(goals))
return jsonify([_goal_to_dict(g) for g in goals])


@bp.post("")
@jwt_required()
def create_goal():
uid = int(get_jwt_identity())
data = request.get_json() or {}
if not data.get("name") or data.get("target_amount") is None:
return jsonify(error="name and target_amount are required"), 400
try:
target = float(data["target_amount"])
if target <= 0:
raise ValueError("target_amount must be positive")
except (ValueError, TypeError) as e:
return jsonify(error=str(e)), 400

deadline = None
if data.get("deadline"):
try:
deadline = date.fromisoformat(data["deadline"])
except ValueError:
return jsonify(error="deadline must be ISO date (YYYY-MM-DD)"), 400

goal = SavingsGoal(
user_id=uid,
name=data["name"],
target_amount=target,
current_amount=float(data.get("current_amount", 0)),
deadline=deadline,
status=SavingsGoalStatus.ACTIVE.value,
)
db.session.add(goal)
db.session.commit()
logger.info("Created savings goal id=%s user=%s name=%s", goal.id, uid, goal.name)
return jsonify(_goal_to_dict(goal)), 201


@bp.get("/<int:goal_id>")
@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))


@bp.put("/<int:goal_id>")
@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:
goal.name = data["name"]
if "target_amount" in data:
try:
val = float(data["target_amount"])
if val <= 0:
raise ValueError("target_amount must be positive")
goal.target_amount = val
except (ValueError, TypeError) as e:
return jsonify(error=str(e)), 400
if "deadline" in data:
if data["deadline"] is None:
goal.deadline = None
else:
try:
goal.deadline = date.fromisoformat(data["deadline"])
except ValueError:
return jsonify(error="deadline must be ISO date (YYYY-MM-DD)"), 400
if "status" in data:
try:
goal.status = SavingsGoalStatus(data["status"]).value
except ValueError:
return jsonify(error=f"invalid status: {data['status']}"), 400
db.session.commit()
logger.info("Updated savings goal id=%s user=%s", goal.id, uid)
return jsonify(_goal_to_dict(goal))


@bp.delete("/<int:goal_id>")
@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
db.session.delete(goal)
db.session.commit()
logger.info("Deleted savings goal id=%s user=%s", goal_id, uid)
return jsonify(message="deleted")


@bp.post("/<int:goal_id>/contribute")
@jwt_required()
def contribute(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 {}
try:
amount = float(data.get("amount", 0))
if amount <= 0:
raise ValueError("amount must be positive")
except (ValueError, TypeError) as e:
return jsonify(error=str(e)), 400

goal.current_amount = float(goal.current_amount) + amount
# Auto-complete if target reached
if float(goal.current_amount) >= float(goal.target_amount):
goal.status = SavingsGoalStatus.COMPLETED.value
db.session.commit()
logger.info(
"Contribution to goal id=%s user=%s amount=%s new_total=%s",
goal.id,
uid,
amount,
goal.current_amount,
)
return jsonify(_goal_to_dict(goal))
Loading