Skip to content
This repository was archived by the owner on Jun 19, 2026. It is now read-only.
Open
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
57 changes: 57 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,60 @@ 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 GoalStatus(str, Enum):
ACTIVE = "ACTIVE"
COMPLETED = "COMPLETED"
ABANDONED = "ABANDONED"


class SavingsGoal(db.Model):
"""A named savings target with optional deadline and deposit history."""
__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)
target_date = db.Column(db.Date, nullable=True)
status = db.Column(db.String(20), default=GoalStatus.ACTIVE.value, nullable=False)
currency = db.Column(db.String(10), default="INR", 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
)
deposits = db.relationship(
"GoalDeposit", backref="goal", cascade="all, delete-orphan", lazy="dynamic"
)
milestones = db.relationship(
"GoalMilestone", backref="goal", cascade="all, delete-orphan", lazy="dynamic"
)


class GoalDeposit(db.Model):
"""A single deposit credited towards a savings goal."""
__tablename__ = "goal_deposits"
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)
note = db.Column(db.String(300), nullable=True)
deposited_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class GoalMilestone(db.Model):
"""Milestone automatically recorded when a goal reaches 25 / 50 / 75 / 100 %."""
__tablename__ = "goal_milestones"
id = db.Column(db.Integer, primary_key=True)
goal_id = db.Column(
db.Integer, db.ForeignKey("savings_goals.id"), nullable=False
)
pct = db.Column(db.Integer, nullable=False) # 25, 50, 75, 100
reached_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")
106 changes: 106 additions & 0 deletions packages/backend/app/routes/savings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Savings goals routes.

Endpoints
---------
GET /savings/goals list all goals for the current user
POST /savings/goals create a new goal
GET /savings/goals/<id> get one goal
PATCH /savings/goals/<id> update name / target_amount / target_date / status
DELETE /savings/goals/<id> delete a goal
POST /savings/goals/<id>/deposit add money to a goal
GET /savings/goals/<id>/milestones list milestones reached
"""
from flask import Blueprint, jsonify, request
from flask_jwt_extended import jwt_required, get_jwt_identity
import logging

from ..services.savings import (
create_goal,
delete_goal,
deposit,
get_goal,
get_goals,
get_milestones,
goal_to_dict,
update_goal,
)

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


@bp.get("/goals")
@jwt_required()
def list_goals():
uid = int(get_jwt_identity())
goals = get_goals(uid)
return jsonify([goal_to_dict(g) for g in goals])


@bp.post("/goals")
@jwt_required()
def create_goal_endpoint():
uid = int(get_jwt_identity())
data = request.get_json() or {}
goal, err = create_goal(uid, data)
if err:
return jsonify(error=err), 400
return jsonify(goal_to_dict(goal)), 201


@bp.get("/goals/<int:goal_id>")
@jwt_required()
def get_goal_endpoint(goal_id: int):
uid = int(get_jwt_identity())
goal = get_goal(uid, goal_id)
if not goal:
return jsonify(error="not found"), 404
return jsonify(goal_to_dict(goal))


@bp.patch("/goals/<int:goal_id>")
@jwt_required()
def update_goal_endpoint(goal_id: int):
uid = int(get_jwt_identity())
data = request.get_json() or {}
goal, err = update_goal(uid, goal_id, data)
if err == "not found":
return jsonify(error=err), 404
if err:
return jsonify(error=err), 400
return jsonify(goal_to_dict(goal))


@bp.delete("/goals/<int:goal_id>")
@jwt_required()
def delete_goal_endpoint(goal_id: int):
uid = int(get_jwt_identity())
if not delete_goal(uid, goal_id):
return jsonify(error="not found"), 404
return "", 204


@bp.post("/goals/<int:goal_id>/deposit")
@jwt_required()
def deposit_endpoint(goal_id: int):
uid = int(get_jwt_identity())
data = request.get_json() or {}
goal, err = deposit(uid, goal_id, data.get("amount"), data.get("note"))
if err == "not found":
return jsonify(error=err), 404
if err:
return jsonify(error=err), 400
return jsonify(goal_to_dict(goal))


@bp.get("/goals/<int:goal_id>/milestones")
@jwt_required()
def milestones_endpoint(goal_id: int):
uid = int(get_jwt_identity())
milestones = get_milestones(uid, goal_id)
if milestones is None:
return jsonify(error="not found"), 404
return jsonify([
{"pct": m.pct, "reached_at": m.reached_at.isoformat()}
for m in milestones
])
216 changes: 216 additions & 0 deletions packages/backend/app/services/savings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
"""Savings goal service.

Public API
----------
create_goal(uid, data) -> SavingsGoal
get_goals(uid) -> list[SavingsGoal]
get_goal(uid, goal_id) -> SavingsGoal | None
update_goal(uid, goal_id, data) -> SavingsGoal | None
delete_goal(uid, goal_id) -> bool
deposit(uid, goal_id, amount, note) -> SavingsGoal | None
get_milestones(uid, goal_id) -> list[GoalMilestone]
goal_to_dict(goal) -> dict
"""
from __future__ import annotations

import logging
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from typing import Any

from ..extensions import db
from ..models import GoalDeposit, GoalMilestone, GoalStatus, SavingsGoal

logger = logging.getLogger("finmind.savings")

_MILESTONE_PCTS = (25, 50, 75, 100)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _parse_amount(value: Any) -> Decimal | None:
try:
d = Decimal(str(value))
return d if d > 0 else None
except (InvalidOperation, TypeError):
return None


def _progress_pct(goal: SavingsGoal) -> float:
if goal.target_amount <= 0:
return 0.0
return float(goal.current_amount / goal.target_amount * 100)


def _record_new_milestones(goal: SavingsGoal) -> None:
"""Record any newly crossed milestone thresholds (idempotent)."""
pct = _progress_pct(goal)
existing = {m.pct for m in goal.milestones}
for threshold in _MILESTONE_PCTS:
if pct >= threshold and threshold not in existing:
db.session.add(GoalMilestone(goal_id=goal.id, pct=threshold))
logger.info(
"Goal id=%s crossed %s%% milestone (current=%.2f target=%.2f)",
goal.id, threshold, goal.current_amount, goal.target_amount,
)


def goal_to_dict(goal: SavingsGoal) -> dict:
return {
"id": goal.id,
"name": goal.name,
"description": goal.description,
"target_amount": float(goal.target_amount),
"current_amount": float(goal.current_amount),
"progress_pct": round(_progress_pct(goal), 2),
"target_date": goal.target_date.isoformat() if goal.target_date else None,
"status": goal.status,
"currency": goal.currency,
"created_at": goal.created_at.isoformat(),
"updated_at": goal.updated_at.isoformat(),
}


# ---------------------------------------------------------------------------
# CRUD
# ---------------------------------------------------------------------------

def create_goal(uid: int, data: dict) -> tuple[SavingsGoal | None, str | None]:
"""Create a new savings goal. Returns (goal, None) or (None, error_msg)."""
name = (data.get("name") or "").strip()
if not name:
return None, "name required"

target = _parse_amount(data.get("target_amount"))
if target is None:
return None, "target_amount must be a positive number"

raw_date = data.get("target_date")
target_date = None
if raw_date:
try:
target_date = date.fromisoformat(str(raw_date))
except ValueError:
return None, "target_date must be ISO 8601 (YYYY-MM-DD)"

from ..models import User
user = db.session.get(User, uid)
currency = data.get("currency") or (user.preferred_currency if user else "INR")

goal = SavingsGoal(
user_id=uid,
name=name,
description=(data.get("description") or "").strip() or None,
target_amount=target,
current_amount=Decimal("0"),
target_date=target_date,
currency=currency,
)
db.session.add(goal)
db.session.commit()
logger.info("Created savings goal id=%s user=%s target=%.2f", goal.id, uid, goal.target_amount)
return goal, None


def get_goals(uid: int) -> list[SavingsGoal]:
return (
db.session.query(SavingsGoal)
.filter_by(user_id=uid)
.order_by(SavingsGoal.created_at.desc())
.all()
)


def get_goal(uid: int, goal_id: int) -> SavingsGoal | None:
return db.session.query(SavingsGoal).filter_by(id=goal_id, user_id=uid).first()


def update_goal(uid: int, goal_id: int, data: dict) -> tuple[SavingsGoal | None, str | None]:
goal = get_goal(uid, goal_id)
if not goal:
return None, "not found"

if "name" in data:
name = (data["name"] or "").strip()
if not name:
return None, "name must not be empty"
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:
return None, "target_amount must be a positive number"
goal.target_amount = target

if "target_date" in data:
raw = data["target_date"]
if raw is None:
goal.target_date = None
else:
try:
goal.target_date = date.fromisoformat(str(raw))
except ValueError:
return None, "target_date must be ISO 8601 (YYYY-MM-DD)"

if "status" in data:
s = str(data["status"]).upper()
valid = {e.value for e in GoalStatus}
if s not in valid:
return None, f"status must be one of {sorted(valid)}"
goal.status = s

goal.updated_at = datetime.utcnow()
db.session.commit()
logger.info("Updated savings goal id=%s user=%s", goal.id, uid)
return goal, None


def delete_goal(uid: int, goal_id: int) -> bool:
goal = get_goal(uid, goal_id)
if not goal:
return False
db.session.delete(goal)
db.session.commit()
logger.info("Deleted savings goal id=%s user=%s", goal_id, uid)
return True


def deposit(
uid: int, goal_id: int, amount: Any, note: str | None = None
) -> tuple[SavingsGoal | None, str | None]:
"""Credit an amount towards the goal; auto-complete when target is reached."""
goal = get_goal(uid, goal_id)
if not goal:
return None, "not found"
if goal.status != GoalStatus.ACTIVE.value:
return None, "only ACTIVE goals accept deposits"

amt = _parse_amount(amount)
if amt is None:
return None, "amount must be a positive number"

db.session.add(GoalDeposit(goal_id=goal.id, amount=amt, note=note or None))
goal.current_amount = (goal.current_amount or Decimal("0")) + amt
goal.updated_at = datetime.utcnow()

# Auto-complete
if goal.current_amount >= goal.target_amount:
goal.current_amount = goal.target_amount
goal.status = GoalStatus.COMPLETED.value
logger.info("Goal id=%s user=%s COMPLETED", goal.id, uid)

_record_new_milestones(goal)
db.session.commit()
return goal, None


def get_milestones(uid: int, goal_id: int) -> list[GoalMilestone] | None:
goal = get_goal(uid, goal_id)
if not goal:
return None
return goal.milestones.order_by(GoalMilestone.pct).all()
Loading